diff options
| author | tamaina <tamaina@hotmail.co.jp> | 2022-11-17 23:35:55 +0900 |
|---|---|---|
| committer | tamaina <tamaina@hotmail.co.jp> | 2022-11-17 23:35:55 +0900 |
| commit | 764da890b6ad3d53808ec592099a93d9d39d7b08 (patch) | |
| tree | b3e9b08bfafa2bbbb5f657af3adb60bcc9510b67 /packages/client/src | |
| parent | fix (diff) | |
| parent | Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop (diff) | |
| download | misskey-764da890b6ad3d53808ec592099a93d9d39d7b08.tar.gz misskey-764da890b6ad3d53808ec592099a93d9d39d7b08.tar.bz2 misskey-764da890b6ad3d53808ec592099a93d9d39d7b08.zip | |
Merge branch 'develop' into pizzax-indexeddb
Diffstat (limited to 'packages/client/src')
456 files changed, 15596 insertions, 10543 deletions
diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts index 2b07dd1990..10257b841f 100644 --- a/packages/client/src/account.ts +++ b/packages/client/src/account.ts @@ -17,6 +17,7 @@ const accountData = localStorage.getItem('account'); export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null; export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator); +export const iAmAdmin = $i != null && $i.isAdmin; export async function signout() { waiting(); @@ -24,6 +25,8 @@ export async function signout() { await removeAccount($i.id); + const accounts = await getAccounts(); + //#region Remove service worker registration try { if (navigator.serviceWorker.controller) { @@ -143,7 +146,7 @@ export async function openAccountMenu(opts: { onChoose?: (account: misskey.entities.UserDetailed) => void; }, ev: MouseEvent) { function showSigninDialog() { - popup(defineAsyncComponent(() => import('@/components/signin-dialog.vue')), {}, { + popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { done: res => { addAccount(res.id, res.i); success(); @@ -152,7 +155,7 @@ export async function openAccountMenu(opts: { } function createAccount() { - popup(defineAsyncComponent(() => import('@/components/signup-dialog.vue')), {}, { + popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { done: res => { addAccount(res.id, res.i); switchAccountWithToken(res.i); @@ -203,17 +206,16 @@ export async function openAccountMenu(opts: { to: `/@${ $i.username }`, avatar: $i, }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { + type: 'parent', icon: 'fas fa-plus', text: i18n.ts.addAccount, - action: () => { - popupMenu([{ - text: i18n.ts.existingAccount, - action: () => { showSigninDialog(); }, - }, { - text: i18n.ts.createAccount, - action: () => { createAccount(); }, - }], ev.currentTarget ?? ev.target); - }, + children: [{ + text: i18n.ts.existingAccount, + action: () => { showSigninDialog(); }, + }, { + text: i18n.ts.createAccount, + action: () => { createAccount(); }, + }], }, { type: 'link', icon: 'fas fa-users', diff --git a/packages/client/src/components/abuse-report.vue b/packages/client/src/components/MkAbuseReport.vue index e8ab6f600e..9a3464b640 100644 --- a/packages/client/src/components/abuse-report.vue +++ b/packages/client/src/components/MkAbuseReport.vue @@ -1,7 +1,7 @@ <template> <div class="bcekxzvu _gap _panel"> <div class="target"> - <MkA v-user-preview="report.targetUserId" class="info" :to="`/user-info/${report.targetUserId}`" :behavior="'window'"> + <MkA v-user-preview="report.targetUserId" class="info" :to="`/user-info/${report.targetUserId}`"> <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true" :disable-link="true"/> <div class="names"> <MkUserName class="name" :user="report.targetUser"/> @@ -9,7 +9,7 @@ </div> </MkA> <MkKeyValue class="_formBlock"> - <template #key>{{ $ts.registeredDate }}</template> + <template #key>{{ i18n.ts.registeredDate }}</template> <template #value>{{ new Date(report.targetUser.createdAt).toLocaleString() }} (<MkTime :time="report.targetUser.createdAt"/>)</template> </MkKeyValue> </div> @@ -18,29 +18,30 @@ <Mfm :text="report.comment"/> </div> <hr/> - <div>{{ $ts.reporter }}: <MkAcct :user="report.reporter"/></div> + <div>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></div> <div v-if="report.assignee"> - {{ $ts.moderator }}: + {{ i18n.ts.moderator }}: <MkAcct :user="report.assignee"/> </div> <div><MkTime :time="report.createdAt"/></div> <div class="action"> <MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved"> - {{ $ts.forwardReport }} - <template #caption>{{ $ts.forwardReportIsAnonymous }}</template> + {{ i18n.ts.forwardReport }} + <template #caption>{{ i18n.ts.forwardReportIsAnonymous }}</template> </MkSwitch> - <MkButton v-if="!report.resolved" primary @click="resolve">{{ $ts.abuseMarkAsResolved }}</MkButton> + <MkButton v-if="!report.resolved" primary @click="resolve">{{ i18n.ts.abuseMarkAsResolved }}</MkButton> </div> </div> </div> </template> <script lang="ts" setup> -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import MkSwitch from '@/components/form/switch.vue'; -import MkKeyValue from '@/components/key-value.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; import { acct, userPage } from '@/filters/user'; import * as os from '@/os'; +import { i18n } from '@/i18n'; const props = defineProps<{ report: any; diff --git a/packages/client/src/components/abuse-report-window.vue b/packages/client/src/components/MkAbuseReportWindow.vue index 5114349620..1862d0a0e4 100644 --- a/packages/client/src/components/abuse-report-window.vue +++ b/packages/client/src/components/MkAbuseReportWindow.vue @@ -1,5 +1,5 @@ <template> -<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')"> +<XWindow ref="uiWindow" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')"> <template #header> <i class="fas fa-exclamation-circle" style="margin-right: 0.5em;"></i> <I18n :src="i18n.ts.reportAbuseOf" tag="span"> @@ -25,9 +25,9 @@ <script setup lang="ts"> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import XWindow from '@/components/ui/window.vue'; +import XWindow from '@/components/MkWindow.vue'; import MkTextarea from '@/components/form/textarea.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -40,7 +40,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const window = ref<InstanceType<typeof XWindow>>(); +const uiWindow = ref<InstanceType<typeof XWindow>>(); const comment = ref(props.initialComment || ''); function send() { @@ -52,7 +52,7 @@ function send() { type: 'success', text: i18n.ts.abuseReported }); - window.value?.close(); + uiWindow.value?.close(); emit('closed'); }); } diff --git a/packages/client/src/components/MkAnalogClock.vue b/packages/client/src/components/MkAnalogClock.vue new file mode 100644 index 0000000000..40ef626aed --- /dev/null +++ b/packages/client/src/components/MkAnalogClock.vue @@ -0,0 +1,225 @@ +<template> +<svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none"> + <template v-if="props.graduations === 'dots'"> + <circle + v-for="(angle, i) in graduationsMajor" + :cx="5 + (Math.sin(angle) * (5 - graduationsPadding))" + :cy="5 - (Math.cos(angle) * (5 - graduationsPadding))" + :r="0.125" + :fill="(props.twentyfour ? h : h % 12) === i ? nowColor : majorGraduationColor" + :opacity="!props.fadeGraduations || (props.twentyfour ? h : h % 12) === i ? 1 : Math.max(0, 1 - (angleDiff(hAngle, angle) / Math.PI) - numbersOpacityFactor)" + /> + </template> + <template v-else-if="props.graduations === 'numbers'"> + <text + v-for="(angle, i) in texts" + :x="5 + (Math.sin(angle) * (5 - textsPadding))" + :y="5 - (Math.cos(angle) * (5 - textsPadding))" + text-anchor="middle" + dominant-baseline="middle" + :font-size="(props.twentyfour ? h : h % 12) === i ? 1 : 0.7" + :font-weight="(props.twentyfour ? h : h % 12) === i ? 'bold' : 'normal'" + :fill="(props.twentyfour ? h : h % 12) === i ? nowColor : 'currentColor'" + :opacity="!props.fadeGraduations || (props.twentyfour ? h : h % 12) === i ? 1 : Math.max(0, 1 - (angleDiff(hAngle, angle) / Math.PI) - numbersOpacityFactor)" + > + {{ i === 0 ? (props.twentyfour ? '24' : '12') : i }} + </text> + </template> + + <!-- + <line + :x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))" + :y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))" + :x2="5 + (Math.sin(sAngle) * ((sHandLengthRatio * 5) - handsPadding))" + :y2="5 - (Math.cos(sAngle) * ((sHandLengthRatio * 5) - handsPadding))" + :stroke="sHandColor" + :stroke-width="thickness / 2" + stroke-linecap="round" + /> + --> + + <line + class="s" + :class="{ animate: !disableSAnimate && sAnimation !== 'none', elastic: sAnimation === 'elastic', easeOut: sAnimation === 'easeOut' }" + :x1="5 - (0 * (sHandLengthRatio * handsTailLength))" + :y1="5 + (1 * (sHandLengthRatio * handsTailLength))" + :x2="5 + (0 * ((sHandLengthRatio * 5) - handsPadding))" + :y2="5 - (1 * ((sHandLengthRatio * 5) - handsPadding))" + :stroke="sHandColor" + :stroke-width="thickness / 2" + :style="`transform: rotateZ(${sAngle}rad)`" + stroke-linecap="round" + /> + + <line + :x1="5 - (Math.sin(mAngle) * (mHandLengthRatio * handsTailLength))" + :y1="5 + (Math.cos(mAngle) * (mHandLengthRatio * handsTailLength))" + :x2="5 + (Math.sin(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" + :y2="5 - (Math.cos(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" + :stroke="mHandColor" + :stroke-width="thickness" + stroke-linecap="round" + /> + + <line + :x1="5 - (Math.sin(hAngle) * (hHandLengthRatio * handsTailLength))" + :y1="5 + (Math.cos(hAngle) * (hHandLengthRatio * handsTailLength))" + :x2="5 + (Math.sin(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" + :y2="5 - (Math.cos(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" + :stroke="hHandColor" + :stroke-width="thickness" + stroke-linecap="round" + /> +</svg> +</template> + +<script lang="ts" setup> +import { ref, computed, onMounted, onBeforeUnmount, shallowRef, nextTick } from 'vue'; +import tinycolor from 'tinycolor2'; +import { globalEvents } from '@/events.js'; + +// https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles +const angleDiff = (a: number, b: number) => { + const x = Math.abs(a - b); + return Math.abs((x + Math.PI) % (Math.PI * 2) - Math.PI); +}; + +const graduationsPadding = 0.5; +const textsPadding = 0.6; +const handsPadding = 1; +const handsTailLength = 0.7; +const hHandLengthRatio = 0.75; +const mHandLengthRatio = 1; +const sHandLengthRatio = 1; +const numbersOpacityFactor = 0.35; + +const props = withDefaults(defineProps<{ + thickness?: number; + offset?: number; + twentyfour?: boolean; + graduations?: 'none' | 'dots' | 'numbers'; + fadeGraduations?: boolean; + sAnimation?: 'none' | 'elastic' | 'easeOut'; +}>(), { + numbers: false, + thickness: 0.1, + offset: 0 - new Date().getTimezoneOffset(), + twentyfour: false, + graduations: 'dots', + fadeGraduations: true, + sAnimation: 'elastic', +}); + +const graduationsMajor = computed(() => { + const angles: number[] = []; + const times = props.twentyfour ? 24 : 12; + for (let i = 0; i < times; i++) { + const angle = Math.PI * i / (times / 2); + angles.push(angle); + } + return angles; +}); +const texts = computed(() => { + const angles: number[] = []; + const times = props.twentyfour ? 24 : 12; + for (let i = 0; i < times; i++) { + const angle = Math.PI * i / (times / 2); + angles.push(angle); + } + return angles; +}); + +let enabled = true; +let majorGraduationColor = $ref<string>(); +//let minorGraduationColor = $ref<string>(); +let sHandColor = $ref<string>(); +let mHandColor = $ref<string>(); +let hHandColor = $ref<string>(); +let nowColor = $ref<string>(); +let h = $ref<number>(0); +let m = $ref<number>(0); +let s = $ref<number>(0); +let hAngle = $ref<number>(0); +let mAngle = $ref<number>(0); +let sAngle = $ref<number>(0); +let disableSAnimate = $ref(false); +let sOneRound = false; + +function tick() { + const now = new Date(); + now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset)); + s = now.getSeconds(); + m = now.getMinutes(); + h = now.getHours(); + hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6); + mAngle = Math.PI * (m + s / 60) / 30; + if (sOneRound) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない) + sAngle = Math.PI * 60 / 30; + window.setTimeout(() => { + disableSAnimate = true; + window.setTimeout(() => { + sAngle = 0; + window.setTimeout(() => { + disableSAnimate = false; + }, 100); + }, 100); + }, 700); + } else { + sAngle = Math.PI * s / 30; + } + sOneRound = s === 59; +} + +tick(); + +function calcColors() { + const computedStyle = getComputedStyle(document.documentElement); + const dark = tinycolor(computedStyle.getPropertyValue('--bg')).isDark(); + const accent = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(); + majorGraduationColor = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; + //minorGraduationColor = dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + sHandColor = dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)'; + mHandColor = tinycolor(computedStyle.getPropertyValue('--fg')).toHexString(); + hHandColor = accent; + nowColor = accent; +} + +calcColors(); + +onMounted(() => { + const update = () => { + if (enabled) { + tick(); + window.setTimeout(update, 1000); + } + }; + update(); + + globalEvents.on('themeChanged', calcColors); +}); + +onBeforeUnmount(() => { + enabled = false; + + globalEvents.off('themeChanged', calcColors); +}); +</script> + +<style lang="scss" scoped> +.mbcofsoe { + display: block; + + > .s { + will-change: transform; + transform-origin: 50% 50%; + + &.animate.elastic { + transition: transform .2s cubic-bezier(.4,2.08,.55,.44); + } + + &.animate.easeOut { + transition: transform .7s cubic-bezier(0,.7,.3,1); + } + } +} +</style> diff --git a/packages/client/src/components/autocomplete.vue b/packages/client/src/components/MkAutocomplete.vue index ae708026e0..144281e3c3 100644 --- a/packages/client/src/components/autocomplete.vue +++ b/packages/client/src/components/MkAutocomplete.vue @@ -20,6 +20,7 @@ <span v-if="emoji.isCustomEmoji" class="emoji"><img :src="defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span> <span v-else-if="!defaultStore.state.useOsNativeEmojis" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span> <span v-else class="emoji">{{ emoji.emoji }}</span> + <!-- eslint-disable-next-line vue/no-v-html --> <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span> <span v-if="emoji.aliasOf" class="alias">({{ emoji.aliasOf }})</span> </li> diff --git a/packages/client/src/components/avatars.vue b/packages/client/src/components/MkAvatars.vue index 958e5db0a1..958e5db0a1 100644 --- a/packages/client/src/components/avatars.vue +++ b/packages/client/src/components/MkAvatars.vue diff --git a/packages/client/src/components/ui/button.vue b/packages/client/src/components/MkButton.vue index e6b20d9881..a052f8f7a9 100644 --- a/packages/client/src/components/ui/button.vue +++ b/packages/client/src/components/MkButton.vue @@ -1,8 +1,9 @@ <template> -<button v-if="!link" class="bghgjjyj _button" +<button + v-if="!link" class="bghgjjyj _button" :class="{ inline, primary, gradate, danger, rounded, full }" :type="type" - @click="$emit('click', $event)" + @click="emit('click', $event)" @mousedown="onMousedown" > <div ref="ripples" class="ripples"></div> @@ -10,7 +11,8 @@ <slot></slot> </div> </button> -<MkA v-else class="bghgjjyj _button" +<MkA + v-else class="bghgjjyj _button" :class="{ inline, primary, gradate, danger, rounded, full }" :to="to" @mousedown="onMousedown" @@ -22,114 +24,77 @@ </MkA> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { nextTick, onMounted } from 'vue'; -export default defineComponent({ - props: { - type: { - type: String, - required: false - }, - primary: { - type: Boolean, - required: false, - default: false - }, - gradate: { - type: Boolean, - required: false, - default: false - }, - rounded: { - type: Boolean, - required: false, - default: false - }, - inline: { - type: Boolean, - required: false, - default: false - }, - link: { - type: Boolean, - required: false, - default: false - }, - to: { - type: String, - required: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - wait: { - type: Boolean, - required: false, - default: false - }, - danger: { - type: Boolean, - required: false, - default: false - }, - full: { - type: Boolean, - required: false, - default: false - }, - }, - emits: ['click'], - mounted() { - if (this.autofocus) { - this.$nextTick(() => { - this.$el.focus(); - }); - } - }, - methods: { - onMousedown(evt: MouseEvent) { - function distance(p, q) { - return Math.hypot(p.x - q.x, p.y - q.y); - } +const props = defineProps<{ + type?: 'button' | 'submit' | 'reset'; + primary?: boolean; + gradate?: boolean; + rounded?: boolean; + inline?: boolean; + link?: boolean; + to?: string; + autofocus?: boolean; + wait?: boolean; + danger?: boolean; + full?: boolean; +}>(); - function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY) { - const origin = { x: circleCenterX, y: circleCenterY }; - const dist1 = distance({ x: 0, y: 0 }, origin); - const dist2 = distance({ x: boxW, y: 0 }, origin); - const dist3 = distance({ x: 0, y: boxH }, origin); - const dist4 = distance({ x: boxW, y: boxH }, origin); - return Math.max(dist1, dist2, dist3, dist4) * 2; - } +const emit = defineEmits<{ + (ev: 'click', payload: MouseEvent): void; +}>(); - const rect = evt.target.getBoundingClientRect(); +let el = $ref<HTMLElement | null>(null); +let ripples = $ref<HTMLElement | null>(null); - const ripple = document.createElement('div'); - ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px'; - ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px'; +onMounted(() => { + if (props.autofocus) { + nextTick(() => { + el!.focus(); + }); + } +}); - this.$refs.ripples.appendChild(ripple); +function distance(p, q): number { + return Math.hypot(p.x - q.x, p.y - q.y); +} - const circleCenterX = evt.clientX - rect.left; - const circleCenterY = evt.clientY - rect.top; +function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY): number { + const origin = { x: circleCenterX, y: circleCenterY }; + const dist1 = distance({ x: 0, y: 0 }, origin); + const dist2 = distance({ x: boxW, y: 0 }, origin); + const dist3 = distance({ x: 0, y: boxH }, origin); + const dist4 = distance({ x: boxW, y: boxH }, origin); + return Math.max(dist1, dist2, dist3, dist4) * 2; +} - const scale = calcCircleScale(evt.target.clientWidth, evt.target.clientHeight, circleCenterX, circleCenterY); +function onMousedown(evt: MouseEvent): void { + const target = evt.target! as HTMLElement; + const rect = target.getBoundingClientRect(); - window.setTimeout(() => { - ripple.style.transform = 'scale(' + (scale / 2) + ')'; - }, 1); - window.setTimeout(() => { - ripple.style.transition = 'all 1s ease'; - ripple.style.opacity = '0'; - }, 1000); - window.setTimeout(() => { - if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple); - }, 2000); - } - } -}); + const ripple = document.createElement('div'); + ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px'; + ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px'; + + ripples!.appendChild(ripple); + + const circleCenterX = evt.clientX - rect.left; + const circleCenterY = evt.clientY - rect.top; + + const scale = calcCircleScale(target.clientWidth, target.clientHeight, circleCenterX, circleCenterY); + + window.setTimeout(() => { + ripple.style.transform = 'scale(' + (scale / 2) + ')'; + }, 1); + window.setTimeout(() => { + ripple.style.transition = 'all 1s ease'; + ripple.style.opacity = '0'; + }, 1000); + window.setTimeout(() => { + if (ripples) ripples.removeChild(ripple); + }, 2000); +} </script> <style lang="scss" scoped> @@ -139,11 +104,10 @@ export default defineComponent({ display: block; min-width: 100px; width: max-content; - padding: 8px 14px; + padding: 8px 16px; text-align: center; font-weight: normal; - font-size: 0.9em; - line-height: 22px; + font-size: 1em; box-shadow: none; text-decoration: none; background: var(--buttonBg); diff --git a/packages/client/src/components/captcha.vue b/packages/client/src/components/MkCaptcha.vue index 183658471b..b399bb8921 100644 --- a/packages/client/src/components/captcha.vue +++ b/packages/client/src/components/MkCaptcha.vue @@ -20,7 +20,7 @@ type Captcha = { getResponse(id: string): string; }; -type CaptchaProvider = 'hcaptcha' | 'recaptcha'; +type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile'; type CaptchaContainer = { readonly [_ in CaptchaProvider]?: Captcha; @@ -48,21 +48,23 @@ const variable = computed(() => { switch (props.provider) { case 'hcaptcha': return 'hcaptcha'; case 'recaptcha': return 'grecaptcha'; + case 'turnstile': return 'turnstile'; } }); -const loaded = computed(() => !!window[variable.value]); +const loaded = !!window[variable.value]; const src = computed(() => { switch (props.provider) { case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off'; case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit'; + case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; } }); const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha); -if (loaded.value) { +if (loaded) { available.value = true; } else { (document.getElementById(props.provider) || document.head.appendChild(Object.assign(document.createElement('script'), { @@ -74,7 +76,7 @@ if (loaded.value) { } function reset() { - if (captcha.value?.reset) captcha.value.reset(); + if (captcha.value.reset) captcha.value.reset(); } function requestRender() { diff --git a/packages/client/src/components/channel-follow-button.vue b/packages/client/src/components/MkChannelFollowButton.vue index dff02beec0..dff02beec0 100644 --- a/packages/client/src/components/channel-follow-button.vue +++ b/packages/client/src/components/MkChannelFollowButton.vue diff --git a/packages/client/src/components/channel-preview.vue b/packages/client/src/components/MkChannelPreview.vue index dd3794a657..dd3794a657 100644 --- a/packages/client/src/components/channel-preview.vue +++ b/packages/client/src/components/MkChannelPreview.vue diff --git a/packages/client/src/components/chart.vue b/packages/client/src/components/MkChart.vue index 5e9c2f03be..31e95404fa 100644 --- a/packages/client/src/components/chart.vue +++ b/packages/client/src/components/MkChart.vue @@ -39,7 +39,7 @@ import zoomPlugin from 'chartjs-plugin-zoom'; //import gradient from 'chartjs-plugin-gradient'; import * as os from '@/os'; import { defaultStore } from '@/store'; -import MkChartTooltip from '@/components/chart-tooltip.vue'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; const props = defineProps({ src: { @@ -160,42 +160,7 @@ const format = (arr) => { })); }; -const tooltipShowing = ref(false); -const tooltipX = ref(0); -const tooltipY = ref(0); -const tooltipTitle = ref(null); -const tooltipSeries = ref(null); -let disposeTooltipComponent; - -os.popup(MkChartTooltip, { - showing: tooltipShowing, - x: tooltipX, - y: tooltipY, - title: tooltipTitle, - series: tooltipSeries, -}, {}).then(({ dispose }) => { - disposeTooltipComponent = dispose; -}); - -function externalTooltipHandler(context) { - if (context.tooltip.opacity === 0) { - tooltipShowing.value = false; - return; - } - - tooltipTitle.value = context.tooltip.title[0]; - tooltipSeries.value = context.tooltip.body.map((b, i) => ({ - backgroundColor: context.tooltip.labelColors[i].backgroundColor, - borderColor: context.tooltip.labelColors[i].borderColor, - text: b.lines[0], - })); - - const rect = context.chart.canvas.getBoundingClientRect(); - - tooltipShowing.value = true; - tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX; - tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY; -} +const { handler: externalTooltipHandler } = useChartTooltip(); const render = () => { if (chartInstance) { @@ -351,7 +316,7 @@ const render = () => { plugins: [{ id: 'vLine', beforeDraw(chart, args, options) { - if (chart.tooltip._active && chart.tooltip._active.length) { + if (chart.tooltip?._active?.length) { const activePoint = chart.tooltip._active[0]; const ctx = chart.ctx; const x = activePoint.element.x; @@ -377,7 +342,7 @@ const exportData = () => { }; const fetchFederationChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/federation', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/federation', { limit: props.limit, span: props.span }); return { series: [{ name: 'Received', @@ -427,7 +392,7 @@ const fetchFederationChart = async (): Promise<typeof chartData> => { }; const fetchApRequestChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/ap-request', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/ap-request', { limit: props.limit, span: props.span }); return { series: [{ name: 'In', @@ -449,7 +414,7 @@ const fetchApRequestChart = async (): Promise<typeof chartData> => { }; const fetchNotesChart = async (type: string): Promise<typeof chartData> => { - const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ name: 'All', @@ -496,7 +461,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { }; const fetchNotesTotalChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ name: 'Combined', @@ -515,7 +480,7 @@ const fetchNotesTotalChart = async (): Promise<typeof chartData> => { }; const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/users', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/users', { limit: props.limit, span: props.span }); return { series: [{ name: 'Combined', @@ -543,7 +508,7 @@ const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => { }; const fetchActiveUsersChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/active-users', { limit: props.limit, span: props.span }); return { series: [{ name: 'Read & Write', @@ -595,7 +560,7 @@ const fetchActiveUsersChart = async (): Promise<typeof chartData> => { }; const fetchDriveChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span }); return { bytes: true, series: [{ @@ -631,7 +596,7 @@ const fetchDriveChart = async (): Promise<typeof chartData> => { }; const fetchDriveFilesChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span }); return { series: [{ name: 'All', @@ -666,7 +631,7 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => { }; const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'In', @@ -688,7 +653,7 @@ const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { }; const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Users', @@ -703,7 +668,7 @@ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData }; const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Notes', @@ -718,7 +683,7 @@ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData }; const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Following', @@ -741,7 +706,7 @@ const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> = }; const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { bytes: true, series: [{ @@ -757,7 +722,7 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char }; const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Drive files', @@ -772,7 +737,7 @@ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof char }; const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); return { series: [...(props.args.withoutAll ? [] : [{ name: 'All', @@ -804,7 +769,7 @@ const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -819,7 +784,7 @@ const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -834,7 +799,7 @@ const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { }; const fetchPerUserDriveChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Inc', @@ -891,10 +856,6 @@ watch(() => [props.src, props.span], fetchAndRender); onMounted(() => { fetchAndRender(); }); - -onUnmounted(() => { - if (disposeTooltipComponent) disposeTooltipComponent(); -}); /* eslint-enable id-denylist */ </script> diff --git a/packages/client/src/components/chart-tooltip.vue b/packages/client/src/components/MkChartTooltip.vue index 20e094a5a7..a92dd36b61 100644 --- a/packages/client/src/components/chart-tooltip.vue +++ b/packages/client/src/components/MkChartTooltip.vue @@ -1,25 +1,27 @@ <template> -<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'left'" :inner-margin="16" @closed="emit('closed')"> - <div v-if="title" class="qpcyisrl"> - <div class="title">{{ title }}</div> - <div v-for="x in series" class="series"> - <span class="color" :style="{ background: x.backgroundColor, borderColor: x.borderColor }"></span> - <span>{{ x.text }}</span> - </div> +<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'top'" :inner-margin="16" @closed="emit('closed')"> + <div v-if="title || series" class="qpcyisrl"> + <div v-if="title" class="title">{{ title }}</div> + <template v-if="series"> + <div v-for="x in series" class="series"> + <span class="color" :style="{ background: x.backgroundColor, borderColor: x.borderColor }"></span> + <span>{{ x.text }}</span> + </div> + </template> </div> </MkTooltip> </template> <script lang="ts" setup> -import { } from 'vue'; -import MkTooltip from './ui/tooltip.vue'; +import { } from 'vue'; +import MkTooltip from './MkTooltip.vue'; const props = defineProps<{ showing: boolean; x: number; y: number; - title: string; - series: { + title?: string; + series?: { backgroundColor: string; borderColor: string; text: string; diff --git a/packages/client/src/components/code-core.vue b/packages/client/src/components/MkCode.core.vue index 45a38afe04..b074028821 100644 --- a/packages/client/src/components/code-core.vue +++ b/packages/client/src/components/MkCode.core.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/no-v-html --> <template> <code v-if="inline" :class="`language-${prismLang}`" v-html="html"></code> <pre v-else :class="`language-${prismLang}`"><code :class="`language-${prismLang}`" v-html="html"></code></pre> @@ -5,7 +6,7 @@ <script lang="ts" setup> import { computed } from 'vue'; -import 'prismjs'; +import Prism from 'prismjs'; import 'prismjs/themes/prism-okaidia.css'; const props = defineProps<{ diff --git a/packages/client/src/components/code.vue b/packages/client/src/components/MkCode.vue index d6478fd2f8..1640258d5b 100644 --- a/packages/client/src/components/code.vue +++ b/packages/client/src/components/MkCode.vue @@ -11,5 +11,5 @@ defineProps<{ inline?: boolean; }>(); -const XCode = defineAsyncComponent(() => import('./code-core.vue')); +const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')); </script> diff --git a/packages/client/src/components/ui/container.vue b/packages/client/src/components/MkContainer.vue index 7c595d8116..4be59adc2a 100644 --- a/packages/client/src/components/ui/container.vue +++ b/packages/client/src/components/MkContainer.vue @@ -10,7 +10,8 @@ </button> </div> </header> - <transition :name="$store.state.animation ? 'container-toggle' : ''" + <transition + :name="$store.state.animation ? 'container-toggle' : ''" @enter="enter" @after-enter="afterEnter" @leave="leave" @@ -34,37 +35,37 @@ export default defineComponent({ showHeader: { type: Boolean, required: false, - default: true + default: true, }, thin: { type: Boolean, required: false, - default: false + default: false, }, naked: { type: Boolean, required: false, - default: false + default: false, }, foldable: { type: Boolean, required: false, - default: false + default: false, }, expanded: { type: Boolean, required: false, - default: true + default: true, }, scrollable: { type: Boolean, required: false, - default: false + default: false, }, maxHeight: { type: Number, required: false, - default: null + default: null, }, }, data() { @@ -79,12 +80,12 @@ export default defineComponent({ const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0; this.$el.style.minHeight = `${headerHeight}px`; if (showBody) { - this.$el.style.flexBasis = `auto`; + this.$el.style.flexBasis = 'auto'; } else { this.$el.style.flexBasis = `${headerHeight}px`; } }, { - immediate: true + immediate: true, }); this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px'); @@ -124,7 +125,7 @@ export default defineComponent({ afterLeave(el) { el.style.height = null; }, - } + }, }); </script> @@ -143,6 +144,7 @@ export default defineComponent({ .ukygtjoj { position: relative; overflow: clip; + contain: content; &.naked { background: transparent !important; diff --git a/packages/client/src/components/ui/context-menu.vue b/packages/client/src/components/MkContextMenu.vue index e637d361cf..cfc9502b41 100644 --- a/packages/client/src/components/ui/context-menu.vue +++ b/packages/client/src/components/MkContextMenu.vue @@ -1,16 +1,16 @@ <template> <transition :name="$store.state.animation ? 'fade' : ''" appear> <div ref="rootEl" class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> - <MkMenu :items="items" class="_popup _shadow" :align="'left'" @close="$emit('closed')"/> + <MkMenu :items="items" :align="'left'" @close="$emit('closed')"/> </div> </transition> </template> <script lang="ts" setup> import { onMounted, onBeforeUnmount } from 'vue'; -import contains from '@/scripts/contains'; -import MkMenu from './menu.vue'; +import MkMenu from './MkMenu.vue'; import { MenuItem } from './types/menu.vue'; +import contains from '@/scripts/contains'; import * as os from '@/os'; const props = defineProps<{ diff --git a/packages/client/src/components/cropper-dialog.vue b/packages/client/src/components/MkCropperDialog.vue index a8bde6ea05..4b05a51252 100644 --- a/packages/client/src/components/cropper-dialog.vue +++ b/packages/client/src/components/MkCropperDialog.vue @@ -9,7 +9,7 @@ @ok="ok()" @closed="$emit('closed')" > - <template #header>{{ $ts.cropImage }}</template> + <template #header>{{ i18n.ts.cropImage }}</template> <template #default="{ width, height }"> <div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`"> <Transition name="fade"> @@ -30,12 +30,13 @@ import { nextTick, onMounted } from 'vue'; import * as misskey from 'misskey-js'; import Cropper from 'cropperjs'; import tinycolor from 'tinycolor2'; -import XModalWindow from '@/components/ui/modal-window.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; import * as os from '@/os'; import { $i } from '@/account'; import { defaultStore } from '@/store'; -import { apiUrl, url } from '@/config'; -import { query } from '@/scripts/url'; +import { apiUrl } from '@/config'; +import { i18n } from '@/i18n'; +import { getProxiedImageUrl } from '@/scripts/media-proxy'; const emit = defineEmits<{ (ev: 'ok', cropped: misskey.entities.DriveFile): void; @@ -48,9 +49,7 @@ const props = defineProps<{ aspectRatio: number; }>(); -const imgUrl = `${url}/proxy/image.webp?${query({ - url: props.file.url, -})}`; +const imgUrl = getProxiedImageUrl(props.file.url); let dialogEl = $ref<InstanceType<typeof XModalWindow>>(); let imgEl = $ref<HTMLImageElement>(); let cropper: Cropper | null = null; @@ -71,10 +70,10 @@ const ok = async () => { method: 'POST', body: formData, }) - .then(response => response.json()) - .then(f => { - res(f); - }); + .then(response => response.json()) + .then(f => { + res(f); + }); }); }); diff --git a/packages/client/src/components/cw-button.vue b/packages/client/src/components/MkCwButton.vue index dd906f9bf3..dd906f9bf3 100644 --- a/packages/client/src/components/cw-button.vue +++ b/packages/client/src/components/MkCwButton.vue diff --git a/packages/client/src/components/date-separated-list.vue b/packages/client/src/components/MkDateSeparatedList.vue index 085ef871e0..f63d9782b6 100644 --- a/packages/client/src/components/date-separated-list.vue +++ b/packages/client/src/components/MkDateSeparatedList.vue @@ -1,6 +1,6 @@ <script lang="ts"> import { defineComponent, h, PropType, TransitionGroup } from 'vue'; -import MkAd from '@/components/global/ad.vue'; +import MkAd from '@/components/global/MkAd.vue'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; @@ -13,22 +13,22 @@ export default defineComponent({ direction: { type: String, required: false, - default: 'down' + default: 'down', }, reversed: { type: Boolean, required: false, - default: false + default: false, }, noGap: { type: Boolean, required: false, - default: false + default: false, }, ad: { type: Boolean, required: false, - default: false + default: false, }, }, @@ -38,7 +38,7 @@ export default defineComponent({ const month = new Date(time).getMonth() + 1; return i18n.t('monthAndDay', { month: month.toString(), - day: date.toString() + day: date.toString(), }); } @@ -48,7 +48,7 @@ export default defineComponent({ if (!slots || !slots.default) return; const el = slots.default({ - item: item + item: item, })[0]; if (el.key == null && item.id) el.key = item.id; @@ -60,20 +60,20 @@ export default defineComponent({ class: 'separator', key: item.id + ':separator', }, h('p', { - class: 'date' + class: 'date', }, [ h('span', [ h('i', { class: 'fas fa-angle-up icon', }), - getDateText(item.createdAt) + getDateText(item.createdAt), ]), h('span', [ getDateText(props.items[i + 1].createdAt), h('i', { class: 'fas fa-angle-down icon', - }) - ]) + }), + ]), ])); return [el, separator]; @@ -93,16 +93,16 @@ export default defineComponent({ return () => h( defaultStore.state.animation ? TransitionGroup : 'div', defaultStore.state.animation ? { - class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), - name: 'list', - tag: 'div', - 'data-direction': props.direction, - 'data-reversed': props.reversed ? 'true' : 'false', - } : { - class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), - }, + class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), + name: 'list', + tag: 'div', + 'data-direction': props.direction, + 'data-reversed': props.reversed ? 'true' : 'false', + } : { + class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), + }, { default: renderChildren }); - } + }, }); </script> diff --git a/packages/client/src/components/dialog.vue b/packages/client/src/components/MkDialog.vue index b090f3cb4e..155473cd75 100644 --- a/packages/client/src/components/dialog.vue +++ b/packages/client/src/components/MkDialog.vue @@ -40,8 +40,8 @@ <script lang="ts" setup> import { onBeforeUnmount, onMounted, ref } from 'vue'; -import MkModal from '@/components/ui/modal.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkModal from '@/components/MkModal.vue'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; import MkSelect from '@/components/form/select.vue'; import { i18n } from '@/i18n'; diff --git a/packages/client/src/components/MkDigitalClock.vue b/packages/client/src/components/MkDigitalClock.vue new file mode 100644 index 0000000000..9ed8d63d19 --- /dev/null +++ b/packages/client/src/components/MkDigitalClock.vue @@ -0,0 +1,77 @@ +<template> +<span class="zjobosdg"> + <span v-text="hh"></span> + <span class="colon" :class="{ showColon }">:</span> + <span v-text="mm"></span> + <span v-if="showS" class="colon" :class="{ showColon }">:</span> + <span v-if="showS" v-text="ss"></span> + <span v-if="showMs" class="colon" :class="{ showColon }">:</span> + <span v-if="showMs" v-text="ms"></span> +</span> +</template> + +<script lang="ts" setup> +import { onUnmounted, ref, watch } from 'vue'; + +const props = withDefaults(defineProps<{ + showS?: boolean; + showMs?: boolean; + offset?: number; +}>(), { + showS: true, + showMs: false, + offset: 0 - new Date().getTimezoneOffset(), +}); + +let intervalId; +const hh = ref(''); +const mm = ref(''); +const ss = ref(''); +const ms = ref(''); +const showColon = ref(false); +let prevSec: number | null = null; + +watch(showColon, (v) => { + if (v) { + window.setTimeout(() => { + showColon.value = false; + }, 30); + } +}); + +const tick = () => { + const now = new Date(); + now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset)); + hh.value = now.getHours().toString().padStart(2, '0'); + mm.value = now.getMinutes().toString().padStart(2, '0'); + ss.value = now.getSeconds().toString().padStart(2, '0'); + ms.value = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0'); + if (now.getSeconds() !== prevSec) showColon.value = true; + prevSec = now.getSeconds(); +}; + +tick(); + +watch(() => props.showMs, () => { + if (intervalId) window.clearInterval(intervalId); + intervalId = window.setInterval(tick, props.showMs ? 10 : 1000); +}, { immediate: true }); + +onUnmounted(() => { + window.clearInterval(intervalId); +}); +</script> + +<style lang="scss" scoped> +.zjobosdg { + > .colon { + opacity: 0; + transition: opacity 1s ease; + + &.showColon { + opacity: 1; + transition: opacity 0s; + } + } +} +</style> diff --git a/packages/client/src/components/drive.file.vue b/packages/client/src/components/MkDrive.file.vue index aaf7ca3ca3..22916d5680 100644 --- a/packages/client/src/components/drive.file.vue +++ b/packages/client/src/components/MkDrive.file.vue @@ -1,5 +1,6 @@ <template> -<div class="ncvczrfv" +<div + class="ncvczrfv" :class="{ isSelected }" draggable="true" :title="title" @@ -34,7 +35,7 @@ import { computed, defineAsyncComponent, ref } from 'vue'; import * as Misskey from 'misskey-js'; import copyToClipboard from '@/scripts/copy-to-clipboard'; -import MkDriveFileThumbnail from './drive-file-thumbnail.vue'; +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import bytes from '@/filters/bytes'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -63,31 +64,31 @@ function getMenu() { return [{ text: i18n.ts.rename, icon: 'fas fa-i-cursor', - action: rename + action: rename, }, { text: props.file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, icon: props.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash', - action: toggleSensitive + action: toggleSensitive, }, { text: i18n.ts.describeFile, icon: 'fas fa-i-cursor', - action: describe + action: describe, }, null, { text: i18n.ts.copyUrl, icon: 'fas fa-link', - action: copyUrl + action: copyUrl, }, { type: 'a', href: props.file.url, target: '_blank', text: i18n.ts.download, icon: 'fas fa-download', - download: props.file.name + download: props.file.name, }, null, { text: i18n.ts.delete, icon: 'fas fa-trash-alt', danger: true, - action: deleteFile + action: deleteFile, }]; } @@ -127,35 +128,35 @@ function rename() { if (canceled) return; os.api('drive/files/update', { fileId: props.file.id, - name: name + name: name, }); }); } function describe() { - os.popup(defineAsyncComponent(() => import('@/components/media-caption.vue')), { + os.popup(defineAsyncComponent(() => import('@/components/MkMediaCaption.vue')), { title: i18n.ts.describeFile, input: { placeholder: i18n.ts.inputNewDescription, default: props.file.comment != null ? props.file.comment : '', }, - image: props.file + image: props.file, }, { done: result => { if (!result || result.canceled) return; let comment = result.result; os.api('drive/files/update', { fileId: props.file.id, - comment: comment.length === 0 ? null : comment + comment: comment.length === 0 ? null : comment, }); - } + }, }, 'closed'); } function toggleSensitive() { os.api('drive/files/update', { fileId: props.file.id, - isSensitive: !props.file.isSensitive + isSensitive: !props.file.isSensitive, }); } @@ -176,7 +177,7 @@ async function deleteFile() { if (canceled) return; os.api('drive/files/delete', { - fileId: props.file.id + fileId: props.file.id, }); } </script> diff --git a/packages/client/src/components/drive.folder.vue b/packages/client/src/components/MkDrive.folder.vue index 3ccb5d6219..6c522c0862 100644 --- a/packages/client/src/components/drive.folder.vue +++ b/packages/client/src/components/MkDrive.folder.vue @@ -1,5 +1,6 @@ <template> -<div class="rghtznwe" +<div + class="rghtznwe" :class="{ draghover }" draggable="true" :title="title" @@ -89,7 +90,22 @@ function onDragover(ev: DragEvent) { const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; if (isFile || isDriveFile || isDriveFolder) { - ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; + switch (ev.dataTransfer.effectAllowed) { + case 'all': + case 'uninitialized': + case 'copy': + case 'copyLink': + case 'copyMove': + ev.dataTransfer.dropEffect = 'copy'; + break; + case 'linkMove': + case 'move': + ev.dataTransfer.dropEffect = 'move'; + break; + default: + ev.dataTransfer.dropEffect = 'none'; + break; + } } else { ev.dataTransfer.dropEffect = 'none'; } @@ -123,7 +139,7 @@ function onDrop(ev: DragEvent) { emit('removeFile', file.id); os.api('drive/files/update', { fileId: file.id, - folderId: props.folder.id + folderId: props.folder.id, }); } //#endregion @@ -139,7 +155,7 @@ function onDrop(ev: DragEvent) { emit('removeFolder', folder.id); os.api('drive/folders/update', { folderId: folder.id, - parentId: props.folder.id + parentId: props.folder.id, }).then(() => { // noop }).catch(err => { @@ -147,13 +163,13 @@ function onDrop(ev: DragEvent) { 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, }); } }); @@ -186,19 +202,19 @@ function rename() { os.inputText({ title: i18n.ts.renameFolder, placeholder: i18n.ts.inputNewFolderName, - default: props.folder.name + default: props.folder.name, }).then(({ canceled, result: name }) => { if (canceled) return; os.api('drive/folders/update', { folderId: props.folder.id, - name: name + name: name, }); }); } function deleteFolder() { os.api('drive/folders/delete', { - folderId: props.folder.id + folderId: props.folder.id, }).then(() => { if (defaultStore.state.uploadFolder === props.folder.id) { defaultStore.set('uploadFolder', null); @@ -209,13 +225,13 @@ function deleteFolder() { 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, }); } }); @@ -230,11 +246,11 @@ function onContextmenu(ev: MouseEvent) { text: i18n.ts.openInWindow, icon: 'fas fa-window-restore', action: () => { - os.popup(defineAsyncComponent(() => import('./drive-window.vue')), { - initialFolder: props.folder + os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), { + initialFolder: props.folder, }, { }, 'closed'); - } + }, }, null, { text: i18n.ts.rename, icon: 'fas fa-i-cursor', diff --git a/packages/client/src/components/drive.nav-folder.vue b/packages/client/src/components/MkDrive.navFolder.vue index 5482703317..455c14f95e 100644 --- a/packages/client/src/components/drive.nav-folder.vue +++ b/packages/client/src/components/MkDrive.navFolder.vue @@ -58,7 +58,22 @@ function onDragover(ev: DragEvent) { const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; if (isFile || isDriveFile || isDriveFolder) { - ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; + switch (ev.dataTransfer.effectAllowed) { + case 'all': + case 'uninitialized': + case 'copy': + case 'copyLink': + case 'copyMove': + ev.dataTransfer.dropEffect = 'copy'; + break; + case 'linkMove': + case 'move': + ev.dataTransfer.dropEffect = 'move'; + break; + default: + ev.dataTransfer.dropEffect = 'none'; + break; + } } else { ev.dataTransfer.dropEffect = 'none'; } diff --git a/packages/client/src/components/drive.vue b/packages/client/src/components/MkDrive.vue index 6c2c8acad0..c79ab97000 100644 --- a/packages/client/src/components/drive.vue +++ b/packages/client/src/components/MkDrive.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" @@ -89,10 +90,10 @@ <script lang="ts" setup> import { markRaw, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import XNavFolder from './drive.nav-folder.vue'; -import XFolder from './drive.folder.vue'; -import XFile from './drive.file.vue'; -import MkButton from './ui/button.vue'; +import MkButton from './MkButton.vue'; +import XNavFolder from '@/components/MkDrive.navFolder.vue'; +import XFolder from '@/components/MkDrive.folder.vue'; +import XFile from '@/components/MkDrive.file.vue'; import * as os from '@/os'; import { stream } from '@/stream'; import { defaultStore } from '@/store'; @@ -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)); @@ -195,7 +196,22 @@ function onDragover(ev: DragEvent): any { const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; if (isFile || isDriveFile || isDriveFolder) { - ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; + switch (ev.dataTransfer.effectAllowed) { + case 'all': + case 'uninitialized': + case 'copy': + case 'copyLink': + case 'copyMove': + ev.dataTransfer.dropEffect = 'copy'; + break; + case 'linkMove': + case 'move': + ev.dataTransfer.dropEffect = 'move'; + break; + default: + ev.dataTransfer.dropEffect = 'none'; + break; + } } else { ev.dataTransfer.dropEffect = 'none'; } @@ -232,7 +248,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 +264,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 +272,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 +294,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 +312,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 +328,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 +343,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 +353,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 +427,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 +526,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 +538,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 +565,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 +585,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 +678,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/drive-file-thumbnail.vue b/packages/client/src/components/MkDriveFileThumbnail.vue index b346585cec..de65d2f25b 100644 --- a/packages/client/src/components/drive-file-thumbnail.vue +++ b/packages/client/src/components/MkDriveFileThumbnail.vue @@ -17,7 +17,7 @@ <script lang="ts" setup> import { computed } from 'vue'; import * as Misskey from 'misskey-js'; -import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; const props = defineProps<{ file: Misskey.entities.DriveFile; diff --git a/packages/client/src/components/drive-select-dialog.vue b/packages/client/src/components/MkDriveSelectDialog.vue index 03974559d2..baab7f1324 100644 --- a/packages/client/src/components/drive-select-dialog.vue +++ b/packages/client/src/components/MkDriveSelectDialog.vue @@ -1,5 +1,6 @@ <template> -<XModalWindow ref="dialog" +<XModalWindow + ref="dialog" :width="800" :height="500" :with-ok-button="true" @@ -20,8 +21,8 @@ <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import XDrive from './drive.vue'; -import XModalWindow from '@/components/ui/modal-window.vue'; +import XDrive from '@/components/MkDrive.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; import number from '@/filters/number'; import { i18n } from '@/i18n'; diff --git a/packages/client/src/components/drive-window.vue b/packages/client/src/components/MkDriveWindow.vue index 5bbfca83c9..617200321b 100644 --- a/packages/client/src/components/drive-window.vue +++ b/packages/client/src/components/MkDriveWindow.vue @@ -1,5 +1,6 @@ <template> -<XWindow ref="window" +<XWindow + ref="window" :initial-width="800" :initial-height="500" :can-resize="true" @@ -13,10 +14,10 @@ </template> <script lang="ts" setup> -import { } from 'vue'; +import { } from 'vue'; import * as Misskey from 'misskey-js'; -import XDrive from './drive.vue'; -import XWindow from '@/components/ui/window.vue'; +import XDrive from '@/components/MkDrive.vue'; +import XWindow from '@/components/MkWindow.vue'; import { i18n } from '@/i18n'; defineProps<{ diff --git a/packages/client/src/components/emoji-picker.section.vue b/packages/client/src/components/MkEmojiPicker.section.vue index 52f7047487..e2a80d5466 100644 --- a/packages/client/src/components/emoji-picker.section.vue +++ b/packages/client/src/components/MkEmojiPicker.section.vue @@ -1,15 +1,17 @@ <template> +<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと --> <section> <header class="_acrylic" @click="shown = !shown"> <i class="toggle fa-fw" :class="shown ? 'fas fa-chevron-down' : 'fas fa-chevron-up'"></i> <slot></slot> ({{ emojis.length }}) </header> - <div v-if="shown"> - <button v-for="emoji in emojis" + <div v-if="shown" class="body"> + <button + v-for="emoji in emojis" :key="emoji" - class="_button" + class="_button item" @click="emit('chosen', emoji, $event)" > - <MkEmoji :emoji="emoji" :normal="true"/> + <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> </button> </div> </section> diff --git a/packages/client/src/components/emoji-picker.vue b/packages/client/src/components/MkEmojiPicker.vue index 64732e7033..3de0afbf50 100644 --- a/packages/client/src/components/emoji-picker.vue +++ b/packages/client/src/components/MkEmojiPicker.vue @@ -3,63 +3,67 @@ <input ref="search" v-model.trim="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @paste.stop="paste" @keyup.enter="done()"> <div ref="emojis" class="emojis"> <section class="result"> - <div v-if="searchResultCustom.length > 0"> - <button v-for="emoji in searchResultCustom" + <div v-if="searchResultCustom.length > 0" class="body"> + <button + v-for="emoji in searchResultCustom" :key="emoji.id" - class="_button" + class="_button item" :title="emoji.name" tabindex="0" @click="chosen(emoji, $event)" > <!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>--> - <img :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + <img class="emoji" :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> </button> </div> - <div v-if="searchResultUnicode.length > 0"> - <button v-for="emoji in searchResultUnicode" + <div v-if="searchResultUnicode.length > 0" class="body"> + <button + v-for="emoji in searchResultUnicode" :key="emoji.name" - class="_button" + class="_button item" :title="emoji.name" tabindex="0" @click="chosen(emoji, $event)" > - <MkEmoji :emoji="emoji.char"/> + <MkEmoji class="emoji" :emoji="emoji.char"/> </button> </div> </section> - <div v-if="tab === 'index'" class="index"> + <div v-if="tab === 'index'" class="group index"> <section v-if="showPinned"> - <div> - <button v-for="emoji in pinned" + <div class="body"> + <button + v-for="emoji in pinned" :key="emoji" - class="_button" + class="_button item" tabindex="0" @click="chosen(emoji, $event)" > - <MkEmoji :emoji="emoji" :normal="true"/> + <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> </button> </div> </section> <section> <header class="_acrylic"><i class="far fa-clock fa-fw"></i> {{ i18n.ts.recentUsed }}</header> - <div> - <button v-for="emoji in recentlyUsedEmojis" + <div class="body"> + <button + v-for="emoji in recentlyUsedEmojis" :key="emoji" - class="_button" + class="_button item" @click="chosen(emoji, $event)" > - <MkEmoji :emoji="emoji" :normal="true"/> + <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> </button> </div> </section> </div> - <div> + <div v-once class="group"> <header class="_acrylic">{{ i18n.ts.customEmojis }}</header> <XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')" @chosen="chosen">{{ category || i18n.ts.other }}</XSection> </div> - <div> + <div v-once class="group"> <header class="_acrylic">{{ i18n.ts.emoji }}</header> <XSection v-for="category in categories" :key="category" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)" @chosen="chosen">{{ category }}</XSection> </div> @@ -76,14 +80,14 @@ <script lang="ts" setup> import { ref, computed, watch, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; +import XSection from '@/components/MkEmojiPicker.section.vue'; import { emojilist, UnicodeEmojiDef, unicodeEmojiCategories as categories } from '@/scripts/emojilist'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; -import Ripple from '@/components/ripple.vue'; +import Ripple from '@/components/MkRipple.vue'; import * as os from '@/os'; import { isTouchUsing } from '@/scripts/touch'; import { deviceKind } from '@/scripts/device-kind'; import { emojiCategories, instance } from '@/instance'; -import XSection from './emoji-picker.section.vue'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; @@ -266,7 +270,7 @@ watch(q, () => { function focus() { if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) { search.value?.focus({ - preventScroll: true + preventScroll: true, }); } } @@ -415,19 +419,16 @@ defineExpose({ font-size: 15px; } - > div { + > .body { display: grid; grid-template-columns: var(--columns); + font-size: 30px; - > button { + > .item { aspect-ratio: 1 / 1; width: auto; height: auto; min-width: 0; - - > * { - font-size: 30px; - } } } } @@ -478,7 +479,7 @@ defineExpose({ display: none; } - > div { + > .group { &:not(.index) { padding: 4px 0 8px 0; border-top: solid 0.5px var(--divider); @@ -513,16 +514,18 @@ defineExpose({ } } - > div { + > .body { position: relative; padding: $pad; - > button { + > .item { position: relative; padding: 0; width: var(--eachSize); height: var(--eachSize); + contain: strict; border-radius: 4px; + font-size: 24px; &:focus-visible { outline: solid 2px var(--focus); @@ -538,8 +541,7 @@ defineExpose({ box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); } - > * { - font-size: 24px; + > .emoji { height: 1.25em; vertical-align: -.25em; pointer-events: none; diff --git a/packages/client/src/components/emoji-picker-dialog.vue b/packages/client/src/components/MkEmojiPickerDialog.vue index 2c0b2e9a8b..3b41f9d75b 100644 --- a/packages/client/src/components/emoji-picker-dialog.vue +++ b/packages/client/src/components/MkEmojiPickerDialog.vue @@ -27,8 +27,8 @@ <script lang="ts" setup> import { ref } from 'vue'; -import MkModal from '@/components/ui/modal.vue'; -import MkEmojiPicker from '@/components/emoji-picker.vue'; +import MkModal from '@/components/MkModal.vue'; +import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; import { defaultStore } from '@/store'; withDefaults(defineProps<{ diff --git a/packages/client/src/components/emoji-picker-window.vue b/packages/client/src/components/MkEmojiPickerWindow.vue index 610690d701..523e4ba695 100644 --- a/packages/client/src/components/emoji-picker-window.vue +++ b/packages/client/src/components/MkEmojiPickerWindow.vue @@ -13,8 +13,8 @@ <script lang="ts" setup> import { } from 'vue'; -import MkWindow from '@/components/ui/window.vue'; -import MkEmojiPicker from '@/components/emoji-picker.vue'; +import MkWindow from '@/components/MkWindow.vue'; +import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; withDefaults(defineProps<{ src?: HTMLElement; diff --git a/packages/client/src/components/featured-photos.vue b/packages/client/src/components/MkFeaturedPhotos.vue index e58b5d2849..e58b5d2849 100644 --- a/packages/client/src/components/featured-photos.vue +++ b/packages/client/src/components/MkFeaturedPhotos.vue diff --git a/packages/client/src/components/MkFileListForAdmin.vue b/packages/client/src/components/MkFileListForAdmin.vue new file mode 100644 index 0000000000..b6429eaf8d --- /dev/null +++ b/packages/client/src/components/MkFileListForAdmin.vue @@ -0,0 +1,118 @@ +<template> +<div> + <MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }"> + <MkA + v-for="file in items" + :key="file.id" + v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${new Date(file.createdAt).toLocaleString()}\nby ${file.user ? '@' + Acct.toString(file.user) : 'system'}`" + :to="`/admin/file/${file.id}`" + class="file _button" + > + <div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + <div v-if="viewMode === 'list'" class="body"> + <div> + <small style="opacity: 0.7;">{{ file.name }}</small> + </div> + <div> + <MkAcct v-if="file.user" :user="file.user"/> + <div v-else>{{ i18n.ts.system }}</div> + </div> + <div> + <span style="margin-right: 1em;">{{ file.type }}</span> + <span>{{ bytes(file.size) }}</span> + </div> + <div> + <span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span> + </div> + </div> + </MkA> + </MkPagination> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import MkSwitch from '@/components/ui/switch.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + pagination: any; + viewMode: 'grid' | 'list'; +}>(); +</script> + +<style lang="scss" scoped> +@keyframes sensitive-blink { + 0% { opacity: 1; } + 50% { opacity: 0; } +} + +.urempief { + margin-top: var(--margin); + + &.list { + > .file { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + + &:hover { + color: var(--accent); + } + + > .thumbnail { + width: 128px; + height: 128px; + } + + > .body { + margin-left: 0.3em; + padding: 8px; + flex: 1; + + @media (max-width: 500px) { + font-size: 14px; + } + } + } + } + + &.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + grid-gap: 12px; + margin: var(--margin) 0; + + > .file { + position: relative; + aspect-ratio: 1; + + > .thumbnail { + width: 100%; + height: 100%; + } + + > .sensitive-label { + position: absolute; + z-index: 10; + top: 8px; + left: 8px; + padding: 2px 4px; + background: #ff0000bf; + color: #fff; + border-radius: 4px; + font-size: 85%; + animation: sensitive-blink 1s infinite; + } + } + } +} +</style> diff --git a/packages/client/src/components/file-type-icon.vue b/packages/client/src/components/MkFileTypeIcon.vue index 11d28188cc..11d28188cc 100644 --- a/packages/client/src/components/file-type-icon.vue +++ b/packages/client/src/components/MkFileTypeIcon.vue diff --git a/packages/client/src/components/ui/folder.vue b/packages/client/src/components/MkFolder.vue index 7daa82cbd3..7daa82cbd3 100644 --- a/packages/client/src/components/ui/folder.vue +++ b/packages/client/src/components/MkFolder.vue diff --git a/packages/client/src/components/follow-button.vue b/packages/client/src/components/MkFollowButton.vue index efee795e43..efee795e43 100644 --- a/packages/client/src/components/follow-button.vue +++ b/packages/client/src/components/MkFollowButton.vue diff --git a/packages/client/src/components/forgot-password.vue b/packages/client/src/components/MkForgotPassword.vue index 19c1f23c85..1b55451c94 100644 --- a/packages/client/src/components/forgot-password.vue +++ b/packages/client/src/components/MkForgotPassword.vue @@ -9,12 +9,12 @@ <form v-if="instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit"> <div class="main _formRoot"> - <MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required> + <MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required> <template #label>{{ i18n.ts.username }}</template> <template #prefix>@</template> </MkInput> - <MkInput v-model="email" class="_formBlock" type="email" spellcheck="false" required> + <MkInput v-model="email" class="_formBlock" type="email" :spellcheck="false" required> <template #label>{{ i18n.ts.emailAddress }}</template> <template #caption>{{ i18n.ts._forgotPassword.enterEmail }}</template> </MkInput> @@ -33,8 +33,8 @@ <script lang="ts" setup> import { } from 'vue'; -import XModalWindow from '@/components/ui/modal-window.vue'; -import MkButton from '@/components/ui/button.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; import * as os from '@/os'; import { instance } from '@/instance'; diff --git a/packages/client/src/components/form-dialog.vue b/packages/client/src/components/MkFormDialog.vue index 11459f5937..b2bf76a8c7 100644 --- a/packages/client/src/components/form-dialog.vue +++ b/packages/client/src/components/MkFormDialog.vue @@ -1,5 +1,6 @@ <template> -<XModalWindow ref="dialog" +<XModalWindow + ref="dialog" :width="450" :can-close="false" :with-ok-button="true" @@ -37,10 +38,10 @@ <option v-for="item in form[item].enum" :key="item.value" :value="item.value">{{ item.label }}</option> </FormSelect> <FormRadios v-else-if="form[item].type === 'radio'" v-model="values[item]" class="_formBlock"> - <template #caption><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <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].options" :key="item.value" :value="item.value">{{ item.label }}</option> </FormRadios> - <FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].mim" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter" class="_formBlock"> + <FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter" class="_formBlock"> <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" #caption>{{ form[item].description }}</template> </FormRange> @@ -55,14 +56,14 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import XModalWindow from '@/components/ui/modal-window.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 MkButton from './ui/button.vue'; +import MkButton from './MkButton.vue'; import FormRadios from './form/radios.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; export default defineComponent({ components: { @@ -91,31 +92,31 @@ export default defineComponent({ data() { return { - values: {} + values: {}, }; }, created() { for (const item in this.form) { - this.values[item] = this.form[item].hasOwnProperty('default') ? this.form[item].default : null; + this.values[item] = this.form[item].default ?? null; } }, methods: { ok() { this.$emit('done', { - result: this.values + result: this.values, }); this.$refs.dialog.close(); }, cancel() { this.$emit('done', { - canceled: true + canceled: true, }); this.$refs.dialog.close(); - } - } + }, + }, }); </script> diff --git a/packages/client/src/components/MkFormula.vue b/packages/client/src/components/MkFormula.vue new file mode 100644 index 0000000000..65a2fee930 --- /dev/null +++ b/packages/client/src/components/MkFormula.vue @@ -0,0 +1,24 @@ +<template> +<XFormula :formula="formula" :block="block"/> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XFormula: defineAsyncComponent(() => import('@/components/MkFormulaCore.vue')), + }, + props: { + formula: { + type: String, + required: true, + }, + block: { + type: Boolean, + required: true, + }, + }, +}); +</script> diff --git a/packages/client/src/components/formula-core.vue b/packages/client/src/components/MkFormulaCore.vue index 49a61ab80e..8db8932fcd 100644 --- a/packages/client/src/components/formula-core.vue +++ b/packages/client/src/components/MkFormulaCore.vue @@ -1,4 +1,4 @@ - +<!-- eslint-disable vue/no-v-html --> <template> <div v-if="block" v-html="compiledFormula"></div> <span v-else v-html="compiledFormula"></span> diff --git a/packages/client/src/components/gallery-post-preview.vue b/packages/client/src/components/MkGalleryPostPreview.vue index 8245902976..a133f6431b 100644 --- a/packages/client/src/components/gallery-post-preview.vue +++ b/packages/client/src/components/MkGalleryPostPreview.vue @@ -14,26 +14,15 @@ </MkA> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import { userName } from '@/filters/user'; -import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import * as os from '@/os'; -export default defineComponent({ - components: { - ImgWithBlurhash - }, - props: { - post: { - type: Object, - required: true - }, - }, - methods: { - userName - } -}); +const props = defineProps<{ + post: any; +}>(); </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/google.vue b/packages/client/src/components/MkGoogle.vue index bb4b439ee8..bb4b439ee8 100644 --- a/packages/client/src/components/google.vue +++ b/packages/client/src/components/MkGoogle.vue diff --git a/packages/client/src/components/image-viewer.vue b/packages/client/src/components/MkImageViewer.vue index 7bc88399ef..f074b1a2f2 100644 --- a/packages/client/src/components/image-viewer.vue +++ b/packages/client/src/components/MkImageViewer.vue @@ -17,7 +17,7 @@ import { } from 'vue'; import * as misskey from 'misskey-js'; import bytes from '@/filters/bytes'; import number from '@/filters/number'; -import MkModal from '@/components/ui/modal.vue'; +import MkModal from '@/components/MkModal.vue'; const props = withDefaults(defineProps<{ image: misskey.entities.DriveFile; diff --git a/packages/client/src/components/img-with-blurhash.vue b/packages/client/src/components/MkImgWithBlurhash.vue index 06ad764403..80d7c201a4 100644 --- a/packages/client/src/components/img-with-blurhash.vue +++ b/packages/client/src/components/MkImgWithBlurhash.vue @@ -11,7 +11,7 @@ import { decode } from 'blurhash'; const props = withDefaults(defineProps<{ src?: string | null; - hash: string; + hash?: string; alt?: string; title?: string | null; size?: number; diff --git a/packages/client/src/components/ui/info.vue b/packages/client/src/components/MkInfo.vue index 8f5986baf7..4fdfc5c5e6 100644 --- a/packages/client/src/components/ui/info.vue +++ b/packages/client/src/components/MkInfo.vue @@ -6,23 +6,12 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@/os'; +<script lang="ts" setup> +import { } from 'vue'; -export default defineComponent({ - props: { - warn: { - type: Boolean, - required: false, - default: false - }, - }, - data() { - return { - }; - } -}); +const props = defineProps<{ + warn?: boolean; +}>(); </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/instance-info.vue b/packages/client/src/components/MkInstanceCardMini.vue index e55c1d8215..f6e2f4eaa7 100644 --- a/packages/client/src/components/instance-info.vue +++ b/packages/client/src/components/MkInstanceCardMini.vue @@ -2,26 +2,28 @@ <div :class="[$style.root, { yellow: instance.isNotResponding, red: instance.isBlocked, gray: instance.isSuspended }]"> <img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/> <div class="body"> - <span class="host">{{ instance.host }}</span> - <span class="sub">{{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</span> + <span class="host">{{ instance.name ?? instance.host }}</span> + <span class="sub _monospace"><b>{{ instance.host }}</b> / {{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</span> </div> - <MkMiniChart v-if="chart" class="chart" :src="chart.requests.received"/> + <MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/> </div> </template> <script lang="ts" setup> import * as misskey from 'misskey-js'; -import MkMiniChart from '@/components/mini-chart.vue'; +import MkMiniChart from '@/components/MkMiniChart.vue'; import * as os from '@/os'; const props = defineProps<{ instance: misskey.entities.Instance; }>(); -const chart = $ref(null); +let chartValues = $ref<number[] | null>(null); -os.api('charts/instance', { host: props.instance.host, limit: 16, span: 'hour' }).then(res => { - chart = res; +os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => { + // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く + res.requests.received.splice(0, 1); + chartValues = res.requests.received; }); </script> @@ -42,7 +44,7 @@ os.api('charts/instance', { host: props.instance.host, limit: 16, span: 'hour' } height: ($bodyTitleHieght + $bodyInfoHieght); object-fit: cover; border-radius: 4px; - margin-right: 8px; + margin-right: 10px; } > :global(.body) { @@ -62,7 +64,9 @@ os.api('charts/instance', { host: props.instance.host, limit: 16, span: 'hour' } } > :global(.sub) { - font-size: 75%; + display: block; + width: 100%; + font-size: 80%; opacity: 0.7; line-height: $bodyInfoHieght; white-space: nowrap; diff --git a/packages/client/src/components/MkInstanceStats.vue b/packages/client/src/components/MkInstanceStats.vue new file mode 100644 index 0000000000..0437e05fad --- /dev/null +++ b/packages/client/src/components/MkInstanceStats.vue @@ -0,0 +1,220 @@ +<template> +<div class="zbcjwnqg"> + <div class="main"> + <div class="body"> + <div class="selects" style="display: flex;"> + <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> + <optgroup :label="i18n.ts.federation"> + <option value="federation">{{ i18n.ts._charts.federation }}</option> + <option value="ap-request">{{ i18n.ts._charts.apRequest }}</option> + </optgroup> + <optgroup :label="i18n.ts.users"> + <option value="users">{{ i18n.ts._charts.usersIncDec }}</option> + <option value="users-total">{{ i18n.ts._charts.usersTotal }}</option> + <option value="active-users">{{ i18n.ts._charts.activeUsers }}</option> + </optgroup> + <optgroup :label="i18n.ts.notes"> + <option value="notes">{{ i18n.ts._charts.notesIncDec }}</option> + <option value="local-notes">{{ i18n.ts._charts.localNotesIncDec }}</option> + <option value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option> + <option value="notes-total">{{ i18n.ts._charts.notesTotal }}</option> + </optgroup> + <optgroup :label="i18n.ts.drive"> + <option value="drive-files">{{ i18n.ts._charts.filesIncDec }}</option> + <option value="drive">{{ i18n.ts._charts.storageUsageIncDec }}</option> + </optgroup> + </MkSelect> + <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> + <option value="hour">{{ i18n.ts.perHour }}</option> + <option value="day">{{ i18n.ts.perDay }}</option> + </MkSelect> + </div> + <div class="chart"> + <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart> + </div> + </div> + </div> + <div class="subpub"> + <div class="sub"> + <div class="title">Sub</div> + <canvas ref="subDoughnutEl"></canvas> + </div> + <div class="pub"> + <div class="title">Pub</div> + <canvas ref="pubDoughnutEl"></canvas> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + DoughnutController, +} from 'chart.js'; +import MkSelect from '@/components/form/select.vue'; +import MkChart from '@/components/MkChart.vue'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + DoughnutController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = withDefaults(defineProps<{ + chartLimit?: number; + detailed?: boolean; +}>(), { + chartLimit: 90, +}); + +const chartSpan = $ref<'hour' | 'day'>('hour'); +const chartSrc = $ref('active-users'); +let subDoughnutEl = $ref<HTMLCanvasElement>(); +let pubDoughnutEl = $ref<HTMLCanvasElement>(); + +const { handler: externalTooltipHandler1 } = useChartTooltip(); +const { handler: externalTooltipHandler2 } = useChartTooltip(); + +function createDoughnut(chartEl, tooltip, data) { + const chartInstance = new Chart(chartEl, { + type: 'doughnut', + data: { + labels: data.map(x => x.name), + datasets: [{ + backgroundColor: data.map(x => x.color), + borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'), + borderWidth: 2, + hoverOffset: 0, + data: data.map(x => x.value), + }], + }, + options: { + maintainAspectRatio: false, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 16, + }, + }, + onClick: (ev) => { + const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; + if (hit && data[hit.index].onClick) { + data[hit.index].onClick(); + } + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: tooltip, + }, + }, + }, + }); + + return chartInstance; +} + +onMounted(() => { + os.apiGet('federation/stats', { limit: 30 }).then(fedStats => { + createDoughnut(subDoughnutEl, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followersCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowersCount }])); + + createDoughnut(pubDoughnutEl, externalTooltipHandler2, fedStats.topPubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followingCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowingCount }])); + }); +}); +</script> + +<style lang="scss" scoped> +.zbcjwnqg { + > .main { + background: var(--panel); + border-radius: var(--radius); + padding: 24px; + margin-bottom: 16px; + + > .body { + > .chart { + padding: 8px 0 0 0; + } + } + } + + > .subpub { + display: flex; + gap: 16px; + + > .sub, > .pub { + flex: 1; + min-width: 0; + position: relative; + background: var(--panel); + border-radius: var(--radius); + padding: 24px; + max-height: 300px; + + > .title { + position: absolute; + top: 24px; + left: 24px; + } + } + + @media (max-width: 600px) { + flex-direction: column; + } + } +} +</style> diff --git a/packages/client/src/components/instance-ticker.vue b/packages/client/src/components/MkInstanceTicker.vue index c32409ecf4..a5ff656f6d 100644 --- a/packages/client/src/components/instance-ticker.vue +++ b/packages/client/src/components/MkInstanceTicker.vue @@ -8,6 +8,8 @@ <script lang="ts" setup> import { } from 'vue'; import { instanceName } from '@/config'; +import { instance as Instance } from '@/instance'; +import { getProxiedImageUrlNullable } from '@/scripts/media-proxy'; const props = defineProps<{ instance?: { @@ -19,15 +21,15 @@ const props = defineProps<{ // if no instance data is given, this is for the local instance const instance = props.instance ?? { - faviconUrl: '/favicon.ico', + faviconUrl: getProxiedImageUrlNullable(Instance.iconUrl) ?? getProxiedImageUrlNullable(Instance.faviconUrl) ?? '/favicon.ico', name: instanceName, - themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content + themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content, }; const themeColor = instance.themeColor ?? '#777777'; const bg = { - background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)` + background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`, }; </script> diff --git a/packages/client/src/components/MkKeyValue.vue b/packages/client/src/components/MkKeyValue.vue new file mode 100644 index 0000000000..586f7a3f9d --- /dev/null +++ b/packages/client/src/components/MkKeyValue.vue @@ -0,0 +1,58 @@ +<template> +<div class="alqyeyti" :class="{ oneline }"> + <div class="key"> + <slot name="key"></slot> + </div> + <div class="value"> + <slot name="value"></slot> + <button v-if="copy" v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="far fa-copy"></i></button> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + copy?: string | null; + oneline?: boolean; +}>(), { + copy: null, + oneline: false, +}); + +const copy_ = () => { + copyToClipboard(props.copy); + os.success(); +}; +</script> + +<style lang="scss" scoped> +.alqyeyti { + > .key { + font-size: 0.85em; + padding: 0 0 0.25em 0; + opacity: 0.75; + } + + &.oneline { + display: flex; + + > .key { + width: 30%; + font-size: 1em; + padding: 0 8px 0 0; + } + + > .value { + width: 70%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } +} +</style> diff --git a/packages/client/src/components/launch-pad.vue b/packages/client/src/components/MkLaunchPad.vue index ffefc1b085..19283178c9 100644 --- a/packages/client/src/components/launch-pad.vue +++ b/packages/client/src/components/MkLaunchPad.vue @@ -15,32 +15,19 @@ </MkA> </template> </div> - <div class="sub"> - <a v-click-anime href="https://misskey-hub.net/help.html" target="_blank" @click.passive="close()"> - <i class="fas fa-question-circle icon"></i> - <div class="text">{{ $ts.help }}</div> - </a> - <MkA v-click-anime to="/about" @click.passive="close()"> - <i class="fas fa-info-circle icon"></i> - <div class="text">{{ $t('aboutX', { x: instanceName }) }}</div> - </MkA> - <MkA v-click-anime to="/about-misskey" @click.passive="close()"> - <img src="/static-assets/favicon.png" class="icon"/> - <div class="text">{{ $ts.aboutMisskey }}</div> - </MkA> - </div> </div> </MkModal> </template> <script lang="ts" setup> -import { } from 'vue'; -import MkModal from '@/components/ui/modal.vue'; -import { menuDef } from '@/menu'; +import { } from 'vue'; +import MkModal from '@/components/MkModal.vue'; +import { navbarItemDef } from '@/navbar'; import { instanceName } from '@/config'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; import { deviceKind } from '@/scripts/device-kind'; +import * as os from '@/os'; const props = withDefaults(defineProps<{ src?: HTMLElement; @@ -61,7 +48,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/link.vue b/packages/client/src/components/MkLink.vue index 846a9a3a76..649523abc2 100644 --- a/packages/client/src/components/link.vue +++ b/packages/client/src/components/MkLink.vue @@ -26,7 +26,7 @@ const target = self ? null : '_blank'; const el = $ref(); useTooltip($$(el), (showing) => { - os.popup(defineAsyncComponent(() => import('@/components/url-preview-popup.vue')), { + os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { showing, url: props.url, source: el, diff --git a/packages/client/src/components/MkMarquee.vue b/packages/client/src/components/MkMarquee.vue new file mode 100644 index 0000000000..5ca04b0b48 --- /dev/null +++ b/packages/client/src/components/MkMarquee.vue @@ -0,0 +1,106 @@ +<script lang="ts"> +import { h, onMounted, onUnmounted, ref, watch } from 'vue'; + +export default { + name: 'MarqueeText', + props: { + duration: { + type: Number, + default: 15, + }, + repeat: { + type: Number, + default: 2, + }, + paused: { + type: Boolean, + default: false, + }, + reverse: { + type: Boolean, + default: false, + }, + }, + setup(props) { + const contentEl = ref(); + + function calc() { + const eachLength = contentEl.value.offsetWidth / props.repeat; + const factor = 3000; + const duration = props.duration / ((1 / eachLength) * factor); + + contentEl.value.style.animationDuration = `${duration}s`; + } + + watch(() => props.duration, calc); + + onMounted(() => { + calc(); + }); + + onUnmounted(() => { + }); + + return { + contentEl, + }; + }, + render({ + $slots, $style, $props: { + duration, repeat, paused, reverse, + }, + }) { + return h('div', { class: [$style.wrap] }, [ + h('span', { + ref: 'contentEl', + class: [ + paused + ? $style.paused + : undefined, + $style.content, + ], + }, Array(repeat).fill( + h('span', { + class: $style.text, + style: { + animationDirection: reverse + ? 'reverse' + : undefined, + }, + }, $slots.default()), + )), + ]); + }, +}; +</script> + +<style lang="scss" module> +.wrap { + overflow: clip; + animation-play-state: running; + + &:hover { + animation-play-state: paused; + } +} +.content { + display: inline-block; + white-space: nowrap; + animation-play-state: inherit; +} +.text { + display: inline-block; + animation-name: marquee; + animation-timing-function: linear; + animation-iteration-count: infinite; + animation-duration: inherit; + animation-play-state: inherit; +} +.paused .text { + animation-play-state: paused; +} +@keyframes marquee { + 0% { transform:translateX(0); } + 100% { transform:translateX(-100%); } +} +</style> diff --git a/packages/client/src/components/media-banner.vue b/packages/client/src/components/MkMediaBanner.vue index 5093f11e97..5093f11e97 100644 --- a/packages/client/src/components/media-banner.vue +++ b/packages/client/src/components/MkMediaBanner.vue diff --git a/packages/client/src/components/media-caption.vue b/packages/client/src/components/MkMediaCaption.vue index feed3854f9..c25755d762 100644 --- a/packages/client/src/components/media-caption.vue +++ b/packages/client/src/components/MkMediaCaption.vue @@ -30,8 +30,8 @@ <script lang="ts"> import { defineComponent } from 'vue'; import { length } from 'stringz'; -import MkModal from '@/components/ui/modal.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkModal from '@/components/MkModal.vue'; +import MkButton from '@/components/MkButton.vue'; import bytes from '@/filters/bytes'; import number from '@/filters/number'; diff --git a/packages/client/src/components/media-image.vue b/packages/client/src/components/MkMediaImage.vue index 43639f6771..92f1bd2dbd 100644 --- a/packages/client/src/components/media-image.vue +++ b/packages/client/src/components/MkMediaImage.vue @@ -2,9 +2,9 @@ <div v-if="hide" class="qjewsnkg" @click="hide = false"> <ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/> <div class="text"> - <div> - <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> - <span>{{ $ts.clickToShow }}</span> + <div class="wrapper"> + <b style="display: block;"><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> + <span style="display: block;">{{ $ts.clickToShow }}</span> </div> </div> </div> @@ -24,7 +24,7 @@ import { watch } from 'vue'; import * as misskey from 'misskey-js'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; -import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import { defaultStore } from '@/store'; const props = defineProps<{ @@ -37,8 +37,8 @@ let hide = $ref(true); const url = (props.raw || defaultStore.state.loadRawImages) ? props.image.url : defaultStore.state.disableShowingAnimatedImages - ? getStaticImageUrl(props.image.thumbnailUrl) - : props.image.thumbnailUrl; + ? getStaticImageUrl(props.image.thumbnailUrl) + : props.image.thumbnailUrl; // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする watch(() => props.image, () => { @@ -68,15 +68,11 @@ watch(() => props.image, () => { justify-content: center; align-items: center; - > div { + > .wrapper { display: table-cell; text-align: center; font-size: 0.8em; color: #fff; - - > * { - display: block; - } } } } diff --git a/packages/client/src/components/media-list.vue b/packages/client/src/components/MkMediaList.vue index 7e330575e1..c6f8612182 100644 --- a/packages/client/src/components/media-list.vue +++ b/packages/client/src/components/MkMediaList.vue @@ -18,9 +18,9 @@ import * as misskey from 'misskey-js'; import PhotoSwipeLightbox from 'photoswipe/lightbox'; import PhotoSwipe from 'photoswipe'; import 'photoswipe/style.css'; -import XBanner from './media-banner.vue'; -import XImage from './media-image.vue'; -import XVideo from './media-video.vue'; +import XBanner from '@/components/MkMediaBanner.vue'; +import XImage from '@/components/MkMediaImage.vue'; +import XVideo from '@/components/MkMediaVideo.vue'; import * as os from '@/os'; import { FILE_TYPE_BROWSERSAFE } from '@/const'; import { defaultStore } from '@/store'; diff --git a/packages/client/src/components/media-video.vue b/packages/client/src/components/MkMediaVideo.vue index 5c38691e69..5c38691e69 100644 --- a/packages/client/src/components/media-video.vue +++ b/packages/client/src/components/MkMediaVideo.vue diff --git a/packages/client/src/components/MkMention.vue b/packages/client/src/components/MkMention.vue new file mode 100644 index 0000000000..3091b435e4 --- /dev/null +++ b/packages/client/src/components/MkMention.vue @@ -0,0 +1,66 @@ +<template> +<MkA v-if="url.startsWith('/')" v-user-preview="canonical" class="akbvjaqn" :class="{ isMe }" :to="url" :style="{ background: bgCss }"> + <img class="icon" :src="`/avatar/@${username}@${host}`" alt=""> + <span class="main"> + <span class="username">@{{ username }}</span> + <span v-if="(host != localHost) || $store.state.showFullAcct" class="host">@{{ toUnicode(host) }}</span> + </span> +</MkA> +<a v-else class="akbvjaqn" :href="url" target="_blank" rel="noopener" :style="{ background: bgCss }"> + <span class="main"> + <span class="username">@{{ username }}</span> + <span class="host">@{{ toUnicode(host) }}</span> + </span> +</a> +</template> + +<script lang="ts" setup> +import { toUnicode } from 'punycode'; +import { } from 'vue'; +import tinycolor from 'tinycolor2'; +import { host as localHost } from '@/config'; +import { $i } from '@/account'; + +const props = defineProps<{ + username: string; + host: string; +}>(); + +const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`; + +const url = `/${canonical}`; + +const isMe = $i && ( + `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() +); + +const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention')); +bg.setAlpha(0.1); +const bgCss = bg.toRgbString(); +</script> + +<style lang="scss" scoped> +.akbvjaqn { + display: inline-block; + padding: 4px 8px 4px 4px; + border-radius: 999px; + color: var(--mention); + + &.isMe { + color: var(--mentionMe); + } + + > .icon { + width: 1.5em; + height: 1.5em; + object-fit: cover; + margin: 0 0.2em 0 0; + vertical-align: bottom; + border-radius: 100%; + } + + > .main > .host { + opacity: 0.5; + } +} +</style> diff --git a/packages/client/src/components/MkMenu.child.vue b/packages/client/src/components/MkMenu.child.vue new file mode 100644 index 0000000000..3ada4afbdc --- /dev/null +++ b/packages/client/src/components/MkMenu.child.vue @@ -0,0 +1,65 @@ +<template> +<div ref="el" class="sfhdhdhr"> + <MkMenu ref="menu" :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/> +</div> +</template> + +<script lang="ts" setup> +import { on } from 'events'; +import { nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'; +import MkMenu from './MkMenu.vue'; +import { MenuItem } from '@/types/menu'; +import * as os from '@/os'; + +const props = defineProps<{ + items: MenuItem[]; + targetElement: HTMLElement; + rootElement: HTMLElement; + width?: number; + viaKeyboard?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; + (ev: 'actioned'): void; +}>(); + +const el = ref<HTMLElement>(); +const align = 'left'; + +function setPosition() { + const rootRect = props.rootElement.getBoundingClientRect(); + const rect = props.targetElement.getBoundingClientRect(); + const left = props.targetElement.offsetWidth; + const top = (rect.top - rootRect.top) - 8; + el.value.style.left = left + 'px'; + el.value.style.top = top + 'px'; +} + +function onChildClosed(actioned?: boolean) { + if (actioned) { + emit('actioned'); + } else { + emit('closed'); + } +} + +onMounted(() => { + setPosition(); + nextTick(() => { + setPosition(); + }); +}); + +defineExpose({ + checkHit: (ev: MouseEvent) => { + return (ev.target === el.value || el.value.contains(ev.target)); + }, +}); +</script> + +<style lang="scss" scoped> +.sfhdhdhr { + position: absolute; +} +</style> diff --git a/packages/client/src/components/MkMenu.vue b/packages/client/src/components/MkMenu.vue new file mode 100644 index 0000000000..578e736c83 --- /dev/null +++ b/packages/client/src/components/MkMenu.vue @@ -0,0 +1,364 @@ +<template> +<div> + <div + ref="itemsEl" v-hotkey="keymap" + class="rrevdjwt _popup _shadow" + :class="{ center: align === 'center', asDrawer }" + :style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }" + @contextmenu.self="e => e.preventDefault()" + > + <template v-for="(item, i) in items2"> + <div v-if="item === null" class="divider"></div> + <span v-else-if="item.type === 'label'" class="label item"> + <span>{{ item.text }}</span> + </span> + <span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item"> + <span><MkEllipsis/></span> + </span> + <MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <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> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </MkA> + <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> + <span>{{ item.text }}</span> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </a> + <button v-else-if="item.type === 'user'" :tabindex="i" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <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> + <span v-else-if="item.type === 'switch'" :tabindex="i" class="item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <FormSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</FormSwitch> + </span> + <button v-else-if="item.type === 'parent'" :tabindex="i" class="_button item parent" :class="{ childShowing: childShowingItem === item }" @mouseenter="showChildren(item, $event)"> + <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> + <span>{{ item.text }}</span> + <span class="caret"><i class="fas fa-caret-right fa-fw"></i></span> + </button> + <button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <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> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </button> + </template> + <span v-if="items2.length === 0" class="none item"> + <span>{{ i18n.ts.none }}</span> + </span> + </div> + <div v-if="childMenu" class="child"> + <XChild ref="child" :items="childMenu" :target-element="childTarget" :root-element="itemsEl" showing @actioned="childActioned"/> + </div> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, onUnmounted, Ref, ref, watch } from 'vue'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import FormSwitch from '@/components/form/switch.vue'; +import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const XChild = defineAsyncComponent(() => import('./MkMenu.child.vue')); + +const props = defineProps<{ + items: MenuItem[]; + viaKeyboard?: boolean; + asDrawer?: boolean; + align?: 'center' | string; + width?: number; + maxHeight?: number; +}>(); + +const emit = defineEmits<{ + (ev: 'close', actioned?: boolean): void; +}>(); + +let itemsEl = $ref<HTMLDivElement>(); + +let items2: InnerMenuItem[] = $ref([]); + +let child = $ref<InstanceType<typeof XChild>>(); + +let keymap = $computed(() => ({ + 'up|k|shift+tab': focusUp, + 'down|j|tab': focusDown, + 'esc': close, +})); + +let childShowingItem = $ref<MenuItem | null>(); + +watch(() => props.items, () => { + const items: (MenuItem | MenuPending)[] = [...props.items].filter(item => item !== undefined); + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + if (item && 'then' in item) { // if item is Promise + items[i] = { type: 'pending' }; + item.then(actualItem => { + items2[i] = actualItem; + }); + } + } + + items2 = items as InnerMenuItem[]; +}, { + immediate: true, +}); + +let childMenu = $ref<MenuItem[] | null>(); +let childTarget = $ref<HTMLElement | null>(); + +function closeChild() { + childMenu = null; + childShowingItem = null; +} + +function childActioned() { + closeChild(); + close(true); +} + +function onGlobalMousedown(event: MouseEvent) { + if (childTarget && (event.target === childTarget || childTarget.contains(event.target))) return; + if (child && child.checkHit(event)) return; + closeChild(); +} + +let childCloseTimer: null | number = null; +function onItemMouseEnter(item) { + childCloseTimer = window.setTimeout(() => { + closeChild(); + }, 300); +} +function onItemMouseLeave(item) { + if (childCloseTimer) window.clearTimeout(childCloseTimer); +} + +async function showChildren(item: MenuItem, ev: MouseEvent) { + if (props.asDrawer) { + os.popupMenu(item.children, ev.currentTarget ?? ev.target); + close(); + } else { + childTarget = ev.currentTarget ?? ev.target; + childMenu = item.children; + childShowingItem = item; + } +} + +function clicked(fn: MenuAction, ev: MouseEvent) { + fn(ev); + close(true); +} + +function close(actioned = false) { + emit('close', actioned); +} + +function focusUp() { + focusPrev(document.activeElement); +} + +function focusDown() { + focusNext(document.activeElement); +} + +onMounted(() => { + if (props.viaKeyboard) { + nextTick(() => { + focusNext(itemsEl.children[0], true, false); + }); + } + + document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); +}); + +onBeforeUnmount(() => { + document.removeEventListener('mousedown', onGlobalMousedown); +}); +</script> + +<style lang="scss" scoped> +.rrevdjwt { + padding: 8px 0; + box-sizing: border-box; + min-width: 200px; + overflow: auto; + overscroll-behavior: contain; + + &.center { + > .item { + text-align: center; + } + } + + > .item { + display: block; + position: relative; + padding: 6px 16px; + width: 100%; + box-sizing: border-box; + white-space: nowrap; + font-size: 0.9em; + line-height: 20px; + 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; + } + + &:not(:disabled):hover { + color: var(--accent); + text-decoration: none; + + &:before { + background: var(--accentedBg); + } + } + + &.danger { + color: #ff2a2a; + + &:hover { + color: #fff; + + &:before { + background: #ff4242; + } + } + + &:active { + color: #fff; + + &:before { + background: #d42e2e; + } + } + } + + &.active { + color: var(--fgOnAccent); + opacity: 1; + + &:before { + background: var(--accent); + } + } + + &:not(:active):focus-visible { + box-shadow: 0 0 0 2px var(--focus) inset; + } + + &.label { + pointer-events: none; + font-size: 0.7em; + padding-bottom: 4px; + + > span { + opacity: 0.7; + } + } + + &.pending { + pointer-events: none; + opacity: 0.7; + } + + &.none { + pointer-events: none; + opacity: 0.7; + } + + &.parent { + display: flex; + align-items: center; + cursor: default; + + > .caret { + margin-left: auto; + } + + &.childShowing { + color: var(--accent); + text-decoration: none; + + &:before { + background: var(--accentedBg); + } + } + } + + > i { + margin-right: 5px; + width: 20px; + } + + > .avatar { + margin-right: 5px; + width: 20px; + height: 20px; + } + + > .indicator { + position: absolute; + top: 5px; + left: 13px; + color: var(--indicator); + font-size: 12px; + animation: blink 1s infinite; + } + } + + > .divider { + margin: 8px 0; + border-top: solid 0.5px var(--divider); + } + + &.asDrawer { + padding: 12px 0 calc(env(safe-area-inset-bottom, 0px) + 12px) 0; + width: 100%; + border-radius: 24px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + + > .item { + font-size: 1em; + padding: 12px 24px; + + &:before { + width: calc(100% - 24px); + border-radius: 12px; + } + + > i { + margin-right: 14px; + width: 24px; + } + } + + > .divider { + margin: 12px 0; + } + } +} +</style> diff --git a/packages/client/src/components/mini-chart.vue b/packages/client/src/components/MkMiniChart.vue index 345b6a0b01..c64ce163f9 100644 --- a/packages/client/src/components/mini-chart.vue +++ b/packages/client/src/components/MkMiniChart.vue @@ -2,33 +2,25 @@ <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" style="overflow:visible"> <defs> <linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0"> - <stop offset="0%" stop-color="hsl(200, 80%, 70%)"></stop> - <stop offset="100%" stop-color="hsl(90, 80%, 70%)"></stop> + <stop offset="0%" :stop-color="color" stop-opacity="0"></stop> + <stop offset="100%" :stop-color="color" stop-opacity="0.65"></stop> </linearGradient> - <mask :id="maskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> - <polygon - :points="polygonPoints" - fill="#fff" - fill-opacity="0.5" - /> - <polyline - :points="polylinePoints" - fill="none" - stroke="#fff" - stroke-width="2" - /> - <circle - :cx="headX" - :cy="headY" - r="3" - fill="#fff" - /> - </mask> </defs> - <rect - x="-10" y="-10" - :width="viewBoxX + 20" :height="viewBoxY + 20" - :style="`stroke: none; fill: url(#${ gradientId }); mask: url(#${ maskId })`" + <polygon + :points="polygonPoints" + :style="`stroke: none; fill: url(#${ gradientId });`" + /> + <polyline + :points="polylinePoints" + fill="none" + :stroke="color" + stroke-width="2" + /> + <circle + :cx="headX" + :cy="headY" + r="3" + :fill="color" /> </svg> </template> @@ -36,6 +28,8 @@ <script lang="ts" setup> import { onUnmounted, watch } from 'vue'; import { v4 as uuid } from 'uuid'; +import tinycolor from 'tinycolor2'; +import { useInterval } from '@/scripts/use-interval'; const props = defineProps<{ src: number[]; @@ -44,12 +38,13 @@ const props = defineProps<{ const viewBoxX = 50; const viewBoxY = 50; const gradientId = uuid(); -const maskId = uuid(); let polylinePoints = $ref(''); let polygonPoints = $ref(''); let headX = $ref<number | null>(null); let headY = $ref<number | null>(null); let clock = $ref<number | null>(null); +const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent')); +const color = accent.toRgbString(); function draw(): void { const stats = props.src.slice().reverse(); @@ -71,9 +66,8 @@ function draw(): void { watch(() => props.src, draw, { immediate: true }); // Vueが何故かWatchを発動させない場合があるので -clock = window.setInterval(draw, 1000); - -onUnmounted(() => { - window.clearInterval(clock); +useInterval(draw, 1000, { + immediate: false, + afterMounted: true, }); </script> diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/MkModal.vue index d6a29ec4b7..2305a02794 100644 --- a/packages/client/src/components/ui/modal.vue +++ b/packages/client/src/components/MkModal.vue @@ -10,7 +10,7 @@ </template> <script lang="ts" setup> -import { nextTick, onMounted, computed, ref, watch, provide } from 'vue'; +import { nextTick, onMounted, watch, provide } from 'vue'; import * as os from '@/os'; import { isTouchUsing } from '@/scripts/touch'; import { defaultStore } from '@/store'; @@ -57,13 +57,13 @@ const emit = defineEmits<{ provide('modal', true); -const maxHeight = ref<number>(); -const fixed = ref(false); -const transformOrigin = ref('center'); -const showing = ref(true); -const content = ref<HTMLElement>(); +let maxHeight = $ref<number>(); +let fixed = $ref(false); +let transformOrigin = $ref('center'); +let showing = $ref(true); +let content = $ref<HTMLElement>(); const zIndex = os.claimZIndex(props.zPriority); -const type = computed(() => { +const type = $computed(() => { if (props.preferType === 'auto') { if (!defaultStore.state.disableDrawer && isTouchUsing && deviceKind === 'smartphone') { return 'drawer'; @@ -80,7 +80,7 @@ let contentClicking = false; const close = () => { // eslint-disable-next-line vue/no-mutating-props if (props.src) props.src.style.pointerEvents = 'auto'; - showing.value = false; + showing = false; emit('close'); }; @@ -89,8 +89,8 @@ const onBgClick = () => { emit('click'); }; -if (type.value === 'drawer') { - maxHeight.value = window.innerHeight / 1.5; +if (type === 'drawer') { + maxHeight = window.innerHeight / 1.5; } const keymap = { @@ -101,22 +101,21 @@ const MARGIN = 16; const align = () => { if (props.src == null) return; - if (type.value === 'drawer') return; - if (type.value === 'dialog') return; + if (type === 'drawer') return; + if (type === 'dialog') return; - const popover = content.value!; - if (popover == null) return; + if (content == null) return; const srcRect = props.src.getBoundingClientRect(); - - const width = popover.offsetWidth; - const height = popover.offsetHeight; + + const width = content!.offsetWidth; + const height = content!.offsetHeight; let left; let top; - const x = srcRect.left + (fixed.value ? 0 : window.pageXOffset); - const y = srcRect.top + (fixed.value ? 0 : window.pageYOffset); + const x = srcRect.left + (fixed ? 0 : window.pageXOffset); + const y = srcRect.top + (fixed ? 0 : window.pageYOffset); if (props.anchor.x === 'center') { left = x + (props.src.offsetWidth / 2) - (width / 2); @@ -134,7 +133,7 @@ const align = () => { top = y + props.src.offsetHeight; } - if (fixed.value) { + if (fixed) { // 画面から横にはみ出る場合 if (left + width > window.innerWidth) { left = window.innerWidth - width; @@ -147,16 +146,16 @@ const align = () => { if (top + height > (window.innerHeight - MARGIN)) { if (props.noOverlap && props.anchor.x === 'center') { if (underSpace >= (upperSpace / 3)) { - maxHeight.value = underSpace; + maxHeight = underSpace; } else { - maxHeight.value = upperSpace; + maxHeight = upperSpace; top = (upperSpace + MARGIN) - height; } } else { top = (window.innerHeight - MARGIN) - height; } } else { - maxHeight.value = underSpace; + maxHeight = underSpace; } } else { // 画面から横にはみ出る場合 @@ -171,16 +170,16 @@ const align = () => { if (top + height - window.pageYOffset > (window.innerHeight - MARGIN)) { if (props.noOverlap && props.anchor.x === 'center') { if (underSpace >= (upperSpace / 3)) { - maxHeight.value = underSpace; + maxHeight = underSpace; } else { - maxHeight.value = upperSpace; + maxHeight = upperSpace; top = window.pageYOffset + ((upperSpace + MARGIN) - height); } } else { top = (window.innerHeight - MARGIN) - height + window.pageYOffset - 1; } } else { - maxHeight.value = underSpace; + maxHeight = underSpace; } } @@ -195,29 +194,29 @@ const align = () => { let transformOriginX = 'center'; let transformOriginY = 'center'; - if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.pageYOffset)) { + if (top >= srcRect.top + props.src.offsetHeight + (fixed ? 0 : window.pageYOffset)) { transformOriginY = 'top'; - } else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.pageYOffset)) { + } else if ((top + height) <= srcRect.top + (fixed ? 0 : window.pageYOffset)) { transformOriginY = 'bottom'; } - if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.pageXOffset)) { + if (left >= srcRect.left + props.src.offsetWidth + (fixed ? 0 : window.pageXOffset)) { transformOriginX = 'left'; - } else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.pageXOffset)) { + } else if ((left + width) <= srcRect.left + (fixed ? 0 : window.pageXOffset)) { transformOriginX = 'right'; } - transformOrigin.value = `${transformOriginX} ${transformOriginY}`; + transformOrigin = `${transformOriginX} ${transformOriginY}`; - popover.style.left = left + 'px'; - popover.style.top = top + 'px'; + content.style.left = left + 'px'; + content.style.top = top + 'px'; }; const onOpened = () => { emit('opened'); // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する - const el = content.value!.children[0]; + const el = content!.children[0]; el.addEventListener('mousedown', ev => { contentClicking = true; window.addEventListener('mouseup', ev => { @@ -235,7 +234,7 @@ onMounted(() => { // eslint-disable-next-line vue/no-mutating-props props.src.style.pointerEvents = 'none'; } - fixed.value = (type.value === 'drawer') || (getFixedContainer(props.src) != null); + fixed = (type === 'drawer') || (getFixedContainer(props.src) != null); await nextTick(); @@ -243,10 +242,9 @@ onMounted(() => { }, { immediate: true }); nextTick(() => { - const popover = content.value; new ResizeObserver((entries, observer) => { align(); - }).observe(popover!); + }).observe(content!); }); }); diff --git a/packages/client/src/components/modal-page-window.vue b/packages/client/src/components/MkModalPageWindow.vue index aef70f113b..cc3f4c96cc 100644 --- a/packages/client/src/components/modal-page-window.vue +++ b/packages/client/src/components/MkModalPageWindow.vue @@ -1,6 +1,6 @@ <template> <MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> - <div ref="rootEl" class="hrmcaedk _window _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }"> + <div ref="rootEl" class="hrmcaedk _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }"> <div class="header" @contextmenu="onContextmenu"> <button v-if="history.length > 0" v-tooltip="$ts.goBack" class="_button" @click="back()"><i class="fas fa-arrow-left"></i></button> <span v-else style="display: inline-block; width: 20px"></span> @@ -22,7 +22,7 @@ <script lang="ts" setup> import { ComputedRef, provide } from 'vue'; -import MkModal from '@/components/ui/modal.vue'; +import MkModal from '@/components/MkModal.vue'; import { popout as _popout } from '@/scripts/popout'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import { url } from '@/config'; @@ -121,6 +121,7 @@ function onContextmenu(ev: MouseEvent) { display: flex; flex-direction: column; contain: content; + border-radius: var(--radius); --root-margin: 24px; @@ -139,7 +140,9 @@ function onContextmenu(ev: MouseEvent) { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - box-shadow: 0px 1px var(--divider); + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); > button { height: $height; diff --git a/packages/client/src/components/ui/modal-window.vue b/packages/client/src/components/MkModalWindow.vue index d2b2ccff7a..5acd8c921f 100644 --- a/packages/client/src/components/ui/modal-window.vue +++ b/packages/client/src/components/MkModalWindow.vue @@ -1,6 +1,6 @@ <template> <MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')"> - <div ref="rootEl" class="ebkgoccj _window _narrow_" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown"> + <div ref="rootEl" class="ebkgoccj _narrow_" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown"> <div ref="headerEl" class="header"> <button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="fas fa-times"></i></button> <span class="title"> @@ -9,12 +9,7 @@ <button v-if="!withOkButton" class="_button" @click="$emit('close')"><i class="fas fa-times"></i></button> <button v-if="withOkButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="fas fa-check"></i></button> </div> - <div v-if="padding" class="body"> - <div class="_section"> - <slot :width="bodyWidth" :height="bodyHeight"></slot> - </div> - </div> - <div v-else class="body"> + <div class="body"> <slot :width="bodyWidth" :height="bodyHeight"></slot> </div> </div> @@ -23,19 +18,17 @@ <script lang="ts" setup> import { onMounted, onUnmounted } from 'vue'; -import MkModal from './modal.vue'; +import MkModal from './MkModal.vue'; const props = withDefaults(defineProps<{ withOkButton: boolean; okButtonDisabled: boolean; - padding: boolean; width: number; height: number | null; scroll: boolean; }>(), { withOkButton: false, okButtonDisabled: false, - padding: false, width: 400, height: null, scroll: true, @@ -96,6 +89,7 @@ defineExpose({ display: flex; flex-direction: column; contain: content; + border-radius: var(--radius); --root-margin: 24px; @@ -104,11 +98,13 @@ defineExpose({ } > .header { - $height: 58px; + $height: 46px; $height-narrow: 42px; display: flex; flex-shrink: 0; - box-shadow: 0px 1px var(--divider); + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); > button { height: $height; @@ -142,7 +138,9 @@ defineExpose({ } > .body { + flex: 1; overflow: auto; + background: var(--panel); } } </style> diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/MkNote.vue index c2c92f541d..97eadb1945 100644 --- a/packages/client/src/components/note.vue +++ b/packages/client/src/components/MkNote.vue @@ -28,12 +28,7 @@ <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> <MkTime :time="note.createdAt"/> </button> - <span v-if="note.visibility !== 'public'" class="visibility"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> + <MkVisibility :note="note"/> </div> </div> <article class="article" @contextmenu.stop="onContextmenu"> @@ -46,7 +41,7 @@ <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> <XCwButton v-model="showContent" :note="appearNote"/> </p> - <div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed }"> + <div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed, isLong }"> <div class="text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> @@ -66,9 +61,12 @@ <XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/> <div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div> - <button v-if="collapsed" class="fade _button" @click="collapsed = false"> + <button v-if="isLong && collapsed" class="fade _button" @click="collapsed = false"> <span>{{ i18n.ts.showMore }}</span> </button> + <button v-else-if="isLong && !collapsed" class="showLess _button" @click="collapsed = true"> + <span>{{ i18n.ts.showLess }}</span> + </button> </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> @@ -108,16 +106,17 @@ import { computed, inject, onMounted, onUnmounted, reactive, ref, Ref } from 'vue'; import * as mfm from 'mfm-js'; import * as misskey from 'misskey-js'; -import MkNoteSub from './MkNoteSub.vue'; -import XNoteHeader from './note-header.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'; -import XPoll from './poll.vue'; -import XRenoteButton from './renote-button.vue'; -import MkUrlPreview from '@/components/url-preview.vue'; -import MkInstanceTicker from '@/components/instance-ticker.vue'; +import MkNoteSub from '@/components/MkNoteSub.vue'; +import XNoteHeader from '@/components/MkNoteHeader.vue'; +import XNoteSimple from '@/components/MkNoteSimple.vue'; +import XReactionsViewer from '@/components/MkReactionsViewer.vue'; +import XMediaList from '@/components/MkMediaList.vue'; +import XCwButton from '@/components/MkCwButton.vue'; +import XPoll from '@/components/MkPoll.vue'; +import XRenoteButton from '@/components/MkRenoteButton.vue'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; +import MkVisibility from '@/components/MkVisibility.vue'; import { pleaseLogin } from '@/scripts/please-login'; import { focusPrev, focusNext } from '@/scripts/focus'; import { checkWordMute } from '@/scripts/check-word-mute'; @@ -130,6 +129,7 @@ import { $i } from '@/account'; import { i18n } from '@/i18n'; import { getNoteMenu } from '@/scripts/get-note-menu'; import { useNoteCapture } from '@/scripts/use-note-capture'; +import { deepClone } from '@/scripts/clone'; const props = defineProps<{ note: misskey.entities.Note; @@ -138,12 +138,12 @@ const props = defineProps<{ const inChannel = inject('inChannel', null); -let note = $ref(JSON.parse(JSON.stringify(props.note))); +let note = $ref(deepClone(props.note)); // plugin if (noteViewInterruptors.length > 0) { onMounted(async () => { - let result = JSON.parse(JSON.stringify(note)); + let result = deepClone(note); for (const interruptor of noteViewInterruptors) { result = await interruptor.handler(result); } @@ -166,10 +166,11 @@ const reactButton = ref<HTMLElement>(); let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note); const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); -const collapsed = ref(appearNote.cw == null && appearNote.text != null && ( +const isLong = (appearNote.cw == null && appearNote.text != null && ( (appearNote.text.split('\n').length > 9) || (appearNote.text.length > 500) )); +const collapsed = ref(appearNote.cw == null && isLong); const isDeleted = ref(false); const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); const translation = ref(null); @@ -406,14 +407,6 @@ function readPromo() { margin-right: 4px; } } - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } } } @@ -454,6 +447,24 @@ function readPromo() { } > .content { + &.isLong { + > .showLess { + width: 100%; + margin-top: 1em; + position: sticky; + bottom: 1em; + + > span { + display: inline-block; + background: var(--popup); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); + } + } + } + &.collapsed { position: relative; max-height: 9em; @@ -566,6 +577,13 @@ function readPromo() { &.max-width_500px { font-size: 0.9em; + + > .article { + > .avatar { + width: 50px; + height: 50px; + } + } } &.max-width_450px { @@ -582,8 +600,8 @@ function readPromo() { > .avatar { margin: 0 10px 8px 0; - width: 50px; - height: 50px; + width: 46px; + height: 46px; top: calc(14px + var(--stickyTop, 0px)); } } @@ -604,8 +622,6 @@ function readPromo() { } &.max-width_300px { - font-size: 0.825em; - > .article { > .avatar { width: 44px; diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/MkNoteDetailed.vue index ba47bfcd4a..82468027fd 100644 --- a/packages/client/src/components/note-detailed.vue +++ b/packages/client/src/components/MkNoteDetailed.vue @@ -14,7 +14,7 @@ <div v-if="isRenote" class="renote"> <MkAvatar class="avatar" :user="note.user"/> <i class="fas fa-retweet"></i> - <I18n :src="$ts.renotedBy" tag="span"> + <I18n :src="i18n.ts.renotedBy" tag="span"> <template #user> <MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)"> <MkUserName :user="note.user"/> @@ -26,12 +26,7 @@ <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> <MkTime :time="note.createdAt"/> </button> - <span v-if="note.visibility !== 'public'" class="visibility"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> + <MkVisibility :note="note"/> </div> </div> <article class="article" @contextmenu.stop="onContextmenu"> @@ -43,12 +38,9 @@ <MkUserName :user="appearNote.user"/> </MkA> <span v-if="appearNote.user.isBot" class="is-bot">bot</span> - <span v-if="appearNote.visibility !== 'public'" class="visibility"> - <i v-if="appearNote.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="appearNote.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="appearNote.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span v-if="appearNote.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> + <div class="info"> + <MkVisibility :note="appearNote"/> + </div> </div> <div class="username"><MkAcct :user="appearNote.user"/></div> <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> @@ -62,7 +54,7 @@ </p> <div v-show="appearNote.cw == null || showContent" class="content"> <div class="text"> - <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> <a v-if="appearNote.renote != null" class="rp">RN:</a> @@ -111,7 +103,7 @@ <MkNoteSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/> </div> <div v-else class="_panel muted" @click="muted = false"> - <I18n :src="$ts.userSaysSomething" tag="small"> + <I18n :src="i18n.ts.userSaysSomething" tag="small"> <template #name> <MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)"> <MkUserName :user="appearNote.user"/> @@ -125,15 +117,16 @@ import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue'; import * as mfm from 'mfm-js'; import * as misskey from 'misskey-js'; -import MkNoteSub from './MkNoteSub.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'; -import XPoll from './poll.vue'; -import XRenoteButton from './renote-button.vue'; -import MkUrlPreview from '@/components/url-preview.vue'; -import MkInstanceTicker from '@/components/instance-ticker.vue'; +import MkNoteSub from '@/components/MkNoteSub.vue'; +import XNoteSimple from '@/components/MkNoteSimple.vue'; +import XReactionsViewer from '@/components/MkReactionsViewer.vue'; +import XMediaList from '@/components/MkMediaList.vue'; +import XCwButton from '@/components/MkCwButton.vue'; +import XPoll from '@/components/MkPoll.vue'; +import XRenoteButton from '@/components/MkRenoteButton.vue'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; +import MkVisibility from '@/components/MkVisibility.vue'; import { pleaseLogin } from '@/scripts/please-login'; import { checkWordMute } from '@/scripts/check-word-mute'; import { userPage } from '@/filters/user'; @@ -146,6 +139,7 @@ import { $i } from '@/account'; import { i18n } from '@/i18n'; import { getNoteMenu } from '@/scripts/get-note-menu'; import { useNoteCapture } from '@/scripts/use-note-capture'; +import { deepClone } from '@/scripts/clone'; const props = defineProps<{ note: misskey.entities.Note; @@ -154,12 +148,12 @@ const props = defineProps<{ const inChannel = inject('inChannel', null); -let note = $ref(JSON.parse(JSON.stringify(props.note))); +let note = $ref(deepClone(props.note)); // plugin if (noteViewInterruptors.length > 0) { onMounted(async () => { - let result = JSON.parse(JSON.stringify(note)); + let result = deepClone(note); for (const interruptor of noteViewInterruptors) { result = await interruptor.handler(result); } @@ -388,14 +382,6 @@ if (appearNote.replyId) { margin-right: 4px; } } - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } } } @@ -405,7 +391,7 @@ if (appearNote.replyId) { > .article { padding: 32px; - font-size: 1.1em; + font-size: 1.2em; > .header { display: flex; @@ -441,6 +427,10 @@ if (appearNote.replyId) { border: solid 0.5px var(--divider); border-radius: 4px; } + + > .info { + float: right; + } } } } diff --git a/packages/client/src/components/note-header.vue b/packages/client/src/components/MkNoteHeader.vue index 56a3a37e75..333c3ddbd9 100644 --- a/packages/client/src/components/note-header.vue +++ b/packages/client/src/components/MkNoteHeader.vue @@ -9,12 +9,7 @@ <MkA class="created-at" :to="notePage(note)"> <MkTime :time="note.createdAt"/> </MkA> - <span v-if="note.visibility !== 'public'" class="visibility"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> + <MkVisibility :note="note"/> </div> </header> </template> @@ -22,6 +17,7 @@ <script lang="ts" setup> import { } from 'vue'; import * as misskey from 'misskey-js'; +import MkVisibility from '@/components/MkVisibility.vue'; import { notePage } from '@/filters/note'; import { userPage } from '@/filters/user'; @@ -74,14 +70,6 @@ defineProps<{ flex-shrink: 0; margin-left: auto; font-size: 0.9em; - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } } } </style> diff --git a/packages/client/src/components/note-preview.vue b/packages/client/src/components/MkNotePreview.vue index a78b499654..a78b499654 100644 --- a/packages/client/src/components/note-preview.vue +++ b/packages/client/src/components/MkNotePreview.vue diff --git a/packages/client/src/components/note-simple.vue b/packages/client/src/components/MkNoteSimple.vue index b813b9a2b9..1bbbe0e1a6 100644 --- a/packages/client/src/components/note-simple.vue +++ b/packages/client/src/components/MkNoteSimple.vue @@ -9,7 +9,7 @@ <XCwButton v-model="showContent" :note="note"/> </p> <div v-show="note.cw == null || showContent" class="content"> - <MkNoteSubNoteContent class="text" :note="note"/> + <MkSubNoteContent class="text" :note="note"/> </div> </div> </div> @@ -19,9 +19,9 @@ <script lang="ts" setup> import { } from 'vue'; import * as misskey from 'misskey-js'; -import XNoteHeader from './note-header.vue'; -import MkNoteSubNoteContent from './sub-note-content.vue'; -import XCwButton from './cw-button.vue'; +import XNoteHeader from '@/components/MkNoteHeader.vue'; +import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; +import XCwButton from '@/components/MkCwButton.vue'; const props = defineProps<{ note: misskey.entities.Note; diff --git a/packages/client/src/components/MkNoteSub.vue b/packages/client/src/components/MkNoteSub.vue index 30c27e6235..a69336f8a1 100644 --- a/packages/client/src/components/MkNoteSub.vue +++ b/packages/client/src/components/MkNoteSub.vue @@ -6,11 +6,11 @@ <XNoteHeader class="header" :note="note" :mini="true"/> <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" /> + <Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> <XCwButton v-model="showContent" :note="note"/> </p> <div v-show="note.cw == null || showContent" class="content"> - <MkNoteSubNoteContent class="text" :note="note"/> + <MkSubNoteContent class="text" :note="note"/> </div> </div> </div> @@ -19,7 +19,7 @@ <MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :depth="depth + 1"/> </template> <div v-else class="more"> - <MkA class="text _link" :to="notePage(note)">{{ $ts.continueThread }} <i class="fas fa-angle-double-right"></i></MkA> + <MkA class="text _link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="fas fa-angle-double-right"></i></MkA> </div> </div> </template> @@ -27,11 +27,12 @@ <script lang="ts" setup> import { } from 'vue'; import * as misskey from 'misskey-js'; +import XNoteHeader from '@/components/MkNoteHeader.vue'; +import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; +import XCwButton from '@/components/MkCwButton.vue'; import { notePage } from '@/filters/note'; -import XNoteHeader from './note-header.vue'; -import MkNoteSubNoteContent from './sub-note-content.vue'; -import XCwButton from './cw-button.vue'; import * as os from '@/os'; +import { i18n } from '@/i18n'; const props = withDefaults(defineProps<{ note: misskey.entities.Note; @@ -49,7 +50,7 @@ let replies: misskey.entities.Note[] = $ref([]); if (props.detail) { os.api('notes/children', { noteId: props.note.id, - limit: 5 + limit: 5, }).then(res => { replies = res; }); diff --git a/packages/client/src/components/notes.vue b/packages/client/src/components/MkNotes.vue index 41bec5a579..5abcdc2298 100644 --- a/packages/client/src/components/notes.vue +++ b/packages/client/src/components/MkNotes.vue @@ -3,7 +3,7 @@ <template #empty> <div class="_fullinfo"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> - <div>{{ $ts.noNotes }}</div> + <div>{{ i18n.ts.noNotes }}</div> </div> </template> @@ -19,10 +19,10 @@ <script lang="ts" setup> import { ref } from 'vue'; -import XNote from '@/components/note.vue'; -import XList from '@/components/date-separated-list.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import { Paging } from '@/components/ui/pagination.vue'; +import XNote from '@/components/MkNote.vue'; +import XList from '@/components/MkDateSeparatedList.vue'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import { i18n } from '@/i18n'; const props = defineProps<{ pagination: Paging; diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/MkNotification.vue index cbfd809f37..c00e9fbf42 100644 --- a/packages/client/src/components/notification.vue +++ b/packages/client/src/components/MkNotification.vue @@ -61,10 +61,10 @@ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> <i class="fas fa-quote-right"></i> </MkA> - <span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span> - <span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $ts.followRequestAccepted }}</span> - <span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $ts.reject }}</button></div></span> - <span v-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ $ts.groupInvited }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ $ts.reject }}</button></div></span> + <span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span> + <span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span> + <span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ i18n.ts.reject }}</button></div></span> + <span v-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ i18n.ts.groupInvited }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ i18n.ts.reject }}</button></div></span> <span v-if="notification.type === 'app'" class="text"> <Mfm :text="notification.body" :nowrap="!full"/> </span> @@ -72,12 +72,12 @@ </div> </template> -<script lang="ts"> -import { defineComponent, ref, onMounted, onUnmounted, watch } from 'vue'; +<script lang="ts" setup> +import { ref, onMounted, onUnmounted, watch } from 'vue'; import * as misskey from 'misskey-js'; -import XReactionIcon from './reaction-icon.vue'; -import MkFollowButton from './follow-button.vue'; -import XReactionTooltip from './reaction-tooltip.vue'; +import XReactionIcon from '@/components/MkReactionIcon.vue'; +import MkFollowButton from '@/components/MkFollowButton.vue'; +import XReactionTooltip from '@/components/MkReactionTooltip.vue'; import { getNoteSummary } from '@/scripts/get-note-summary'; import { notePage } from '@/filters/note'; import { userPage } from '@/filters/user'; @@ -86,105 +86,77 @@ import * as os from '@/os'; import { stream } from '@/stream'; import { useTooltip } from '@/scripts/use-tooltip'; -export default defineComponent({ - components: { - XReactionIcon, MkFollowButton, - }, - - props: { - notification: { - type: Object, - required: true, - }, - withTime: { - type: Boolean, - required: false, - default: false, - }, - full: { - type: Boolean, - required: false, - default: false, - }, - }, +const props = withDefaults(defineProps<{ + notification: misskey.entities.Notification; + withTime?: boolean; + full?: boolean; +}>(), { + withTime: false, + full: false, +}); - setup(props) { - const elRef = ref<HTMLElement>(null); - const reactionRef = ref(null); +const elRef = ref<HTMLElement>(null); +const reactionRef = ref(null); - onMounted(() => { - if (!props.notification.isRead) { - const readObserver = new IntersectionObserver((entries, observer) => { - if (!entries.some(entry => entry.isIntersecting)) return; - stream.send('readNotification', { - id: props.notification.id, - }); - observer.disconnect(); - }); +let readObserver: IntersectionObserver | undefined; +let connection; - readObserver.observe(elRef.value); +onMounted(() => { + if (!props.notification.isRead) { + readObserver = new IntersectionObserver((entries, observer) => { + if (!entries.some(entry => entry.isIntersecting)) return; + stream.send('readNotification', { + id: props.notification.id, + }); + observer.disconnect(); + }); - const connection = stream.useChannel('main'); - connection.on('readAllNotifications', () => readObserver.disconnect()); + readObserver.observe(elRef.value); - watch(props.notification.isRead, () => { - readObserver.disconnect(); - }); + connection = stream.useChannel('main'); + connection.on('readAllNotifications', () => readObserver.disconnect()); - onUnmounted(() => { - readObserver.disconnect(); - connection.dispose(); - }); - } + watch(props.notification.isRead, () => { + readObserver.disconnect(); }); + } +}); - const followRequestDone = ref(false); - const groupInviteDone = ref(false); +onUnmounted(() => { + if (readObserver) readObserver.disconnect(); + if (connection) connection.dispose(); +}); - const acceptFollowRequest = () => { - followRequestDone.value = true; - os.api('following/requests/accept', { userId: props.notification.user.id }); - }; +const followRequestDone = ref(false); +const groupInviteDone = ref(false); - const rejectFollowRequest = () => { - followRequestDone.value = true; - os.api('following/requests/reject', { userId: props.notification.user.id }); - }; +const acceptFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/accept', { userId: props.notification.user.id }); +}; - const acceptGroupInvitation = () => { - groupInviteDone.value = true; - os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id }); - }; +const rejectFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/reject', { userId: props.notification.user.id }); +}; - const rejectGroupInvitation = () => { - groupInviteDone.value = true; - os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id }); - }; +const acceptGroupInvitation = () => { + groupInviteDone.value = true; + os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id }); +}; - useTooltip(reactionRef, (showing) => { - os.popup(XReactionTooltip, { - showing, - reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction, - emojis: props.notification.note.emojis, - targetElement: reactionRef.value.$el, - }, {}, 'closed'); - }); +const rejectGroupInvitation = () => { + groupInviteDone.value = true; + os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id }); +}; - return { - getNoteSummary: (note: misskey.entities.Note) => getNoteSummary(note), - followRequestDone, - groupInviteDone, - notePage, - userPage, - acceptFollowRequest, - rejectFollowRequest, - acceptGroupInvitation, - rejectGroupInvitation, - elRef, - reactionRef, - i18n, - }; - }, +useTooltip(reactionRef, (showing) => { + os.popup(XReactionTooltip, { + showing, + reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction, + emojis: props.notification.note.emojis, + targetElement: reactionRef.value.$el, + }, {}, 'closed'); }); </script> @@ -205,13 +177,7 @@ export default defineComponent({ &.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/MkNotificationSettingWindow.vue b/packages/client/src/components/MkNotificationSettingWindow.vue new file mode 100644 index 0000000000..75bea2976c --- /dev/null +++ b/packages/client/src/components/MkNotificationSettingWindow.vue @@ -0,0 +1,87 @@ +<template> +<XModalWindow + ref="dialog" + :width="400" + :height="450" + :with-ok-button="true" + :ok-button-disabled="false" + @ok="ok()" + @close="dialog.close()" + @closed="emit('closed')" +> + <template #header>{{ i18n.ts.notificationSetting }}</template> + <div class="_monolithic_"> + <div v-if="showGlobalToggle" class="_section"> + <MkSwitch v-model="useGlobalSetting"> + {{ i18n.ts.useGlobalSetting }} + <template #caption>{{ i18n.ts.useGlobalSettingDesc }}</template> + </MkSwitch> + </div> + <div v-if="!useGlobalSetting" class="_section"> + <MkInfo>{{ i18n.ts.notificationSettingDesc }}</MkInfo> + <MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton> + <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> + <MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype]">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import { notificationTypes } from 'misskey-js'; +import MkSwitch from './form/switch.vue'; +import MkInfo from './MkInfo.vue'; +import MkButton from './MkButton.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; +import { i18n } from '@/i18n'; + +const emit = defineEmits<{ + (ev: 'done', v: { includingTypes: string[] | null }): void, + (ev: 'closed'): void, +}>(); + +const props = withDefaults(defineProps<{ + includingTypes?: typeof notificationTypes[number][] | null; + showGlobalToggle?: boolean; +}>(), { + includingTypes: () => [], + showGlobalToggle: true, +}); + +let includingTypes = $computed(() => props.includingTypes || []); + +const dialog = $ref<InstanceType<typeof XModalWindow>>(); + +let typesMap = $ref<Record<typeof notificationTypes[number], boolean>>({}); +let useGlobalSetting = $ref((includingTypes === null || includingTypes.length === 0) && props.showGlobalToggle); + +for (const ntype of notificationTypes) { + typesMap[ntype] = includingTypes.includes(ntype); +} + +function ok() { + if (useGlobalSetting) { + emit('done', { includingTypes: null }); + } else { + emit('done', { + includingTypes: (Object.keys(typesMap) as typeof notificationTypes[number][]) + .filter(type => typesMap[type]), + }); + } + + dialog.close(); +} + +function disableAll() { + for (const type in typesMap) { + typesMap[type as typeof notificationTypes[number]] = false; + } +} + +function enableAll() { + for (const type in typesMap) { + typesMap[type as typeof notificationTypes[number]] = true; + } +} +</script> diff --git a/packages/client/src/components/notification-toast.vue b/packages/client/src/components/MkNotificationToast.vue index b808647bb4..398f64d544 100644 --- a/packages/client/src/components/notification-toast.vue +++ b/packages/client/src/components/MkNotificationToast.vue @@ -8,7 +8,7 @@ <script lang="ts" setup> import { onMounted } from 'vue'; -import XNotification from './notification.vue'; +import XNotification from '@/components/MkNotification.vue'; import * as os from '@/os'; defineProps<{ diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/MkNotifications.vue index 8eb569c369..0e1cc06743 100644 --- a/packages/client/src/components/notifications.vue +++ b/packages/client/src/components/MkNotifications.vue @@ -3,7 +3,7 @@ <template #empty> <div class="_fullinfo"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> - <div>{{ $ts.noNotifications }}</div> + <div>{{ i18n.ts.noNotifications }}</div> </div> </template> @@ -19,13 +19,14 @@ <script lang="ts" setup> import { defineComponent, markRaw, onUnmounted, onMounted, computed, ref } from 'vue'; import { notificationTypes } from 'misskey-js'; -import MkPagination, { Paging } from '@/components/ui/pagination.vue'; -import XNotification from '@/components/notification.vue'; -import XList from '@/components/date-separated-list.vue'; -import XNote from '@/components/note.vue'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import XNotification from '@/components/MkNotification.vue'; +import XList from '@/components/MkDateSeparatedList.vue'; +import XNote from '@/components/MkNote.vue'; import * as os from '@/os'; import { stream } from '@/stream'; import { $i } from '@/account'; +import { i18n } from '@/i18n'; const props = defineProps<{ includeTypes?: typeof notificationTypes[number][]; @@ -60,8 +61,10 @@ const onNotification = (notification) => { } }; +let connection; + onMounted(() => { - const connection = stream.useChannel('main'); + connection = stream.useChannel('main'); connection.on('notification', onNotification); connection.on('readAllNotifications', () => { if (pagingComponent.value) { @@ -87,10 +90,10 @@ onMounted(() => { } } }); +}); - onUnmounted(() => { - connection.dispose(); - }); +onUnmounted(() => { + if (connection) connection.dispose(); }); </script> diff --git a/packages/client/src/components/number-diff.vue b/packages/client/src/components/MkNumberDiff.vue index e7d4a5472a..e7d4a5472a 100644 --- a/packages/client/src/components/number-diff.vue +++ b/packages/client/src/components/MkNumberDiff.vue diff --git a/packages/client/src/components/MkObjectView.value.vue b/packages/client/src/components/MkObjectView.value.vue new file mode 100644 index 0000000000..0c7230d783 --- /dev/null +++ b/packages/client/src/components/MkObjectView.value.vue @@ -0,0 +1,160 @@ +<template> +<div class="igpposuu _monospace"> + <div v-if="value === null" class="null">null</div> + <div v-else-if="typeof value === 'boolean'" class="boolean" :class="{ true: value, false: !value }">{{ value ? 'true' : 'false' }}</div> + <div v-else-if="typeof value === 'string'" class="string">"{{ value }}"</div> + <div v-else-if="typeof value === 'number'" class="number">{{ number(value) }}</div> + <div v-else-if="isArray(value) && isEmpty(value)" class="array empty">[]</div> + <div v-else-if="isArray(value)" class="array"> + <div v-for="i in value.length" class="element"> + {{ i }}: <XValue :value="value[i - 1]" collapsed/> + </div> + </div> + <div v-else-if="isObject(value) && isEmpty(value)" class="object empty">{}</div> + <div v-else-if="isObject(value)" class="object"> + <div v-for="k in Object.keys(value)" class="kv"> + <button class="toggle _button" :class="{ visible: collapsable(value[k]) }" @click="collapsed[k] = !collapsed[k]">{{ collapsed[k] ? '+' : '-' }}</button> + <div class="k">{{ k }}:</div> + <div v-if="collapsed[k]" class="v"> + <button class="_button" @click="collapsed[k] = !collapsed[k]"> + <template v-if="typeof value[k] === 'string'">"..."</template> + <template v-else-if="isArray(value[k])">[...]</template> + <template v-else-if="isObject(value[k])">{...}</template> + </button> + </div> + <div v-else class="v"><XValue :value="value[k]"/></div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, reactive, ref } from 'vue'; +import number from '@/filters/number'; + +export default defineComponent({ + name: 'XValue', + + props: { + value: { + required: true, + }, + }, + + setup(props) { + const collapsed = reactive({}); + + if (isObject(props.value)) { + for (const key in props.value) { + collapsed[key] = collapsable(props.value[key]); + } + } + + function isObject(v): boolean { + return typeof v === 'object' && !Array.isArray(v) && v !== null; + } + + function isArray(v): boolean { + return Array.isArray(v); + } + + function isEmpty(v): boolean { + return (isArray(v) && v.length === 0) || (isObject(v) && Object.keys(v).length === 0); + } + + function collapsable(v): boolean { + return (isObject(v) || isArray(v)) && !isEmpty(v); + } + + return { + number, + collapsed, + isObject, + isArray, + isEmpty, + collapsable, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.igpposuu { + display: inline; + + > .null { + display: inline; + opacity: 0.7; + } + + > .boolean { + display: inline; + color: var(--codeBoolean); + + &.true { + font-weight: bold; + } + + &.false { + opacity: 0.7; + } + } + + > .string { + display: inline; + color: var(--codeString); + } + + > .number { + display: inline; + color: var(--codeNumber); + } + + > .array.empty { + display: inline; + opacity: 0.7; + } + + > .array:not(.empty) { + display: inline; + + > .element { + display: block; + padding-left: 16px; + } + } + + > .object.empty { + display: inline; + opacity: 0.7; + } + + > .object:not(.empty) { + display: inline; + + > .kv { + display: block; + padding-left: 16px; + + > .toggle { + width: 16px; + color: var(--accent); + visibility: hidden; + + &.visible { + visibility: visible; + } + } + + > .k { + display: inline; + margin-right: 8px; + } + + > .v { + display: inline; + } + } + } +} +</style> diff --git a/packages/client/src/components/MkObjectView.vue b/packages/client/src/components/MkObjectView.vue new file mode 100644 index 0000000000..55578a37f6 --- /dev/null +++ b/packages/client/src/components/MkObjectView.vue @@ -0,0 +1,20 @@ +<template> +<div class="zhyxdalp"> + <XValue :value="value" :collapsed="false"/> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XValue from './MkObjectView.value.vue'; + +const props = defineProps<{ + value: Record<string, unknown>; +}>(); +</script> + +<style lang="scss" scoped> +.zhyxdalp { + +} +</style> diff --git a/packages/client/src/components/page-preview.vue b/packages/client/src/components/MkPagePreview.vue index 090aff6c65..009582e540 100644 --- a/packages/client/src/components/page-preview.vue +++ b/packages/client/src/components/MkPagePreview.vue @@ -1,5 +1,5 @@ <template> -<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block _isolated" tabindex="-1"> +<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block" tabindex="-1"> <div v-if="page.eyeCatchingImage" class="thumbnail" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div> <article> <header> @@ -23,12 +23,12 @@ export default defineComponent({ props: { page: { type: Object, - required: true + required: true, }, }, methods: { - userName - } + userName, + }, }); </script> diff --git a/packages/client/src/components/page-window.vue b/packages/client/src/components/MkPageWindow.vue index 7de09d3be4..d58b914036 100644 --- a/packages/client/src/components/page-window.vue +++ b/packages/client/src/components/MkPageWindow.vue @@ -25,8 +25,8 @@ <script lang="ts" setup> import { ComputedRef, inject, provide } from 'vue'; -import RouterView from './global/router-view.vue'; -import XWindow from '@/components/ui/window.vue'; +import RouterView from '@/components/global/RouterView.vue'; +import XWindow from '@/components/MkWindow.vue'; import { popout as _popout } from '@/scripts/popout'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import { url } from '@/config'; @@ -48,7 +48,10 @@ const router = new Router(routes, props.initialPath); let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); let windowEl = $ref<InstanceType<typeof XWindow>>(); -const history = $ref<string[]>([props.initialPath]); +const history = $ref<{ path: string; key: any; }[]>([{ + path: router.getCurrentPath(), + key: router.getCurrentKey(), +}]); const buttonsLeft = $computed(() => { const buttons = []; @@ -72,7 +75,7 @@ const buttonsRight = $computed(() => { }); router.addListener('push', ctx => { - history.push(router.getCurrentPath()); + history.push({ path: ctx.path, key: ctx.key }); }); provide('router', router); @@ -111,7 +114,7 @@ function menu(ev) { function back() { history.pop(); - router.change(history[history.length - 1]); + router.replace(history[history.length - 1].path, history[history.length - 1].key); } function close() { @@ -119,7 +122,7 @@ function close() { } function expand() { - mainRouter.push(router.getCurrentPath()); + mainRouter.push(router.getCurrentPath(), 'forcePage'); windowEl.close(); } @@ -136,5 +139,6 @@ defineExpose({ <style lang="scss" scoped> .yrolvcoq { min-height: 100%; + background: var(--bg); } </style> diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/MkPagination.vue index c081e06acd..291409171a 100644 --- a/packages/client/src/components/ui/pagination.vue +++ b/packages/client/src/components/MkPagination.vue @@ -8,7 +8,7 @@ <slot name="empty"> <div class="_fullinfo"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> - <div>{{ $ts.nothing }}</div> + <div>{{ i18n.ts.nothing }}</div> </div> </slot> </div> @@ -16,14 +16,14 @@ <div v-else ref="rootEl"> <div v-show="pagination.reversed && more" key="_more_" class="cxiknjgy _gap"> <MkButton v-if="!moreFetching" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead"> - {{ $ts.loadMore }} + {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else class="loading"/> </div> <slot :items="items"></slot> <div v-show="!pagination.reversed && more" key="_more_" class="cxiknjgy _gap"> <MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore"> - {{ $ts.loadMore }} + {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else class="loading"/> </div> @@ -36,7 +36,8 @@ import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, import * as misskey from 'misskey-js'; import * as os from '@/os'; import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from '@/scripts/scroll'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; const SECOND_FETCH_LIMIT = 30; @@ -133,8 +134,10 @@ const fetchMore = async (): Promise<void> => { limit: SECOND_FETCH_LIMIT + 1, ...(props.pagination.offsetMode ? { offset: offset.value, + } : props.pagination.reversed ? { + sinceId: items.value[0].id, } : { - untilId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id, + untilId: items.value[items.value.length - 1].id, }), }).then(res => { for (let i = 0; i < res.length; i++) { @@ -169,8 +172,10 @@ const fetchMoreAhead = async (): Promise<void> => { limit: SECOND_FETCH_LIMIT + 1, ...(props.pagination.offsetMode ? { offset: offset.value, + } : props.pagination.reversed ? { + untilId: items.value[0].id, } : { - sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id, + sinceId: items.value[items.value.length - 1].id, }), }).then(res => { if (res.length > SECOND_FETCH_LIMIT) { @@ -192,21 +197,23 @@ const prepend = (item: Item): void => { if (props.pagination.reversed) { if (rootEl.value) { const container = getScrollContainer(rootEl.value); - if (container == null) return; // TODO? - - const pos = getScrollPosition(rootEl.value); - const viewHeight = container.clientHeight; - const height = container.scrollHeight; - const isBottom = (pos + viewHeight > height - 32); - if (isBottom) { - // オーバーフローしたら古いアイテムは捨てる - if (items.value.length >= props.displayLimit) { - // このやり方だとVue 3.2以降アニメーションが動かなくなる - //items.value = items.value.slice(-props.displayLimit); - while (items.value.length >= props.displayLimit) { - items.value.shift(); + if (container == null) { + // TODO? + } else { + const pos = getScrollPosition(rootEl.value); + const viewHeight = container.clientHeight; + const height = container.scrollHeight; + const isBottom = (pos + viewHeight > height - 32); + if (isBottom) { + // オーバーフローしたら古いアイテムは捨てる + if (items.value.length >= props.displayLimit) { + // このやり方だとVue 3.2以降アニメーションが動かなくなる + //items.value = items.value.slice(-props.displayLimit); + while (items.value.length >= props.displayLimit) { + items.value.shift(); + } + more.value = true; } - more.value = true; } } } diff --git a/packages/client/src/components/MkPoll.vue b/packages/client/src/components/MkPoll.vue new file mode 100644 index 0000000000..d90af1cfee --- /dev/null +++ b/packages/client/src/components/MkPoll.vue @@ -0,0 +1,152 @@ +<template> +<div class="tivcixzd" :class="{ done: closed || isVoted }"> + <ul> + <li v-for="(choice, i) in note.poll.choices" :key="i" :class="{ voted: choice.voted }" @click="vote(i)"> + <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> + <span> + <template v-if="choice.isVoted"><i class="fas fa-check"></i></template> + <Mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/> + <span v-if="showResult" class="votes">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span> + </span> + </li> + </ul> + <p v-if="!readOnly"> + <span>{{ $t('_poll.totalVotes', { n: total }) }}</span> + <span> · </span> + <a v-if="!closed && !isVoted" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a> + <span v-if="isVoted">{{ i18n.ts._poll.voted }}</span> + <span v-else-if="closed">{{ i18n.ts._poll.closed }}</span> + <span v-if="remaining > 0"> · {{ timer }}</span> + </p> +</div> +</template> + +<script lang="ts" setup> +import { computed, onUnmounted, ref, toRef } from 'vue'; +import * as misskey from 'misskey-js'; +import { sum } from '@/scripts/array'; +import { pleaseLogin } from '@/scripts/please-login'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { useInterval } from '@/scripts/use-interval'; + +const props = defineProps<{ + note: misskey.entities.Note; + readOnly?: boolean; +}>(); + +const remaining = ref(-1); + +const total = computed(() => sum(props.note.poll.choices.map(x => x.votes))); +const closed = computed(() => remaining.value === 0); +const isVoted = computed(() => !props.note.poll.multiple && props.note.poll.choices.some(c => c.isVoted)); +const timer = computed(() => i18n.t( + remaining.value >= 86400 ? '_poll.remainingDays' : + remaining.value >= 3600 ? '_poll.remainingHours' : + remaining.value >= 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', { + s: Math.floor(remaining.value % 60), + m: Math.floor(remaining.value / 60) % 60, + h: Math.floor(remaining.value / 3600) % 24, + d: Math.floor(remaining.value / 86400), + })); + +const showResult = ref(props.readOnly || isVoted.value); + +// 期限付きアンケート +if (props.note.poll.expiresAt) { + const tick = () => { + remaining.value = Math.floor(Math.max(new Date(props.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000); + if (remaining.value === 0) { + showResult.value = true; + } + }; + + useInterval(tick, 3000, { + immediate: true, + afterMounted: false, + }); +} + +const vote = async (id) => { + pleaseLogin(); + + if (props.readOnly || closed.value || isVoted.value) return; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('voteConfirm', { choice: props.note.poll.choices[id].text }), + }); + if (canceled) return; + + await os.api('notes/polls/vote', { + noteId: props.note.id, + choice: id, + }); + if (!showResult.value) showResult.value = !props.note.poll.multiple; +}; +</script> + +<style lang="scss" scoped> +.tivcixzd { + > ul { + display: block; + margin: 0; + padding: 0; + list-style: none; + + > li { + display: block; + position: relative; + margin: 4px 0; + padding: 4px; + //border: solid 0.5px var(--divider); + background: var(--accentedBg); + border-radius: 4px; + overflow: hidden; + cursor: pointer; + + > .backdrop { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--accent); + background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB)); + transition: width 1s ease; + } + + > span { + position: relative; + display: inline-block; + padding: 3px 5px; + background: var(--panel); + border-radius: 3px; + + > i { + margin-right: 4px; + color: var(--accent); + } + + > .votes { + margin-left: 4px; + opacity: 0.7; + } + } + } + } + + > p { + color: var(--fg); + + a { + color: inherit; + } + } + + &.done { + > ul > li { + cursor: default; + } + } +} +</style> diff --git a/packages/client/src/components/poll-editor.vue b/packages/client/src/components/MkPollEditor.vue index 9aa5510c7f..3b08a63535 100644 --- a/packages/client/src/components/poll-editor.vue +++ b/packages/client/src/components/MkPollEditor.vue @@ -1,45 +1,45 @@ <template> <div class="zmdxowus"> <p v-if="choices.length < 2" class="caution"> - <i class="fas fa-exclamation-triangle"></i>{{ $ts._poll.noOnlyOneChoice }} + <i class="fas fa-exclamation-triangle"></i>{{ i18n.ts._poll.noOnlyOneChoice }} </p> <ul> <li v-for="(choice, i) in choices" :key="i"> - <MkInput class="input" :model-value="choice" :placeholder="$t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)"> + <MkInput class="input" small :model-value="choice" :placeholder="$t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)"> </MkInput> <button class="_button" @click="remove(i)"> <i class="fas fa-times"></i> </button> </li> </ul> - <MkButton v-if="choices.length < 10" class="add" @click="add">{{ $ts.add }}</MkButton> - <MkButton v-else class="add" disabled>{{ $ts._poll.noMore }}</MkButton> - <MkSwitch v-model="multiple">{{ $ts._poll.canMultipleVote }}</MkSwitch> + <MkButton v-if="choices.length < 10" class="add" @click="add">{{ i18n.ts.add }}</MkButton> + <MkButton v-else class="add" disabled>{{ i18n.ts._poll.noMore }}</MkButton> + <MkSwitch v-model="multiple">{{ i18n.ts._poll.canMultipleVote }}</MkSwitch> <section> <div> - <MkSelect v-model="expiration"> - <template #label>{{ $ts._poll.expiration }}</template> - <option value="infinite">{{ $ts._poll.infinite }}</option> - <option value="at">{{ $ts._poll.at }}</option> - <option value="after">{{ $ts._poll.after }}</option> + <MkSelect v-model="expiration" small> + <template #label>{{ i18n.ts._poll.expiration }}</template> + <option value="infinite">{{ i18n.ts._poll.infinite }}</option> + <option value="at">{{ i18n.ts._poll.at }}</option> + <option value="after">{{ i18n.ts._poll.after }}</option> </MkSelect> <section v-if="expiration === 'at'"> - <MkInput v-model="atDate" type="date" class="input"> - <template #label>{{ $ts._poll.deadlineDate }}</template> + <MkInput v-model="atDate" small type="date" class="input"> + <template #label>{{ i18n.ts._poll.deadlineDate }}</template> </MkInput> - <MkInput v-model="atTime" type="time" class="input"> - <template #label>{{ $ts._poll.deadlineTime }}</template> + <MkInput v-model="atTime" small type="time" class="input"> + <template #label>{{ i18n.ts._poll.deadlineTime }}</template> </MkInput> </section> <section v-else-if="expiration === 'after'"> - <MkInput v-model="after" type="number" class="input"> - <template #label>{{ $ts._poll.duration }}</template> + <MkInput v-model="after" small type="number" class="input"> + <template #label>{{ i18n.ts._poll.duration }}</template> </MkInput> - <MkSelect v-model="unit"> - <option value="second">{{ $ts._time.second }}</option> - <option value="minute">{{ $ts._time.minute }}</option> - <option value="hour">{{ $ts._time.hour }}</option> - <option value="day">{{ $ts._time.day }}</option> + <MkSelect v-model="unit" small> + <option value="second">{{ i18n.ts._time.second }}</option> + <option value="minute">{{ i18n.ts._time.minute }}</option> + <option value="hour">{{ i18n.ts._time.hour }}</option> + <option value="day">{{ i18n.ts._time.day }}</option> </MkSelect> </section> </div> @@ -49,12 +49,13 @@ <script lang="ts" setup> import { ref, watch } from 'vue'; -import { addTime } from '@/scripts/time'; -import { formatDateTimeString } from '@/scripts/format-time-string'; import MkInput from './form/input.vue'; import MkSelect from './form/select.vue'; import MkSwitch from './form/switch.vue'; -import MkButton from './ui/button.vue'; +import MkButton from './MkButton.vue'; +import { formatDateTimeString } from '@/scripts/format-time-string'; +import { addTime } from '@/scripts/time'; +import { i18n } from '@/i18n'; const props = defineProps<{ modelValue: { @@ -116,8 +117,11 @@ function get() { let base = parseInt(after.value); switch (unit.value) { case 'day': base *= 24; + // fallthrough case 'hour': base *= 60; + // fallthrough case 'minute': base *= 60; + // fallthrough case 'second': return base *= 1000; default: return null; } @@ -129,7 +133,7 @@ function get() { ...( expiration.value === 'at' ? { expiresAt: calcAt() } : expiration.value === 'after' ? { expiredAfter: calcAfter() } : {} - ) + ), }; } diff --git a/packages/client/src/components/ui/popup-menu.vue b/packages/client/src/components/MkPopupMenu.vue index 2bc7030d77..f04c7f5618 100644 --- a/packages/client/src/components/ui/popup-menu.vue +++ b/packages/client/src/components/MkPopupMenu.vue @@ -1,13 +1,13 @@ <template> <MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="modal.close()" @closed="emit('closed')"> - <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq _popup _shadow" :class="{ drawer: type === 'drawer' }" @close="modal.close()"/> + <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq" :class="{ drawer: type === 'drawer' }" @close="modal.close()"/> </MkModal> </template> <script lang="ts" setup> import { } from 'vue'; -import MkModal from './modal.vue'; -import MkMenu from './menu.vue'; +import MkModal from './MkModal.vue'; +import MkMenu from './MkMenu.vue'; import { MenuItem } from '@/types/menu'; defineProps<{ diff --git a/packages/client/src/components/post-form.vue b/packages/client/src/components/MkPostForm.vue index 0197313e0e..24f2bfb9e6 100644 --- a/packages/client/src/components/post-form.vue +++ b/packages/client/src/components/MkPostForm.vue @@ -1,5 +1,6 @@ <template> -<div v-size="{ max: [310, 500] }" class="gafaadew" +<div + v-size="{ max: [310, 500] }" class="gafaadew" :class="{ modal, _popup: modal }" @dragover.stop="onDragover" @dragenter="onDragenter" @@ -11,7 +12,7 @@ <button v-click-anime v-tooltip="i18n.ts.switchAccount" class="account _button" @click="openAccountMenu"> <MkAvatar :user="postAccount ?? $i" class="avatar"/> </button> - <div> + <div class="right"> <span class="text-count" :class="{ over: textLength > maxTextLength }">{{ maxTextLength - textLength }}</span> <span v-if="localOnly" class="local-only"><i class="fas fa-biohazard"></i></span> <button ref="visibilityButton" v-tooltip="i18n.ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility"> @@ -68,26 +69,27 @@ import * as misskey from 'misskey-js'; 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 XPostFormAttaches from './post-form-attaches.vue'; -import XPollEditor from './poll-editor.vue'; +import * as Acct from 'misskey-js/built/acct'; +import { throttle } from 'throttle-debounce'; +import XNoteSimple from '@/components/MkNoteSimple.vue'; +import XNotePreview from '@/components/MkNotePreview.vue'; +import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; +import XPollEditor from '@/components/MkPollEditor.vue'; import { host, url } from '@/config'; import { erase, unique } from '@/scripts/array'; import { extractMentions } from '@/scripts/extract-mentions'; -import * as Acct from 'misskey-js/built/acct'; import { formatTimeString } from '@/scripts/format-time-string'; import { Autocomplete } from '@/scripts/autocomplete'; import * as os from '@/os'; import { stream } from '@/stream'; import { selectFiles } from '@/scripts/select-file'; import { defaultStore, notePostInterruptors, postFormActions } from '@/store'; -import { throttle } from 'throttle-debounce'; -import MkInfo from '@/components/ui/info.vue'; +import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account'; import { uploadFile } from '@/scripts/upload'; +import { deepClone } from '@/scripts/clone'; const modal = inject('modal'); @@ -181,7 +183,7 @@ const placeholder = $computed((): string => { i18n.ts._postForm._placeholders.c, i18n.ts._postForm._placeholders.d, i18n.ts._postForm._placeholders.e, - i18n.ts._postForm._placeholders.f + i18n.ts._postForm._placeholders.f, ]; return xs[Math.floor(Math.random() * xs.length)]; } @@ -238,10 +240,10 @@ if (props.reply && props.reply.text != null) { for (const x of extractMentions(ast)) { const mention = x.host ? - `@${x.username}@${toASCII(x.host)}` : - (otherHost == null || otherHost === host) ? - `@${x.username}` : - `@${x.username}@${toASCII(otherHost)}`; + `@${x.username}@${toASCII(x.host)}` : + (otherHost == null || otherHost === host) ? + `@${x.username}` : + `@${x.username}@${toASCII(otherHost)}`; // 自分は除外 if ($i.username === x.username && (x.host == null || x.host === host)) continue; @@ -263,7 +265,7 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib visibility = props.reply.visibility; if (props.reply.visibility === 'specified') { os.api('users/show', { - userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId) + userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId), }).then(users => { users.forEach(pushVisibleUser); }); @@ -383,7 +385,7 @@ function setVisibility() { return; } - os.popup(defineAsyncComponent(() => import('./visibility-picker.vue')), { + os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), { currentVisibility: visibility, currentLocalOnly: localOnly, src: visibilityButton, @@ -399,7 +401,7 @@ function setVisibility() { if (defaultStore.state.rememberNoteVisibility) { defaultStore.set('localOnly', localOnly); } - } + }, }, 'closed'); } @@ -478,7 +480,22 @@ function onDragover(ev) { if (isFile || isDriveFile) { ev.preventDefault(); draghover = true; - ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; + switch (ev.dataTransfer.effectAllowed) { + case 'all': + case 'uninitialized': + case 'copy': + case 'copyLink': + case 'copyMove': + ev.dataTransfer.dropEffect = 'copy'; + break; + case 'linkMove': + case 'move': + ev.dataTransfer.dropEffect = 'move'; + break; + default: + ev.dataTransfer.dropEffect = 'none'; + break; + } } } @@ -522,8 +539,8 @@ function saveDraft() { visibility: visibility, localOnly: localOnly, files: files, - poll: poll - } + poll: poll, + }, }; localStorage.setItem('drafts', JSON.stringify(draftData)); @@ -559,7 +576,7 @@ async function post() { // plugin if (notePostInterruptors.length > 0) { for (const interruptor of notePostInterruptors) { - postData = await interruptor.handler(JSON.parse(JSON.stringify(postData))); + postData = await interruptor.handler(deepClone(postData)); } } @@ -612,11 +629,11 @@ function showActions(ev) { text: action.title, action: () => { action.handler({ - text: text + text: text, }, (key, value) => { if (key === 'text') { text = value; } }); - } + }, })), ev.currentTarget ?? ev.target); } @@ -726,7 +743,7 @@ onMounted(() => { } } - > div { + > .right { position: absolute; top: 0; right: 0; @@ -924,7 +941,7 @@ onMounted(() => { line-height: 50px; } - > div { + > .right { > .text-count { line-height: 50px; } diff --git a/packages/client/src/components/post-form-attaches.vue b/packages/client/src/components/MkPostFormAttaches.vue index 6b9827407b..a8ec8c33ba 100644 --- a/packages/client/src/components/post-form-attaches.vue +++ b/packages/client/src/components/MkPostFormAttaches.vue @@ -2,7 +2,7 @@ <div v-show="files.length != 0" class="skeikyzd"> <XDraggable v-model="_files" class="files" item-key="id" animation="150" delay="100" delay-on-touch-only="true"> <template #item="{element}"> - <div @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> + <div class="file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> <MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/> <div v-if="element.isSensitive" class="sensitive"> <i class="fas fa-exclamation-triangle icon"></i> @@ -16,24 +16,24 @@ <script lang="ts"> import { defineComponent, defineAsyncComponent } from 'vue'; -import MkDriveFileThumbnail from './drive-file-thumbnail.vue'; +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import * as os from '@/os'; export default defineComponent({ components: { XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), - MkDriveFileThumbnail + MkDriveFileThumbnail, }, props: { files: { type: Array, - required: true + required: true, }, detachMediaFn: { type: Function, - required: false - } + required: false, + }, }, emits: ['updated', 'detach', 'changeSensitive', 'changeName'], @@ -51,8 +51,8 @@ export default defineComponent({ }, set(value) { this.$emit('updated', value); - } - } + }, + }, }, methods: { @@ -66,7 +66,7 @@ export default defineComponent({ toggleSensitive(file) { os.api('drive/files/update', { fileId: file.id, - isSensitive: !file.isSensitive + isSensitive: !file.isSensitive, }).then(() => { this.$emit('changeSensitive', file, !file.isSensitive); }); @@ -75,12 +75,12 @@ export default defineComponent({ const { canceled, result } = await os.inputText({ title: this.$ts.enterFileName, default: file.name, - allowEmpty: false + allowEmpty: false, }); if (canceled) return; os.api('drive/files/update', { fileId: file.id, - name: result + name: result, }).then(() => { this.$emit('changeName', file, result); file.name = result; @@ -88,13 +88,13 @@ export default defineComponent({ }, async describe(file) { - os.popup(defineAsyncComponent(() => import("@/components/media-caption.vue")), { + os.popup(defineAsyncComponent(() => import('@/components/MkMediaCaption.vue')), { title: this.$ts.describeFile, input: { placeholder: this.$ts.inputNewDescription, - default: file.comment !== null ? file.comment : "", + default: file.comment !== null ? file.comment : '', }, - image: file + image: file, }, { done: result => { if (!result || result.canceled) return; @@ -105,7 +105,7 @@ export default defineComponent({ }).then(() => { file.comment = comment; }); - } + }, }, 'closed'); }, @@ -114,22 +114,22 @@ export default defineComponent({ this.menu = os.popupMenu([{ text: this.$ts.renameFile, icon: 'fas fa-i-cursor', - action: () => { this.rename(file); } + action: () => { this.rename(file); }, }, { text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye', - action: () => { this.toggleSensitive(file); } + action: () => { this.toggleSensitive(file); }, }, { text: this.$ts.describeFile, icon: 'fas fa-i-cursor', - action: () => { this.describe(file); } + action: () => { this.describe(file); }, }, { text: this.$ts.attachCancel, icon: 'fas fa-times-circle', - action: () => { this.detachMedia(file.id); } + action: () => { this.detachMedia(file.id); }, }], ev.currentTarget ?? ev.target).then(() => this.menu = null); - } - } + }, + }, }); </script> @@ -142,7 +142,7 @@ export default defineComponent({ display: flex; flex-wrap: wrap; - > div { + > .file { position: relative; width: 64px; height: 64px; diff --git a/packages/client/src/components/post-form-dialog.vue b/packages/client/src/components/MkPostFormDialog.vue index dc4e842059..6dabb1db14 100644 --- a/packages/client/src/components/post-form-dialog.vue +++ b/packages/client/src/components/MkPostFormDialog.vue @@ -6,8 +6,8 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import MkModal from '@/components/ui/modal.vue'; -import MkPostForm from '@/components/post-form.vue'; +import MkModal from '@/components/MkModal.vue'; +import MkPostForm from '@/components/MkPostForm.vue'; export default defineComponent({ components: { diff --git a/packages/client/src/components/reaction-icon.vue b/packages/client/src/components/MkReactionIcon.vue index 5638c9a816..5638c9a816 100644 --- a/packages/client/src/components/reaction-icon.vue +++ b/packages/client/src/components/MkReactionIcon.vue diff --git a/packages/client/src/components/reaction-tooltip.vue b/packages/client/src/components/MkReactionTooltip.vue index b53061df48..2da97cf4f3 100644 --- a/packages/client/src/components/reaction-tooltip.vue +++ b/packages/client/src/components/MkReactionTooltip.vue @@ -9,8 +9,8 @@ <script lang="ts" setup> import { } from 'vue'; -import MkTooltip from './ui/tooltip.vue'; -import XReactionIcon from './reaction-icon.vue'; +import MkTooltip from './MkTooltip.vue'; +import XReactionIcon from '@/components/MkReactionIcon.vue'; const props = defineProps<{ reaction: string; diff --git a/packages/client/src/components/reactions-viewer.details.vue b/packages/client/src/components/MkReactionsViewer.details.vue index eb889c4888..8c423807ba 100644 --- a/packages/client/src/components/reactions-viewer.details.vue +++ b/packages/client/src/components/MkReactionsViewer.details.vue @@ -18,8 +18,8 @@ <script lang="ts" setup> import { } from 'vue'; -import MkTooltip from './ui/tooltip.vue'; -import XReactionIcon from './reaction-icon.vue'; +import MkTooltip from './MkTooltip.vue'; +import XReactionIcon from '@/components/MkReactionIcon.vue'; const props = defineProps<{ reaction: string; @@ -50,14 +50,14 @@ const emit = defineEmits<{ } > .name { - font-size: 0.9em; + font-size: 1em; } } > .users { flex: 1; min-width: 0; - font-size: 0.9em; + font-size: 0.95em; border-left: solid 0.5px var(--divider); padding-left: 10px; margin-left: 10px; diff --git a/packages/client/src/components/MkReactionsViewer.reaction.vue b/packages/client/src/components/MkReactionsViewer.reaction.vue new file mode 100644 index 0000000000..31342b0b48 --- /dev/null +++ b/packages/client/src/components/MkReactionsViewer.reaction.vue @@ -0,0 +1,135 @@ +<template> +<button + v-if="count > 0" + ref="buttonRef" + v-ripple="canToggle" + class="hkzvhatu _button" + :class="{ reacted: note.myReaction == reaction, canToggle }" + @click="toggleReaction()" +> + <XReactionIcon class="icon" :reaction="reaction" :custom-emojis="note.emojis"/> + <span class="count">{{ count }}</span> +</button> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import XDetails from '@/components/MkReactionsViewer.details.vue'; +import XReactionIcon from '@/components/MkReactionIcon.vue'; +import * as os from '@/os'; +import { useTooltip } from '@/scripts/use-tooltip'; +import { $i } from '@/account'; + +const props = defineProps<{ + reaction: string; + count: number; + isInitial: boolean; + note: misskey.entities.Note; +}>(); + +const buttonRef = ref<HTMLElement>(); + +const canToggle = computed(() => !props.reaction.match(/@\w/) && $i); + +const toggleReaction = () => { + if (!canToggle.value) return; + + const oldReaction = props.note.myReaction; + if (oldReaction) { + os.api('notes/reactions/delete', { + noteId: props.note.id, + }).then(() => { + if (oldReaction !== props.reaction) { + os.api('notes/reactions/create', { + noteId: props.note.id, + reaction: props.reaction, + }); + } + }); + } else { + os.api('notes/reactions/create', { + noteId: props.note.id, + reaction: props.reaction, + }); + } +}; + +const anime = () => { + if (document.hidden) return; + + // TODO: 新しくリアクションが付いたことが視覚的に分かりやすいアニメーション +}; + +watch(() => props.count, (newCount, oldCount) => { + if (oldCount < newCount) anime(); +}); + +onMounted(() => { + if (!props.isInitial) anime(); +}); + +useTooltip(buttonRef, async (showing) => { + const reactions = await os.apiGet('notes/reactions', { + noteId: props.note.id, + type: props.reaction, + limit: 11, + _cacheKey_: props.count, + }); + + const users = reactions.map(x => x.user); + + os.popup(XDetails, { + showing, + reaction: props.reaction, + emojis: props.note.emojis, + users, + count: props.count, + targetElement: buttonRef.value, + }, {}, 'closed'); +}, 100); +</script> + +<style lang="scss" scoped> +.hkzvhatu { + display: inline-block; + height: 32px; + margin: 2px; + padding: 0 6px; + border-radius: 4px; + + &.canToggle { + background: rgba(0, 0, 0, 0.05); + + &:hover { + background: rgba(0, 0, 0, 0.1); + } + } + + &:not(.canToggle) { + cursor: default; + } + + &.reacted { + background: var(--accent); + + &:hover { + background: var(--accent); + } + + > .count { + color: var(--fgOnAccent); + } + + > .icon { + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5)); + } + } + + > .count { + font-size: 0.9em; + line-height: 32px; + margin: 0 0 0 4px; + } +} +</style> diff --git a/packages/client/src/components/reactions-viewer.vue b/packages/client/src/components/MkReactionsViewer.vue index a9bf51f65f..a88311efa1 100644 --- a/packages/client/src/components/reactions-viewer.vue +++ b/packages/client/src/components/MkReactionsViewer.vue @@ -8,7 +8,7 @@ import { computed } from 'vue'; import * as misskey from 'misskey-js'; import { $i } from '@/account'; -import XReaction from './reactions-viewer.reaction.vue'; +import XReaction from '@/components/MkReactionsViewer.reaction.vue'; const props = defineProps<{ note: misskey.entities.Note; diff --git a/packages/client/src/components/remote-caution.vue b/packages/client/src/components/MkRemoteCaution.vue index aa623f0fb0..e9461197ca 100644 --- a/packages/client/src/components/remote-caution.vue +++ b/packages/client/src/components/MkRemoteCaution.vue @@ -1,8 +1,10 @@ <template> -<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ $ts.remoteUserCaution }}<a :href="href" rel="nofollow noopener" target="_blank">{{ $ts.showOnRemote }}</a></div> +<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ i18n.ts.remoteUserCaution }}<a class="link" :href="href" rel="nofollow noopener" target="_blank">{{ i18n.ts.showOnRemote }}</a></div> </template> <script lang="ts" setup> +import { i18n } from '@/i18n'; + defineProps<{ href: string; }>(); @@ -15,7 +17,7 @@ defineProps<{ background: var(--infoWarnBg); color: var(--infoWarnFg); - > a { + > .link { margin-left: 4px; color: var(--accent); } diff --git a/packages/client/src/components/MkRenoteButton.vue b/packages/client/src/components/MkRenoteButton.vue new file mode 100644 index 0000000000..413f3406a4 --- /dev/null +++ b/packages/client/src/components/MkRenoteButton.vue @@ -0,0 +1,99 @@ +<template> +<button + v-if="canRenote" + ref="buttonRef" + class="eddddedb _button canRenote" + @click="renote()" +> + <i class="fas fa-retweet"></i> + <p v-if="count > 0" class="count">{{ count }}</p> +</button> +<button v-else class="eddddedb _button"> + <i class="fas fa-ban"></i> +</button> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import * as misskey from 'misskey-js'; +import XDetails from '@/components/MkUsersTooltip.vue'; +import { pleaseLogin } from '@/scripts/please-login'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { useTooltip } from '@/scripts/use-tooltip'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + note: misskey.entities.Note; + count: number; +}>(); + +const buttonRef = ref<HTMLElement>(); + +const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id); + +useTooltip(buttonRef, async (showing) => { + const renotes = await os.api('notes/renotes', { + noteId: props.note.id, + limit: 11, + }); + + const users = renotes.map(x => x.user); + + if (users.length < 1) return; + + os.popup(XDetails, { + showing, + users, + count: props.count, + targetElement: buttonRef.value, + }, {}, 'closed'); +}); + +const renote = (viaKeyboard = false) => { + pleaseLogin(); + os.popupMenu([{ + text: i18n.ts.renote, + icon: 'fas fa-retweet', + action: () => { + os.api('notes/create', { + renoteId: props.note.id, + }); + }, + }, { + text: i18n.ts.quote, + icon: 'fas fa-quote-right', + action: () => { + os.post({ + renote: props.note, + }); + }, + }], buttonRef.value, { + viaKeyboard, + }); +}; +</script> + +<style lang="scss" scoped> +.eddddedb { + display: inline-block; + height: 32px; + margin: 2px; + padding: 0 6px; + border-radius: 4px; + + &:not(.canRenote) { + cursor: default; + } + + &.renoted { + background: var(--accent); + } + + > .count { + display: inline; + margin-left: 8px; + opacity: 0.7; + } +} +</style> diff --git a/packages/client/src/components/ripple.vue b/packages/client/src/components/MkRipple.vue index 401e78e304..9d93211d5f 100644 --- a/packages/client/src/components/ripple.vue +++ b/packages/client/src/components/MkRipple.vue @@ -1,8 +1,9 @@ <template> -<div class="vswabwbm" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }" :class="{ active }"> +<div class="vswabwbm" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }"> <svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"> <circle fill="none" cx="64" cy="64"> - <animate attributeName="r" + <animate + attributeName="r" begin="0s" dur="0.5s" values="4; 32" calcMode="spline" @@ -10,7 +11,8 @@ keySplines="0.165, 0.84, 0.44, 1" repeatCount="1" /> - <animate attributeName="stroke-width" + <animate + attributeName="stroke-width" begin="0s" dur="0.5s" values="16; 0" calcMode="spline" @@ -21,7 +23,8 @@ </circle> <g fill="none" fill-rule="evenodd"> <circle v-for="(particle, i) in particles" :key="i" :fill="particle.color"> - <animate attributeName="r" + <animate + attributeName="r" begin="0s" dur="0.8s" :values="`${particle.size}; 0`" calcMode="spline" @@ -29,7 +32,8 @@ keySplines="0.165, 0.84, 0.44, 1" repeatCount="1" /> - <animate attributeName="cx" + <animate + attributeName="cx" begin="0s" dur="0.8s" :values="`${particle.xA}; ${particle.xB}`" calcMode="spline" @@ -37,7 +41,8 @@ keySplines="0.3, 0.61, 0.355, 1" repeatCount="1" /> - <animate attributeName="cy" + <animate + attributeName="cy" begin="0s" dur="0.8s" :values="`${particle.yA}; ${particle.yB}`" calcMode="spline" @@ -51,59 +56,47 @@ </div> </template> -<script lang="ts"> -import { defineComponent, onMounted } from 'vue'; +<script lang="ts" setup> +import { onMounted } from 'vue'; import * as os from '@/os'; -export default defineComponent({ - props: { - x: { - type: Number, - required: true - }, - y: { - type: Number, - required: true - }, - particle: { - type: Boolean, - required: false, - default: true, - } - }, - emits: ['end'], - setup(props, context) { - const particles = []; - const origin = 64; - const colors = ['#FF1493', '#00FFFF', '#FFE202']; +const props = withDefaults(defineProps<{ + x: number; + y: number; + particle?: boolean; +}>(), { + particle: true, +}); - if (props.particle) { - for (let i = 0; i < 12; i++) { - const angle = Math.random() * (Math.PI * 2); - const pos = Math.random() * 16; - const velocity = 16 + (Math.random() * 48); - particles.push({ - size: 4 + (Math.random() * 8), - xA: origin + (Math.sin(angle) * pos), - yA: origin + (Math.cos(angle) * pos), - xB: origin + (Math.sin(angle) * (pos + velocity)), - yB: origin + (Math.cos(angle) * (pos + velocity)), - color: colors[Math.floor(Math.random() * colors.length)] - }); - } - } +const emit = defineEmits<{ + (ev: 'end'): void; +}>(); - onMounted(() => { - window.setTimeout(() => { - context.emit('end'); - }, 1100); +const particles = []; +const origin = 64; +const colors = ['#FF1493', '#00FFFF', '#FFE202']; +const zIndex = os.claimZIndex('high'); + +if (props.particle) { + for (let i = 0; i < 12; i++) { + const angle = Math.random() * (Math.PI * 2); + const pos = Math.random() * 16; + const velocity = 16 + (Math.random() * 48); + particles.push({ + size: 4 + (Math.random() * 8), + xA: origin + (Math.sin(angle) * pos), + yA: origin + (Math.cos(angle) * pos), + xB: origin + (Math.sin(angle) * (pos + velocity)), + yB: origin + (Math.cos(angle) * (pos + velocity)), + color: colors[Math.floor(Math.random() * colors.length)], }); + } +} - return { - particles, - zIndex: os.claimZIndex('high'), - }; - }, +onMounted(() => { + window.setTimeout(() => { + emit('end'); + }, 1100); }); </script> diff --git a/packages/client/src/components/sample.vue b/packages/client/src/components/MkSample.vue index f80b9c96b7..1169654d09 100644 --- a/packages/client/src/components/sample.vue +++ b/packages/client/src/components/MkSample.vue @@ -29,7 +29,7 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; import MkSwitch from '@/components/form/switch.vue'; import MkTextarea from '@/components/form/textarea.vue'; diff --git a/packages/client/src/components/signin.vue b/packages/client/src/components/MkSignin.vue index b772d1479b..5613e5cf02 100644 --- a/packages/client/src/components/signin.vue +++ b/packages/client/src/components/MkSignin.vue @@ -6,7 +6,7 @@ {{ message }} </MkInfo> <div v-if="!totpLogin" class="normal-signin"> - <MkInput v-model="username" class="_formBlock" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange"> + <MkInput v-model="username" class="_formBlock" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange"> <template #prefix>@</template> <template #suffix>@{{ host }}</template> </MkInput> @@ -32,7 +32,7 @@ <template #label>{{ i18n.ts.password }}</template> <template #prefix><i class="fas fa-lock"></i></template> </MkInput> - <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false" required> <template #label>{{ i18n.ts.token }}</template> <template #prefix><i class="fas fa-gavel"></i></template> </MkInput> @@ -51,9 +51,9 @@ <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; import { toUnicode } from 'punycode/'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; -import MkInfo from '@/components/ui/info.vue'; +import MkInfo from '@/components/MkInfo.vue'; import { apiUrl, host as configHost } from '@/config'; import { byteify, hexify } from '@/scripts/2fa'; import * as os from '@/os'; @@ -237,7 +237,7 @@ function loginFailed(err) { } function resetPassword() { - os.popup(defineAsyncComponent(() => import('@/components/forgot-password.vue')), {}, { + os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { }, 'closed'); } </script> diff --git a/packages/client/src/components/signin-dialog.vue b/packages/client/src/components/MkSigninDialog.vue index 848b11fada..fd27244516 100644 --- a/packages/client/src/components/signin-dialog.vue +++ b/packages/client/src/components/MkSigninDialog.vue @@ -1,11 +1,12 @@ <template> -<XModalWindow ref="dialog" +<XModalWindow + ref="dialog" :width="370" :height="400" @close="onClose" @closed="emit('closed')" > - <template #header>{{ $ts.login }}</template> + <template #header>{{ i18n.ts.login }}</template> <MkSignin :auto-set="autoSet" :message="message" @login="onLogin"/> </XModalWindow> @@ -13,15 +14,16 @@ <script lang="ts" setup> import { } from 'vue'; -import XModalWindow from '@/components/ui/modal-window.vue'; -import MkSignin from './signin.vue'; +import MkSignin from '@/components/MkSignin.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; +import { i18n } from '@/i18n'; const props = withDefaults(defineProps<{ autoSet?: boolean; message?: string, }>(), { autoSet: false, - message: '' + message: '', }); const emit = defineEmits<{ diff --git a/packages/client/src/components/MkSignup.vue b/packages/client/src/components/MkSignup.vue new file mode 100644 index 0000000000..c1f91b18c2 --- /dev/null +++ b/packages/client/src/components/MkSignup.vue @@ -0,0 +1,246 @@ +<template> +<form class="qlvuhzng _formRoot" autocomplete="new-password" @submit.prevent="onSubmit"> + <MkInput v-if="instance.disableRegistration" v-model="invitationCode" class="_formBlock" type="text" :spellcheck="false" required> + <template #label>{{ i18n.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>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.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> {{ i18n.ts.checking }}</span> + <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ i18n.ts.available }}</span> + <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ i18n.ts.unavailable }}</span> + <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ i18n.ts.error }}</span> + <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ i18n.ts.usernameInvalidFormat }}</span> + <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ i18n.ts.tooShort }}</span> + <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ i18n.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>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.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> {{ i18n.ts.checking }}</span> + <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ i18n.ts.available }}</span> + <span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ i18n.ts._emailUnavailable.used }}</span> + <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ i18n.ts._emailUnavailable.format }}</span> + <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ i18n.ts._emailUnavailable.disposable }}</span> + <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ i18n.ts._emailUnavailable.mx }}</span> + <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ i18n.ts._emailUnavailable.smtp }}</span> + <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ i18n.ts.unavailable }}</span> + <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ i18n.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>{{ i18n.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> {{ i18n.ts.weakPassword }}</span> + <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="fas fa-check fa-fw"></i> {{ i18n.ts.normalPassword }}</span> + <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ i18n.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>{{ i18n.ts.password }} ({{ i18n.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> {{ i18n.ts.passwordMatched }}</span> + <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ i18n.ts.passwordNotMatched }}</span> + </template> + </MkInput> + <MkSwitch v-if="instance.tosUrl" v-model="ToSAgreement" class="_formBlock tou"> + <I18n :src="i18n.ts.agreeTo"> + <template #0> + <a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.tos }}</a> + </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"/> + <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" class="_formBlock captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> + <MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ i18n.ts.start }}</MkButton> +</form> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import getPasswordStrength from 'syuilo-password-strength'; +import { toUnicode } from 'punycode/'; +import MkButton from './MkButton.vue'; +import MkInput from './form/input.vue'; +import MkSwitch from './form/switch.vue'; +import MkCaptcha from '@/components/MkCaptcha.vue'; +import * as config from '@/config'; +import * as os from '@/os'; +import { login } from '@/account'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + autoSet?: boolean; +}>(), { + autoSet: false, +}); + +const emit = defineEmits<{ + (ev: 'signup', user: Record<string, any>): void; + (ev: 'signupEmailPending'): void; +}>(); + +const host = toUnicode(config.host); + +let hcaptcha = $ref(); +let recaptcha = $ref(); +let turnstile = $ref(); + +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); +let turnstileResponse = $ref(null); + +const shouldDisableSubmitting = $computed((): boolean => { + return submitting || + instance.tosUrl && !ToSAgreement || + instance.enableHcaptcha && !hCaptchaResponse || + instance.enableRecaptcha && !reCaptchaResponse || + instance.enableTurnstile && !turnstileResponse || + passwordRetypeState === 'not-match'; +}); + +function onChangeUsername(): void { + if (username === '') { + usernameState = null; + return; + } + + { + const err = + !username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' : + username.length < 1 ? 'min-range' : + username.length > 20 ? 'max-range' : + null; + + if (err) { + usernameState = err; + return; + } + } + + usernameState = 'wait'; + + os.api('username/available', { + username, + }).then(result => { + usernameState = result.available ? 'ok' : 'unavailable'; + }).catch(() => { + usernameState = 'error'; + }); +} + +function onChangeEmail(): void { + if (email === '') { + emailState = null; + return; + } + + emailState = 'wait'; + + 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'; + }); +} + +function onChangePassword(): void { + if (password === '') { + passwordStrength = ''; + return; + } + + const strength = getPasswordStrength(password); + passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; +} + +function onChangePasswordRetype(): void { + if (retypedPassword === '') { + passwordRetypeState = null; + return; + } + + passwordRetypeState = password === retypedPassword ? 'match' : 'not-match'; +} + +function onSubmit(): void { + if (submitting) return; + submitting = true; + + os.api('signup', { + username, + password, + emailAddress: email, + invitationCode, + 'hcaptcha-response': hCaptchaResponse, + 'g-recaptcha-response': reCaptchaResponse, + 'turnstile-response': turnstileResponse, + }).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 (props.autoSet) { + login(res.i); + } + }); + } + }).catch(() => { + submitting = false; + hcaptcha.reset?.(); + recaptcha.reset?.(); + turnstile.reset?.(); + + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + }); +} +</script> + +<style lang="scss" scoped> +.qlvuhzng { + .captcha { + margin: 16px 0; + } +} +</style> diff --git a/packages/client/src/components/signup-dialog.vue b/packages/client/src/components/MkSignupDialog.vue index 6dad9257a4..d6d4553bc6 100644 --- a/packages/client/src/components/signup-dialog.vue +++ b/packages/client/src/components/MkSignupDialog.vue @@ -1,11 +1,12 @@ <template> -<XModalWindow ref="dialog" +<XModalWindow + ref="dialog" :width="366" :height="500" @close="dialog.close()" @closed="$emit('closed')" > - <template #header>{{ $ts.signup }}</template> + <template #header>{{ i18n.ts.signup }}</template> <div class="_monolithic_"> <div class="_section"> @@ -17,8 +18,9 @@ <script lang="ts" setup> import { } from 'vue'; -import XModalWindow from '@/components/ui/modal-window.vue'; -import XSignup from './signup.vue'; +import XSignup from '@/components/MkSignup.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; +import { i18n } from '@/i18n'; const props = withDefaults(defineProps<{ autoSet?: boolean; diff --git a/packages/client/src/components/sparkle.vue b/packages/client/src/components/MkSparkle.vue index f52e5a3f9b..cdeaf9c417 100644 --- a/packages/client/src/components/sparkle.vue +++ b/packages/client/src/components/MkSparkle.vue @@ -33,7 +33,8 @@ </svg> --> <svg v-for="particle in particles" :key="particle.id" :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg"> - <path style="transform-origin: center; transform-box: fill-box;" + <path + style="transform-origin: center; transform-box: fill-box;" :transform="`translate(${particle.x} ${particle.y})`" :fill="particle.color" d="M29.427,2.011C29.721,0.83 30.782,0 32,0C33.218,0 34.279,0.83 34.573,2.011L39.455,21.646C39.629,22.347 39.991,22.987 40.502,23.498C41.013,24.009 41.653,24.371 42.354,24.545L61.989,29.427C63.17,29.721 64,30.782 64,32C64,33.218 63.17,34.279 61.989,34.573L42.354,39.455C41.653,39.629 41.013,39.991 40.502,40.502C39.991,41.013 39.629,41.653 39.455,42.354L34.573,61.989C34.279,63.17 33.218,64 32,64C30.782,64 29.721,63.17 29.427,61.989L24.545,42.354C24.371,41.653 24.009,41.013 23.498,40.502C22.987,39.991 22.347,39.629 21.646,39.455L2.011,34.573C0.83,34.279 0,33.218 0,32C0,30.782 0.83,29.721 2.011,29.427L21.646,24.545C22.347,24.371 22.987,24.009 23.498,23.498C24.009,22.987 24.371,22.347 24.545,21.646L29.427,2.011Z" @@ -62,61 +63,51 @@ </span> </template> -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; -import * as os from '@/os'; +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; -export default defineComponent({ - setup() { - const particles = ref([]); - const el = ref<HTMLElement>(); - const width = ref(0); - const height = ref(0); - const colors = ['#FF1493', '#00FFFF', '#FFE202', '#FFE202', '#FFE202']; +const particles = ref([]); +const el = ref<HTMLElement>(); +const width = ref(0); +const height = ref(0); +const colors = ['#FF1493', '#00FFFF', '#FFE202', '#FFE202', '#FFE202']; +let stop = false; +let ro: ResizeObserver | undefined; - onMounted(() => { - const ro = new ResizeObserver((entries, observer) => { - width.value = el.value?.offsetWidth + 64; - height.value = el.value?.offsetHeight + 64; - }); - ro.observe(el.value); - let stop = false; - const add = () => { - if (stop) return; - const x = (Math.random() * (width.value - 64)); - const y = (Math.random() * (height.value - 64)); - const sizeFactor = Math.random(); - const particle = { - id: Math.random().toString(), - x, - y, - size: 0.2 + ((sizeFactor / 10) * 3), - dur: 1000 + (sizeFactor * 1000), - color: colors[Math.floor(Math.random() * colors.length)], - }; - particles.value.push(particle); - window.setTimeout(() => { - particles.value = particles.value.filter(x => x.id !== particle.id); - }, particle.dur - 100); +onMounted(() => { + ro = new ResizeObserver((entries, observer) => { + width.value = el.value?.offsetWidth + 64; + height.value = el.value?.offsetHeight + 64; + }); + ro.observe(el.value); + const add = () => { + if (stop) return; + const x = (Math.random() * (width.value - 64)); + const y = (Math.random() * (height.value - 64)); + const sizeFactor = Math.random(); + const particle = { + id: Math.random().toString(), + x, + y, + size: 0.2 + ((sizeFactor / 10) * 3), + dur: 1000 + (sizeFactor * 1000), + color: colors[Math.floor(Math.random() * colors.length)], + }; + particles.value.push(particle); + window.setTimeout(() => { + particles.value = particles.value.filter(x => x.id !== particle.id); + }, particle.dur - 100); - window.setTimeout(() => { - add(); - }, 500 + (Math.random() * 500)); - }; + window.setTimeout(() => { add(); - onUnmounted(() => { - ro.disconnect(); - stop = true; - }); - }); + }, 500 + (Math.random() * 500)); + }; + add(); +}); - return { - el, - width, - height, - particles, - }; - }, +onUnmounted(() => { + if (ro) ro.disconnect(); + stop = true; }); </script> diff --git a/packages/client/src/components/sub-note-content.vue b/packages/client/src/components/MkSubNoteContent.vue index d6a37d07be..237f4cf228 100644 --- a/packages/client/src/components/sub-note-content.vue +++ b/packages/client/src/components/MkSubNoteContent.vue @@ -1,8 +1,8 @@ <template> <div class="wrmlmaau" :class="{ collapsed }"> <div class="body"> - <span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> - <span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span> + <span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> + <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span> <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA> <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> @@ -12,20 +12,21 @@ <XMediaList :media-list="note.files"/> </details> <details v-if="note.poll"> - <summary>{{ $ts.poll }}</summary> + <summary>{{ i18n.ts.poll }}</summary> <XPoll :note="note"/> </details> <button v-if="collapsed" class="fade _button" @click="collapsed = false"> - <span>{{ $ts.showMore }}</span> + <span>{{ i18n.ts.showMore }}</span> </button> </div> </template> <script lang="ts" setup> import { } from 'vue'; -import XPoll from './poll.vue'; -import XMediaList from './media-list.vue'; import * as misskey from 'misskey-js'; +import XMediaList from '@/components/MkMediaList.vue'; +import XPoll from '@/components/MkPoll.vue'; +import { i18n } from '@/i18n'; const props = defineProps<{ note: misskey.entities.Note; diff --git a/packages/client/src/components/ui/super-menu.vue b/packages/client/src/components/MkSuperMenu.vue index 78f524036d..8ce2dc5dc2 100644 --- a/packages/client/src/components/ui/super-menu.vue +++ b/packages/client/src/components/MkSuperMenu.vue @@ -30,7 +30,7 @@ export default defineComponent({ props: { def: { type: Array, - required: true + required: true, }, grid: { type: Boolean, @@ -64,7 +64,7 @@ export default defineComponent({ box-sizing: border-box; padding: 10px 16px 10px 8px; border-radius: 9px; - font-size: 0.95em; + font-size: 0.9em; &:hover { text-decoration: none; diff --git a/packages/client/src/components/tab.vue b/packages/client/src/components/MkTab.vue index c629727358..669e9e2e11 100644 --- a/packages/client/src/components/tab.vue +++ b/packages/client/src/components/MkTab.vue @@ -18,13 +18,13 @@ export default defineComponent({ disabled: this.modelValue === option.props.value, onClick: () => { this.$emit('update:modelValue', option.props.value); - } + }, }, option.children), [ - [resolveDirective('click-anime')] + [resolveDirective('click-anime')], ]))), [ - [resolveDirective('size'), { max: [500] }] + [resolveDirective('size'), { max: [500] }], ]); - } + }, }); </script> diff --git a/packages/client/src/components/MkTagCloud.vue b/packages/client/src/components/MkTagCloud.vue new file mode 100644 index 0000000000..2dfd26edb0 --- /dev/null +++ b/packages/client/src/components/MkTagCloud.vue @@ -0,0 +1,90 @@ +<template> +<div ref="rootEl" class="meijqfqm"> + <canvas :id="idForCanvas" ref="canvasEl" class="canvas" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas> + <div :id="idForTags" ref="tagsEl" class="tags"> + <ul> + <slot></slot> + </ul> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref, watch, PropType, onBeforeUnmount } from 'vue'; +import tinycolor from 'tinycolor2'; + +const loaded = !!window.TagCanvas; +const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz'; +const computedStyle = getComputedStyle(document.documentElement); +const idForCanvas = Array.from(Array(16)).map(() => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); +const idForTags = Array.from(Array(16)).map(() => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); +let available = $ref(false); +let rootEl = $ref<HTMLElement | null>(null); +let canvasEl = $ref<HTMLCanvasElement | null>(null); +let tagsEl = $ref<HTMLElement | null>(null); +let width = $ref(300); + +watch($$(available), () => { + try { + window.TagCanvas.Start(idForCanvas, idForTags, { + textColour: '#ffffff', + outlineColour: tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(), + outlineRadius: 10, + initial: [-0.030, -0.010], + frontSelect: true, + imageRadius: 8, + //dragControl: true, + dragThreshold: 3, + wheelZoom: false, + reverse: true, + depth: 0.5, + maxSpeed: 0.2, + minSpeed: 0.003, + stretchX: 0.8, + stretchY: 0.8, + }); + } catch (err) {} +}); + +onMounted(() => { + width = rootEl.offsetWidth; + + if (loaded) { + available = true; + } else { + document.head.appendChild(Object.assign(document.createElement('script'), { + async: true, + src: '/client-assets/tagcanvas.min.js', + })).addEventListener('load', () => available = true); + } +}); + +onBeforeUnmount(() => { + if (window.TagCanvas) window.TagCanvas.Delete(idForCanvas); +}); + +defineExpose({ + update: () => { + window.TagCanvas.Update(idForCanvas); + }, +}); +</script> + +<style lang="scss" scoped> +.meijqfqm { + position: relative; + overflow: clip; + display: grid; + place-items: center; + + > .canvas { + display: block; + } + + > .tags { + position: absolute; + top: 999px; + left: 999px; + } +} +</style> diff --git a/packages/client/src/components/timeline.vue b/packages/client/src/components/MkTimeline.vue index a3fa27ab78..831a194ce3 100644 --- a/packages/client/src/components/timeline.vue +++ b/packages/client/src/components/MkTimeline.vue @@ -4,7 +4,7 @@ <script lang="ts" setup> import { ref, computed, provide, onUnmounted } from 'vue'; -import XNotes from './notes.vue'; +import XNotes from '@/components/MkNotes.vue'; import * as os from '@/os'; import { stream } from '@/stream'; import * as sound from '@/scripts/sound'; @@ -59,10 +59,10 @@ let connection2; if (props.src === 'antenna') { endpoint = 'antennas/notes'; query = { - antennaId: props.antenna + antennaId: props.antenna, }; connection = stream.useChannel('antenna', { - antennaId: props.antenna + antennaId: props.antenna, }); connection.on('note', prepend); } else if (props.src === 'home') { @@ -92,7 +92,7 @@ if (props.src === 'antenna') { } else if (props.src === 'directs') { endpoint = 'notes/mentions'; query = { - visibility: 'specified' + visibility: 'specified', }; const onNote = note => { if (note.visibility === 'specified') { @@ -104,10 +104,10 @@ if (props.src === 'antenna') { } else if (props.src === 'list') { endpoint = 'notes/user-list-timeline'; query = { - listId: props.list + listId: props.list, }; connection = stream.useChannel('userList', { - listId: props.list + listId: props.list, }); connection.on('note', prepend); connection.on('userAdded', onUserAdded); @@ -115,10 +115,10 @@ if (props.src === 'antenna') { } else if (props.src === 'channel') { endpoint = 'channels/timeline'; query = { - channelId: props.channel + channelId: props.channel, }; connection = stream.useChannel('channel', { - channelId: props.channel + channelId: props.channel, }); connection.on('note', prepend); } diff --git a/packages/client/src/components/toast.vue b/packages/client/src/components/MkToast.vue index c9fad64eb6..c9fad64eb6 100644 --- a/packages/client/src/components/toast.vue +++ b/packages/client/src/components/MkToast.vue diff --git a/packages/client/src/components/MkTokenGenerateWindow.vue b/packages/client/src/components/MkTokenGenerateWindow.vue new file mode 100644 index 0000000000..b846034a24 --- /dev/null +++ b/packages/client/src/components/MkTokenGenerateWindow.vue @@ -0,0 +1,90 @@ +<template> +<XModalWindow + ref="dialog" + :width="400" + :height="450" + :with-ok-button="true" + :ok-button-disabled="false" + :can-close="false" + @close="dialog.close()" + @closed="$emit('closed')" + @ok="ok()" +> + <template #header>{{ title || $ts.generateAccessToken }}</template> + <div v-if="information" class="_section"> + <MkInfo warn>{{ information }}</MkInfo> + </div> + <div class="_section"> + <MkInput v-model="name"> + <template #label>{{ $ts.name }}</template> + </MkInput> + </div> + <div class="_section"> + <div style="margin-bottom: 16px;"><b>{{ $ts.permission }}</b></div> + <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton> + <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton> + <MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ $t(`_permissions.${kind}`) }}</MkSwitch> + </div> +</XModalWindow> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import { permissions as kinds } from 'misskey-js'; +import MkInput from './form/input.vue'; +import MkSwitch from './form/switch.vue'; +import MkButton from './MkButton.vue'; +import MkInfo from './MkInfo.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; + +const props = withDefaults(defineProps<{ + title?: string | null; + information?: string | null; + initialName?: string | null; + initialPermissions?: string[] | null; +}>(), { + title: null, + information: null, + initialName: null, + initialPermissions: null, +}); + +const emit = defineEmits<{ + (ev: 'closed'): void; + (ev: 'done', result: { name: string | null, permissions: string[] }): void; +}>(); + +const dialog = $ref<InstanceType<typeof XModalWindow>>(); +let name = $ref(props.initialName); +let permissions = $ref({}); + +if (props.initialPermissions) { + for (const kind of props.initialPermissions) { + permissions[kind] = true; + } +} else { + for (const kind of kinds) { + permissions[kind] = false; + } +} + +function ok(): void { + emit('done', { + name: name, + permissions: Object.keys(permissions).filter(p => permissions[p]), + }); + dialog.close(); +} + +function disableAll(): void { + for (const p in permissions) { + permissions[p] = false; + } +} + +function enableAll(): void { + for (const p in permissions) { + permissions[p] = true; + } +} +</script> diff --git a/packages/client/src/components/MkTooltip.vue b/packages/client/src/components/MkTooltip.vue new file mode 100644 index 0000000000..4c6258d245 --- /dev/null +++ b/packages/client/src/components/MkTooltip.vue @@ -0,0 +1,101 @@ +<template> +<transition :name="$store.state.animation ? 'tooltip' : ''" appear @after-leave="emit('closed')"> + <div v-show="showing" ref="el" class="buebdbiu _acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }"> + <slot> + <Mfm v-if="asMfm" :text="text"/> + <span v-else>{{ text }}</span> + </slot> + </div> +</transition> +</template> + +<script lang="ts" setup> +import { nextTick, onMounted, onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import { calcPopupPosition } from '@/scripts/popup-position'; + +const props = withDefaults(defineProps<{ + showing: boolean; + targetElement?: HTMLElement; + x?: number; + y?: number; + text?: string; + asMfm?: boolean; + maxWidth?: number; + direction?: 'top' | 'bottom' | 'right' | 'left'; + innerMargin?: number; +}>(), { + maxWidth: 250, + direction: 'top', + innerMargin: 0, +}); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +const el = ref<HTMLElement>(); +const zIndex = os.claimZIndex('high'); + +function setPosition() { + const data = calcPopupPosition(el.value, { + anchorElement: props.targetElement, + direction: props.direction, + align: 'center', + innerMargin: props.innerMargin, + x: props.x, + y: props.y, + }); + + el.value.style.transformOrigin = data.transformOrigin; + el.value.style.left = data.left + 'px'; + el.value.style.top = data.top + 'px'; +} + +let loopHandler; + +onMounted(() => { + nextTick(() => { + setPosition(); + + const loop = () => { + loopHandler = window.requestAnimationFrame(() => { + setPosition(); + loop(); + }); + }; + + loop(); + }); +}); + +onUnmounted(() => { + window.cancelAnimationFrame(loopHandler); +}); +</script> + +<style lang="scss" scoped> +.tooltip-enter-active, +.tooltip-leave-active { + opacity: 1; + transform: scale(1); + transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), opacity 200ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tooltip-enter-from, +.tooltip-leave-active { + opacity: 0; + transform: scale(0.75); +} + +.buebdbiu { + position: absolute; + font-size: 0.8em; + padding: 8px 12px; + box-sizing: border-box; + text-align: center; + border-radius: 4px; + border: solid 0.5px var(--divider); + pointer-events: none; + transform-origin: center center; +} +</style> diff --git a/packages/client/src/components/updated.vue b/packages/client/src/components/MkUpdated.vue index 375ac0dbbb..48aeb30224 100644 --- a/packages/client/src/components/updated.vue +++ b/packages/client/src/components/MkUpdated.vue @@ -1,22 +1,23 @@ <template> <MkModal ref="modal" :z-priority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')"> <div class="ewlycnyt"> - <div class="title"><MkSparkle>{{ $ts.misskeyUpdated }}</MkSparkle></div> + <div class="title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div> <div class="version">✨{{ version }}🚀</div> - <MkButton full @click="whatIsNew">{{ $ts.whatIsNew }}</MkButton> - <MkButton class="gotIt" primary full @click="$refs.modal.close()">{{ $ts.gotIt }}</MkButton> + <MkButton full @click="whatIsNew">{{ i18n.ts.whatIsNew }}</MkButton> + <MkButton class="gotIt" primary full @click="$refs.modal.close()">{{ i18n.ts.gotIt }}</MkButton> </div> </MkModal> </template> <script lang="ts" setup> import { ref } from 'vue'; -import MkModal from '@/components/ui/modal.vue'; -import MkButton from '@/components/ui/button.vue'; -import MkSparkle from '@/components/sparkle.vue'; +import MkModal from '@/components/MkModal.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkSparkle from '@/components/MkSparkle.vue'; import { version } from '@/config'; +import { i18n } from '@/i18n'; -const modal = ref(); +const modal = ref<InstanceType<typeof MkModal>>(); const whatIsNew = () => { modal.value.close(); diff --git a/packages/client/src/components/url-preview.vue b/packages/client/src/components/MkUrlPreview.vue index 6c593c7b41..af27f644ed 100644 --- a/packages/client/src/components/url-preview.vue +++ b/packages/client/src/components/MkUrlPreview.vue @@ -1,16 +1,16 @@ <template> <div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> - <button class="disablePlayer" :title="$ts.disablePlayer" @click="playerEnabled = false"><i class="fas fa-times"></i></button> - <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen /> + <button class="disablePlayer" :title="i18n.ts.disablePlayer" @click="playerEnabled = false"><i class="fas fa-times"></i></button> + <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/> </div> <div v-else-if="tweetId && tweetExpanded" ref="twitter" class="twitter"> <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe> </div> <div v-else v-size="{ max: [400, 350] }" class="mk-url-preview"> <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> - <component :is="self ? 'MkA' : 'a'" v-if="!fetching" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url"> + <component :is="self ? 'MkA' : 'a'" v-if="!fetching" class="link" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url"> <div v-if="thumbnail" class="thumbnail" :style="`background-image: url('${thumbnail}')`"> - <button v-if="!playerEnabled && player.url" class="_button" :title="$ts.enablePlayer" @click.prevent="playerEnabled = true"><i class="fas fa-play-circle"></i></button> + <button v-if="!playerEnabled && player.url" class="_button" :title="i18n.ts.enablePlayer" @click.prevent="isMobile? playerEnabled = true : openPlayer()"><i class="fas fa-play-circle"></i></button> </div> <article> <header> @@ -26,15 +26,18 @@ </transition> <div v-if="tweetId" class="expandTweet"> <a @click="tweetExpanded = true"> - <i class="fab fa-twitter"></i> {{ $ts.expandTweet }} + <i class="fab fa-twitter"></i> {{ i18n.ts.expandTweet }} </a> </div> </div> </template> <script lang="ts" setup> -import { onMounted, onUnmounted } from 'vue'; +import { defineAsyncComponent, onMounted, onUnmounted } from 'vue'; import { url as local, lang } from '@/config'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; +import { deviceKind } from '@/scripts/device-kind'; const props = withDefaults(defineProps<{ url: string; @@ -45,6 +48,9 @@ const props = withDefaults(defineProps<{ compact: false, }); +const MOBILE_THRESHOLD = 500; +const isMobile = $ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD); + const self = props.url.startsWith(local); const attr = self ? 'to' : 'href'; const target = self ? null : '_blank'; @@ -57,7 +63,7 @@ let sitename = $ref<string | null>(null); let player = $ref({ url: null, width: null, - height: null + height: null, }); let playerEnabled = $ref(false); let tweetId = $ref<string | null>(null); @@ -67,7 +73,7 @@ let tweetHeight = $ref(150); const requestUrl = new URL(props.url); -if (requestUrl.hostname === 'twitter.com') { +if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twitter.com') { const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/); if (m) tweetId = m[1]; } @@ -102,6 +108,12 @@ function adjustTweetHeight(message: any) { if (height) tweetHeight = height; } +const openPlayer = (): void => { + os.popup(defineAsyncComponent(() => import('@/components/MkYoutubePlayer.vue')), { + url: requestUrl.href, + }); +}; + (window as any).addEventListener('message', adjustTweetHeight); onUnmounted(() => { @@ -143,7 +155,7 @@ onUnmounted(() => { .mk-url-preview { &.max-width_400px { - > a { + > .link { font-size: 12px; > .thumbnail { @@ -157,7 +169,7 @@ onUnmounted(() => { } &.max-width_350px { - > a { + > .link { font-size: 10px; > .thumbnail { @@ -205,7 +217,7 @@ onUnmounted(() => { } } - > a { + > .link { position: relative; display: block; font-size: 14px; diff --git a/packages/client/src/components/MkUrlPreviewPopup.vue b/packages/client/src/components/MkUrlPreviewPopup.vue new file mode 100644 index 0000000000..f343c6d8a6 --- /dev/null +++ b/packages/client/src/components/MkUrlPreviewPopup.vue @@ -0,0 +1,45 @@ +<template> +<div class="fgmtyycl" :style="{ zIndex, top: top + 'px', left: left + 'px' }"> + <transition :name="$store.state.animation ? 'zoom' : ''" @after-leave="emit('closed')"> + <MkUrlPreview v-if="showing" class="_popup _shadow" :url="url"/> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import * as os from '@/os'; + +const props = defineProps<{ + showing: boolean; + url: string; + source: HTMLElement; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +const zIndex = os.claimZIndex('middle'); +let top = $ref(0); +let left = $ref(0); + +onMounted(() => { + const rect = props.source.getBoundingClientRect(); + const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset; + const y = rect.top + props.source.offsetHeight + window.pageYOffset; + + top = y; + left = x; +}); +</script> + +<style lang="scss" scoped> +.fgmtyycl { + position: absolute; + width: 500px; + max-width: calc(90vw - 12px); + pointer-events: none; +} +</style> diff --git a/packages/client/src/components/MkUserCardMini.vue b/packages/client/src/components/MkUserCardMini.vue new file mode 100644 index 0000000000..1a4c494987 --- /dev/null +++ b/packages/client/src/components/MkUserCardMini.vue @@ -0,0 +1,99 @@ +<template> +<div :class="[$style.root, { yellow: user.isSilenced, red: user.isSuspended, gray: false }]"> + <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/> + <div class="body"> + <span class="name"><MkUserName class="name" :user="user"/></span> + <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> + </div> + <MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/> +</div> +</template> + +<script lang="ts" setup> +import * as misskey from 'misskey-js'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import { acct } from '@/filters/user'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +let chartValues = $ref<number[] | null>(null); + +os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => { + // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く + res.inc.splice(0, 1); + chartValues = res.inc; +}); +</script> + +<style lang="scss" module> +.root { + $bodyTitleHieght: 18px; + $bodyInfoHieght: 16px; + + display: flex; + align-items: center; + padding: 16px; + background: var(--panel); + border-radius: 8px; + + > :global(.avatar) { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + margin-right: 12px; + } + + > :global(.body) { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; + + > :global(.name) { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $bodyTitleHieght; + } + + > :global(.sub) { + display: block; + width: 100%; + font-size: 95%; + opacity: 0.7; + line-height: $bodyInfoHieght; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + > :global(.chart) { + height: 30px; + } + + &:global(.yellow) { + --c: rgb(255 196 0 / 15%); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } + + &:global(.red) { + --c: rgb(255 0 0 / 15%); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } + + &:global(.gray) { + --c: var(--bg); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } +} +</style> diff --git a/packages/client/src/components/user-info.vue b/packages/client/src/components/MkUserInfo.vue index 6a25d412fc..036cbea304 100644 --- a/packages/client/src/components/user-info.vue +++ b/packages/client/src/components/MkUserInfo.vue @@ -6,21 +6,22 @@ <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> <p class="username"><MkAcct :user="user"/></p> </div> + <span v-if="$i && $i.id !== user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span> <div class="description"> <div v-if="user.description" class="mfm"> <Mfm :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/> </div> - <span v-else style="opacity: 0.7;">{{ $ts.noAccountDescription }}</span> + <span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span> </div> <div class="status"> <div> - <p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span> + <p>{{ i18n.ts.notes }}</p><span>{{ user.notesCount }}</span> </div> <div> - <p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span> + <p>{{ i18n.ts.following }}</p><span>{{ user.followingCount }}</span> </div> <div> - <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span> + <p>{{ i18n.ts.followers }}</p><span>{{ user.followersCount }}</span> </div> </div> <MkFollowButton v-if="$i && user.id != $i.id" class="koudoku-button" :user="user" mini/> @@ -29,8 +30,9 @@ <script lang="ts" setup> import * as misskey from 'misskey-js'; -import MkFollowButton from './follow-button.vue'; +import MkFollowButton from '@/components/MkFollowButton.vue'; import { userPage } from '@/filters/user'; +import { i18n } from '@/i18n'; defineProps<{ user: misskey.entities.UserDetailed; @@ -80,7 +82,18 @@ defineProps<{ opacity: 0.7; } } - + + > .followed { + position: absolute; + top: 12px; + left: 12px; + padding: 4px 8px; + color: #fff; + background: rgba(0, 0, 0, 0.7); + font-size: 0.7em; + border-radius: 6px; + } + > .description { padding: 16px; font-size: 0.8em; diff --git a/packages/client/src/components/user-list.vue b/packages/client/src/components/MkUserList.vue index 3e273721c7..e1f47c7673 100644 --- a/packages/client/src/components/user-list.vue +++ b/packages/client/src/components/MkUserList.vue @@ -3,7 +3,7 @@ <template #empty> <div class="_fullinfo"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> - <div>{{ $ts.noUsers }}</div> + <div>{{ i18n.ts.noUsers }}</div> </div> </template> @@ -17,10 +17,10 @@ <script lang="ts" setup> import { ref } from 'vue'; -import MkUserInfo from '@/components/user-info.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import { Paging } from '@/components/ui/pagination.vue'; +import MkUserInfo from '@/components/MkUserInfo.vue'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; import { userPage } from '@/filters/user'; +import { i18n } from '@/i18n'; const props = defineProps<{ pagination: Paging; diff --git a/packages/client/src/components/user-online-indicator.vue b/packages/client/src/components/MkUserOnlineIndicator.vue index a4f6f80383..a4f6f80383 100644 --- a/packages/client/src/components/user-online-indicator.vue +++ b/packages/client/src/components/MkUserOnlineIndicator.vue diff --git a/packages/client/src/components/user-preview.vue b/packages/client/src/components/MkUserPreview.vue index f80947f75a..4de2e8baa2 100644 --- a/packages/client/src/components/user-preview.vue +++ b/packages/client/src/components/MkUserPreview.vue @@ -1,8 +1,10 @@ <template> -<transition :name="$store.state.animation ? 'popup' : ''" appear @after-leave="$emit('closed')"> - <div v-if="showing" class="fxxzrfni _popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { $emit('mouseover'); }" @mouseleave="() => { $emit('mouseleave'); }"> - <div v-if="fetched" class="info"> - <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> +<transition :name="$store.state.animation ? 'popup' : ''" appear @after-leave="emit('closed')"> + <div v-if="showing" class="fxxzrfni _popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }"> + <div v-if="user != null" class="info"> + <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"> + <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span> + </div> <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> <div class="title"> <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> @@ -31,71 +33,51 @@ </transition> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { onMounted } from 'vue'; import * as Acct from 'misskey-js/built/acct'; -import MkFollowButton from './follow-button.vue'; +import * as misskey from 'misskey-js'; +import MkFollowButton from '@/components/MkFollowButton.vue'; import { userPage } from '@/filters/user'; import * as os from '@/os'; -export default defineComponent({ - components: { - MkFollowButton - }, - - props: { - showing: { - type: Boolean, - required: true - }, - q: { - type: String, - required: true - }, - source: { - required: true - } - }, +const props = defineProps<{ + showing: boolean; + q: string; + source: HTMLElement; +}>(); - emits: ['closed', 'mouseover', 'mouseleave'], +const emit = defineEmits<{ + (ev: 'closed'): void; + (ev: 'mouseover'): void; + (ev: 'mouseleave'): void; +}>(); - data() { - return { - user: null, - fetched: false, - top: 0, - left: 0, - zIndex: os.claimZIndex('middle'), - }; - }, +const zIndex = os.claimZIndex('middle'); +let user = $ref<misskey.entities.UserDetailed | null>(null); +let top = $ref(0); +let left = $ref(0); - mounted() { - if (typeof this.q === 'object') { - this.user = this.q; - this.fetched = true; - } else { - const query = this.q.startsWith('@') ? - Acct.parse(this.q.substr(1)) : - { userId: this.q }; - - os.api('users/show', query).then(user => { - if (!this.showing) return; - this.user = user; - this.fetched = true; - }); - } +onMounted(() => { + if (typeof props.q === 'object') { + user = props.q; + } else { + const query = props.q.startsWith('@') ? + Acct.parse(props.q.substr(1)) : + { userId: props.q }; - const rect = this.source.getBoundingClientRect(); - const x = ((rect.left + (this.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset; - const y = rect.top + this.source.offsetHeight + window.pageYOffset; + os.api('users/show', query).then(res => { + if (!props.showing) return; + user = res; + }); + } - this.top = y; - this.left = x; - }, + const rect = props.source.getBoundingClientRect(); + const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset; + const y = rect.top + props.source.offsetHeight + window.pageYOffset; - methods: { - userPage - } + top = y; + left = x; }); </script> @@ -120,6 +102,16 @@ export default defineComponent({ background-color: rgba(0, 0, 0, 0.1); background-size: cover; background-position: center; + > .followed { + position: absolute; + top: 12px; + left: 12px; + padding: 4px 8px; + color: #fff; + background: rgba(0, 0, 0, 0.7); + font-size: 0.7em; + border-radius: 6px; + } } > .avatar { diff --git a/packages/client/src/components/user-select-dialog.vue b/packages/client/src/components/MkUserSelectDialog.vue index b34d21af07..07caedfe3a 100644 --- a/packages/client/src/components/user-select-dialog.vue +++ b/packages/client/src/components/MkUserSelectDialog.vue @@ -1,5 +1,6 @@ <template> -<XModalWindow ref="dialogEl" +<XModalWindow + ref="dialogEl" :with-ok-button="true" :ok-button-disabled="selected == null" @click="cancel()" @@ -7,16 +8,16 @@ @ok="ok()" @closed="$emit('closed')" > - <template #header>{{ $ts.selectUser }}</template> + <template #header>{{ i18n.ts.selectUser }}</template> <div class="tbhwbxda"> <div class="form"> <FormSplit :min-width="170"> - <MkInput ref="usernameEl" v-model="username" @update:modelValue="search"> - <template #label>{{ $ts.username }}</template> + <MkInput v-model="username" :autofocus="true" @update:modelValue="search"> + <template #label>{{ i18n.ts.username }}</template> <template #prefix>@</template> </MkInput> <MkInput v-model="host" @update:modelValue="search"> - <template #label>{{ $ts.host }}</template> + <template #label>{{ i18n.ts.host }}</template> <template #prefix>@</template> </MkInput> </FormSplit> @@ -32,7 +33,7 @@ </div> </div> <div v-else class="empty"> - <span>{{ $ts.noUsers }}</span> + <span>{{ i18n.ts.noUsers }}</span> </div> </div> <div v-if="username == '' && host == ''" class="recent"> @@ -55,9 +56,10 @@ import { nextTick, onMounted } from 'vue'; import * as misskey from 'misskey-js'; import MkInput from '@/components/form/input.vue'; import FormSplit from '@/components/form/split.vue'; -import XModalWindow from '@/components/ui/modal-window.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; import * as os from '@/os'; import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; const emit = defineEmits<{ (ev: 'ok', selected: misskey.entities.UserDetailed): void; @@ -70,15 +72,8 @@ let host = $ref(''); let users: misskey.entities.UserDetailed[] = $ref([]); let recentUsers: misskey.entities.UserDetailed[] = $ref([]); let selected: misskey.entities.UserDetailed | null = $ref(null); -let usernameEl: HTMLElement = $ref(); let dialogEl = $ref(); -const focus = () => { - if (usernameEl) { - usernameEl.focus(); - } -}; - const search = () => { if (username === '' && host === '') { users = []; @@ -88,7 +83,7 @@ const search = () => { username: username, host: host, limit: 10, - detail: false + detail: false, }).then(_users => { users = _users; }); @@ -112,12 +107,6 @@ const cancel = () => { }; onMounted(() => { - focus(); - - nextTick(() => { - focus(); - }); - os.api('users/show', { userIds: defaultStore.state.recentlyUsedUsers, }).then(users => { diff --git a/packages/client/src/components/renote.details.vue b/packages/client/src/components/MkUsersTooltip.vue index 2df19bcd3f..4ccc44b47e 100644 --- a/packages/client/src/components/renote.details.vue +++ b/packages/client/src/components/MkUsersTooltip.vue @@ -12,7 +12,7 @@ <script lang="ts" setup> import { } from 'vue'; -import MkTooltip from './ui/tooltip.vue'; +import MkTooltip from './MkTooltip.vue'; const props = defineProps<{ users: any[]; // TODO diff --git a/packages/client/src/components/MkVisibility.vue b/packages/client/src/components/MkVisibility.vue new file mode 100644 index 0000000000..739720bf91 --- /dev/null +++ b/packages/client/src/components/MkVisibility.vue @@ -0,0 +1,47 @@ +<template> +<span v-if="note.visibility !== 'public'" :class="$style.visibility"> + <i v-if="note.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="note.visibility === 'specified'" ref="specified" class="fas fa-envelope"></i> +</span> +<span v-if="note.localOnly" :class="$style.localOnly"><i class="fas fa-biohazard"></i></span> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import XDetails from '@/components/MkUsersTooltip.vue'; +import * as os from '@/os'; +import { useTooltip } from '@/scripts/use-tooltip'; + +const props = defineProps<{ + note: { + visibility: string; + localOnly?: boolean; + visibleUserIds?: string[]; + }, +}>(); + +const specified = $ref<HTMLElement>(); + +if (props.note.visibility === 'specified') { + useTooltip($$(specified), async (showing) => { + const users = await os.api('users/show', { + userIds: props.note.visibleUserIds, + limit: 10, + }); + + os.popup(XDetails, { + showing, + users, + count: props.note.visibleUserIds.length, + targetElement: specified, + }, {}, 'closed'); + }); +} +</script> + +<style lang="scss" module> +.visibility, .localOnly { + margin-left: 0.5em; +} +</style> diff --git a/packages/client/src/components/visibility-picker.vue b/packages/client/src/components/MkVisibilityPicker.vue index c717c3a461..ecc022eca0 100644 --- a/packages/client/src/components/visibility-picker.vue +++ b/packages/client/src/components/MkVisibilityPicker.vue @@ -4,37 +4,37 @@ <button key="public" class="_button" :class="{ active: v === 'public' }" data-index="1" @click="choose('public')"> <div><i class="fas fa-globe"></i></div> <div> - <span>{{ $ts._visibility.public }}</span> - <span>{{ $ts._visibility.publicDescription }}</span> + <span>{{ i18n.ts._visibility.public }}</span> + <span>{{ i18n.ts._visibility.publicDescription }}</span> </div> </button> <button key="home" class="_button" :class="{ active: v === 'home' }" data-index="2" @click="choose('home')"> <div><i class="fas fa-home"></i></div> <div> - <span>{{ $ts._visibility.home }}</span> - <span>{{ $ts._visibility.homeDescription }}</span> + <span>{{ i18n.ts._visibility.home }}</span> + <span>{{ i18n.ts._visibility.homeDescription }}</span> </div> </button> <button key="followers" class="_button" :class="{ active: v === 'followers' }" data-index="3" @click="choose('followers')"> <div><i class="fas fa-unlock"></i></div> <div> - <span>{{ $ts._visibility.followers }}</span> - <span>{{ $ts._visibility.followersDescription }}</span> + <span>{{ i18n.ts._visibility.followers }}</span> + <span>{{ i18n.ts._visibility.followersDescription }}</span> </div> </button> <button key="specified" :disabled="localOnly" class="_button" :class="{ active: v === 'specified' }" data-index="4" @click="choose('specified')"> <div><i class="fas fa-envelope"></i></div> <div> - <span>{{ $ts._visibility.specified }}</span> - <span>{{ $ts._visibility.specifiedDescription }}</span> + <span>{{ i18n.ts._visibility.specified }}</span> + <span>{{ i18n.ts._visibility.specifiedDescription }}</span> </div> </button> <div class="divider"></div> <button key="localOnly" class="_button localOnly" :class="{ active: localOnly }" data-index="5" @click="localOnly = !localOnly"> <div><i class="fas fa-biohazard"></i></div> <div> - <span>{{ $ts._visibility.localOnly }}</span> - <span>{{ $ts._visibility.localOnlyDescription }}</span> + <span>{{ i18n.ts._visibility.localOnly }}</span> + <span>{{ i18n.ts._visibility.localOnlyDescription }}</span> </div> <div><i :class="localOnly ? 'fas fa-toggle-on' : 'fas fa-toggle-off'"></i></div> </button> @@ -45,7 +45,8 @@ <script lang="ts" setup> import { nextTick, watch } from 'vue'; import * as misskey from 'misskey-js'; -import MkModal from '@/components/ui/modal.vue'; +import MkModal from '@/components/MkModal.vue'; +import { i18n } from '@/i18n'; const modal = $ref<InstanceType<typeof MkModal>>(); @@ -105,7 +106,7 @@ function choose(visibility: typeof misskey.noteVisibilities[number]): void { } &.active { - color: #fff; + color: var(--fgOnAccent); background: var(--accent); } diff --git a/packages/client/src/components/waiting-dialog.vue b/packages/client/src/components/MkWaitingDialog.vue index 9e631b55b1..77b664d3b1 100644 --- a/packages/client/src/components/waiting-dialog.vue +++ b/packages/client/src/components/MkWaitingDialog.vue @@ -10,7 +10,7 @@ <script lang="ts" setup> import { watch, ref } from 'vue'; -import MkModal from '@/components/ui/modal.vue'; +import MkModal from '@/components/MkModal.vue'; const modal = ref<InstanceType<typeof MkModal>>(); diff --git a/packages/client/src/components/MkWidgets.vue b/packages/client/src/components/MkWidgets.vue new file mode 100644 index 0000000000..fcb0d11af4 --- /dev/null +++ b/packages/client/src/components/MkWidgets.vue @@ -0,0 +1,167 @@ +<template> +<div class="vjoppmmu"> + <template v-if="edit"> + <header> + <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" class="mk-widget-select"> + <template #label>{{ i18n.ts.selectWidget }}</template> + <option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.t(`_widgets.${widget}`) }}</option> + </MkSelect> + <MkButton inline primary class="mk-widget-add" @click="addWidget"><i class="fas fa-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton> + </header> + <XDraggable + v-model="widgets_" + item-key="id" + handle=".handle" + animation="150" + > + <template #item="{element}"> + <div class="customize-container"> + <button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="fas fa-cog"></i></button> + <button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="fas fa-times"></i></button> + <div class="handle"> + <component :is="`mkw-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :widget="element" @updateProps="updateWidget(element.id, $event)"/> + </div> + </div> + </template> + </XDraggable> + </template> + <component :is="`mkw-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" class="widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, reactive, ref, computed } from 'vue'; +import { v4 as uuid } from 'uuid'; +import MkSelect from '@/components/form/select.vue'; +import MkButton from '@/components/MkButton.vue'; +import { widgets as widgetDefs } from '@/widgets'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const XDraggable = defineAsyncComponent(() => import('vuedraggable')); + +type Widget = { + name: string; + id: string; + data: Record<string, any>; +}; + +const props = defineProps<{ + widgets: Widget[]; + edit: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'updateWidgets', widgets: Widget[]): void; + (ev: 'addWidget', widget: Widget): void; + (ev: 'removeWidget', widget: Widget): void; + (ev: 'updateWidget', widget: Partial<Widget>): void; + (ev: 'exit'): void; +}>(); + +const widgetRefs = {}; +const configWidget = (id: string) => { + widgetRefs[id].configure(); +}; +const widgetAdderSelected = ref(null); +const addWidget = () => { + if (widgetAdderSelected.value == null) return; + + emit('addWidget', { + name: widgetAdderSelected.value, + id: uuid(), + data: {}, + }); + + widgetAdderSelected.value = null; +}; +const removeWidget = (widget) => { + emit('removeWidget', widget); +}; +const updateWidget = (id, data) => { + emit('updateWidget', { id, data }); +}; +const widgets_ = computed({ + get: () => props.widgets, + set: (value) => { + emit('updateWidgets', value); + }, +}); + +function onContextmenu(widget: Widget, ev: MouseEvent) { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(ev.target)) return; + if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; + if (window.getSelection()?.toString() !== '') return; + + os.contextMenu([{ + type: 'label', + text: i18n.t(`_widgets.${widget.name}`), + }, { + icon: 'fas fa-cog', + text: i18n.ts.settings, + action: () => { + configWidget(widget.id); + }, + }], ev); +} +</script> + +<style lang="scss" scoped> +.vjoppmmu { + > header { + margin: 16px 0; + + > * { + width: 100%; + padding: 4px; + } + } + + > .widget, .customize-container { + contain: content; + margin: var(--margin) 0; + + &:first-of-type { + margin-top: 0; + } + } + + .customize-container { + position: relative; + cursor: move; + + > .config, + > .remove { + position: absolute; + z-index: 10000; + top: 8px; + width: 32px; + height: 32px; + color: #fff; + background: rgba(#000, 0.7); + border-radius: 4px; + } + + > .config { + right: 8px + 8px + 32px; + } + + > .remove { + right: 8px; + } + + > .handle { + > .widget { + pointer-events: none; + } + } + } +} +</style> diff --git a/packages/client/src/components/MkWindow.vue b/packages/client/src/components/MkWindow.vue new file mode 100644 index 0000000000..758d4d47b6 --- /dev/null +++ b/packages/client/src/components/MkWindow.vue @@ -0,0 +1,563 @@ +<template> +<transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')"> + <div v-if="showing" ref="rootEl" class="ebkgocck" :class="{ maximized }"> + <div class="body _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown"> + <div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu"> + <span class="left"> + <button v-for="button in buttonsLeft" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button> + </span> + <span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown"> + <slot name="header"></slot> + </span> + <span class="right"> + <button v-for="button in buttonsRight" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button> + <button v-if="canResize && maximized" class="button _button" @click="unMaximize()"><i class="fas fa-window-restore"></i></button> + <button v-else-if="canResize && !maximized" class="button _button" @click="maximize()"><i class="fas fa-window-maximize"></i></button> + <button v-if="closeButton" class="button _button" @click="close()"><i class="fas fa-times"></i></button> + </span> + </div> + <div class="body"> + <slot></slot> + </div> + </div> + <template v-if="canResize"> + <div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div> + <div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div> + <div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div> + <div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div> + <div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div> + <div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div> + <div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div> + <div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div> + </template> + </div> +</transition> +</template> + +<script lang="ts" setup> +import { onBeforeUnmount, onMounted, provide } from 'vue'; +import contains from '@/scripts/contains'; +import * as os from '@/os'; +import { MenuItem } from '@/types/menu'; + +const minHeight = 50; +const minWidth = 250; + +function dragListen(fn: (ev: MouseEvent) => void) { + window.addEventListener('mousemove', fn); + window.addEventListener('touchmove', fn); + window.addEventListener('mouseleave', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); + window.addEventListener('touchend', dragClear.bind(null, fn)); +} + +function dragClear(fn) { + window.removeEventListener('mousemove', fn); + window.removeEventListener('touchmove', fn); + window.removeEventListener('mouseleave', dragClear); + window.removeEventListener('mouseup', dragClear); + window.removeEventListener('touchend', dragClear); +} + +const props = withDefaults(defineProps<{ + initialWidth?: number; + initialHeight?: number | null; + canResize?: boolean; + closeButton?: boolean; + mini?: boolean; + front?: boolean; + contextmenu?: MenuItem[] | null; + buttonsLeft?: any[]; + buttonsRight?: any[]; +}>(), { + initialWidth: 400, + initialHeight: null, + canResize: false, + closeButton: true, + mini: false, + front: false, + contextmenu: null, + buttonsLeft: () => [], + buttonsRight: () => [], +}); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +provide('inWindow', true); + +let rootEl = $ref<HTMLElement | null>(); +let showing = $ref(true); +let beforeClickedAt = 0; +let maximized = $ref(false); +let unMaximizedTop = ''; +let unMaximizedLeft = ''; +let unMaximizedWidth = ''; +let unMaximizedHeight = ''; + +function close() { + showing = false; +} + +function onKeydown(evt) { + if (evt.which === 27) { // Esc + evt.preventDefault(); + evt.stopPropagation(); + close(); + } +} + +function onContextmenu(ev: MouseEvent) { + if (props.contextmenu) { + os.contextMenu(props.contextmenu, ev); + } +} + +// 最前面へ移動 +function top() { + if (rootEl) { + rootEl.style.zIndex = os.claimZIndex(props.front ? 'middle' : 'low'); + } +} + +function maximize() { + maximized = true; + unMaximizedTop = rootEl.style.top; + unMaximizedLeft = rootEl.style.left; + unMaximizedWidth = rootEl.style.width; + unMaximizedHeight = rootEl.style.height; + rootEl.style.top = '0'; + rootEl.style.left = '0'; + rootEl.style.width = '100%'; + rootEl.style.height = '100%'; +} + +function unMaximize() { + maximized = false; + rootEl.style.top = unMaximizedTop; + rootEl.style.left = unMaximizedLeft; + rootEl.style.width = unMaximizedWidth; + rootEl.style.height = unMaximizedHeight; +} + +function onBodyMousedown() { + top(); +} + +function onDblClick() { + maximize(); +} + +function onHeaderMousedown(evt: MouseEvent) { + // 右クリックはコンテキストメニューを開こうとした可能性が高いため無視 + if (evt.button === 2) return; + + let beforeMaximized = false; + + if (maximized) { + beforeMaximized = true; + unMaximize(); + } + + // ダブルクリック判定 + if (Date.now() - beforeClickedAt < 300) { + beforeClickedAt = Date.now(); + onDblClick(); + return; + } + + beforeClickedAt = Date.now(); + + const main = rootEl; + if (main == null) return; + + if (!contains(main, document.activeElement)) main.focus(); + + const position = main.getBoundingClientRect(); + + const clickX = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientX : evt.clientX; + const clickY = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientY : evt.clientY; + const moveBaseX = beforeMaximized ? parseInt(unMaximizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる + const moveBaseY = beforeMaximized ? 20 : clickY - position.top; + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + + function move(x: number, y: number) { + let moveLeft = x - moveBaseX; + let moveTop = y - moveBaseY; + + // 下はみ出し + if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight; + + // 左はみ出し + if (moveLeft < 0) moveLeft = 0; + + // 上はみ出し + if (moveTop < 0) moveTop = 0; + + // 右はみ出し + if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; + + rootEl.style.left = moveLeft + 'px'; + rootEl.style.top = moveTop + 'px'; + } + + if (beforeMaximized) { + move(clickX, clickY); + } + + // 動かした時 + dragListen(me => { + const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX; + const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY; + + move(x, y); + }); +} + +// 上ハンドル掴み時 +function onTopHandleMousedown(evt) { + const main = rootEl; + + const base = evt.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + move > 0) { + if (height + -move > minHeight) { + applyTransformHeight(height + -move); + applyTransformTop(top + move); + } else { // 最小の高さより小さくなろうとした時 + applyTransformHeight(minHeight); + applyTransformTop(top + (height - minHeight)); + } + } else { // 上のはみ出し時 + applyTransformHeight(top + height); + applyTransformTop(0); + } + }); +} + +// 右ハンドル掴み時 +function onRightHandleMousedown(evt) { + const main = rootEl; + + const base = evt.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + const browserWidth = window.innerWidth; + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + width + move < browserWidth) { + if (width + move > minWidth) { + applyTransformWidth(width + move); + } else { // 最小の幅より小さくなろうとした時 + applyTransformWidth(minWidth); + } + } else { // 右のはみ出し時 + applyTransformWidth(browserWidth - left); + } + }); +} + +// 下ハンドル掴み時 +function onBottomHandleMousedown(evt) { + const main = rootEl; + + const base = evt.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + const browserHeight = window.innerHeight; + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + height + move < browserHeight) { + if (height + move > minHeight) { + applyTransformHeight(height + move); + } else { // 最小の高さより小さくなろうとした時 + applyTransformHeight(minHeight); + } + } else { // 下のはみ出し時 + applyTransformHeight(browserHeight - top); + } + }); +} + +// 左ハンドル掴み時 +function onLeftHandleMousedown(evt) { + const main = rootEl; + + const base = evt.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + move > 0) { + if (width + -move > minWidth) { + applyTransformWidth(width + -move); + applyTransformLeft(left + move); + } else { // 最小の幅より小さくなろうとした時 + applyTransformWidth(minWidth); + applyTransformLeft(left + (width - minWidth)); + } + } else { // 左のはみ出し時 + applyTransformWidth(left + width); + applyTransformLeft(0); + } + }); +} + +// 左上ハンドル掴み時 +function onTopLeftHandleMousedown(evt) { + onTopHandleMousedown(evt); + onLeftHandleMousedown(evt); +} + +// 右上ハンドル掴み時 +function onTopRightHandleMousedown(evt) { + onTopHandleMousedown(evt); + onRightHandleMousedown(evt); +} + +// 右下ハンドル掴み時 +function onBottomRightHandleMousedown(evt) { + onBottomHandleMousedown(evt); + onRightHandleMousedown(evt); +} + +// 左下ハンドル掴み時 +function onBottomLeftHandleMousedown(evt) { + onBottomHandleMousedown(evt); + onLeftHandleMousedown(evt); +} + +// 高さを適用 +function applyTransformHeight(height) { + if (height > window.innerHeight) height = window.innerHeight; + rootEl.style.height = height + 'px'; +} + +// 幅を適用 +function applyTransformWidth(width) { + if (width > window.innerWidth) width = window.innerWidth; + rootEl.style.width = width + 'px'; +} + +// Y座標を適用 +function applyTransformTop(top) { + rootEl.style.top = top + 'px'; +} + +// X座標を適用 +function applyTransformLeft(left) { + rootEl.style.left = left + 'px'; +} + +function onBrowserResize() { + const main = rootEl; + const position = main.getBoundingClientRect(); + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + if (position.left < 0) main.style.left = '0'; // 左はみ出し + if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し + if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し + if (position.top < 0) main.style.top = '0'; // 上はみ出し +} + +onMounted(() => { + if (props.initialWidth) applyTransformWidth(props.initialWidth); + if (props.initialHeight) applyTransformHeight(props.initialHeight); + + applyTransformTop((window.innerHeight / 2) - (rootEl.offsetHeight / 2)); + applyTransformLeft((window.innerWidth / 2) - (rootEl.offsetWidth / 2)); + + // 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする + top(); + + window.addEventListener('resize', onBrowserResize); +}); + +onBeforeUnmount(() => { + window.removeEventListener('resize', onBrowserResize); +}); + +defineExpose({ + close, +}); +</script> + +<style lang="scss" scoped> +.window-enter-active, .window-leave-active { + transition: opacity 0.2s, transform 0.2s !important; +} +.window-enter-from, .window-leave-to { + pointer-events: none; + opacity: 0; + transform: scale(0.9); +} + +.ebkgocck { + position: fixed; + top: 0; + left: 0; + + > .body { + overflow: clip; + display: flex; + flex-direction: column; + contain: content; + width: 100%; + height: 100%; + border-radius: var(--radius); + + > .header { + --height: 42px; + + &.mini { + --height: 38px; + } + + display: flex; + position: relative; + z-index: 1; + flex-shrink: 0; + user-select: none; + height: var(--height); + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + //border-bottom: solid 1px var(--divider); + font-size: 95%; + font-weight: bold; + + > .left, > .right { + > .button { + height: var(--height); + width: var(--height); + + &:hover { + color: var(--fgHighlighted); + } + + &.highlighted { + color: var(--accent); + } + } + } + + > .left { + margin-right: 16px; + } + + > .right { + min-width: 16px; + } + + > .title { + flex: 1; + position: relative; + line-height: var(--height); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: move; + } + } + + > .body { + flex: 1; + overflow: auto; + background: var(--panel); + } + } + + > .handle { + $size: 8px; + + position: absolute; + + &.top { + top: -($size); + left: 0; + width: 100%; + height: $size; + cursor: ns-resize; + } + + &.right { + top: 0; + right: -($size); + width: $size; + height: 100%; + cursor: ew-resize; + } + + &.bottom { + bottom: -($size); + left: 0; + width: 100%; + height: $size; + cursor: ns-resize; + } + + &.left { + top: 0; + left: -($size); + width: $size; + height: 100%; + cursor: ew-resize; + } + + &.top-left { + top: -($size); + left: -($size); + width: $size * 2; + height: $size * 2; + cursor: nwse-resize; + } + + &.top-right { + top: -($size); + right: -($size); + width: $size * 2; + height: $size * 2; + cursor: nesw-resize; + } + + &.bottom-right { + bottom: -($size); + right: -($size); + width: $size * 2; + height: $size * 2; + cursor: nwse-resize; + } + + &.bottom-left { + bottom: -($size); + left: -($size); + width: $size * 2; + height: $size * 2; + cursor: nesw-resize; + } + } + + &.maximized { + > .body { + border-radius: 0; + } + } +} +</style> diff --git a/packages/client/src/components/MkYoutubePlayer.vue b/packages/client/src/components/MkYoutubePlayer.vue new file mode 100644 index 0000000000..a6840ce647 --- /dev/null +++ b/packages/client/src/components/MkYoutubePlayer.vue @@ -0,0 +1,72 @@ +<template> +<XWindow :initial-width="640" :initial-height="402" :can-resize="true" :close-button="true"> + <template #header> + <i class="icon fa-brands fa-youtube" style="margin-right: 0.5em;"></i> + <span>{{ title ?? 'YouTube Player' }}</span> + </template> + + <div class="poamfof"> + <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <div v-if="player.url" class="player"> + <iframe v-if="!fetching" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/> + </div> + </transition> + <MkLoading v-if="fetching"/> + <MkError v-else-if="!player.url" @retry="ytFetch()"/> + </div> +</XWindow> +</template> + +<script lang="ts" setup> +import XWindow from '@/components/MkWindow.vue'; +import { lang } from '@/config'; + +const props = defineProps<{ + url: string; +}>(); + +const requestUrl = new URL(props.url); + +let fetching = $ref(true); +let title = $ref<string | null>(null); +let player = $ref({ + url: null, + width: null, + height: null, +}); + +const requestLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP'); + +const ytFetch = (): void => { + fetching = true; + fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => { + res.json().then(info => { + if (info.url == null) return; + title = info.title; + fetching = false; + player = info.player; + }); + }); +}; + +ytFetch(); + +</script> + +<style lang="scss"> +.poamfof { + position: relative; + overflow: hidden; + height: 100%; + + .player { + position: absolute; + inset: 0; + + iframe { + width: 100%; + height: 100%; + } + } +} +</style> diff --git a/packages/client/src/components/analog-clock.vue b/packages/client/src/components/analog-clock.vue deleted file mode 100644 index 18dd1e3f41..0000000000 --- a/packages/client/src/components/analog-clock.vue +++ /dev/null @@ -1,108 +0,0 @@ -<template> -<svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none"> - <circle v-for="(angle, i) in graduations" - :key="i" - :cx="5 + (Math.sin(angle) * (5 - graduationsPadding))" - :cy="5 - (Math.cos(angle) * (5 - graduationsPadding))" - :r="i % 5 == 0 ? 0.125 : 0.05" - :fill="i % 5 == 0 ? majorGraduationColor : minorGraduationColor" - /> - - <line - :x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))" - :y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))" - :x2="5 + (Math.sin(sAngle) * ((sHandLengthRatio * 5) - handsPadding))" - :y2="5 - (Math.cos(sAngle) * ((sHandLengthRatio * 5) - handsPadding))" - :stroke="sHandColor" - :stroke-width="thickness / 2" - stroke-linecap="round" - /> - - <line - :x1="5 - (Math.sin(mAngle) * (mHandLengthRatio * handsTailLength))" - :y1="5 + (Math.cos(mAngle) * (mHandLengthRatio * handsTailLength))" - :x2="5 + (Math.sin(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" - :y2="5 - (Math.cos(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" - :stroke="mHandColor" - :stroke-width="thickness" - stroke-linecap="round" - /> - - <line - :x1="5 - (Math.sin(hAngle) * (hHandLengthRatio * handsTailLength))" - :y1="5 + (Math.cos(hAngle) * (hHandLengthRatio * handsTailLength))" - :x2="5 + (Math.sin(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" - :y2="5 - (Math.cos(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" - :stroke="hHandColor" - :stroke-width="thickness" - stroke-linecap="round" - /> -</svg> -</template> - -<script lang="ts" setup> -import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; -import tinycolor from 'tinycolor2'; - -withDefaults(defineProps<{ - thickness: number; -}>(), { - thickness: 0.1, -}); - -const now = ref(new Date()); -const enabled = ref(true); -const graduationsPadding = ref(0.5); -const handsPadding = ref(1); -const handsTailLength = ref(0.7); -const hHandLengthRatio = ref(0.75); -const mHandLengthRatio = ref(1); -const sHandLengthRatio = ref(1); -const computedStyle = getComputedStyle(document.documentElement); - -const dark = computed(() => tinycolor(computedStyle.getPropertyValue('--bg')).isDark()); -const majorGraduationColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'); -const minorGraduationColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'); -const sHandColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)'); -const mHandColor = computed(() => tinycolor(computedStyle.getPropertyValue('--fg')).toHexString()); -const hHandColor = computed(() => tinycolor(computedStyle.getPropertyValue('--accent')).toHexString()); -const s = computed(() => now.value.getSeconds()); -const m = computed(() => now.value.getMinutes()); -const h = computed(() => now.value.getHours()); -const hAngle = computed(() => Math.PI * (h.value % 12 + (m.value + s.value / 60) / 60) / 6); -const mAngle = computed(() => Math.PI * (m.value + s.value / 60) / 30); -const sAngle = computed(() => Math.PI * s.value / 30); -const graduations = computed(() => { - const angles: number[] = []; - for (let i = 0; i < 60; i++) { - const angle = Math.PI * i / 30; - angles.push(angle); - } - - return angles; -}); - -function tick() { - now.value = new Date(); -} - -onMounted(() => { - const update = () => { - if (enabled.value) { - tick(); - window.setTimeout(update, 1000); - } - }; - update(); -}); - -onBeforeUnmount(() => { - enabled.value = false; -}); -</script> - -<style lang="scss" scoped> -.mbcofsoe { - display: block; -} -</style> diff --git a/packages/client/src/components/form/checkbox.vue b/packages/client/src/components/form/checkbox.vue new file mode 100644 index 0000000000..bd56c9c7ea --- /dev/null +++ b/packages/client/src/components/form/checkbox.vue @@ -0,0 +1,144 @@ +<template> +<div + class="ziffeoms" + :class="{ disabled, checked }" +> + <input + ref="input" + type="checkbox" + :disabled="disabled" + @keydown.enter="toggle" + > + <span ref="button" v-adaptive-border v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle"> + <i class="check fas fa-check"></i> + </span> + <span class="label"> + <!-- TODO: 無名slotの方は廃止 --> + <span @click="toggle"><slot name="label"></slot><slot></slot></span> + <p class="caption"><slot name="caption"></slot></p> + </span> +</div> +</template> + +<script lang="ts" setup> +import { toRefs, Ref } from 'vue'; +import * as os from '@/os'; +import Ripple from '@/components/MkRipple.vue'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + modelValue: boolean | Ref<boolean>; + disabled?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', v: boolean): void; +}>(); + +let button = $ref<HTMLElement>(); +const checked = toRefs(props).modelValue; +const toggle = () => { + if (props.disabled) return; + emit('update:modelValue', !checked.value); + + if (!checked.value) { + const rect = button.getBoundingClientRect(); + const x = rect.left + (button.offsetWidth / 2); + const y = rect.top + (button.offsetHeight / 2); + os.popup(Ripple, { x, y, particle: false }, {}, 'end'); + } +}; +</script> + +<style lang="scss" scoped> +.ziffeoms { + position: relative; + display: flex; + transition: all 0.2s ease; + + > * { + user-select: none; + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: relative; + display: inline-flex; + flex-shrink: 0; + margin: 0; + box-sizing: border-box; + width: 23px; + height: 23px; + outline: none; + background: var(--panel); + border: solid 1px var(--panel); + border-radius: 4px; + cursor: pointer; + transition: inherit; + + > .check { + margin: auto; + opacity: 0; + color: var(--fgOnAccent); + font-size: 13px; + transform: scale(0.5); + transition: all 0.2s ease; + } + } + + &:hover { + > .button { + border-color: var(--inputBorderHover) !important; + } + } + + > .label { + margin-left: 12px; + margin-top: 2px; + display: block; + transition: inherit; + color: var(--fg); + + > span { + display: block; + line-height: 20px; + cursor: pointer; + transition: inherit; + } + + > .caption { + margin: 8px 0 0 0; + color: var(--fgTransparentWeak); + font-size: 0.85em; + + &:empty { + display: none; + } + } + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + background-color: var(--accent) !important; + border-color: var(--accent) !important; + + > .check { + opacity: 1; + transform: scale(1); + } + } + } +} +</style> diff --git a/packages/client/src/components/form/group.vue b/packages/client/src/components/form/group.vue deleted file mode 100644 index 1e8376ca44..0000000000 --- a/packages/client/src/components/form/group.vue +++ /dev/null @@ -1,36 +0,0 @@ -<template> -<div v-sticky-container class="adfeebaf _formBlock"> - <div class="label"><slot name="label"></slot></div> - <div class="main _formRoot"> - <slot></slot> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ -}); -</script> - -<style lang="scss" scoped> -.adfeebaf { - padding: 24px 24px; - border: solid 1px var(--divider); - border-radius: var(--radius); - - > .label { - font-weight: bold; - padding: 0 0 16px 0; - - &:empty { - display: none; - } - } - - > .main { - - } -} -</style> diff --git a/packages/client/src/components/form/input.vue b/packages/client/src/components/form/input.vue index 7165671af3..382b2ed528 100644 --- a/packages/client/src/components/form/input.vue +++ b/packages/client/src/components/form/input.vue @@ -3,7 +3,8 @@ <div class="label" @click="focus"><slot name="label"></slot></div> <div class="input" :class="{ inline, disabled, focused }"> <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div> - <input ref="inputEl" + <input + ref="inputEl" v-model="v" v-adaptive-border :type="type" @@ -28,180 +29,123 @@ </div> <div class="caption"><slot name="caption"></slot></div> - <MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="fas fa-check"></i> {{ $ts.save }}</MkButton> + <MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="fas fa-check"></i> {{ i18n.ts.save }}</MkButton> </div> </template> -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +<script lang="ts" setup> +import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; import { debounce } from 'throttle-debounce'; +import MkButton from '@/components/MkButton.vue'; +import { useInterval } from '@/scripts/use-interval'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkButton, - }, +const props = defineProps<{ + modelValue: string | number; + type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search'; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + pattern?: string; + placeholder?: string; + autofocus?: boolean; + autocomplete?: boolean; + spellcheck?: boolean; + step?: any; + datalist?: string[]; + inline?: boolean; + debounce?: boolean; + manualSave?: boolean; + small?: boolean; + large?: boolean; +}>(); - 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 - }, - step: { - required: false - }, - datalist: { - type: Array, - required: false, - }, - inline: { - type: Boolean, - required: false, - default: false - }, - debounce: { - type: Boolean, - required: false, - default: false - }, - manualSave: { - type: Boolean, - required: false, - default: false - }, - }, +const emit = defineEmits<{ + (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'keydown', _ev: KeyboardEvent): void; + (ev: 'enter'): void; + (ev: 'update:modelValue', value: string | number): void; +}>(); - emits: ['change', 'keydown', 'enter', 'update:modelValue'], +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); +const invalid = ref(false); +const filled = computed(() => v.value !== '' && v.value != null); +const inputEl = ref<HTMLElement>(); +const prefixEl = ref<HTMLElement>(); +const suffixEl = ref<HTMLElement>(); +const height = + props.small ? 36 : + props.large ? 40 : + 38; - setup(props, context) { - 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); - const invalid = ref(false); - const filled = computed(() => v.value !== '' && v.value != null); - const inputEl = ref<HTMLElement>(); - const prefixEl = ref<HTMLElement>(); - const suffixEl = ref<HTMLElement>(); +const focus = () => inputEl.value.focus(); +const onInput = (ev: KeyboardEvent) => { + changed.value = true; + emit('change', ev); +}; +const onKeydown = (ev: KeyboardEvent) => { + emit('keydown', ev); - 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; - if (type?.value === 'number') { - context.emit('update:modelValue', parseFloat(v.value)); - } else { - context.emit('update:modelValue', v.value); - } - }; - - const debouncedUpdated = debounce(1000, updated); + if (ev.code === 'Enter') { + emit('enter'); + } +}; - watch(modelValue, newValue => { - v.value = newValue; - }); +const updated = () => { + changed.value = false; + if (type.value === 'number') { + emit('update:modelValue', parseFloat(v.value)); + } else { + emit('update:modelValue', v.value); + } +}; - watch(v, newValue => { - if (!props.manualSave) { - if (props.debounce) { - debouncedUpdated(); - } else { - updated(); - } - } +const debouncedUpdated = debounce(1000, updated); - invalid.value = inputEl.value.validity.badInput; - }); +watch(modelValue, newValue => { + v.value = newValue; +}); - onMounted(() => { - nextTick(() => { - if (autofocus.value) { - focus(); - } +watch(v, newValue => { + if (!props.manualSave) { + if (props.debounce) { + debouncedUpdated(); + } else { + updated(); + } + } - // このコンポーネントが作成された時、非表示状態である場合がある - // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する - const clock = window.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); + invalid.value = inputEl.value.validity.badInput; +}); - onUnmounted(() => { - window.clearInterval(clock); - }); - }); - }); +// このコンポーネントが作成された時、非表示状態である場合がある +// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する +useInterval(() => { + 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, { + immediate: true, + afterMounted: true, +}); - return { - id, - v, - focused, - invalid, - changed, - filled, - inputEl, - prefixEl, - suffixEl, - focus, - onInput, - onKeydown, - updated, - }; - }, +onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + }); }); </script> @@ -228,14 +172,13 @@ export default defineComponent({ } > .input { - $height: 42px; position: relative; > input { appearance: none; -webkit-appearance: none; display: block; - height: $height; + height: v-bind("height + 'px'"); width: 100%; margin: 0; padding: 0 12px; @@ -265,7 +208,7 @@ export default defineComponent({ top: 0; padding: 0 12px; font-size: 1em; - height: $height; + height: v-bind("height + 'px'"); pointer-events: none; &:empty { diff --git a/packages/client/src/components/form/link.vue b/packages/client/src/components/form/link.vue index b74e9bd684..34b641ffb6 100644 --- a/packages/client/src/components/form/link.vue +++ b/packages/client/src/components/form/link.vue @@ -19,33 +19,16 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; -export default defineComponent({ - props: { - to: { - type: String, - required: true - }, - active: { - type: Boolean, - required: false - }, - external: { - type: Boolean, - required: false - }, - behavior: { - type: String, - required: false, - }, - inline: { - type: Boolean, - required: false - }, - }, -}); +const props = defineProps<{ + to: string; + active?: boolean; + external?: boolean; + behavior?: null | 'window' | 'browser' | 'modalWindow'; + inline?: boolean; +}>(); </script> <style lang="scss" scoped> @@ -61,7 +44,7 @@ export default defineComponent({ align-items: center; width: 100%; box-sizing: border-box; - padding: 12px 14px 12px 14px; + padding: 10px 14px; background: var(--buttonBg); border-radius: 6px; font-size: 0.9em; diff --git a/packages/client/src/components/form/radio.vue b/packages/client/src/components/form/radio.vue index 2becbec6f3..b36f7e9fdc 100644 --- a/packages/client/src/components/form/radio.vue +++ b/packages/client/src/components/form/radio.vue @@ -7,7 +7,8 @@ :aria-disabled="disabled" @click="toggle" > - <input type="radio" + <input + type="radio" :disabled="disabled" > <span class="button"> @@ -17,34 +18,25 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; -export default defineComponent({ - props: { - modelValue: { - required: false - }, - value: { - required: false - }, - disabled: { - type: Boolean, - default: false - } - }, - computed: { - checked(): boolean { - return this.modelValue === this.value; - } - }, - methods: { - toggle() { - if (this.disabled) return; - this.$emit('update:modelValue', this.value); - } - } -}); +const props = defineProps<{ + modelValue: any; + value: any; + disabled: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: any): void; +}>(); + +let checked = $computed(() => props.modelValue === props.value); + +function toggle(): void { + if (props.disabled) return; + emit('update:modelValue', props.value); +} </script> <style lang="scss" scoped> @@ -53,12 +45,13 @@ export default defineComponent({ display: inline-block; text-align: left; cursor: pointer; - padding: 10px 12px; + padding: 8px 10px; + min-width: 60px; background-color: var(--panel); background-clip: padding-box !important; border: solid 1px var(--panel); border-radius: 6px; - transition: all 0.3s; + transition: all 0.2s; > * { user-select: none; diff --git a/packages/client/src/components/form/radios.vue b/packages/client/src/components/form/radios.vue index a52acae9e1..bde4a8fb00 100644 --- a/packages/client/src/components/form/radios.vue +++ b/packages/client/src/components/form/radios.vue @@ -4,11 +4,11 @@ import MkRadio from './radio.vue'; export default defineComponent({ components: { - MkRadio + MkRadio, }, props: { modelValue: { - required: false + required: false, }, }, data() { @@ -19,7 +19,7 @@ export default defineComponent({ watch: { value() { this.$emit('update:modelValue', this.value); - } + }, }, render() { let options = this.$slots.default(); @@ -30,25 +30,25 @@ export default defineComponent({ if (options.length === 1 && options[0].props == null) options = options[0].children; return h('div', { - class: 'novjtcto' + class: 'novjtcto', }, [ ...(label ? [h('div', { - class: 'label' + class: 'label', }, [label])] : []), h('div', { - class: 'body' + class: 'body', }, options.map(option => h(MkRadio, { - key: option.key, - value: option.props.value, - modelValue: this.value, - 'onUpdate:modelValue': value => this.value = value, - }, option.children)), + key: option.key, + value: option.props.value, + modelValue: this.value, + 'onUpdate:modelValue': value => this.value = value, + }, option.children)), ), ...(caption ? [h('div', { - class: 'caption' + class: 'caption', }, [caption])] : []), ]); - } + }, }); </script> @@ -65,9 +65,9 @@ export default defineComponent({ } > .body { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - grid-gap: 12px; + display: flex; + gap: 12px; + flex-wrap: wrap; } > .caption { diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue index 9bf7651119..db21c35717 100644 --- a/packages/client/src/components/form/range.vue +++ b/packages/client/src/components/form/range.vue @@ -1,164 +1,144 @@ <template> -<div class="timctyfi" :class="{ disabled }"> +<div class="timctyfi" :class="{ disabled, easing }"> <div class="label"><slot name="label"></slot></div> <div v-adaptive-border class="body"> <div ref="containerEl" class="container"> <div class="track"> - <div class="highlight" :style="{ width: (steppedValue * 100) + '%' }"></div> + <div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div> </div> - <div v-if="steps" class="ticks"> + <div v-if="steps && showTicks" class="ticks"> <div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div> </div> <div ref="thumbEl" v-tooltip="textConverter(finalValue)" class="thumb" :style="{ left: thumbPosition + 'px' }" @mousedown="onMousedown" @touchstart="onMousedown"></div> </div> </div> + <div class="caption"><slot name="caption"></slot></div> </div> </template> -<script lang="ts"> -import { computed, defineAsyncComponent, defineComponent, onMounted, onUnmounted, ref, watch } from 'vue'; +<script lang="ts" setup> +import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue'; import * as os from '@/os'; -export default defineComponent({ - props: { - modelValue: { - 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 - }, - textConverter: { - type: Function, - required: false, - default: (v) => v.toString(), - }, - }, +const props = withDefaults(defineProps<{ + modelValue: number; + disabled?: boolean; + min: number; + max: number; + step?: number; + textConverter?: (value: number) => string, + showTicks?: boolean; + easing?: boolean; +}>(), { + step: 1, + textConverter: (v) => v.toString(), + easing: false, +}); - setup(props, context) { - const containerEl = ref<HTMLElement>(); - const thumbEl = ref<HTMLElement>(); +const emit = defineEmits<{ + (ev: 'update:modelValue', value: number): void; +}>(); - const rawValue = ref((props.modelValue - props.min) / (props.max - props.min)); - const steppedValue = computed(() => { - if (props.step) { - const step = props.step / (props.max - props.min); - return (step * Math.round(rawValue.value / step)); - } else { - return rawValue.value; - } - }); - const finalValue = computed(() => { - return (steppedValue.value * (props.max - props.min)) + props.min; - }); - watch(finalValue, () => { - context.emit('update:modelValue', finalValue.value); - }); +const containerEl = ref<HTMLElement>(); +const thumbEl = ref<HTMLElement>(); - const thumbWidth = computed(() => { - if (thumbEl.value == null) return 0; - return thumbEl.value!.offsetWidth; - }); - const thumbPosition = ref(0); - const calcThumbPosition = () => { - if (containerEl.value == null) { - thumbPosition.value = 0; - } else { - thumbPosition.value = (containerEl.value.offsetWidth - thumbWidth.value) * steppedValue.value; - } - }; - watch([steppedValue, containerEl], calcThumbPosition); - onMounted(() => { - const ro = new ResizeObserver((entries, observer) => { - calcThumbPosition(); - }); - ro.observe(containerEl.value); - onUnmounted(() => { - ro.disconnect(); - }); - }); +const rawValue = ref((props.modelValue - props.min) / (props.max - props.min)); +const steppedRawValue = computed(() => { + if (props.step) { + const step = props.step / (props.max - props.min); + return (step * Math.round(rawValue.value / step)); + } else { + return rawValue.value; + } +}); +const finalValue = computed(() => { + if (Number.isInteger(props.step)) { + return Math.round((steppedRawValue.value * (props.max - props.min)) + props.min); + } else { + return (steppedRawValue.value * (props.max - props.min)) + props.min; + } +}); - const steps = computed(() => { - if (props.step) { - return (props.max - props.min) / props.step; - } else { - return 0; - } - }); +const thumbWidth = computed(() => { + if (thumbEl.value == null) return 0; + return thumbEl.value!.offsetWidth; +}); +const thumbPosition = ref(0); +const calcThumbPosition = () => { + if (containerEl.value == null) { + thumbPosition.value = 0; + } else { + thumbPosition.value = (containerEl.value.offsetWidth - thumbWidth.value) * steppedRawValue.value; + } +}; +watch([steppedRawValue, containerEl], calcThumbPosition); + +let ro: ResizeObserver | undefined; + +onMounted(() => { + ro = new ResizeObserver((entries, observer) => { + calcThumbPosition(); + }); + ro.observe(containerEl.value); +}); + +onUnmounted(() => { + if (ro) ro.disconnect(); +}); - const onMousedown = (ev: MouseEvent | TouchEvent) => { - ev.preventDefault(); +const steps = computed(() => { + if (props.step) { + return (props.max - props.min) / props.step; + } else { + return 0; + } +}); - const tooltipShowing = ref(true); - os.popup(defineAsyncComponent(() => import('@/components/ui/tooltip.vue')), { - showing: tooltipShowing, - text: computed(() => { - return props.textConverter(finalValue.value); - }), - targetElement: thumbEl, - }, {}, 'closed'); +const onMousedown = (ev: MouseEvent | TouchEvent) => { + ev.preventDefault(); - const style = document.createElement('style'); - style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); - document.head.appendChild(style); + const tooltipShowing = ref(true); + os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), { + showing: tooltipShowing, + text: computed(() => { + return props.textConverter(finalValue.value); + }), + targetElement: thumbEl, + }, {}, 'closed'); - const onDrag = (ev: MouseEvent | TouchEvent) => { - ev.preventDefault(); - const containerRect = containerEl.value!.getBoundingClientRect(); - const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX; - const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth.value / 2)); - rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth.value))); - }; + const style = document.createElement('style'); + style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); + document.head.appendChild(style); - const onMouseup = () => { - document.head.removeChild(style); - tooltipShowing.value = false; - window.removeEventListener('mousemove', onDrag); - window.removeEventListener('touchmove', onDrag); - window.removeEventListener('mouseup', onMouseup); - window.removeEventListener('touchend', onMouseup); - }; + const onDrag = (ev: MouseEvent | TouchEvent) => { + ev.preventDefault(); + const containerRect = containerEl.value!.getBoundingClientRect(); + const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX; + const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth.value / 2)); + rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth.value))); + }; - window.addEventListener('mousemove', onDrag); - window.addEventListener('touchmove', onDrag); - window.addEventListener('mouseup', onMouseup, { once: true }); - window.addEventListener('touchend', onMouseup, { once: true }); - }; + let beforeValue = finalValue.value; - return { - rawValue, - finalValue, - steppedValue, - onMousedown, - containerEl, - thumbEl, - thumbPosition, - steps, - }; - }, -}); + const onMouseup = () => { + document.head.removeChild(style); + tooltipShowing.value = false; + window.removeEventListener('mousemove', onDrag); + window.removeEventListener('touchmove', onDrag); + window.removeEventListener('mouseup', onMouseup); + window.removeEventListener('touchend', onMouseup); + + // 値が変わってたら通知 + if (beforeValue !== finalValue.value) { + emit('update:modelValue', finalValue.value); + } + }; + + window.addEventListener('mousemove', onDrag); + window.addEventListener('touchmove', onDrag); + window.addEventListener('mouseup', onMouseup, { once: true }); + window.addEventListener('touchend', onMouseup, { once: true }); +}; </script> <style lang="scss" scoped> @@ -191,7 +171,7 @@ export default defineComponent({ $thumbWidth: 20px; > .body { - padding: 12px; + padding: 10px 12px; background: var(--panel); border: solid 1px var(--panel); border-radius: 6px; @@ -220,7 +200,6 @@ export default defineComponent({ height: 100%; background: var(--accent); opacity: 0.5; - transition: width 0.2s cubic-bezier(0,0,0,1); } } @@ -253,7 +232,6 @@ export default defineComponent({ cursor: grab; background: var(--accent); border-radius: 999px; - transition: left 0.2s cubic-bezier(0,0,0,1); &:hover { background: var(--accentLighten); @@ -261,5 +239,21 @@ export default defineComponent({ } } } + + &.easing { + > .body { + > .container { + > .track { + > .highlight { + transition: width 0.2s cubic-bezier(0,0,0,1); + } + } + + > .thumb { + transition: left 0.2s cubic-bezier(0,0,0,1); + } + } + } + } } </style> diff --git a/packages/client/src/components/form/select.vue b/packages/client/src/components/form/select.vue index 87196027a8..313ba41cd3 100644 --- a/packages/client/src/components/form/select.vue +++ b/packages/client/src/components/form/select.vue @@ -3,7 +3,8 @@ <div class="label" @click="focus"><slot name="label"></slot></div> <div ref="container" class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick"> <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div> - <select ref="inputEl" + <select + ref="inputEl" v-model="v" v-adaptive-border class="select" @@ -21,182 +22,146 @@ </div> <div class="caption"><slot name="caption"></slot></div> - <MkButton v-if="manualSave && changed" primary @click="updated"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton v-if="manualSave && changed" primary @click="updated"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton> </div> </template> -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +<script lang="ts" setup> +import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots } from 'vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkButton, - }, +const props = defineProps<{ + modelValue: string; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + placeholder?: string; + autofocus?: boolean; + inline?: boolean; + manualSave?: boolean; + small?: boolean; + large?: boolean; +}>(); - 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 - }, - }, +const emit = defineEmits<{ + (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'update:modelValue', value: string): void; +}>(); - emits: ['change', 'update:modelValue'], +const slots = useSlots(); - 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 container = ref(null); +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 container = ref(null); +const height = + props.small ? 36 : + props.large ? 40 : + 38; - const focus = () => inputEl.value.focus(); - const onInput = (ev) => { - changed.value = true; - context.emit('change', ev); - }; +const focus = () => inputEl.value.focus(); +const onInput = (ev) => { + changed.value = true; + emit('change', ev); +}; - const updated = () => { - changed.value = false; - context.emit('update:modelValue', v.value); - }; +const updated = () => { + changed.value = false; + emit('update:modelValue', v.value); +}; - watch(modelValue, newValue => { - v.value = newValue; - }); +watch(modelValue, newValue => { + v.value = newValue; +}); - watch(v, newValue => { - if (!props.manualSave) { - updated(); - } +watch(v, newValue => { + if (!props.manualSave) { + updated(); + } - invalid.value = inputEl.value.validity.badInput; - }); + invalid.value = inputEl.value.validity.badInput; +}); - onMounted(() => { - nextTick(() => { - if (autofocus.value) { - focus(); - } +// このコンポーネントが作成された時、非表示状態である場合がある +// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する +useInterval(() => { + 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, { + immediate: true, + afterMounted: true, +}); - // このコンポーネントが作成された時、非表示状態である場合がある - // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する - const clock = window.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); +onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + }); +}); - onUnmounted(() => { - window.clearInterval(clock); - }); - }); - }); +const onClick = (ev: MouseEvent) => { + focused.value = true; - const onClick = (ev: MouseEvent) => { - focused.value = true; + const menu = []; + let options = slots.default!(); - const menu = []; - let options = context.slots.default(); + const pushOption = (option: VNode) => { + menu.push({ + text: option.children, + active: v.value === option.props.value, + action: () => { + v.value = option.props.value; + }, + }); + }; - const pushOption = (option: VNode) => { + const scanOptions = (options: VNode[]) => { + for (const vnode of options) { + if (vnode.type === 'optgroup') { + const optgroup = vnode; menu.push({ - text: option.children, - active: v.value === option.props.value, - action: () => { - v.value = option.props.value; - }, + type: 'label', + text: optgroup.props.label, }); - }; - - const scanOptions = (options: VNode[]) => { - for (const vnode of options) { - if (vnode.type === 'optgroup') { - const optgroup = vnode; - menu.push({ - type: 'label', - text: optgroup.props.label, - }); - scanOptions(optgroup.children); - } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある - const fragment = vnode; - scanOptions(fragment.children); - } else { - const option = vnode; - pushOption(option); - } - } - }; - - scanOptions(options); + scanOptions(optgroup.children); + } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある + const fragment = vnode; + scanOptions(fragment.children); + } else if (vnode.props == null) { // v-if で条件が false のときにこうなる + // nop? + } else { + const option = vnode; + pushOption(option); + } + } + }; - os.popupMenu(menu, container.value, { - width: container.value.offsetWidth, - }).then(() => { - focused.value = false; - }); - }; + scanOptions(options); - return { - v, - focused, - invalid, - changed, - filled, - inputEl, - prefixEl, - suffixEl, - container, - focus, - onInput, - onClick, - updated, - }; - }, -}); + os.popupMenu(menu, container.value, { + width: container.value.offsetWidth, + }).then(() => { + focused.value = false; + }); +}; </script> <style lang="scss" scoped> @@ -222,7 +187,6 @@ export default defineComponent({ } > .input { - $height: 42px; position: relative; cursor: pointer; @@ -236,7 +200,7 @@ export default defineComponent({ appearance: none; -webkit-appearance: none; display: block; - height: $height; + height: v-bind("height + 'px'"); width: 100%; margin: 0; padding: 0 12px; @@ -253,6 +217,7 @@ export default defineComponent({ cursor: pointer; transition: border-color 0.1s ease-out; pointer-events: none; + user-select: none; } > .prefix, @@ -264,7 +229,7 @@ export default defineComponent({ top: 0; padding: 0 12px; font-size: 1em; - height: $height; + height: v-bind("height + 'px'"); pointer-events: none; &:empty { diff --git a/packages/client/src/components/form/slot.vue b/packages/client/src/components/form/slot.vue index d031b2effc..79ce8fe51f 100644 --- a/packages/client/src/components/form/slot.vue +++ b/packages/client/src/components/form/slot.vue @@ -8,12 +8,12 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; -export default defineComponent({ - -}); +function focus() { + // TODO +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/form/split.vue b/packages/client/src/components/form/split.vue index 676b293967..301a8a84e5 100644 --- a/packages/client/src/components/form/split.vue +++ b/packages/client/src/components/form/split.vue @@ -6,9 +6,9 @@ <script lang="ts" setup> const props = withDefaults(defineProps<{ - minWidth: number; + minWidth?: number; }>(), { - minWidth: 210, + minWidth: 210, }); const minWidth = props.minWidth + 'px'; diff --git a/packages/client/src/components/form/suspense.vue b/packages/client/src/components/form/suspense.vue index 2ad55dacae..132eafd138 100644 --- a/packages/client/src/components/form/suspense.vue +++ b/packages/client/src/components/form/suspense.vue @@ -17,7 +17,7 @@ <script lang="ts"> import { defineComponent, PropType, ref, watch } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; export default defineComponent({ components: { diff --git a/packages/client/src/components/form/switch.vue b/packages/client/src/components/form/switch.vue index fadb770aee..1ed00ae655 100644 --- a/packages/client/src/components/form/switch.vue +++ b/packages/client/src/components/form/switch.vue @@ -1,6 +1,6 @@ <template> <div - class="ziffeoms" + class="ziffeomt" :class="{ disabled, checked }" > <input @@ -9,8 +9,8 @@ :disabled="disabled" @keydown.enter="toggle" > - <span ref="button" v-adaptive-border v-tooltip="checked ? $ts.itsOn : $ts.itsOff" class="button" @click.prevent="toggle"> - <i class="check fas fa-check"></i> + <span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle"> + <div class="knob"></div> </span> <span class="label"> <!-- TODO: 無名slotの方は廃止 --> @@ -23,7 +23,7 @@ <script lang="ts" setup> import { toRefs, Ref } from 'vue'; import * as os from '@/os'; -import Ripple from '@/components/ripple.vue'; +import { i18n } from '@/i18n'; const props = defineProps<{ modelValue: boolean | Ref<boolean>; @@ -41,16 +41,13 @@ const toggle = () => { emit('update:modelValue', !checked.value); if (!checked.value) { - const rect = button.getBoundingClientRect(); - const x = rect.left + (button.offsetWidth / 2); - const y = rect.top + (button.offsetHeight / 2); - os.popup(Ripple, { x, y, particle: false }, {}, 'end'); + } }; </script> <style lang="scss" scoped> -.ziffeoms { +.ziffeomt { position: relative; display: flex; transition: all 0.2s ease; @@ -73,21 +70,25 @@ const toggle = () => { flex-shrink: 0; margin: 0; box-sizing: border-box; - width: 23px; + width: 32px; height: 23px; outline: none; - background: var(--panel); - border: solid 1px var(--panel); - border-radius: 4px; + background: var(--swutchOffBg); + background-clip: content-box; + border: solid 1px var(--swutchOffBg); + border-radius: 999px; cursor: pointer; transition: inherit; + user-select: none; - > .check { - margin: auto; - opacity: 0; - color: var(--fgOnAccent); - font-size: 13px; - transform: scale(0.5); + > .knob { + position: absolute; + top: 3px; + left: 3px; + width: 15px; + height: 15px; + background: var(--swutchOffFg); + border-radius: 999px; transition: all 0.2s ease; } } @@ -130,12 +131,12 @@ const toggle = () => { &.checked { > .button { - background-color: var(--accent) !important; - border-color: var(--accent) !important; + background-color: var(--swutchOnBg) !important; + border-color: var(--swutchOnBg) !important; - > .check { - opacity: 1; - transform: scale(1); + > .knob { + left: 12px; + background: var(--swutchOnFg); } } } diff --git a/packages/client/src/components/form/textarea.vue b/packages/client/src/components/form/textarea.vue index c9ba9b97a2..ca57aa62a5 100644 --- a/packages/client/src/components/form/textarea.vue +++ b/packages/client/src/components/form/textarea.vue @@ -2,7 +2,8 @@ <div class="adhpbeos"> <div class="label" @click="focus"><slot name="label"></slot></div> <div class="input" :class="{ disabled, focused, tall, pre }"> - <textarea ref="inputEl" + <textarea + ref="inputEl" v-model="v" v-adaptive-border :class="{ code, _monospace: code }" @@ -21,14 +22,15 @@ </div> <div class="caption"><slot name="caption"></slot></div> - <MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton> </div> </template> <script lang="ts"> import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import MkButton from '@/components/ui/button.vue'; import { debounce } from 'throttle-debounce'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; export default defineComponent({ components: { @@ -37,66 +39,66 @@ export default defineComponent({ props: { modelValue: { - required: true + required: true, }, type: { type: String, - required: false + required: false, }, required: { type: Boolean, - required: false + required: false, }, readonly: { type: Boolean, - required: false + required: false, }, disabled: { type: Boolean, - required: false + required: false, }, pattern: { type: String, - required: false + required: false, }, placeholder: { type: String, - required: false + required: false, }, autofocus: { type: Boolean, required: false, - default: false + default: false, }, autocomplete: { - required: false + required: false, }, spellcheck: { - required: false + required: false, }, code: { type: Boolean, - required: false + required: false, }, tall: { type: Boolean, required: false, - default: false + default: false, }, pre: { type: Boolean, required: false, - default: false + default: false, }, debounce: { type: Boolean, required: false, - default: false + default: false, }, manualSave: { type: Boolean, required: false, - default: false + default: false, }, }, @@ -166,6 +168,7 @@ export default defineComponent({ onInput, onKeydown, updated, + i18n, }; }, }); diff --git a/packages/client/src/components/formula.vue b/packages/client/src/components/formula.vue deleted file mode 100644 index fbb40bace7..0000000000 --- a/packages/client/src/components/formula.vue +++ /dev/null @@ -1,23 +0,0 @@ -<template> -<XFormula :formula="formula" :block="block" /> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue';import * as os from '@/os'; - -export default defineComponent({ - components: { - XFormula: defineAsyncComponent(() => import('./formula-core.vue')) - }, - props: { - formula: { - type: String, - required: true - }, - block: { - type: Boolean, - required: true - } - } -}); -</script> diff --git a/packages/client/src/components/global/a.vue b/packages/client/src/components/global/MkA.vue index c7cf12e8c8..67bf54def8 100644 --- a/packages/client/src/components/global/a.vue +++ b/packages/client/src/components/global/MkA.vue @@ -50,7 +50,7 @@ function onContextmenu(ev) { icon: 'fas fa-expand-alt', text: i18n.ts.showInPage, action: () => { - router.push(props.to); + router.push(props.to, 'forcePage'); }, }, null, { icon: 'fas fa-external-link-alt', @@ -79,7 +79,7 @@ function popout() { popout_(props.to); } -function nav() { +function nav(ev: MouseEvent) { if (props.behavior === 'browser') { location.href = props.to; return; @@ -93,6 +93,10 @@ function nav() { } } - router.push(props.to); + if (ev.shiftKey) { + return openWindow(); + } + + router.push(props.to, ev.ctrlKey ? 'forcePage' : null); } </script> diff --git a/packages/client/src/components/global/acct.vue b/packages/client/src/components/global/MkAcct.vue index c3e806b5fb..c3e806b5fb 100644 --- a/packages/client/src/components/global/acct.vue +++ b/packages/client/src/components/global/MkAcct.vue diff --git a/packages/client/src/components/global/MkAd.vue b/packages/client/src/components/global/MkAd.vue new file mode 100644 index 0000000000..8161ef3792 --- /dev/null +++ b/packages/client/src/components/global/MkAd.vue @@ -0,0 +1,186 @@ +<template> +<div v-if="chosen" class="qiivuoyo"> + <div v-if="!showMenu" class="main" :class="chosen.place"> + <a :href="chosen.url" target="_blank"> + <img :src="chosen.imageUrl"> + <button class="_button menu" @click.prevent.stop="toggleMenu"><span class="fas fa-info-circle info-circle"></span></button> + </a> + </div> + <div v-else class="menu"> + <div class="body"> + <div>Ads by {{ host }}</div> + <!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>--> + <MkButton v-if="chosen.ratio !== 0" class="button" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton> + <button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button> + </div> + </div> +</div> +<div v-else></div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import { instance } from '@/instance'; +import { host } from '@/config'; +import MkButton from '@/components/MkButton.vue'; +import { defaultStore } from '@/store'; +import * as os from '@/os'; + +type Ad = (typeof instance)['ads'][number]; + +const props = defineProps<{ + prefer: string[]; + specify?: Ad; +}>(); + +const showMenu = ref(false); +const toggleMenu = (): void => { + showMenu.value = !showMenu.value; +}; + +const choseAd = (): Ad | null => { + if (props.specify) { + return props.specify; + } + + const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? { + ...ad, + ratio: 0, + } : ad); + + let ads = allAds.filter(ad => props.prefer.includes(ad.place)); + + if (ads.length === 0) { + ads = allAds.filter(ad => ad.place === 'square'); + } + + const lowPriorityAds = ads.filter(ad => ad.ratio === 0); + ads = ads.filter(ad => ad.ratio !== 0); + + if (ads.length === 0) { + if (lowPriorityAds.length !== 0) { + return lowPriorityAds[Math.floor(Math.random() * lowPriorityAds.length)]; + } else { + return null; + } + } + + const totalFactor = ads.reduce((a, b) => a + b.ratio, 0); + const r = Math.random() * totalFactor; + + let stackedFactor = 0; + for (const ad of ads) { + if (r >= stackedFactor && r <= stackedFactor + ad.ratio) { + return ad; + } else { + stackedFactor += ad.ratio; + } + } + + return null; +}; + +const chosen = ref(choseAd()); + +function reduceFrequency(): void { + if (chosen.value == null) return; + if (defaultStore.state.mutedAds.includes(chosen.value.id)) return; + defaultStore.push('mutedAds', chosen.value.id); + os.success(); + chosen.value = choseAd(); + showMenu.value = false; +} +</script> + +<style lang="scss" scoped> +.qiivuoyo { + background-size: auto auto; + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--ad) 8px, var(--ad) 14px ); + + > .main { + text-align: center; + + > a { + display: inline-block; + position: relative; + vertical-align: bottom; + + &:hover { + > img { + filter: contrast(120%); + } + } + + > img { + display: block; + object-fit: contain; + margin: auto; + border-radius: 5px; + } + + > .menu { + position: absolute; + top: 1px; + right: 1px; + + > .info-circle { + border: 3px solid var(--panel); + border-radius: 50%; + background: var(--panel); + } + } + } + + &.square { + > a , + > a > img { + max-width: min(300px, 100%); + max-height: 300px; + } + } + + &.horizontal { + padding: 8px; + + > a , + > a > img { + max-width: min(600px, 100%); + max-height: 80px; + } + } + + &.horizontal-big { + padding: 8px; + + > a , + > a > img { + max-width: min(600px, 100%); + max-height: 250px; + } + } + + &.vertical { + > a , + > a > img { + max-width: min(100px, 100%); + } + } + } + + > .menu { + padding: 8px; + text-align: center; + + > .body { + padding: 8px; + margin: 0 auto; + max-width: 400px; + border: solid 1px var(--divider); + + > .button { + margin: 8px auto; + } + } + } +} +</style> diff --git a/packages/client/src/components/global/avatar.vue b/packages/client/src/components/global/MkAvatar.vue index 4868896c99..5f3e3c176d 100644 --- a/packages/client/src/components/global/avatar.vue +++ b/packages/client/src/components/global/MkAvatar.vue @@ -15,7 +15,7 @@ import * as misskey from 'misskey-js'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; import { acct, userPage } from '@/filters/user'; -import MkUserOnlineIndicator from '@/components/user-online-indicator.vue'; +import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue'; import { defaultStore } from '@/store'; const props = withDefaults(defineProps<{ diff --git a/packages/client/src/components/global/ellipsis.vue b/packages/client/src/components/global/MkEllipsis.vue index 0a46f486d6..0a46f486d6 100644 --- a/packages/client/src/components/global/ellipsis.vue +++ b/packages/client/src/components/global/MkEllipsis.vue diff --git a/packages/client/src/components/global/MkEmoji.vue b/packages/client/src/components/global/MkEmoji.vue new file mode 100644 index 0000000000..106778aee2 --- /dev/null +++ b/packages/client/src/components/global/MkEmoji.vue @@ -0,0 +1,69 @@ +<template> +<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt" decoding="async"/> +<img v-else-if="char && !useOsNativeEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt" decoding="async"/> +<span v-else-if="char && useOsNativeEmojis">{{ char }}</span> +<span v-else>{{ emoji }}</span> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import { CustomEmoji } from 'misskey-js/built/entities'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { char2filePath } from '@/scripts/twemoji-base'; +import { defaultStore } from '@/store'; +import { instance } from '@/instance'; + +const props = defineProps<{ + emoji: string; + normal?: boolean; + noStyle?: boolean; + customEmojis?: CustomEmoji[]; + isReaction?: boolean; +}>(); + +const isCustom = computed(() => props.emoji.startsWith(':')); +const char = computed(() => isCustom.value ? null : props.emoji); +const useOsNativeEmojis = computed(() => defaultStore.state.useOsNativeEmojis && !props.isReaction); +const ce = computed(() => props.customEmojis ?? instance.emojis ?? []); +const customEmoji = computed(() => isCustom.value ? ce.value.find(x => x.name === props.emoji.substr(1, props.emoji.length - 2)) : null); +const url = computed(() => { + if (char.value) { + return char2filePath(char.value); + } else { + return defaultStore.state.disableShowingAnimatedImages + ? getStaticImageUrl(customEmoji.value.url) + : customEmoji.value.url; + } +}); +const alt = computed(() => customEmoji.value ? `:${customEmoji.value.name}:` : char.value); +</script> + +<style lang="scss" scoped> +.mk-emoji { + height: 1.25em; + vertical-align: -0.25em; + + &.custom { + height: 2.5em; + vertical-align: middle; + transition: transform 0.2s ease; + + &:hover { + transform: scale(1.2); + } + + &.normal { + height: 1.25em; + vertical-align: -0.25em; + + &:hover { + transform: none; + } + } + } + + &.noStyle { + height: auto !important; + } +} +</style> diff --git a/packages/client/src/components/global/error.vue b/packages/client/src/components/global/MkError.vue index 98b96fb414..6e75a69ec3 100644 --- a/packages/client/src/components/global/error.vue +++ b/packages/client/src/components/global/MkError.vue @@ -2,14 +2,15 @@ <transition :name="$store.state.animation ? 'zoom' : ''" appear> <div class="mjndxjcg"> <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> - <p><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</p> - <MkButton class="button" @click="() => $emit('retry')">{{ $ts.retry }}</MkButton> + <p><i class="fas fa-exclamation-triangle"></i> {{ i18n.ts.somethingHappened }}</p> + <MkButton class="button" @click="() => $emit('retry')">{{ i18n.ts.retry }}</MkButton> </div> </transition> </template> <script lang="ts" setup> -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/global/loading.vue b/packages/client/src/components/global/MkLoading.vue index 5a7e362fcf..bcf5925234 100644 --- a/packages/client/src/components/global/loading.vue +++ b/packages/client/src/components/global/MkLoading.vue @@ -16,9 +16,7 @@ </template> <script lang="ts" setup> -import { useCssModule } from 'vue'; - -useCssModule(); +import { } from 'vue'; const props = withDefaults(defineProps<{ inline?: boolean; @@ -46,7 +44,7 @@ const props = withDefaults(defineProps<{ text-align: center; cursor: wait; - --size: 40px; + --size: 38px; &.colored { color: var(--accent); diff --git a/packages/client/src/components/global/misskey-flavored-markdown.vue b/packages/client/src/components/global/MkMisskeyFlavoredMarkdown.vue index 70d0108e9f..70d0108e9f 100644 --- a/packages/client/src/components/global/misskey-flavored-markdown.vue +++ b/packages/client/src/components/global/MkMisskeyFlavoredMarkdown.vue diff --git a/packages/client/src/components/global/page-header.vue b/packages/client/src/components/global/MkPageHeader.vue index c01631c6a3..ba75b2446b 100644 --- a/packages/client/src/components/global/page-header.vue +++ b/packages/client/src/components/global/MkPageHeader.vue @@ -1,5 +1,8 @@ <template> <div v-if="show" ref="el" class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick"> + <div v-if="narrow" class="buttons left"> + <MkAvatar v-if="props.displayMyAvatar && $i" class="avatar" :user="$i" :disable-preview="true"/> + </div> <template v-if="metadata"> <div v-if="!hideTitle" class="titleContainer" @click="showTabsPopup"> <MkAvatar v-if="metadata.avatar" class="avatar" :user="metadata.avatar" :disable-preview="true" :show-indicator="true"/> @@ -12,49 +15,59 @@ {{ metadata.subtitle }} </div> <div v-if="narrow && hasTabs" class="subtitle activeTab"> - {{ tabs.find(tab => tab.active)?.title }} + {{ tabs.find(tab => tab.key === props.tab)?.title }} <i class="chevron fas fa-chevron-down"></i> </div> </div> </div> <div v-if="!narrow || hideTitle" class="tabs"> - <button v-for="tab in tabs" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.active }" @click="tab.onClick"> + <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)"> <i v-if="tab.icon" class="icon" :class="tab.icon"></i> <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> </button> + <div ref="tabHighlightEl" class="highlight"></div> </div> </template> <div class="buttons right"> <template v-for="action in actions"> - <button v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> + <button v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> </template> </div> </div> </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, ref, inject } from 'vue'; +import { computed, onMounted, onUnmounted, ref, inject, watch, shallowReactive, nextTick, reactive } from 'vue'; import tinycolor from 'tinycolor2'; import { popupMenu } from '@/os'; import { scrollToTop } from '@/scripts/scroll'; import { i18n } from '@/i18n'; import { globalEvents } from '@/events'; -import { injectPageMetadata, PageMetadata } from '@/scripts/page-metadata'; +import { injectPageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; + +type Tab = { + key?: string | null; + title: string; + icon?: string; + iconOnly?: boolean; + onClick?: (ev: MouseEvent) => void; +}; const props = defineProps<{ - tabs?: { - title: string; - active: boolean; - icon?: string; - iconOnly?: boolean; - onClick: () => void; - }[]; + tabs?: Tab[]; + tab?: string; actions?: { text: string; icon: string; handler: (ev: MouseEvent) => void; }[]; thin?: boolean; + displayMyAvatar?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'update:tab', key: string); }>(); const metadata = injectPageMetadata(); @@ -63,6 +76,8 @@ const hideTitle = inject('shouldOmitHeaderTitle', false); const thin_ = props.thin || inject('shouldHeaderThin', false); const el = $ref<HTMLElement | null>(null); +const tabRefs = {}; +const tabHighlightEl = $ref<HTMLElement | null>(null); const bg = ref(null); let narrow = $ref(false); const height = ref(0); @@ -80,7 +95,10 @@ const showTabsPopup = (ev: MouseEvent) => { const menu = props.tabs.map(tab => ({ text: tab.title, icon: tab.icon, - action: tab.onClick, + active: tab.key != null && tab.key === props.tab, + action: (ev) => { + onTabClick(tab, ev); + }, })); popupMenu(menu, ev.currentTarget ?? ev.target); }; @@ -93,6 +111,24 @@ const onClick = () => { scrollToTop(el, { behavior: 'smooth' }); }; +function onTabMousedown(tab: Tab, ev: MouseEvent): void { + // ユーザビリティの観点からmousedown時にはonClickは呼ばない + if (tab.key) { + emit('update:tab', tab.key); + } +} + +function onTabClick(tab: Tab, ev: MouseEvent): void { + if (tab.onClick) { + ev.preventDefault(); + ev.stopPropagation(); + tab.onClick(ev); + } + if (tab.key) { + emit('update:tab', tab.key); + } +} + const calcBg = () => { const rawBg = metadata?.bg || 'var(--bg)'; const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); @@ -106,10 +142,26 @@ onMounted(() => { calcBg(); globalEvents.on('themeChanged', calcBg); + watch(() => [props.tab, props.tabs], () => { + nextTick(() => { + const tabEl = tabRefs[props.tab]; + if (tabEl && tabHighlightEl) { + // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabEl.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.style.width = rect.width + 'px'; + tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px'; + } + }); + }, { + immediate: true, + }); + if (el && el.parentElement) { narrow = el.parentElement.offsetWidth < 500; ro = new ResizeObserver((entries, observer) => { - if (el.parentElement) { + if (el.parentElement && document.body.contains(el)) { narrow = el.parentElement.offsetWidth < 500; } }); @@ -125,18 +177,17 @@ onUnmounted(() => { <style lang="scss" scoped> .fdidabkb { - --height: 60px; + --height: 55px; 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); + contain: strict; + height: var(--height); &.thin { - --height: 50px; + --height: 45px; > .buttons { > .button { @@ -151,7 +202,6 @@ onUnmounted(() => { > .titleContainer { flex: 1; margin: 0 auto; - margin-left: var(--height); > *:first-child { margin-left: auto; @@ -167,9 +217,24 @@ onUnmounted(() => { --margin: 8px; display: flex; align-items: center; + min-width: var(--height); height: var(--height); margin: 0 var(--margin); + &.left { + margin-right: auto; + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: bottom; + margin: 0 8px; + pointer-events: none; + } + } + &.right { margin-left: auto; } @@ -227,6 +292,8 @@ onUnmounted(() => { > .icon { margin-right: 8px; + width: 16px; + text-align: center; } > .title { @@ -257,6 +324,7 @@ onUnmounted(() => { } > .tabs { + position: relative; margin-left: 16px; font-size: 0.8em; overflow: auto; @@ -276,25 +344,22 @@ onUnmounted(() => { &.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; } } + + > .highlight { + position: absolute; + bottom: 0; + height: 3px; + background: var(--accent); + border-radius: 999px; + transition: all 0.2s ease; + pointer-events: none; + } } } </style> diff --git a/packages/client/src/components/global/MkSpacer.vue b/packages/client/src/components/global/MkSpacer.vue new file mode 100644 index 0000000000..53adf07771 --- /dev/null +++ b/packages/client/src/components/global/MkSpacer.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" setup> +import { inject, onMounted, onUnmounted, ref } from 'vue'; +import { deviceKind } from '@/scripts/device-kind'; + +const props = withDefaults(defineProps<{ + contentMax?: number | null; + marginMin?: number; + marginMax?: number; +}>(), { + contentMax: null, + marginMin: 12, + marginMax: 24, +}); + +let ro: ResizeObserver; +let root = $ref<HTMLElement>(); +let content = $ref<HTMLElement>(); +let margin = $ref(0); +const shouldSpacerMin = inject('shouldSpacerMin', false); + +const adjust = (rect: { width: number; height: number; }) => { + if (shouldSpacerMin || deviceKind === 'smartphone') { + margin = props.marginMin; + return; + } + + if (rect.width > (props.contentMax ?? 0) || (rect.width > 360 && window.innerWidth > 400)) { + margin = props.marginMax; + } else { + margin = props.marginMin; + } +}; + +onMounted(() => { + ro = new ResizeObserver((entries) => { + /* iOSが対応していない + adjust({ + width: entries[0].borderBoxSize[0].inlineSize, + height: entries[0].borderBoxSize[0].blockSize, + }); + */ + adjust({ + width: root!.offsetWidth, + height: root!.offsetHeight, + }); + }); + ro.observe(root!); + + if (props.contentMax) { + content!.style.maxWidth = `${props.contentMax}px`; + } +}); + +onUnmounted(() => { + ro.disconnect(); +}); +</script> + +<style lang="scss" module> +.root { + box-sizing: border-box; + width: 100%; +} + +.content { + margin: 0 auto; +} +</style> diff --git a/packages/client/src/components/global/MkStickyContainer.vue b/packages/client/src/components/global/MkStickyContainer.vue new file mode 100644 index 0000000000..44f4f065a6 --- /dev/null +++ b/packages/client/src/components/global/MkStickyContainer.vue @@ -0,0 +1,66 @@ +<template> +<div ref="rootEl"> + <div ref="headerEl"> + <slot name="header"></slot> + </div> + <div ref="bodyEl" :data-sticky-container-header-height="headerHeight"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +// なんか動かない +//const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP'); +const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP'; +</script> + +<script lang="ts" setup> +import { onMounted, onUnmounted, provide, inject, Ref, ref, watch } from 'vue'; + +const rootEl = $ref<HTMLElement>(); +const headerEl = $ref<HTMLElement>(); +const bodyEl = $ref<HTMLElement>(); + +let headerHeight = $ref<string | undefined>(); +let childStickyTop = $ref(0); +const parentStickyTop = inject<Ref<number>>(CURRENT_STICKY_TOP, ref(0)); +provide(CURRENT_STICKY_TOP, $$(childStickyTop)); + +const calc = () => { + childStickyTop = parentStickyTop.value + headerEl.offsetHeight; + headerHeight = headerEl.offsetHeight.toString(); +}; + +const observer = new ResizeObserver(() => { + window.setTimeout(() => { + calc(); + }, 100); +}); + +onMounted(() => { + calc(); + + watch(parentStickyTop, calc); + + watch($$(childStickyTop), () => { + bodyEl.style.setProperty('--stickyTop', `${childStickyTop}px`); + }, { + immediate: true, + }); + + headerEl.style.position = 'sticky'; + headerEl.style.top = 'var(--stickyTop, 0)'; + headerEl.style.zIndex = '1000'; + + observer.observe(headerEl); +}); + +onUnmounted(() => { + observer.disconnect(); +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/client/src/components/global/time.vue b/packages/client/src/components/global/MkTime.vue index a7f142f961..f72b153f56 100644 --- a/packages/client/src/components/global/time.vue +++ b/packages/client/src/components/global/MkTime.vue @@ -20,18 +20,18 @@ const props = withDefaults(defineProps<{ const _time = typeof props.time === 'string' ? new Date(props.time) : props.time; const absolute = _time.toLocaleString(); -let now = $ref(new Date()); +let now = $shallowRef(new Date()); const relative = $computed(() => { const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/; return ( - ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) : - ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) : - ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago / 604800).toString() }) : - ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) : - ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) : - ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) : - ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) : - ago >= -1 ? i18n.ts._ago.justNow : + ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) : + ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) : + ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago / 604800).toString() }) : + ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) : + ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) : + ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) : + ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) : + ago >= -1 ? i18n.ts._ago.justNow : i18n.ts._ago.future); }); @@ -50,7 +50,7 @@ if (props.mode === 'relative' || props.mode === 'detail') { tickId = window.requestAnimationFrame(tick); onUnmounted(() => { - window.clearTimeout(tickId); + window.cancelAnimationFrame(tickId); }); } </script> diff --git a/packages/client/src/components/global/MkUrl.vue b/packages/client/src/components/global/MkUrl.vue new file mode 100644 index 0000000000..740ce29080 --- /dev/null +++ b/packages/client/src/components/global/MkUrl.vue @@ -0,0 +1,89 @@ +<template> +<component + :is="self ? 'MkA' : 'a'" ref="el" class="ieqqeuvs _link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel" :target="target" + @contextmenu.stop="() => {}" +> + <template v-if="!self"> + <span class="schema">{{ schema }}//</span> + <span class="hostname">{{ hostname }}</span> + <span v-if="port != ''" class="port">:{{ port }}</span> + </template> + <template v-if="pathname === '/' && self"> + <span class="self">{{ hostname }}</span> + </template> + <span v-if="pathname != ''" class="pathname">{{ self ? pathname.substring(1) : pathname }}</span> + <span class="query">{{ query }}</span> + <span class="hash">{{ hash }}</span> + <i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i> +</component> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, ref } from 'vue'; +import { toUnicode as decodePunycode } from 'punycode/'; +import { url as local } from '@/config'; +import * as os from '@/os'; +import { useTooltip } from '@/scripts/use-tooltip'; +import { safeURIDecode } from '@/scripts/safe-uri-decode'; + +const props = defineProps<{ + url: string; + rel?: string; +}>(); + +const self = props.url.startsWith(local); +const url = new URL(props.url); +const el = ref(); + +useTooltip(el, (showing) => { + os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { + showing, + url: props.url, + source: el.value, + }, {}, 'closed'); +}); + +const schema = url.protocol; +const hostname = decodePunycode(url.hostname); +const port = url.port; +const pathname = safeURIDecode(url.pathname); +const query = safeURIDecode(url.search); +const hash = safeURIDecode(url.hash); +const attr = self ? 'to' : 'href'; +const target = self ? null : '_blank'; +</script> + +<style lang="scss" scoped> +.ieqqeuvs { + word-break: break-all; + + > .icon { + padding-left: 2px; + font-size: .9em; + } + + > .self { + font-weight: bold; + } + + > .schema { + opacity: 0.5; + } + + > .hostname { + font-weight: bold; + } + + > .pathname { + opacity: 0.8; + } + + > .query { + opacity: 0.5; + } + + > .hash { + font-style: italic; + } +} +</style> diff --git a/packages/client/src/components/global/user-name.vue b/packages/client/src/components/global/MkUserName.vue index 090de3df30..090de3df30 100644 --- a/packages/client/src/components/global/user-name.vue +++ b/packages/client/src/components/global/MkUserName.vue diff --git a/packages/client/src/components/global/RouterView.vue b/packages/client/src/components/global/RouterView.vue new file mode 100644 index 0000000000..e21a57471c --- /dev/null +++ b/packages/client/src/components/global/RouterView.vue @@ -0,0 +1,61 @@ +<template> +<KeepAlive :max="defaultStore.state.numberOfPageCache"> + <Suspense> + <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> + + <template #fallback> + <MkLoading/> + </template> + </Suspense> +</KeepAlive> +</template> + +<script lang="ts" setup> +import { inject, nextTick, onBeforeUnmount, onMounted, onUnmounted, provide, watch } from 'vue'; +import { Resolved, Router } from '@/nirax'; +import { defaultStore } from '@/store'; + +const props = defineProps<{ + router?: Router; +}>(); + +const router = props.router ?? inject('router'); + +if (router == null) { + throw new Error('no router provided'); +} + +const currentDepth = inject('routerCurrentDepth', 0); +provide('routerCurrentDepth', currentDepth + 1); + +function resolveNested(current: Resolved, d = 0): Resolved | null { + if (d === currentDepth) { + return current; + } else { + if (current.child) { + return resolveNested(current.child, d + 1); + } else { + return null; + } + } +} + +const current = resolveNested(router.current)!; +let currentPageComponent = $shallowRef(current.route.component); +let currentPageProps = $ref(current.props); +let key = $ref(current.route.path + JSON.stringify(Object.fromEntries(current.props))); + +function onChange({ resolved, key: newKey }) { + const current = resolveNested(resolved); + if (current == null) return; + currentPageComponent = current.route.component; + currentPageProps = current.props; + key = current.route.path + JSON.stringify(Object.fromEntries(current.props)); +} + +router.addListener('change', onChange); + +onBeforeUnmount(() => { + router.removeListener('change', onChange); +}); +</script> diff --git a/packages/client/src/components/global/ad.vue b/packages/client/src/components/global/ad.vue deleted file mode 100644 index 180dabb2a2..0000000000 --- a/packages/client/src/components/global/ad.vue +++ /dev/null @@ -1,200 +0,0 @@ -<template> -<div v-if="ad" class="qiivuoyo"> - <div v-if="!showMenu" class="main" :class="ad.place"> - <a :href="ad.url" target="_blank"> - <img :src="ad.imageUrl"> - <button class="_button menu" @click.prevent.stop="toggleMenu"><span class="fas fa-info-circle"></span></button> - </a> - </div> - <div v-else class="menu"> - <div class="body"> - <div>Ads by {{ host }}</div> - <!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>--> - <MkButton v-if="ad.ratio !== 0" class="button" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton> - <button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button> - </div> - </div> -</div> -<div v-else></div> -</template> - -<script lang="ts"> -import { defineComponent, ref } from 'vue'; -import { instance } from '@/instance'; -import { host } from '@/config'; -import MkButton from '@/components/ui/button.vue'; -import { defaultStore } from '@/store'; -import * as os from '@/os'; - -export default defineComponent({ - components: { - MkButton - }, - - props: { - prefer: { - type: Array, - required: true - }, - specify: { - type: Object, - required: false - }, - }, - - setup(props) { - const showMenu = ref(false); - const toggleMenu = () => { - showMenu.value = !showMenu.value; - }; - - const choseAd = (): (typeof instance)['ads'][number] | null => { - if (props.specify) { - return props.specify as (typeof instance)['ads'][number]; - } - - const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? { - ...ad, - ratio: 0 - } : ad); - - let ads = allAds.filter(ad => props.prefer.includes(ad.place)); - - if (ads.length === 0) { - ads = allAds.filter(ad => ad.place === 'square'); - } - - const lowPriorityAds = ads.filter(ad => ad.ratio === 0); - ads = ads.filter(ad => ad.ratio !== 0); - - if (ads.length === 0) { - if (lowPriorityAds.length !== 0) { - return lowPriorityAds[Math.floor(Math.random() * lowPriorityAds.length)]; - } else { - return null; - } - } - - const totalFactor = ads.reduce((a, b) => a + b.ratio, 0); - const r = Math.random() * totalFactor; - - let stackedFactor = 0; - for (const ad of ads) { - if (r >= stackedFactor && r <= stackedFactor + ad.ratio) { - return ad; - } else { - stackedFactor += ad.ratio; - } - } - - return null; - }; - - const chosen = ref(choseAd()); - - const reduceFrequency = () => { - if (chosen.value == null) return; - if (defaultStore.state.mutedAds.includes(chosen.value.id)) return; - defaultStore.push('mutedAds', chosen.value.id); - os.success(); - chosen.value = choseAd(); - showMenu.value = false; - }; - - return { - ad: chosen, - showMenu, - toggleMenu, - host, - reduceFrequency, - }; - } -}); -</script> - -<style lang="scss" scoped> -.qiivuoyo { - background-size: auto auto; - background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--ad) 8px, var(--ad) 14px ); - - > .main { - text-align: center; - - > a { - display: inline-block; - position: relative; - vertical-align: bottom; - - &:hover { - > img { - filter: contrast(120%); - } - } - - > img { - display: block; - object-fit: contain; - margin: auto; - } - - > .menu { - position: absolute; - top: 0; - right: 0; - background: var(--panel); - } - } - - &.square { - > a , - > a > img { - max-width: min(300px, 100%); - max-height: 300px; - } - } - - &.horizontal { - padding: 8px; - - > a , - > a > img { - max-width: min(600px, 100%); - max-height: 80px; - } - } - - &.horizontal-big { - padding: 8px; - - > a , - > a > img { - max-width: min(600px, 100%); - max-height: 250px; - } - } - - &.vertical { - > a , - > a > img { - max-width: min(100px, 100%); - } - } - } - - > .menu { - padding: 8px; - text-align: center; - - > .body { - padding: 8px; - margin: 0 auto; - max-width: 400px; - border: solid 1px var(--divider); - - > .button { - margin: 8px auto; - } - } - } -} -</style> diff --git a/packages/client/src/components/global/emoji.vue b/packages/client/src/components/global/emoji.vue deleted file mode 100644 index 23cb649f7a..0000000000 --- a/packages/client/src/components/global/emoji.vue +++ /dev/null @@ -1,96 +0,0 @@ -char2filePath<template> -<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt" decoding="async"/> -<img v-else-if="char && !useOsNativeEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt" decoding="async"/> -<span v-else-if="char && useOsNativeEmojis">{{ char }}</span> -<span v-else>{{ emoji }}</span> -</template> - -<script lang="ts"> -import { computed, defineComponent, ref, watch } from 'vue'; -import { getStaticImageUrl } from '@/scripts/get-static-image-url'; -import { char2filePath } from '@/scripts/twemoji-base'; -import { defaultStore } from '@/store'; -import { instance } from '@/instance'; - -export default defineComponent({ - props: { - emoji: { - type: String, - required: true - }, - normal: { - type: Boolean, - required: false, - default: false - }, - noStyle: { - type: Boolean, - required: false, - default: false - }, - customEmojis: { - required: false - }, - isReaction: { - type: Boolean, - default: false - }, - }, - - setup(props) { - const isCustom = computed(() => props.emoji.startsWith(':')); - const char = computed(() => isCustom.value ? null : props.emoji); - const useOsNativeEmojis = computed(() => defaultStore.state.useOsNativeEmojis && !props.isReaction); - const ce = computed(() => props.customEmojis || instance.emojis || []); - const customEmoji = computed(() => isCustom.value ? ce.value.find(x => x.name === props.emoji.substr(1, props.emoji.length - 2)) : null); - const url = computed(() => { - if (char.value) { - return char2filePath(char.value); - } else { - return defaultStore.state.disableShowingAnimatedImages - ? getStaticImageUrl(customEmoji.value.url) - : customEmoji.value.url; - } - }); - const alt = computed(() => customEmoji.value ? `:${customEmoji.value.name}:` : char.value); - - return { - url, - char, - alt, - customEmoji, - useOsNativeEmojis, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.mk-emoji { - height: 1.25em; - vertical-align: -0.25em; - - &.custom { - height: 2.5em; - vertical-align: middle; - transition: transform 0.2s ease; - - &:hover { - transform: scale(1.2); - } - - &.normal { - height: 1.25em; - vertical-align: -0.25em; - - &:hover { - transform: none; - } - } - } - - &.noStyle { - height: auto !important; - } -} -</style> diff --git a/packages/client/src/components/global/i18n.ts b/packages/client/src/components/global/i18n.ts index abf0c96856..1fd293ba10 100644 --- a/packages/client/src/components/global/i18n.ts +++ b/packages/client/src/components/global/i18n.ts @@ -30,7 +30,7 @@ export default defineComponent({ } else { if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen)); parsed.push({ - arg: str.substring(nextBracketOpen + 1, nextBracketClose) + arg: str.substring(nextBracketOpen + 1, nextBracketClose), }); } @@ -38,5 +38,5 @@ export default defineComponent({ } return h(this.tag, parsed.map(x => typeof x === 'string' ? (this.textTag ? h(this.textTag, x) : x) : this.$slots[x.arg]())); - } + }, }); diff --git a/packages/client/src/components/global/router-view.vue b/packages/client/src/components/global/router-view.vue deleted file mode 100644 index 393ba30c3d..0000000000 --- a/packages/client/src/components/global/router-view.vue +++ /dev/null @@ -1,39 +0,0 @@ -<template> -<KeepAlive max="5"> - <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> -</KeepAlive> -</template> - -<script lang="ts" setup> -import { inject, nextTick, onMounted, onUnmounted, watch } from 'vue'; -import { Router } from '@/nirax'; - -const props = defineProps<{ - router?: Router; -}>(); - -const emit = defineEmits<{ -}>(); - -const router = props.router ?? inject('router'); - -if (router == null) { - throw new Error('no router provided'); -} - -let currentPageComponent = $ref(router.getCurrentComponent()); -let currentPageProps = $ref(router.getCurrentProps()); -let key = $ref(router.getCurrentKey()); - -function onChange({ route, props: newProps, key: newKey }) { - currentPageComponent = route.component; - currentPageProps = newProps; - key = newKey; -} - -router.addListener('change', onChange); - -onUnmounted(() => { - router.removeListener('change', onChange); -}); -</script> diff --git a/packages/client/src/components/global/spacer.vue b/packages/client/src/components/global/spacer.vue deleted file mode 100644 index f2eda1907b..0000000000 --- a/packages/client/src/components/global/spacer.vue +++ /dev/null @@ -1,93 +0,0 @@ -<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 { deviceKind } from '@/scripts/device-kind'; -import { defineComponent, inject, onMounted, onUnmounted, ref } from 'vue'; - -export default defineComponent({ - props: { - contentMax: { - type: Number, - required: false, - default: null, - }, - marginMin: { - type: Number, - required: false, - default: 12, - }, - marginMax: { - type: Number, - required: false, - default: 24, - }, - }, - - setup(props, context) { - let ro: ResizeObserver; - const root = ref<HTMLElement>(); - const content = ref<HTMLElement>(); - const margin = ref(0); - const shouldSpacerMin = inject('shouldSpacerMin', false); - const adjust = (rect: { width: number; height: number; }) => { - if (shouldSpacerMin || deviceKind === 'smartphone') { - margin.value = props.marginMin; - return; - } - - if (rect.width > props.contentMax || (rect.width > 360 && window.innerWidth > 400)) { - margin.value = props.marginMax; - } else { - margin.value = props.marginMin; - } - }; - - 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/packages/client/src/components/global/sticky-container.vue b/packages/client/src/components/global/sticky-container.vue deleted file mode 100644 index 98a7ee9c30..0000000000 --- a/packages/client/src/components/global/sticky-container.vue +++ /dev/null @@ -1,66 +0,0 @@ -<template> -<div ref="rootEl"> - <slot name="header"></slot> - <div ref="bodyEl" :data-sticky-container-header-height="headerHeight"> - <slot></slot> - </div> -</div> -</template> - -<script lang="ts" setup> -import { onMounted, onUnmounted } from 'vue'; - -const props = withDefaults(defineProps<{ - autoSticky?: boolean; -}>(), { - autoSticky: false, -}); - -const rootEl = $ref<HTMLElement>(); -const bodyEl = $ref<HTMLElement>(); - -let headerHeight = $ref<string | undefined>(); - -const calc = () => { - const currentStickyTop = getComputedStyle(rootEl).getPropertyValue('--stickyTop') || '0px'; - - const header = rootEl.children[0] as HTMLElement; - if (header === bodyEl) { - bodyEl.style.setProperty('--stickyTop', currentStickyTop); - } else { - bodyEl.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); - headerHeight = header.offsetHeight.toString(); - - if (props.autoSticky) { - header.style.setProperty('--stickyTop', currentStickyTop); - header.style.position = 'sticky'; - header.style.top = 'var(--stickyTop)'; - header.style.zIndex = '1'; - } - } -}; - -const observer = new MutationObserver(() => { - window.setTimeout(() => { - calc(); - }, 100); -}); - -onMounted(() => { - calc(); - - observer.observe(rootEl, { - attributes: false, - childList: true, - subtree: false, - }); -}); - -onUnmounted(() => { - observer.disconnect(); -}); -</script> - -<style lang="scss" module> - -</style> diff --git a/packages/client/src/components/global/url.vue b/packages/client/src/components/global/url.vue deleted file mode 100644 index 34ba9024cc..0000000000 --- a/packages/client/src/components/global/url.vue +++ /dev/null @@ -1,110 +0,0 @@ -<template> -<component :is="self ? 'MkA' : 'a'" ref="el" class="ieqqeuvs _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target" - @contextmenu.stop="() => {}" -> - <template v-if="!self"> - <span class="schema">{{ schema }}//</span> - <span class="hostname">{{ hostname }}</span> - <span v-if="port != ''" class="port">:{{ port }}</span> - </template> - <template v-if="pathname === '/' && self"> - <span class="self">{{ hostname }}</span> - </template> - <span v-if="pathname != ''" class="pathname">{{ self ? pathname.substr(1) : pathname }}</span> - <span class="query">{{ query }}</span> - <span class="hash">{{ hash }}</span> - <i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i> -</component> -</template> - -<script lang="ts"> -import { defineAsyncComponent, defineComponent, ref } from 'vue'; -import { toUnicode as decodePunycode } from 'punycode/'; -import { url as local } from '@/config'; -import * as os from '@/os'; -import { useTooltip } from '@/scripts/use-tooltip'; - -function safeURIDecode(str: string) { - try { - return decodeURIComponent(str); - } catch { - return str; - } -} - -export default defineComponent({ - props: { - url: { - type: String, - required: true, - }, - rel: { - type: String, - required: false, - default: null, - } - }, - setup(props) { - const self = props.url.startsWith(local); - const url = new URL(props.url); - const el = ref(); - - useTooltip(el, (showing) => { - os.popup(defineAsyncComponent(() => import('@/components/url-preview-popup.vue')), { - showing, - url: props.url, - source: el.value, - }, {}, 'closed'); - }); - - return { - local, - schema: url.protocol, - hostname: decodePunycode(url.hostname), - port: url.port, - pathname: safeURIDecode(url.pathname), - query: safeURIDecode(url.search), - hash: safeURIDecode(url.hash), - self: self, - attr: self ? 'to' : 'href', - target: self ? null : '_blank', - el, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.ieqqeuvs { - word-break: break-all; - - > .icon { - padding-left: 2px; - font-size: .9em; - } - - > .self { - font-weight: bold; - } - - > .schema { - opacity: 0.5; - } - - > .hostname { - font-weight: bold; - } - - > .pathname { - opacity: 0.8; - } - - > .query { - opacity: 0.5; - } - - > .hash { - font-style: italic; - } -} -</style> diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts index aa8a591e51..8639257003 100644 --- a/packages/client/src/components/index.ts +++ b/packages/client/src/components/index.ts @@ -1,22 +1,22 @@ import { App } from 'vue'; -import Mfm from './global/misskey-flavored-markdown.vue'; -import MkA from './global/a.vue'; -import MkAcct from './global/acct.vue'; -import MkAvatar from './global/avatar.vue'; -import MkEmoji from './global/emoji.vue'; -import MkUserName from './global/user-name.vue'; -import MkEllipsis from './global/ellipsis.vue'; -import MkTime from './global/time.vue'; -import MkUrl from './global/url.vue'; +import Mfm from './global/MkMisskeyFlavoredMarkdown.vue'; +import MkA from './global/MkA.vue'; +import MkAcct from './global/MkAcct.vue'; +import MkAvatar from './global/MkAvatar.vue'; +import MkEmoji from './global/MkEmoji.vue'; +import MkUserName from './global/MkUserName.vue'; +import MkEllipsis from './global/MkEllipsis.vue'; +import MkTime from './global/MkTime.vue'; +import MkUrl from './global/MkUrl.vue'; import I18n from './global/i18n'; -import RouterView from './global/router-view.vue'; -import MkLoading from './global/loading.vue'; -import MkError from './global/error.vue'; -import MkAd from './global/ad.vue'; -import MkPageHeader from './global/page-header.vue'; -import MkSpacer from './global/spacer.vue'; -import MkStickyContainer from './global/sticky-container.vue'; +import RouterView from './global/RouterView.vue'; +import MkLoading from './global/MkLoading.vue'; +import MkError from './global/MkError.vue'; +import MkAd from './global/MkAd.vue'; +import MkPageHeader from './global/MkPageHeader.vue'; +import MkSpacer from './global/MkSpacer.vue'; +import MkStickyContainer from './global/MkStickyContainer.vue'; export default function(app: App) { app.component('I18n', I18n); diff --git a/packages/client/src/components/instance-stats.vue b/packages/client/src/components/instance-stats.vue deleted file mode 100644 index f386a8de9a..0000000000 --- a/packages/client/src/components/instance-stats.vue +++ /dev/null @@ -1,81 +0,0 @@ -<template> -<div class="zbcjwnqg"> - <div class="selects" style="display: flex;"> - <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> - <optgroup :label="$ts.federation"> - <option value="federation">{{ $ts._charts.federation }}</option> - <option value="ap-request">{{ $ts._charts.apRequest }}</option> - </optgroup> - <optgroup :label="$ts.users"> - <option value="users">{{ $ts._charts.usersIncDec }}</option> - <option value="users-total">{{ $ts._charts.usersTotal }}</option> - <option value="active-users">{{ $ts._charts.activeUsers }}</option> - </optgroup> - <optgroup :label="$ts.notes"> - <option value="notes">{{ $ts._charts.notesIncDec }}</option> - <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option> - <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option> - <option value="notes-total">{{ $ts._charts.notesTotal }}</option> - </optgroup> - <optgroup :label="$ts.drive"> - <option value="drive-files">{{ $ts._charts.filesIncDec }}</option> - <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option> - </optgroup> - </MkSelect> - <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> - <option value="hour">{{ $ts.perHour }}</option> - <option value="day">{{ $ts.perDay }}</option> - </MkSelect> - </div> - <div class="chart"> - <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, ref } from 'vue'; -import MkSelect from '@/components/form/select.vue'; -import MkChart from '@/components/chart.vue'; - -export default defineComponent({ - components: { - MkSelect, - MkChart, - }, - - props: { - chartLimit: { - type: Number, - required: false, - default: 90 - }, - detailed: { - type: Boolean, - required: false, - default: false - }, - }, - - setup() { - const chartSpan = ref<'hour' | 'day'>('hour'); - const chartSrc = ref('active-users'); - - return { - chartSrc, - chartSpan, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.zbcjwnqg { - > .selects { - } - - > .chart { - padding: 8px 0 0 0; - } -} -</style> diff --git a/packages/client/src/components/key-value.vue b/packages/client/src/components/key-value.vue deleted file mode 100644 index da98abd77c..0000000000 --- a/packages/client/src/components/key-value.vue +++ /dev/null @@ -1,73 +0,0 @@ -<template> -<div class="alqyeyti" :class="{ oneline }"> - <div class="key"> - <slot name="key"></slot> - </div> - <div class="value"> - <slot name="value"></slot> - <button v-if="copy" v-tooltip="$ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="far fa-copy"></i></button> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import copyToClipboard from '@/scripts/copy-to-clipboard'; -import * as os from '@/os'; - -export default defineComponent({ - props: { - copy: { - type: String, - required: false, - default: null, - }, - oneline: { - type: Boolean, - required: false, - default: false, - }, - }, - - setup(props) { - const copy_ = () => { - copyToClipboard(props.copy); - os.success(); - }; - - return { - copy_ - }; - }, -}); -</script> - -<style lang="scss" scoped> -.alqyeyti { - > .key, > .value { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - > .key { - font-size: 0.85em; - padding: 0 0 0.25em 0; - opacity: 0.75; - } - - &.oneline { - display: flex; - - > .key { - width: 30%; - font-size: 1em; - padding: 0 8px 0 0; - } - - > .value { - width: 70%; - } - } -} -</style> diff --git a/packages/client/src/components/mention.vue b/packages/client/src/components/mention.vue deleted file mode 100644 index 70c2f49afa..0000000000 --- a/packages/client/src/components/mention.vue +++ /dev/null @@ -1,86 +0,0 @@ -<template> -<MkA v-if="url.startsWith('/')" v-user-preview="canonical" :class="[$style.root, { isMe }]" :to="url" :style="{ background: bg }"> - <img :class="$style.icon" :src="`/avatar/@${username}@${host}`" alt=""> - <span class="main"> - <span class="username">@{{ username }}</span> - <span v-if="(host != localHost) || $store.state.showFullAcct" :class="$style.mainHost">@{{ toUnicode(host) }}</span> - </span> -</MkA> -<a v-else :class="$style.root" :href="url" target="_blank" rel="noopener" :style="{ background: bg }"> - <span class="main"> - <span class="username">@{{ username }}</span> - <span :class="$style.mainHost">@{{ toUnicode(host) }}</span> - </span> -</a> -</template> - -<script lang="ts"> -import { defineComponent, useCssModule } from 'vue'; -import tinycolor from 'tinycolor2'; -import { toUnicode } from 'punycode'; -import { host as localHost } from '@/config'; -import { $i } from '@/account'; - -export default defineComponent({ - props: { - username: { - type: String, - required: true - }, - host: { - type: String, - required: true - } - }, - - setup(props) { - const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`; - - const url = `/${canonical}`; - - const isMe = $i && ( - `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() - ); - - const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention')); - bg.setAlpha(0.1); - - useCssModule(); - - return { - localHost, - isMe, - url, - canonical, - toUnicode, - bg: bg.toRgbString(), - }; - }, -}); -</script> - -<style lang="scss" module> -.root { - display: inline-block; - padding: 4px 8px 4px 4px; - border-radius: 999px; - color: var(--mention); - - &.isMe { - color: var(--mentionMe); - } -} - -.icon { - width: 1.5em; - height: 1.5em; - object-fit: cover; - margin: 0 0.2em 0 0; - vertical-align: bottom; - border-radius: 100%; -} - -.mainHost { - opacity: 0.5; -} -</style> diff --git a/packages/client/src/components/mfm.ts b/packages/client/src/components/mfm.ts index 4556a82d55..688857a499 100644 --- a/packages/client/src/components/mfm.ts +++ b/packages/client/src/components/mfm.ts @@ -1,15 +1,15 @@ import { VNode, defineComponent, h } from 'vue'; import * as mfm from 'mfm-js'; -import MkUrl from '@/components/global/url.vue'; -import MkLink from '@/components/link.vue'; -import MkMention from '@/components/mention.vue'; -import MkEmoji from '@/components/global/emoji.vue'; +import MkUrl from '@/components/global/MkUrl.vue'; +import MkLink from '@/components/MkLink.vue'; +import MkMention from '@/components/MkMention.vue'; +import MkEmoji from '@/components/global/MkEmoji.vue'; import { concat } from '@/scripts/array'; -import MkFormula from '@/components/formula.vue'; -import MkCode from '@/components/code.vue'; -import MkGoogle from '@/components/google.vue'; -import MkSparkle from '@/components/sparkle.vue'; -import MkA from '@/components/global/a.vue'; +import MkFormula from '@/components/MkFormula.vue'; +import MkCode from '@/components/MkCode.vue'; +import MkGoogle from '@/components/MkGoogle.vue'; +import MkSparkle from '@/components/MkSparkle.vue'; +import MkA from '@/components/global/MkA.vue'; import { host } from '@/config'; import { MFM_TAGS } from '@/scripts/mfm-tags'; @@ -17,37 +17,37 @@ export default defineComponent({ props: { text: { type: String, - required: true + required: true, }, plain: { type: Boolean, - default: false + default: false, }, nowrap: { type: Boolean, - default: false + default: false, }, author: { type: Object, - default: null + default: null, }, i: { type: Object, - default: null + default: null, }, customEmojis: { required: false, }, isNote: { type: Boolean, - default: true + default: true, }, }, render() { if (this.text == null || this.text === '') return; - const ast = (this.plain ? mfm.parsePlain : mfm.parse)(this.text, { fnNameList: MFM_TAGS }); + const ast = (this.plain ? mfm.parseSimple : mfm.parse)(this.text, { fnNameList: MFM_TAGS }); const validTime = (t: string | null | undefined) => { if (t == null) return null; @@ -82,7 +82,7 @@ export default defineComponent({ case 'italic': { return h('i', { - style: 'font-style: oblique;' + style: 'font-style: oblique;', }, genEl(token.children)); } @@ -201,13 +201,13 @@ export default defineComponent({ case 'small': { return [h('small', { - style: 'opacity: 0.7;' + style: 'opacity: 0.7;', }, genEl(token.children))]; } case 'center': { return [h('div', { - style: 'text-align:center;' + style: 'text-align:center;', }, genEl(token.children))]; } @@ -231,7 +231,7 @@ export default defineComponent({ return [h(MkMention, { key: Math.random(), host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host, - username: token.props.username + username: token.props.username, })]; } @@ -239,7 +239,7 @@ export default defineComponent({ return [h(MkA, { key: Math.random(), to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`, - style: 'color:var(--hashtag);' + style: 'color:var(--hashtag);', }, `#${token.props.hashtag}`)]; } @@ -255,18 +255,18 @@ export default defineComponent({ return [h(MkCode, { key: Math.random(), code: token.props.code, - inline: true + inline: true, })]; } case 'quote': { if (!this.nowrap) { return [h('div', { - class: 'quote' + class: 'quote', }, genEl(token.children))]; } else { return [h('span', { - class: 'quote' + class: 'quote', }, genEl(token.children))]; } } @@ -276,7 +276,7 @@ export default defineComponent({ key: Math.random(), emoji: `:${token.props.name}:`, customEmojis: this.customEmojis, - normal: this.plain + normal: this.plain, })]; } @@ -285,7 +285,7 @@ export default defineComponent({ key: Math.random(), emoji: token.props.emoji, customEmojis: this.customEmojis, - normal: this.plain + normal: this.plain, })]; } @@ -293,7 +293,7 @@ export default defineComponent({ return [h(MkFormula, { key: Math.random(), formula: token.props.formula, - block: false + block: false, })]; } @@ -301,17 +301,21 @@ export default defineComponent({ return [h(MkFormula, { key: Math.random(), formula: token.props.formula, - block: true + block: true, })]; } case 'search': { return [h(MkGoogle, { key: Math.random(), - q: token.props.query + q: token.props.query, })]; } + case 'plain': { + return [h('span', genEl(token.children))]; + } + default: { console.error('unrecognized ast type:', token.type); @@ -322,5 +326,5 @@ export default defineComponent({ // Parse ast to DOM return h('span', genEl(ast)); - } + }, }); diff --git a/packages/client/src/components/notification-setting-window.vue b/packages/client/src/components/notification-setting-window.vue deleted file mode 100644 index 64d828394b..0000000000 --- a/packages/client/src/components/notification-setting-window.vue +++ /dev/null @@ -1,100 +0,0 @@ -<template> -<XModalWindow - ref="dialog" - :width="400" - :height="450" - :with-ok-button="true" - :ok-button-disabled="false" - @ok="ok()" - @close="$refs.dialog.close()" - @closed="$emit('closed')" -> - <template #header>{{ $ts.notificationSetting }}</template> - <div class="_monolithic_"> - <div v-if="showGlobalToggle" class="_section"> - <MkSwitch v-model="useGlobalSetting"> - {{ $ts.useGlobalSetting }} - <template #caption>{{ $ts.useGlobalSettingDesc }}</template> - </MkSwitch> - </div> - <div v-if="!useGlobalSetting" class="_section"> - <MkInfo>{{ $ts.notificationSettingDesc }}</MkInfo> - <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton> - <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton> - <MkSwitch v-for="type in notificationTypes" :key="type" v-model="typesMap[type]">{{ $t(`_notification._types.${type}`) }}</MkSwitch> - </div> - </div> -</XModalWindow> -</template> - -<script lang="ts"> -import { defineComponent, PropType } from 'vue'; -import { notificationTypes } from 'misskey-js'; -import MkSwitch from './form/switch.vue'; -import MkInfo from './ui/info.vue'; -import MkButton from './ui/button.vue'; -import XModalWindow from '@/components/ui/modal-window.vue'; - -export default defineComponent({ - components: { - XModalWindow, - MkSwitch, - MkInfo, - MkButton, - }, - - props: { - includingTypes: { - // TODO: これで型に合わないものを弾いてくれるのかどうか要調査 - type: Array as PropType<typeof notificationTypes[number][]>, - required: false, - default: null, - }, - showGlobalToggle: { - type: Boolean, - required: false, - default: true, - }, - }, - - emits: ['done', 'closed'], - - data() { - return { - typesMap: {} as Record<typeof notificationTypes[number], boolean>, - useGlobalSetting: false, - notificationTypes, - }; - }, - - created() { - this.useGlobalSetting = this.includingTypes === null && this.showGlobalToggle; - - for (const type of this.notificationTypes) { - this.typesMap[type] = this.includingTypes === null || this.includingTypes.includes(type); - } - }, - - methods: { - ok() { - const includingTypes = this.useGlobalSetting ? null : (Object.keys(this.typesMap) as typeof notificationTypes[number][]) - .filter(type => this.typesMap[type]); - - this.$emit('done', { includingTypes }); - this.$refs.dialog.close(); - }, - - disableAll() { - for (const type in this.typesMap) { - this.typesMap[type as typeof notificationTypes[number]] = false; - } - }, - - enableAll() { - for (const type in this.typesMap) { - this.typesMap[type as typeof notificationTypes[number]] = true; - } - }, - }, -}); -</script> diff --git a/packages/client/src/components/object-view.value.vue b/packages/client/src/components/object-view.value.vue deleted file mode 100644 index 6f388636dd..0000000000 --- a/packages/client/src/components/object-view.value.vue +++ /dev/null @@ -1,108 +0,0 @@ -<template> -<div class="igpposuu _monospace"> - <div v-if="value === null" class="null">null</div> - <div v-else-if="typeof value === 'boolean'" class="boolean">{{ value ? 'true' : 'false' }}</div> - <div v-else-if="typeof value === 'string'" class="string">"{{ value }}"</div> - <div v-else-if="typeof value === 'number'" class="number">{{ number(value) }}</div> - <div v-else-if="Array.isArray(value)" class="array"> - <button @click="collapsed_ = !collapsed_">[ {{ collapsed_ ? '+' : '-' }} ]</button> - <template v-if="!collapsed_"> - <div v-for="i in value.length" class="element"> - {{ i }}: <XValue :value="value[i - 1]" collapsed/> - </div> - </template> - </div> - <div v-else-if="typeof value === 'object'" class="object"> - <button @click="collapsed_ = !collapsed_">{ {{ collapsed_ ? '+' : '-' }} }</button> - <template v-if="!collapsed_"> - <div v-for="k in Object.keys(value)" class="kv"> - <div class="k">{{ k }}:</div> - <div class="v"><XValue :value="value[k]" collapsed/></div> - </div> - </template> - </div> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, ref } from 'vue'; -import number from '@/filters/number'; - -export default defineComponent({ - name: 'XValue', - - props: { - value: { - type: Object, - required: true, - }, - collapsed: { - type: Boolean, - required: false, - default: false, - }, - }, - - setup(props) { - const collapsed_ = ref(props.collapsed); - - return { - number, - collapsed_, - }; - } -}); -</script> - -<style lang="scss" scoped> -.igpposuu { - display: inline; - - > .null { - display: inline; - opacity: 0.7; - } - - > .boolean { - display: inline; - color: var(--codeBoolean); - } - - > .string { - display: inline; - color: var(--codeString); - } - - > .number { - display: inline; - color: var(--codeNumber); - } - - > .array { - display: inline; - - > .element { - display: block; - padding-left: 16px; - } - } - - > .object { - display: inline; - - > .kv { - display: block; - padding-left: 16px; - - > .k { - display: inline; - margin-right: 8px; - } - - > .v { - display: inline; - } - } - } -} -</style> diff --git a/packages/client/src/components/object-view.vue b/packages/client/src/components/object-view.vue deleted file mode 100644 index e9db96de8c..0000000000 --- a/packages/client/src/components/object-view.vue +++ /dev/null @@ -1,33 +0,0 @@ -<template> -<div class="zhyxdalp"> - <XValue :value="value" :collapsed="false"/> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent } from 'vue'; -import XValue from './object-view.value.vue'; - -export default defineComponent({ - components: { - XValue - }, - - props: { - value: { - type: Object, - required: true, - }, - }, - - setup(props) { - - } -}); -</script> - -<style lang="scss" scoped> -.zhyxdalp { - -} -</style> diff --git a/packages/client/src/components/page/page.button.vue b/packages/client/src/components/page/page.button.vue index a87f6e9f02..e8878df8de 100644 --- a/packages/client/src/components/page/page.button.vue +++ b/packages/client/src/components/page/page.button.vue @@ -6,7 +6,7 @@ <script lang="ts"> import { defineComponent, PropType, unref } from 'vue'; -import MkButton from '../ui/button.vue'; +import MkButton from '../MkButton.vue'; import * as os from '@/os'; import { ButtonBlock } from '@/scripts/hpml/block'; import { Hpml } from '@/scripts/hpml/evaluator'; diff --git a/packages/client/src/components/page/page.counter.vue b/packages/client/src/components/page/page.counter.vue index b1af8954b0..6eeef71431 100644 --- a/packages/client/src/components/page/page.counter.vue +++ b/packages/client/src/components/page/page.counter.vue @@ -6,7 +6,7 @@ <script lang="ts"> import { computed, defineComponent, PropType } from 'vue'; -import MkButton from '../ui/button.vue'; +import MkButton from '../MkButton.vue'; import * as os from '@/os'; import { CounterVarBlock } from '@/scripts/hpml/block'; import { Hpml } from '@/scripts/hpml/evaluator'; diff --git a/packages/client/src/components/page/page.image.vue b/packages/client/src/components/page/page.image.vue index 6e38a9f424..8ba70c5855 100644 --- a/packages/client/src/components/page/page.image.vue +++ b/packages/client/src/components/page/page.image.vue @@ -6,7 +6,7 @@ <script lang="ts" setup> import { defineComponent, PropType } from 'vue'; -import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import * as os from '@/os'; import { ImageBlock } from '@/scripts/hpml/block'; import { Hpml } from '@/scripts/hpml/evaluator'; diff --git a/packages/client/src/components/page/page.note.vue b/packages/client/src/components/page/page.note.vue index 3bb338b095..431f0b08df 100644 --- a/packages/client/src/components/page/page.note.vue +++ b/packages/client/src/components/page/page.note.vue @@ -7,8 +7,8 @@ <script lang="ts"> import { defineComponent, onMounted, PropType, Ref, ref } from 'vue'; -import XNote from '@/components/note.vue'; -import XNoteDetailed from '@/components/note-detailed.vue'; +import XNote from '@/components/MkNote.vue'; +import XNoteDetailed from '@/components/MkNoteDetailed.vue'; import * as os from '@/os'; import { NoteBlock } from '@/scripts/hpml/block'; diff --git a/packages/client/src/components/page/page.post.vue b/packages/client/src/components/page/page.post.vue index 3401f945bd..f655196359 100644 --- a/packages/client/src/components/page/page.post.vue +++ b/packages/client/src/components/page/page.post.vue @@ -11,7 +11,7 @@ <script lang="ts"> import { defineComponent, PropType } from 'vue'; import MkTextarea from '../form/textarea.vue'; -import MkButton from '../ui/button.vue'; +import MkButton from '../MkButton.vue'; import { apiUrl } from '@/config'; import * as os from '@/os'; import { PostBlock } from '@/scripts/hpml/block'; diff --git a/packages/client/src/components/page/page.text.vue b/packages/client/src/components/page/page.text.vue index 8d2955466d..b4abe82840 100644 --- a/packages/client/src/components/page/page.text.vue +++ b/packages/client/src/components/page/page.text.vue @@ -14,7 +14,7 @@ import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; export default defineComponent({ components: { - MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + MkUrlPreview: defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')), }, props: { block: { diff --git a/packages/client/src/components/page/page.vue b/packages/client/src/components/page/page.vue index a067762372..58c43b22bc 100644 --- a/packages/client/src/components/page/page.vue +++ b/packages/client/src/components/page/page.vue @@ -24,7 +24,6 @@ export default defineComponent({ }, }, setup(props, ctx) { - const hpml = new Hpml(props.page, { randomSeed: Math.random(), visitor: $i, diff --git a/packages/client/src/components/poll.vue b/packages/client/src/components/poll.vue deleted file mode 100644 index 171b4a4770..0000000000 --- a/packages/client/src/components/poll.vue +++ /dev/null @@ -1,169 +0,0 @@ -<template> -<div class="tivcixzd" :class="{ done: closed || isVoted }"> - <ul> - <li v-for="(choice, i) in note.poll.choices" :key="i" :class="{ voted: choice.voted }" @click="vote(i)"> - <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> - <span> - <template v-if="choice.isVoted"><i class="fas fa-check"></i></template> - <Mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/> - <span v-if="showResult" class="votes">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span> - </span> - </li> - </ul> - <p v-if="!readOnly"> - <span>{{ $t('_poll.totalVotes', { n: total }) }}</span> - <span> · </span> - <a v-if="!closed && !isVoted" @click="showResult = !showResult">{{ showResult ? $ts._poll.vote : $ts._poll.showResult }}</a> - <span v-if="isVoted">{{ $ts._poll.voted }}</span> - <span v-else-if="closed">{{ $ts._poll.closed }}</span> - <span v-if="remaining > 0"> · {{ timer }}</span> - </p> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, onUnmounted, ref, toRef } from 'vue'; -import { sum } from '@/scripts/array'; -import * as os from '@/os'; -import { i18n } from '@/i18n'; - -export default defineComponent({ - props: { - note: { - type: Object, - required: true - }, - readOnly: { - type: Boolean, - required: false, - default: false, - } - }, - - setup(props) { - const remaining = ref(-1); - - const total = computed(() => sum(props.note.poll.choices.map(x => x.votes))); - const closed = computed(() => remaining.value === 0); - const isVoted = computed(() => !props.note.poll.multiple && props.note.poll.choices.some(c => c.isVoted)); - const timer = computed(() => i18n.t( - remaining.value >= 86400 ? '_poll.remainingDays' : - remaining.value >= 3600 ? '_poll.remainingHours' : - remaining.value >= 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', { - s: Math.floor(remaining.value % 60), - m: Math.floor(remaining.value / 60) % 60, - h: Math.floor(remaining.value / 3600) % 24, - d: Math.floor(remaining.value / 86400) - })); - - const showResult = ref(props.readOnly || isVoted.value); - - // 期限付きアンケート - if (props.note.poll.expiresAt) { - const tick = () => { - remaining.value = Math.floor(Math.max(new Date(props.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000); - if (remaining.value === 0) { - showResult.value = true; - } - }; - - tick(); - const intevalId = window.setInterval(tick, 3000); - onUnmounted(() => { - window.clearInterval(intevalId); - }); - } - - const vote = async (id) => { - if (props.readOnly || closed.value || isVoted.value) return; - - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.t('voteConfirm', { choice: props.note.poll.choices[id].text }), - }); - if (canceled) return; - - await os.api('notes/polls/vote', { - noteId: props.note.id, - choice: id, - }); - if (!showResult.value) showResult.value = !props.note.poll.multiple; - }; - - return { - remaining, - showResult, - total, - isVoted, - closed, - timer, - vote, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.tivcixzd { - > ul { - display: block; - margin: 0; - padding: 0; - list-style: none; - - > li { - display: block; - position: relative; - margin: 4px 0; - padding: 4px; - //border: solid 0.5px var(--divider); - background: var(--accentedBg); - border-radius: 4px; - overflow: hidden; - cursor: pointer; - - > .backdrop { - position: absolute; - top: 0; - left: 0; - height: 100%; - background: var(--accent); - background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB)); - transition: width 1s ease; - } - - > span { - position: relative; - display: inline-block; - padding: 3px 5px; - background: var(--panel); - border-radius: 3px; - - > i { - margin-right: 4px; - color: var(--accent); - } - - > .votes { - margin-left: 4px; - opacity: 0.7; - } - } - } - } - - > p { - color: var(--fg); - - a { - color: inherit; - } - } - - &.done { - > ul > li { - cursor: default; - } - } -} -</style> diff --git a/packages/client/src/components/queue-chart.vue b/packages/client/src/components/queue-chart.vue deleted file mode 100644 index 7bb548cf06..0000000000 --- a/packages/client/src/components/queue-chart.vue +++ /dev/null @@ -1,232 +0,0 @@ -<template> -<canvas ref="chartEl"></canvas> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; -import { - Chart, - ArcElement, - LineElement, - BarElement, - PointElement, - BarController, - LineController, - CategoryScale, - LinearScale, - TimeScale, - Legend, - Title, - Tooltip, - SubTitle, - Filler, -} from 'chart.js'; -import number from '@/filters/number'; -import * as os from '@/os'; -import { defaultStore } from '@/store'; - -Chart.register( - ArcElement, - LineElement, - BarElement, - PointElement, - BarController, - LineController, - CategoryScale, - LinearScale, - TimeScale, - Legend, - Title, - Tooltip, - SubTitle, - Filler, -); - -const alpha = (hex, a) => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; - const r = parseInt(result[1], 16); - const g = parseInt(result[2], 16); - const b = parseInt(result[3], 16); - return `rgba(${r}, ${g}, ${b}, ${a})`; -}; - -export default defineComponent({ - props: { - domain: { - type: String, - required: true, - }, - connection: { - required: true, - }, - }, - - setup(props) { - const chartEl = ref<HTMLCanvasElement>(null); - - const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - - // フォントカラー - Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - - onMounted(() => { - const chartInstance = new Chart(chartEl.value, { - type: 'line', - data: { - labels: [], - datasets: [{ - label: 'Process', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#00E396', - backgroundColor: alpha('#00E396', 0.1), - data: [] - }, { - label: 'Active', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#00BCD4', - backgroundColor: alpha('#00BCD4', 0.1), - data: [] - }, { - label: 'Waiting', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#FFB300', - backgroundColor: alpha('#FFB300', 0.1), - yAxisID: 'y2', - data: [] - }, { - label: 'Delayed', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#E53935', - borderDash: [5, 5], - fill: false, - yAxisID: 'y2', - data: [] - }], - }, - options: { - aspectRatio: 2.5, - layout: { - padding: { - left: 16, - right: 16, - top: 16, - bottom: 8, - }, - }, - scales: { - x: { - grid: { - display: true, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', - }, - ticks: { - display: false, - maxTicksLimit: 10 - }, - }, - y: { - min: 0, - stack: 'queue', - stackWeight: 2, - grid: { - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', - }, - }, - y2: { - min: 0, - offset: true, - stack: 'queue', - stackWeight: 1, - grid: { - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', - }, - }, - }, - interaction: { - intersect: false, - }, - plugins: { - legend: { - position: 'bottom', - labels: { - boxWidth: 16, - }, - }, - tooltip: { - mode: 'index', - animation: { - duration: 0, - }, - }, - }, - }, - }); - - const onStats = (stats) => { - chartInstance.data.labels.push(''); - chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); - chartInstance.data.datasets[1].data.push(stats[props.domain].active); - chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); - chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); - if (chartInstance.data.datasets[0].data.length > 200) { - chartInstance.data.labels.shift(); - chartInstance.data.datasets[0].data.shift(); - chartInstance.data.datasets[1].data.shift(); - chartInstance.data.datasets[2].data.shift(); - chartInstance.data.datasets[3].data.shift(); - } - chartInstance.update(); - }; - - const onStatsLog = (statsLog) => { - for (const stats of [...statsLog].reverse()) { - chartInstance.data.labels.push(''); - chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); - chartInstance.data.datasets[1].data.push(stats[props.domain].active); - chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); - chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); - if (chartInstance.data.datasets[0].data.length > 200) { - chartInstance.data.labels.shift(); - chartInstance.data.datasets[0].data.shift(); - chartInstance.data.datasets[1].data.shift(); - chartInstance.data.datasets[2].data.shift(); - chartInstance.data.datasets[3].data.shift(); - } - } - chartInstance.update(); - }; - - props.connection.on('stats', onStats); - props.connection.on('statsLog', onStatsLog); - - onUnmounted(() => { - props.connection.off('stats', onStats); - props.connection.off('statsLog', onStatsLog); - }); - }); - - return { - chartEl, - }; - }, -}); -</script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/client/src/components/reactions-viewer.reaction.vue b/packages/client/src/components/reactions-viewer.reaction.vue deleted file mode 100644 index 91a90a6996..0000000000 --- a/packages/client/src/components/reactions-viewer.reaction.vue +++ /dev/null @@ -1,159 +0,0 @@ -<template> -<button - v-if="count > 0" - ref="buttonRef" - v-ripple="canToggle" - class="hkzvhatu _button" - :class="{ reacted: note.myReaction == reaction, canToggle }" - @click="toggleReaction()" -> - <XReactionIcon class="icon" :reaction="reaction" :custom-emojis="note.emojis"/> - <span class="count">{{ count }}</span> -</button> -</template> - -<script lang="ts"> -import { computed, defineComponent, onMounted, ref, watch } from 'vue'; -import XDetails from '@/components/reactions-viewer.details.vue'; -import XReactionIcon from '@/components/reaction-icon.vue'; -import * as os from '@/os'; -import { useTooltip } from '@/scripts/use-tooltip'; -import { $i } from '@/account'; - -export default defineComponent({ - components: { - XReactionIcon - }, - - props: { - reaction: { - type: String, - required: true, - }, - count: { - type: Number, - required: true, - }, - isInitial: { - type: Boolean, - required: true, - }, - note: { - type: Object, - required: true, - }, - }, - - setup(props) { - const buttonRef = ref<HTMLElement>(); - - const canToggle = computed(() => !props.reaction.match(/@\w/) && $i); - - const toggleReaction = () => { - if (!canToggle.value) return; - - const oldReaction = props.note.myReaction; - if (oldReaction) { - os.api('notes/reactions/delete', { - noteId: props.note.id - }).then(() => { - if (oldReaction !== props.reaction) { - os.api('notes/reactions/create', { - noteId: props.note.id, - reaction: props.reaction - }); - } - }); - } else { - os.api('notes/reactions/create', { - noteId: props.note.id, - reaction: props.reaction - }); - } - }; - - const anime = () => { - if (document.hidden) return; - - // TODO: 新しくリアクションが付いたことが視覚的に分かりやすいアニメーション - }; - - watch(() => props.count, (newCount, oldCount) => { - if (oldCount < newCount) anime(); - }); - - onMounted(() => { - if (!props.isInitial) anime(); - }); - - useTooltip(buttonRef, async (showing) => { - const reactions = await os.api('notes/reactions', { - noteId: props.note.id, - type: props.reaction, - limit: 11 - }); - - const users = reactions.map(x => x.user); - - os.popup(XDetails, { - showing, - reaction: props.reaction, - emojis: props.note.emojis, - users, - count: props.count, - targetElement: buttonRef.value, - }, {}, 'closed'); - }); - - return { - buttonRef, - canToggle, - toggleReaction, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.hkzvhatu { - display: inline-block; - height: 32px; - margin: 2px; - padding: 0 6px; - border-radius: 4px; - - &.canToggle { - background: rgba(0, 0, 0, 0.05); - - &:hover { - background: rgba(0, 0, 0, 0.1); - } - } - - &:not(.canToggle) { - cursor: default; - } - - &.reacted { - background: var(--accent); - - &:hover { - background: var(--accent); - } - - > .count { - color: var(--fgOnAccent); - } - - > .icon { - filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5)); - } - } - - > .count { - font-size: 0.9em; - line-height: 32px; - margin: 0 0 0 4px; - } -} -</style> diff --git a/packages/client/src/components/renote-button.vue b/packages/client/src/components/renote-button.vue deleted file mode 100644 index 8d9f08b4c2..0000000000 --- a/packages/client/src/components/renote-button.vue +++ /dev/null @@ -1,113 +0,0 @@ -<template> -<button v-if="canRenote" - ref="buttonRef" - class="eddddedb _button canRenote" - @click="renote()" -> - <i class="fas fa-retweet"></i> - <p v-if="count > 0" class="count">{{ count }}</p> -</button> -<button v-else class="eddddedb _button"> - <i class="fas fa-ban"></i> -</button> -</template> - -<script lang="ts"> -import { computed, defineComponent, ref } from 'vue'; -import XDetails from '@/components/renote.details.vue'; -import { pleaseLogin } from '@/scripts/please-login'; -import * as os from '@/os'; -import { $i } from '@/account'; -import { useTooltip } from '@/scripts/use-tooltip'; -import { i18n } from '@/i18n'; - -export default defineComponent({ - props: { - count: { - type: Number, - required: true, - }, - note: { - type: Object, - required: true, - }, - }, - - setup(props) { - const buttonRef = ref<HTMLElement>(); - - const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id); - - useTooltip(buttonRef, async (showing) => { - const renotes = await os.api('notes/renotes', { - noteId: props.note.id, - limit: 11 - }); - - const users = renotes.map(x => x.user); - - if (users.length < 1) return; - - os.popup(XDetails, { - showing, - users, - count: props.count, - targetElement: buttonRef.value - }, {}, 'closed'); - }); - - const renote = (viaKeyboard = false) => { - pleaseLogin(); - os.popupMenu([{ - text: i18n.ts.renote, - icon: 'fas fa-retweet', - action: () => { - os.api('notes/create', { - renoteId: props.note.id - }); - } - }, { - text: i18n.ts.quote, - icon: 'fas fa-quote-right', - action: () => { - os.post({ - renote: props.note, - }); - } - }], buttonRef.value, { - viaKeyboard - }); - }; - - return { - buttonRef, - canRenote, - renote, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.eddddedb { - display: inline-block; - height: 32px; - margin: 2px; - padding: 0 6px; - border-radius: 4px; - - &:not(.canRenote) { - cursor: default; - } - - &.renoted { - background: var(--accent); - } - - > .count { - display: inline; - margin-left: 8px; - opacity: 0.7; - } -} -</style> diff --git a/packages/client/src/components/signup.vue b/packages/client/src/components/signup.vue deleted file mode 100644 index 3f2af306e5..0000000000 --- a/packages/client/src/components/signup.vue +++ /dev/null @@ -1,261 +0,0 @@ -<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> - </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> -</form> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -const getPasswordStrength = await import('syuilo-password-strength'); -import { toUnicode } from 'punycode/'; -import { host, url } from '@/config'; -import MkButton from './ui/button.vue'; -import MkInput from './form/input.vue'; -import MkSwitch from './form/switch.vue'; -import * as os from '@/os'; -import { login } from '@/account'; - -export default defineComponent({ - components: { - MkButton, - MkInput, - MkSwitch, - MkCaptcha: defineAsyncComponent(() => import('./captcha.vue')), - }, - - props: { - autoSet: { - type: Boolean, - required: false, - default: false, - } - }, - - emits: ['signup'], - - 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, - }; - }, - - computed: { - meta() { - return this.$instance; - }, - - shouldDisableSubmitting(): boolean { - return this.submitting || - this.meta.tosUrl && !this.ToSAgreement || - this.meta.enableHcaptcha && !this.hCaptchaResponse || - this.meta.enableRecaptcha && !this.reCaptchaResponse || - this.passwordRetypeState === 'not-match'; - }, - - shouldShowProfileUrl(): boolean { - return (this.username !== '' && - this.usernameState !== 'invalid-format' && - this.usernameState !== 'min-range' && - this.usernameState !== 'max-range'); - } - }, - - methods: { - onChangeUsername() { - if (this.username === '') { - this.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; - - if (err) { - this.usernameState = err; - return; - } - - this.usernameState = 'wait'; - - os.api('username/available', { - username: this.username - }).then(result => { - this.usernameState = result.available ? 'ok' : 'unavailable'; - }).catch(err => { - this.usernameState = 'error'; - }); - }, - - 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' : - 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'; - }); - }, - - onChangePassword() { - if (this.password === '') { - this.passwordStrength = ''; - return; - } - - const strength = getPasswordStrength(this.password); - this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; - }, - - onChangePasswordRetype() { - if (this.retypedPassword === '') { - this.passwordRetypeState = null; - return; - } - - this.passwordRetypeState = this.password === this.retypedPassword ? 'match' : 'not-match'; - }, - - onSubmit() { - if (this.submitting) return; - this.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); - - if (this.autoSet) { - login(res.i); - } - }); - } - }).catch(() => { - this.submitting = false; - this.$refs.hcaptcha?.reset?.(); - this.$refs.recaptcha?.reset?.(); - - os.alert({ - type: 'error', - text: this.$ts.somethingHappened - }); - }); - } - } -}); -</script> - -<style lang="scss" scoped> -.qlvuhzng { - .captcha { - margin: 16px 0; - } -} -</style> diff --git a/packages/client/src/components/token-generate-window.vue b/packages/client/src/components/token-generate-window.vue deleted file mode 100644 index bf5775d4d8..0000000000 --- a/packages/client/src/components/token-generate-window.vue +++ /dev/null @@ -1,117 +0,0 @@ -<template> -<XModalWindow ref="dialog" - :width="400" - :height="450" - :with-ok-button="true" - :ok-button-disabled="false" - :can-close="false" - @close="$refs.dialog.close()" - @closed="$emit('closed')" - @ok="ok()" -> - <template #header>{{ title || $ts.generateAccessToken }}</template> - <div v-if="information" class="_section"> - <MkInfo warn>{{ information }}</MkInfo> - </div> - <div class="_section"> - <MkInput v-model="name"> - <template #label>{{ $ts.name }}</template> - </MkInput> - </div> - <div class="_section"> - <div style="margin-bottom: 16px;"><b>{{ $ts.permission }}</b></div> - <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton> - <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton> - <MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ $t(`_permissions.${kind}`) }}</MkSwitch> - </div> -</XModalWindow> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { permissions } from 'misskey-js'; -import XModalWindow from '@/components/ui/modal-window.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'; - -export default defineComponent({ - components: { - XModalWindow, - MkInput, - MkTextarea, - MkSwitch, - MkButton, - MkInfo, - }, - - props: { - title: { - type: String, - required: false, - default: null - }, - information: { - type: String, - required: false, - default: null - }, - initialName: { - type: String, - required: false, - default: null - }, - initialPermissions: { - type: Array, - required: false, - default: null - } - }, - - emits: ['done', 'closed'], - - data() { - return { - name: this.initialName, - permissions: {}, - kinds: permissions - }; - }, - - created() { - if (this.initialPermissions) { - for (const kind of this.initialPermissions) { - this.permissions[kind] = true; - } - } else { - for (const kind of this.kinds) { - this.permissions[kind] = false; - } - } - }, - - methods: { - ok() { - this.$emit('done', { - name: this.name, - permissions: Object.keys(this.permissions).filter(p => this.permissions[p]) - }); - this.$refs.dialog.close(); - }, - - disableAll() { - for (const p in this.permissions) { - this.permissions[p] = false; - } - }, - - enableAll() { - for (const p in this.permissions) { - this.permissions[p] = true; - } - } - } -}); -</script> diff --git a/packages/client/src/components/ui/hr.vue b/packages/client/src/components/ui/hr.vue deleted file mode 100644 index 6b075cb440..0000000000 --- a/packages/client/src/components/ui/hr.vue +++ /dev/null @@ -1,16 +0,0 @@ -<template> -<div class="evrzpitu"></div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue';import * as os from '@/os'; - -export default defineComponent({}); -</script> - -<style lang="scss" scoped> -.evrzpitu - margin 16px 0 - border-bottom solid var(--lineWidth) var(--faceDivider) - -</style> diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue deleted file mode 100644 index dad5dfa8b0..0000000000 --- a/packages/client/src/components/ui/menu.vue +++ /dev/null @@ -1,278 +0,0 @@ -<template> -<div - ref="itemsEl" v-hotkey="keymap" - class="rrevdjwt" - :class="{ center: align === 'center', asDrawer }" - :style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }" - @contextmenu.self="e => e.preventDefault()" -> - <template v-for="(item, i) in items2"> - <div v-if="item === null" class="divider"></div> - <span v-else-if="item.type === 'label'" class="label item"> - <span>{{ item.text }}</span> - </span> - <span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item"> - <span><MkEllipsis/></span> - </span> - <MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button item" @click.passive="close()"> - <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> - <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> - </MkA> - <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button item" @click="close()"> - <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> - <span>{{ item.text }}</span> - <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> - </a> - <button v-else-if="item.type === 'user'" :tabindex="i" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)"> - <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> - <span v-else-if="item.type === 'switch'" :tabindex="i" class="item"> - <FormSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</FormSwitch> - </span> - <button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)"> - <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> - <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> - </button> - </template> - <span v-if="items2.length === 0" class="none item"> - <span>{{ $ts.none }}</span> - </span> -</div> -</template> - -<script lang="ts" setup> -import { nextTick, onMounted, watch } from 'vue'; -import { focusPrev, focusNext } from '@/scripts/focus'; -import FormSwitch from '@/components/form/switch.vue'; -import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu'; - -const props = defineProps<{ - items: MenuItem[]; - viaKeyboard?: boolean; - asDrawer?: boolean; - align?: 'center' | string; - width?: number; - maxHeight?: number; -}>(); - -const emit = defineEmits<{ - (ev: 'close'): void; -}>(); - -let itemsEl = $ref<HTMLDivElement>(); - -let items2: InnerMenuItem[] = $ref([]); - -let keymap = $computed(() => ({ - 'up|k|shift+tab': focusUp, - 'down|j|tab': focusDown, - 'esc': close, -})); - -watch(() => props.items, () => { - const items: (MenuItem | MenuPending)[] = [...props.items].filter(item => item !== undefined); - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - - if (item && 'then' in item) { // if item is Promise - items[i] = { type: 'pending' }; - item.then(actualItem => { - items2[i] = actualItem; - }); - } - } - - items2 = items as InnerMenuItem[]; -}, { - immediate: true, -}); - -onMounted(() => { - if (props.viaKeyboard) { - nextTick(() => { - focusNext(itemsEl.children[0], true, false); - }); - } -}); - -function clicked(fn: MenuAction, ev: MouseEvent) { - fn(ev); - close(); -} - -function close() { - emit('close'); -} - -function focusUp() { - focusPrev(document.activeElement); -} - -function focusDown() { - focusNext(document.activeElement); -} -</script> - -<style lang="scss" scoped> -.rrevdjwt { - padding: 8px 0; - box-sizing: border-box; - min-width: 200px; - overflow: auto; - overscroll-behavior: contain; - - &.center { - > .item { - text-align: center; - } - } - - > .item { - display: block; - position: relative; - padding: 8px 18px; - width: 100%; - box-sizing: border-box; - white-space: nowrap; - font-size: 0.9em; - line-height: 20px; - 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; - } - - &:not(:disabled):hover { - color: var(--accent); - text-decoration: none; - - &:before { - background: var(--accentedBg); - } - } - - &.danger { - color: #ff2a2a; - - &:hover { - color: #fff; - - &:before { - background: #ff4242; - } - } - - &:active { - color: #fff; - - &:before { - background: #d42e2e; - } - } - } - - &.active { - color: var(--fgOnAccent); - opacity: 1; - - &:before { - background: var(--accent); - } - } - - &:not(:active):focus-visible { - box-shadow: 0 0 0 2px var(--focus) inset; - } - - &.label { - pointer-events: none; - font-size: 0.7em; - padding-bottom: 4px; - - > span { - opacity: 0.7; - } - } - - &.pending { - pointer-events: none; - opacity: 0.7; - } - - &.none { - pointer-events: none; - opacity: 0.7; - } - - > i { - margin-right: 5px; - width: 20px; - } - - > .avatar { - margin-right: 5px; - width: 20px; - height: 20px; - } - - > .indicator { - position: absolute; - top: 5px; - left: 13px; - color: var(--indicator); - font-size: 12px; - animation: blink 1s infinite; - } - } - - > .divider { - margin: 8px 0; - border-top: solid 0.5px var(--divider); - } - - &.asDrawer { - padding: 12px 0 calc(env(safe-area-inset-bottom, 0px) + 12px) 0; - width: 100%; - - > .item { - font-size: 1em; - padding: 12px 24px; - - &:before { - width: calc(100% - 24px); - border-radius: 12px; - } - - > i { - margin-right: 14px; - width: 24px; - } - } - - > .divider { - margin: 12px 0; - } - } -} -</style> diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue deleted file mode 100644 index 3cd4378f03..0000000000 --- a/packages/client/src/components/ui/window.vue +++ /dev/null @@ -1,529 +0,0 @@ -<template> -<transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')"> - <div v-if="showing" class="ebkgocck"> - <div class="body _window _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown"> - <div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu"> - <span class="left"> - <button v-for="button in buttonsLeft" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button> - </span> - <span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown"> - <slot name="header"></slot> - </span> - <span class="right"> - <button v-for="button in buttonsRight" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button> - <button v-if="closeButton" class="button _button" @click="close()"><i class="fas fa-times"></i></button> - </span> - </div> - <div v-if="padding" class="body"> - <div class="_section"> - <slot></slot> - </div> - </div> - <div v-else class="body"> - <slot></slot> - </div> - </div> - <template v-if="canResize"> - <div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div> - <div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div> - <div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div> - <div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div> - <div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div> - <div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div> - <div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div> - <div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div> - </template> - </div> -</transition> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import contains from '@/scripts/contains'; -import * as os from '@/os'; - -const minHeight = 50; -const minWidth = 250; - -function dragListen(fn) { - window.addEventListener('mousemove', fn); - window.addEventListener('touchmove', fn); - window.addEventListener('mouseleave', dragClear.bind(null, fn)); - window.addEventListener('mouseup', dragClear.bind(null, fn)); - window.addEventListener('touchend', dragClear.bind(null, fn)); -} - -function dragClear(fn) { - window.removeEventListener('mousemove', fn); - window.removeEventListener('touchmove', fn); - window.removeEventListener('mouseleave', dragClear); - window.removeEventListener('mouseup', dragClear); - window.removeEventListener('touchend', dragClear); -} - -export default defineComponent({ - provide: { - inWindow: true, - }, - - props: { - padding: { - type: Boolean, - required: false, - default: false, - }, - initialWidth: { - type: Number, - required: false, - default: 400, - }, - initialHeight: { - type: Number, - required: false, - default: null, - }, - canResize: { - type: Boolean, - required: false, - default: false, - }, - closeButton: { - type: Boolean, - required: false, - default: true, - }, - mini: { - type: Boolean, - required: false, - default: false, - }, - front: { - type: Boolean, - required: false, - default: false, - }, - contextmenu: { - type: Array, - required: false, - }, - buttonsLeft: { - type: Array, - required: false, - default: [], - }, - buttonsRight: { - type: Array, - required: false, - default: [], - }, - }, - - emits: ['closed'], - - data() { - return { - showing: true, - id: Math.random().toString(), // TODO: UUIDとかにする - }; - }, - - mounted() { - if (this.initialWidth) this.applyTransformWidth(this.initialWidth); - if (this.initialHeight) this.applyTransformHeight(this.initialHeight); - - this.applyTransformTop((window.innerHeight / 2) - (this.$el.offsetHeight / 2)); - this.applyTransformLeft((window.innerWidth / 2) - (this.$el.offsetWidth / 2)); - - // 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする - this.top(); - - window.addEventListener('resize', this.onBrowserResize); - }, - - unmounted() { - window.removeEventListener('resize', this.onBrowserResize); - }, - - methods: { - close() { - this.showing = false; - }, - - onKeydown(evt) { - if (evt.which === 27) { // Esc - evt.preventDefault(); - evt.stopPropagation(); - this.close(); - } - }, - - onContextmenu(ev: MouseEvent) { - if (this.contextmenu) { - os.contextMenu(this.contextmenu, ev); - } - }, - - // 最前面へ移動 - top() { - (this.$el as any).style.zIndex = os.claimZIndex(this.front ? 'middle' : 'low'); - }, - - onBodyMousedown() { - this.top(); - }, - - onHeaderMousedown(evt: MouseEvent) { - // 右クリックはコンテキストメニューを開こうとした可能性が高いため無視 - if (evt.button === 2) return; - - const main = this.$el as any; - - if (!contains(main, document.activeElement)) main.focus(); - - const position = main.getBoundingClientRect(); - - const clickX = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientX : evt.clientX; - const clickY = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientY : evt.clientY; - const moveBaseX = clickX - position.left; - const moveBaseY = clickY - position.top; - const browserWidth = window.innerWidth; - const browserHeight = window.innerHeight; - const windowWidth = main.offsetWidth; - const windowHeight = main.offsetHeight; - - // 動かした時 - dragListen(me => { - const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX; - const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY; - - let moveLeft = x - moveBaseX; - let moveTop = y - moveBaseY; - - // 下はみ出し - if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight; - - // 左はみ出し - if (moveLeft < 0) moveLeft = 0; - - // 上はみ出し - if (moveTop < 0) moveTop = 0; - - // 右はみ出し - if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; - - this.$el.style.left = moveLeft + 'px'; - this.$el.style.top = moveTop + 'px'; - }); - }, - - // 上ハンドル掴み時 - onTopHandleMousedown(evt) { - const main = this.$el as any; - - const base = evt.clientY; - const height = parseInt(getComputedStyle(main, '').height, 10); - const top = parseInt(getComputedStyle(main, '').top, 10); - - // 動かした時 - dragListen(me => { - const move = me.clientY - base; - if (top + move > 0) { - if (height + -move > minHeight) { - this.applyTransformHeight(height + -move); - this.applyTransformTop(top + move); - } else { // 最小の高さより小さくなろうとした時 - this.applyTransformHeight(minHeight); - this.applyTransformTop(top + (height - minHeight)); - } - } else { // 上のはみ出し時 - this.applyTransformHeight(top + height); - this.applyTransformTop(0); - } - }); - }, - - // 右ハンドル掴み時 - onRightHandleMousedown(evt) { - const main = this.$el as any; - - const base = evt.clientX; - const width = parseInt(getComputedStyle(main, '').width, 10); - const left = parseInt(getComputedStyle(main, '').left, 10); - const browserWidth = window.innerWidth; - - // 動かした時 - dragListen(me => { - const move = me.clientX - base; - if (left + width + move < browserWidth) { - if (width + move > minWidth) { - this.applyTransformWidth(width + move); - } else { // 最小の幅より小さくなろうとした時 - this.applyTransformWidth(minWidth); - } - } else { // 右のはみ出し時 - this.applyTransformWidth(browserWidth - left); - } - }); - }, - - // 下ハンドル掴み時 - onBottomHandleMousedown(evt) { - const main = this.$el as any; - - const base = evt.clientY; - const height = parseInt(getComputedStyle(main, '').height, 10); - const top = parseInt(getComputedStyle(main, '').top, 10); - const browserHeight = window.innerHeight; - - // 動かした時 - dragListen(me => { - const move = me.clientY - base; - if (top + height + move < browserHeight) { - if (height + move > minHeight) { - this.applyTransformHeight(height + move); - } else { // 最小の高さより小さくなろうとした時 - this.applyTransformHeight(minHeight); - } - } else { // 下のはみ出し時 - this.applyTransformHeight(browserHeight - top); - } - }); - }, - - // 左ハンドル掴み時 - onLeftHandleMousedown(evt) { - const main = this.$el as any; - - const base = evt.clientX; - const width = parseInt(getComputedStyle(main, '').width, 10); - const left = parseInt(getComputedStyle(main, '').left, 10); - - // 動かした時 - dragListen(me => { - const move = me.clientX - base; - if (left + move > 0) { - if (width + -move > minWidth) { - this.applyTransformWidth(width + -move); - this.applyTransformLeft(left + move); - } else { // 最小の幅より小さくなろうとした時 - this.applyTransformWidth(minWidth); - this.applyTransformLeft(left + (width - minWidth)); - } - } else { // 左のはみ出し時 - this.applyTransformWidth(left + width); - this.applyTransformLeft(0); - } - }); - }, - - // 左上ハンドル掴み時 - onTopLeftHandleMousedown(evt) { - this.onTopHandleMousedown(evt); - this.onLeftHandleMousedown(evt); - }, - - // 右上ハンドル掴み時 - onTopRightHandleMousedown(evt) { - this.onTopHandleMousedown(evt); - this.onRightHandleMousedown(evt); - }, - - // 右下ハンドル掴み時 - onBottomRightHandleMousedown(evt) { - this.onBottomHandleMousedown(evt); - this.onRightHandleMousedown(evt); - }, - - // 左下ハンドル掴み時 - onBottomLeftHandleMousedown(evt) { - this.onBottomHandleMousedown(evt); - this.onLeftHandleMousedown(evt); - }, - - // 高さを適用 - applyTransformHeight(height) { - if (height > window.innerHeight) height = window.innerHeight; - (this.$el as any).style.height = height + 'px'; - }, - - // 幅を適用 - applyTransformWidth(width) { - if (width > window.innerWidth) width = window.innerWidth; - (this.$el as any).style.width = width + 'px'; - }, - - // Y座標を適用 - applyTransformTop(top) { - (this.$el as any).style.top = top + 'px'; - }, - - // X座標を適用 - applyTransformLeft(left) { - (this.$el as any).style.left = left + 'px'; - }, - - onBrowserResize() { - const main = this.$el as any; - const position = main.getBoundingClientRect(); - const browserWidth = window.innerWidth; - const browserHeight = window.innerHeight; - const windowWidth = main.offsetWidth; - const windowHeight = main.offsetHeight; - if (position.left < 0) main.style.left = 0; // 左はみ出し - if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し - if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し - if (position.top < 0) main.style.top = 0; // 上はみ出し - }, - }, -}); -</script> - -<style lang="scss" scoped> -.window-enter-active, .window-leave-active { - transition: opacity 0.2s, transform 0.2s !important; -} -.window-enter-from, .window-leave-to { - pointer-events: none; - opacity: 0; - transform: scale(0.9); -} - -.ebkgocck { - position: fixed; - top: 0; - left: 0; - - > .body { - overflow: hidden; - display: flex; - flex-direction: column; - contain: content; - width: 100%; - height: 100%; - - > .header { - --height: 50px; - - &.mini { - --height: 38px; - } - - display: flex; - position: relative; - z-index: 1; - flex-shrink: 0; - user-select: none; - height: var(--height); - border-bottom: solid 1px var(--divider); - - > .left, > .right { - > .button { - height: var(--height); - width: var(--height); - - &:hover { - color: var(--fgHighlighted); - } - - &.highlighted { - color: var(--accent); - } - } - } - - > .left { - margin-right: 16px; - } - - > .right { - min-width: 16px; - } - - > .title { - flex: 1; - position: relative; - line-height: var(--height); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - cursor: move; - } - } - - > .body { - flex: 1; - overflow: auto; - } - } - - > .handle { - $size: 8px; - - position: absolute; - - &.top { - top: -($size); - left: 0; - width: 100%; - height: $size; - cursor: ns-resize; - } - - &.right { - top: 0; - right: -($size); - width: $size; - height: 100%; - cursor: ew-resize; - } - - &.bottom { - bottom: -($size); - left: 0; - width: 100%; - height: $size; - cursor: ns-resize; - } - - &.left { - top: 0; - left: -($size); - width: $size; - height: 100%; - cursor: ew-resize; - } - - &.top-left { - top: -($size); - left: -($size); - width: $size * 2; - height: $size * 2; - cursor: nwse-resize; - } - - &.top-right { - top: -($size); - right: -($size); - width: $size * 2; - height: $size * 2; - cursor: nesw-resize; - } - - &.bottom-right { - bottom: -($size); - right: -($size); - width: $size * 2; - height: $size * 2; - cursor: nwse-resize; - } - - &.bottom-left { - bottom: -($size); - left: -($size); - width: $size * 2; - height: $size * 2; - cursor: nesw-resize; - } - } -} -</style> diff --git a/packages/client/src/components/url-preview-popup.vue b/packages/client/src/components/url-preview-popup.vue deleted file mode 100644 index 5f3717ab91..0000000000 --- a/packages/client/src/components/url-preview-popup.vue +++ /dev/null @@ -1,60 +0,0 @@ -<template> -<div class="fgmtyycl" :style="{ zIndex, top: top + 'px', left: left + 'px' }"> - <transition :name="$store.state.animation ? 'zoom' : ''" @after-leave="$emit('closed')"> - <MkUrlPreview v-if="showing" class="_popup _shadow" :url="url"/> - </transition> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkUrlPreview from './url-preview.vue'; -import * as os from '@/os'; - -export default defineComponent({ - components: { - MkUrlPreview - }, - - props: { - url: { - type: String, - required: true - }, - source: { - required: true - }, - showing: { - type: Boolean, - required: true - }, - }, - - data() { - return { - u: null, - top: 0, - left: 0, - zIndex: os.claimZIndex('middle'), - }; - }, - - mounted() { - const rect = this.source.getBoundingClientRect(); - const x = Math.max((rect.left + (this.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset; - const y = rect.top + this.source.offsetHeight + window.pageYOffset; - - this.top = y; - this.left = x; - }, -}); -</script> - -<style lang="scss" scoped> -.fgmtyycl { - position: absolute; - width: 500px; - max-width: calc(90vw - 12px); - pointer-events: none; -} -</style> diff --git a/packages/client/src/components/widgets.vue b/packages/client/src/components/widgets.vue deleted file mode 100644 index 74dd79f733..0000000000 --- a/packages/client/src/components/widgets.vue +++ /dev/null @@ -1,146 +0,0 @@ -<template> -<div class="vjoppmmu"> - <template v-if="edit"> - <header> - <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" class="mk-widget-select"> - <template #label>{{ $ts.selectWidget }}</template> - <option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ $t(`_widgets.${widget}`) }}</option> - </MkSelect> - <MkButton inline primary class="mk-widget-add" @click="addWidget"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> - <MkButton inline @click="$emit('exit')">{{ $ts.close }}</MkButton> - </header> - <XDraggable - v-model="widgets_" - item-key="id" - handle=".handle" - animation="150" - > - <template #item="{element}"> - <div class="customize-container"> - <button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="fas fa-cog"></i></button> - <button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="fas fa-times"></i></button> - <component :is="`mkw-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="handle" :widget="element" @updateProps="updateWidget(element.id, $event)"/> - </div> - </template> - </XDraggable> - </template> - <component :is="`mkw-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" class="widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent, reactive, ref, computed } from 'vue'; -import { v4 as uuid } from 'uuid'; -import MkSelect from '@/components/form/select.vue'; -import MkButton from '@/components/ui/button.vue'; -import { widgets as widgetDefs } from '@/widgets'; - -export default defineComponent({ - components: { - XDraggable: defineAsyncComponent(() => import('vuedraggable')), - MkSelect, - MkButton, - }, - - props: { - widgets: { - type: Array, - required: true, - }, - edit: { - type: Boolean, - required: true, - }, - }, - - emits: ['updateWidgets', 'addWidget', 'removeWidget', 'updateWidget', 'exit'], - - setup(props, context) { - const widgetRefs = reactive({}); - const configWidget = (id: string) => { - widgetRefs[id].configure(); - }; - const widgetAdderSelected = ref(null); - const addWidget = () => { - if (widgetAdderSelected.value == null) return; - - context.emit('addWidget', { - name: widgetAdderSelected.value, - id: uuid(), - data: {}, - }); - - widgetAdderSelected.value = null; - }; - const removeWidget = (widget) => { - context.emit('removeWidget', widget); - }; - const updateWidget = (id, data) => { - context.emit('updateWidget', { id, data }); - }; - const widgets_ = computed({ - get: () => props.widgets, - set: (value) => { - context.emit('updateWidgets', value); - }, - }); - - return { - widgetRefs, - configWidget, - widgetAdderSelected, - widgetDefs, - addWidget, - removeWidget, - updateWidget, - widgets_, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.vjoppmmu { - > header { - margin: 16px 0; - - > * { - width: 100%; - padding: 4px; - } - } - - > .widget, .customize-container { - margin: var(--margin) 0; - - &:first-of-type { - margin-top: 0; - } - } - - .customize-container { - position: relative; - cursor: move; - - > .config, - > .remove { - position: absolute; - z-index: 10000; - top: 8px; - width: 32px; - height: 32px; - color: #fff; - background: rgba(#000, 0.7); - border-radius: 4px; - } - - > .config { - right: 8px + 8px + 32px; - } - - > .remove { - right: 8px; - } - } -} -</style> diff --git a/packages/client/src/directives/get-size.ts b/packages/client/src/directives/get-size.ts index 2c4e9c188d..76b54ea4b0 100644 --- a/packages/client/src/directives/get-size.ts +++ b/packages/client/src/directives/get-size.ts @@ -34,7 +34,6 @@ function calc(src: Element) { export default { mounted(src, binding, vn) { - const resize = new ResizeObserver((entries, observer) => { calc(src); }); 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/ripple.ts b/packages/client/src/directives/ripple.ts index f1d41ddb0e..5329d021fb 100644 --- a/packages/client/src/directives/ripple.ts +++ b/packages/client/src/directives/ripple.ts @@ -1,4 +1,4 @@ -import Ripple from '@/components/ripple.vue'; +import Ripple from '@/components/MkRipple.vue'; import { popup } from '@/os'; export default { diff --git a/packages/client/src/directives/size.ts b/packages/client/src/directives/size.ts index 51855e0de5..c472a528ac 100644 --- a/packages/client/src/directives/size.ts +++ b/packages/client/src/directives/size.ts @@ -27,8 +27,8 @@ function getClassOrder(width: number, queue: Value): ClassOrder { ...(queue.min ? queue.min.filter(v => width >= v).map(getMinClass) : []), ], remove: [ - ...(queue.max ? queue.max.filter(v => width > v).map(getMaxClass) : []), - ...(queue.min ? queue.min.filter(v => width < v).map(getMinClass) : []), + ...(queue.max ? queue.max.filter(v => width > v).map(getMaxClass) : []), + ...(queue.min ? queue.min.filter(v => width < v).map(getMinClass) : []), ] }; } 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/directives/tooltip.ts b/packages/client/src/directives/tooltip.ts index 588bbca3a9..5d13497b5f 100644 --- a/packages/client/src/directives/tooltip.ts +++ b/packages/client/src/directives/tooltip.ts @@ -7,10 +7,11 @@ import { popup, alert } from '@/os'; const start = isTouchUsing ? 'touchstart' : 'mouseover'; const end = isTouchUsing ? 'touchend' : 'mouseleave'; -const delay = 100; export default { mounted(el: HTMLElement, binding, vn) { + const delay = binding.modifiers.noDelay ? 0 : 100; + const self = (el as any)._tooltipDirective_ = {} as any; self.text = binding.value as string; @@ -45,10 +46,11 @@ export default { if (self.text == null) return; const showing = ref(true); - popup(defineAsyncComponent(() => import('@/components/ui/tooltip.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), { showing, text: self.text, asMfm: binding.modifiers.mfm, + direction: binding.modifiers.left ? 'left' : binding.modifiers.right ? 'right' : binding.modifiers.top ? 'top' : binding.modifiers.bottom ? 'bottom' : 'top', targetElement: el, }, {}, 'closed'); diff --git a/packages/client/src/directives/user-preview.ts b/packages/client/src/directives/user-preview.ts index 9d18a69877..c461676624 100644 --- a/packages/client/src/directives/user-preview.ts +++ b/packages/client/src/directives/user-preview.ts @@ -24,7 +24,7 @@ export class UserPreview { const showing = ref(true); - popup(defineAsyncComponent(() => import('@/components/user-preview.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkUserPreview.vue')), { showing, q: this.user, source: this.el diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts index 0be831f70a..5af1136061 100644 --- a/packages/client/src/init.ts +++ b/packages/client/src/init.ts @@ -14,7 +14,7 @@ if (localStorage.getItem('accounts') != null) { //#endregion import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue'; -import compareVersions from 'compare-versions'; +import { compareVersions } from 'compare-versions'; import JSON5 from 'json5'; import widgets from '@/widgets'; @@ -40,402 +40,397 @@ import { getUrlWithoutLoginId } from '@/scripts/login-id'; import { getAccountFromId } from '@/scripts/get-account-from-id'; import { deckStore } from './ui/deck/deck-store'; -console.info(`Misskey v${version}`); +(async () => { + console.info(`Misskey v${version}`); -await defaultStore.ready; - -if (_DEV_) { - console.warn('Development mode!!!'); + if (_DEV_) { + console.warn('Development mode!!!'); - console.info(`vue ${vueVersion}`); + console.info(`vue ${vueVersion}`); - (window as any).$i = $i; - (window as any).$store = defaultStore; + (window as any).$i = $i; + (window as any).$store = defaultStore; - window.addEventListener('error', event => { - console.error(event); - /* - alert({ - type: 'error', - title: 'DEV: Unhandled error', - text: event.message + window.addEventListener('error', event => { + console.error(event); + /* + alert({ + type: 'error', + title: 'DEV: Unhandled error', + text: event.message + }); + */ }); - */ - }); - window.addEventListener('unhandledrejection', event => { - console.error(event); - /* - alert({ - type: 'error', - title: 'DEV: Unhandled promise rejection', - text: event.reason + window.addEventListener('unhandledrejection', event => { + console.error(event); + /* + alert({ + type: 'error', + title: 'DEV: Unhandled promise rejection', + text: event.reason + }); + */ }); - */ - }); -} + } -// タッチデバイスでCSSの:hoverを機能させる -document.addEventListener('touchend', () => {}, { passive: true }); + await defaultStore.ready; -// 一斉リロード -reloadChannel.addEventListener('message', path => { - if (path !== null) location.href = path; - else location.reload(); -}); + // タッチデバイスでCSSの:hoverを機能させる + document.addEventListener('touchend', () => {}, { passive: true }); -//#region SEE: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ -// TODO: いつの日にか消したい -const vh = window.innerHeight * 0.01; -document.documentElement.style.setProperty('--vh', `${vh}px`); -window.addEventListener('resize', () => { - const vh = window.innerHeight * 0.01; - document.documentElement.style.setProperty('--vh', `${vh}px`); -}); -//#endregion + // 一斉リロード + reloadChannel.addEventListener('message', path => { + if (path !== null) location.href = path; + else location.reload(); + }); -// If mobile, insert the viewport meta tag -if (['smartphone', 'tablet'].includes(deviceKind)) { - const viewport = document.getElementsByName('viewport').item(0); - viewport.setAttribute('content', - `${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`); -} + // If mobile, insert the viewport meta tag + if (['smartphone', 'tablet'].includes(deviceKind)) { + const viewport = document.getElementsByName('viewport').item(0); + viewport.setAttribute('content', + `${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`); + } -//#region Set lang attr -const html = document.documentElement; -html.setAttribute('lang', lang); -//#endregion + //#region Set lang attr + const html = document.documentElement; + html.setAttribute('lang', lang); + //#endregion -//#region loginId -const params = new URLSearchParams(location.search); -const loginId = params.get('loginId'); + //#region loginId + const params = new URLSearchParams(location.search); + const loginId = params.get('loginId'); -if (loginId) { - const target = getUrlWithoutLoginId(location.href); + if (loginId) { + const target = getUrlWithoutLoginId(location.href); - if (!$i || $i.id !== loginId) { - const account = await getAccountFromId(loginId); - if (account) { - await login(account.token, target); + if (!$i || $i.id !== loginId) { + const account = await getAccountFromId(loginId); + if (account) { + await login(account.token, target); + } } - } - - history.replaceState({ misskey: 'loginId' }, '', target); -} - -//#endregion -//#region Fetch user -if ($i && $i.token) { - if (_DEV_) { - console.log('account cache found. refreshing...'); - } - - refreshAccount(); -} else { - if (_DEV_) { - console.log('no account cache found.'); + history.replaceState({ misskey: 'loginId' }, '', target); } - // 連携ログインの場合用にCookieを参照する - const i = (document.cookie.match(/igi=(\w+)/) || [null, null])[1]; + //#endregion - if (i != null && i !== 'null') { + //#region Fetch user + if ($i && $i.token) { if (_DEV_) { - console.log('signing...'); + console.log('account cache found. refreshing...'); } - try { - document.body.innerHTML = '<div>Please wait...</div>'; - await login(i); - } catch (err) { - // Render the error screen - // TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな) - document.body.innerHTML = '<div id="err">Oops!</div>'; - } + refreshAccount(); } else { if (_DEV_) { - console.log('not signed in'); + console.log('no account cache found.'); + } + + // 連携ログインの場合用にCookieを参照する + const i = (document.cookie.match(/igi=(\w+)/) || [null, null])[1]; + + if (i != null && i !== 'null') { + if (_DEV_) { + console.log('signing...'); + } + + try { + document.body.innerHTML = '<div>Please wait...</div>'; + await login(i); + } catch (err) { + // Render the error screen + // TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな) + document.body.innerHTML = '<div id="err">Oops!</div>'; + } + } else { + if (_DEV_) { + console.log('not signed in'); + } } } -} -//#endregion + //#endregion -const fetchInstanceMetaPromise = fetchInstance(); + const fetchInstanceMetaPromise = fetchInstance(); -fetchInstanceMetaPromise.then(() => { - localStorage.setItem('v', instance.version); + fetchInstanceMetaPromise.then(() => { + localStorage.setItem('v', instance.version); - // Init service worker - initializeSw(); -}); + // Init service worker + initializeSw(); + }); -const app = createApp( - window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) : - !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : - ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : - ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : - defineAsyncComponent(() => import('@/ui/universal.vue')), -); + const app = createApp( + window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) : + !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : + ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : + ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : + defineAsyncComponent(() => import('@/ui/universal.vue')), + ); -if (_DEV_) { - app.config.performance = true; -} + if (_DEV_) { + app.config.performance = true; + } -app.config.globalProperties = { - $i, - $store: defaultStore, - $instance: instance, - $t: i18n.t, - $ts: i18n.ts, -}; + app.config.globalProperties = { + $i, + $store: defaultStore, + $instance: instance, + $t: i18n.t, + $ts: i18n.ts, + }; -widgets(app); -directives(app); -components(app); + widgets(app); + directives(app); + components(app); -const splash = document.getElementById('splash'); -// 念のためnullチェック(HTMLが古い場合があるため(そのうち消す)) -if (splash) splash.addEventListener('transitionend', () => { - splash.remove(); -}); + const splash = document.getElementById('splash'); + // 念のためnullチェック(HTMLが古い場合があるため(そのうち消す)) + if (splash) splash.addEventListener('transitionend', () => { + splash.remove(); + }); -if (ui === 'deck') await deckStore.ready; + await deckStore.ready; -// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210 -// なぜかinit.tsの内容が2回実行されることがあるため、mountするdivを1つに制限する -const rootEl = (() => { - const MISSKEY_MOUNT_DIV_ID = 'misskey_app'; + // https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210 + // なぜかinit.tsの内容が2回実行されることがあるため、mountするdivを1つに制限する + const rootEl = (() => { + const MISSKEY_MOUNT_DIV_ID = 'misskey_app'; - const currentEl = document.getElementById(MISSKEY_MOUNT_DIV_ID); + const currentEl = document.getElementById(MISSKEY_MOUNT_DIV_ID); - if (currentEl) { - console.warn('multiple import detected'); - return currentEl; - } + if (currentEl) { + console.warn('multiple import detected'); + return currentEl; + } - const rootEl = document.createElement('div'); - rootEl.id = MISSKEY_MOUNT_DIV_ID; - document.body.appendChild(rootEl); - return rootEl; -})(); + const rootEl = document.createElement('div'); + rootEl.id = MISSKEY_MOUNT_DIV_ID; + document.body.appendChild(rootEl); + return rootEl; + })(); -app.mount(rootEl); + app.mount(rootEl); -// boot.jsのやつを解除 -window.onerror = null; -window.onunhandledrejection = null; + // boot.jsのやつを解除 + window.onerror = null; + window.onunhandledrejection = null; -reactionPicker.init(); + reactionPicker.init(); -if (splash) { - splash.style.opacity = '0'; - splash.style.pointerEvents = 'none'; -} + if (splash) { + splash.style.opacity = '0'; + splash.style.pointerEvents = 'none'; + } -// クライアントが更新されたか? -const lastVersion = localStorage.getItem('lastVersion'); -if (lastVersion !== version) { - localStorage.setItem('lastVersion', version); + // クライアントが更新されたか? + const lastVersion = localStorage.getItem('lastVersion'); + if (lastVersion !== version) { + localStorage.setItem('lastVersion', version); - // テーマリビルドするため - localStorage.removeItem('theme'); + // テーマリビルドするため + localStorage.removeItem('theme'); - try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため - if (lastVersion != null && compareVersions(version, lastVersion) === 1) { - // ログインしてる場合だけ - if ($i) { - popup(defineAsyncComponent(() => import('@/components/updated.vue')), {}, {}, 'closed'); + try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため + if (lastVersion != null && compareVersions(version, lastVersion) === 1) { + // ログインしてる場合だけ + if ($i) { + popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed'); + } } + } catch (err) { } - } catch (err) { } -} -// NOTE: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため) -watch(defaultStore.reactiveState.darkMode, (darkMode) => { - applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme')); -}, { immediate: localStorage.theme == null }); + // NOTE: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため) + watch(defaultStore.reactiveState.darkMode, (darkMode) => { + applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme')); + }, { immediate: localStorage.theme == null }); -const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme')); -const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme')); - -watch(darkTheme, (theme) => { - if (defaultStore.state.darkMode) { - applyTheme(theme); - } -}); + const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme')); + const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme')); -watch(lightTheme, (theme) => { - if (!defaultStore.state.darkMode) { - applyTheme(theme); - } -}); + watch(darkTheme, (theme) => { + if (defaultStore.state.darkMode) { + applyTheme(theme); + } + }); -//#region Sync dark mode -if (ColdDeviceStorage.get('syncDeviceDarkMode')) { - defaultStore.set('darkMode', isDeviceDarkmode()); -} + watch(lightTheme, (theme) => { + if (!defaultStore.state.darkMode) { + applyTheme(theme); + } + }); -window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => { + //#region Sync dark mode if (ColdDeviceStorage.get('syncDeviceDarkMode')) { - defaultStore.set('darkMode', mql.matches); + defaultStore.set('darkMode', isDeviceDarkmode()); } -}); -//#endregion -fetchInstanceMetaPromise.then(() => { - if (defaultStore.state.themeInitial) { - if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON5.parse(instance.defaultLightTheme)); - if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON5.parse(instance.defaultDarkTheme)); - defaultStore.set('themeInitial', false); - } -}); + window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => { + if (ColdDeviceStorage.get('syncDeviceDarkMode')) { + defaultStore.set('darkMode', mql.matches); + } + }); + //#endregion -// shortcut -document.addEventListener('keydown', makeHotkey({ - 'd': () => { - defaultStore.set('darkMode', !defaultStore.state.darkMode); - }, - 'p|n': post, - 's': search, - //TODO: 'h|/': help -})); + fetchInstanceMetaPromise.then(() => { + if (defaultStore.state.themeInitial) { + if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON5.parse(instance.defaultLightTheme)); + if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON5.parse(instance.defaultDarkTheme)); + defaultStore.set('themeInitial', false); + } + }); -watch(defaultStore.reactiveState.useBlurEffectForModal, v => { - document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none'); -}, { immediate: true }); + watch(defaultStore.reactiveState.useBlurEffectForModal, v => { + document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none'); + }, { immediate: true }); -watch(defaultStore.reactiveState.useBlurEffect, v => { - if (v) { - document.documentElement.style.removeProperty('--blur'); - } else { - document.documentElement.style.setProperty('--blur', 'none'); - } -}, { immediate: true }); + watch(defaultStore.reactiveState.useBlurEffect, v => { + if (v) { + document.documentElement.style.removeProperty('--blur'); + } else { + document.documentElement.style.setProperty('--blur', 'none'); + } + }, { immediate: true }); -let reloadDialogShowing = false; -stream.on('_disconnected_', async () => { - if (defaultStore.state.serverDisconnectedBehavior === 'reload') { - location.reload(); - } else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { - if (reloadDialogShowing) return; - reloadDialogShowing = true; - const { canceled } = await confirm({ - type: 'warning', - title: i18n.ts.disconnectedFromServer, - text: i18n.ts.reloadConfirm, - }); - reloadDialogShowing = false; - if (!canceled) { + let reloadDialogShowing = false; + stream.on('_disconnected_', async () => { + if (defaultStore.state.serverDisconnectedBehavior === 'reload') { location.reload(); + } else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { + if (reloadDialogShowing) return; + reloadDialogShowing = true; + const { canceled } = await confirm({ + type: 'warning', + title: i18n.ts.disconnectedFromServer, + text: i18n.ts.reloadConfirm, + }); + reloadDialogShowing = false; + if (!canceled) { + location.reload(); + } } - } -}); - -stream.on('emojiAdded', emojiData => { - // TODO - //store.commit('instance/set', ); -}); + }); -for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { - import('./plugin').then(({ install }) => { - install(plugin); + stream.on('emojiAdded', emojiData => { + // TODO + //store.commit('instance/set', ); }); -} -if ($i) { - if ($i.isDeleted) { - alert({ - type: 'warning', - text: i18n.ts.accountDeletionInProgress, + for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { + import('./plugin').then(({ install }) => { + install(plugin); }); } - const lastUsed = localStorage.getItem('lastUsed'); - if (lastUsed) { - const lastUsedDate = parseInt(lastUsed, 10); - // 二時間以上前なら - if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) { - toast(i18n.t('welcomeBackWithName', { - name: $i.name || $i.username, - })); + const hotkeys = { + 'd': (): void => { + defaultStore.set('darkMode', !defaultStore.state.darkMode); + }, + 's': search, + }; + + if ($i) { + // only add post shortcuts if logged in + hotkeys['p|n'] = post; + + if ($i.isDeleted) { + alert({ + type: 'warning', + text: i18n.ts.accountDeletionInProgress, + }); } - } - localStorage.setItem('lastUsed', Date.now().toString()); - if ('Notification' in window) { - // 許可を得ていなかったらリクエスト - if (Notification.permission === 'default') { - Notification.requestPermission(); + const lastUsed = localStorage.getItem('lastUsed'); + if (lastUsed) { + const lastUsedDate = parseInt(lastUsed, 10); + // 二時間以上前なら + if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) { + toast(i18n.t('welcomeBackWithName', { + name: $i.name || $i.username, + })); + } } - } + localStorage.setItem('lastUsed', Date.now().toString()); - const main = markRaw(stream.useChannel('main', null, 'System')); + if ('Notification' in window) { + // 許可を得ていなかったらリクエスト + if (Notification.permission === 'default') { + Notification.requestPermission(); + } + } - // 自分の情報が更新されたとき - main.on('meUpdated', i => { - updateAccount(i); - }); + const main = markRaw(stream.useChannel('main', null, 'System')); - main.on('readAllNotifications', () => { - updateAccount({ hasUnreadNotification: false }); - }); + // 自分の情報が更新されたとき + main.on('meUpdated', i => { + updateAccount(i); + }); - main.on('unreadNotification', () => { - updateAccount({ hasUnreadNotification: true }); - }); + main.on('readAllNotifications', () => { + updateAccount({ hasUnreadNotification: false }); + }); - main.on('unreadMention', () => { - updateAccount({ hasUnreadMentions: true }); - }); + main.on('unreadNotification', () => { + updateAccount({ hasUnreadNotification: true }); + }); - main.on('readAllUnreadMentions', () => { - updateAccount({ hasUnreadMentions: false }); - }); + main.on('unreadMention', () => { + updateAccount({ hasUnreadMentions: true }); + }); - main.on('unreadSpecifiedNote', () => { - updateAccount({ hasUnreadSpecifiedNotes: true }); - }); + main.on('readAllUnreadMentions', () => { + updateAccount({ hasUnreadMentions: false }); + }); - main.on('readAllUnreadSpecifiedNotes', () => { - updateAccount({ hasUnreadSpecifiedNotes: false }); - }); + main.on('unreadSpecifiedNote', () => { + updateAccount({ hasUnreadSpecifiedNotes: true }); + }); - main.on('readAllMessagingMessages', () => { - updateAccount({ hasUnreadMessagingMessage: false }); - }); + main.on('readAllUnreadSpecifiedNotes', () => { + updateAccount({ hasUnreadSpecifiedNotes: false }); + }); - main.on('unreadMessagingMessage', () => { - updateAccount({ hasUnreadMessagingMessage: true }); - sound.play('chatBg'); - }); + main.on('readAllMessagingMessages', () => { + updateAccount({ hasUnreadMessagingMessage: false }); + }); - main.on('readAllAntennas', () => { - updateAccount({ hasUnreadAntenna: false }); - }); + main.on('unreadMessagingMessage', () => { + updateAccount({ hasUnreadMessagingMessage: true }); + sound.play('chatBg'); + }); - main.on('unreadAntenna', () => { - updateAccount({ hasUnreadAntenna: true }); - sound.play('antenna'); - }); + main.on('readAllAntennas', () => { + updateAccount({ hasUnreadAntenna: false }); + }); - main.on('readAllAnnouncements', () => { - updateAccount({ hasUnreadAnnouncement: false }); - }); + main.on('unreadAntenna', () => { + updateAccount({ hasUnreadAntenna: true }); + sound.play('antenna'); + }); - main.on('readAllChannels', () => { - updateAccount({ hasUnreadChannel: false }); - }); + main.on('readAllAnnouncements', () => { + updateAccount({ hasUnreadAnnouncement: false }); + }); - main.on('unreadChannel', () => { - updateAccount({ hasUnreadChannel: true }); - sound.play('channel'); - }); + main.on('readAllChannels', () => { + updateAccount({ hasUnreadChannel: false }); + }); - // トークンが再生成されたとき - // このままではMisskeyが利用できないので強制的にサインアウトさせる - main.on('myTokenRegenerated', () => { - signout(); - }); -} + main.on('unreadChannel', () => { + updateAccount({ hasUnreadChannel: true }); + sound.play('channel'); + }); + + // トークンが再生成されたとき + // このままではMisskeyが利用できないので強制的にサインアウトさせる + main.on('myTokenRegenerated', () => { + signout(); + }); + } + + // shortcut + document.addEventListener('keydown', makeHotkey(hotkeys)); +})(); diff --git a/packages/client/src/menu.ts b/packages/client/src/navbar.ts index 5e281f4ea1..03e00b1c17 100644 --- a/packages/client/src/menu.ts +++ b/packages/client/src/navbar.ts @@ -1,13 +1,12 @@ import { computed, ref, reactive } from 'vue'; import { $i } from './account'; -import { mainRouter } from '@/router'; import { search } from '@/scripts/search'; import * as os from '@/os'; 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', @@ -35,11 +34,6 @@ export const menuDef = reactive({ indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest), to: '/my/follow-requests', }, - featured: { - title: 'featured', - icon: 'fas fa-fire-alt', - to: '/featured', - }, explore: { title: 'explore', icon: 'fas fa-hashtag', @@ -60,71 +54,21 @@ export const menuDef = reactive({ title: 'lists', icon: 'fas fa-list-ul', show: computed(() => $i != null), - active: computed(() => mainRouter.currentRoute.value.path.startsWith('/timeline/list/') || mainRouter.currentRoute.value.path === '/my/lists' || mainRouter.currentRoute.value.path.startsWith('/my/lists/')), - action: (ev) => { - const items = ref([{ - type: 'pending', - }]); - os.api('users/lists/list').then(lists => { - const _items = [...lists.map(list => ({ - type: 'link', - text: list.name, - to: `/timeline/list/${list.id}`, - })), null, { - type: 'link', - to: '/my/lists', - text: i18n.ts.manageLists, - icon: 'fas fa-cog', - }]; - items.value = _items; - }); - os.popupMenu(items, ev.currentTarget ?? ev.target); - }, + to: '/my/lists', }, + /* groups: { title: 'groups', icon: 'fas fa-users', show: computed(() => $i != null), to: '/my/groups', }, + */ antennas: { title: 'antennas', icon: 'fas fa-satellite', show: computed(() => $i != null), - active: computed(() => mainRouter.currentRoute.value.path.startsWith('/timeline/antenna/') || mainRouter.currentRoute.value.path === '/my/antennas' || mainRouter.currentRoute.value.path.startsWith('/my/antennas/')), - action: (ev) => { - const items = ref([{ - type: 'pending', - }]); - os.api('antennas/list').then(antennas => { - const _items = [...antennas.map(antenna => ({ - type: 'link', - text: antenna.name, - to: `/timeline/antenna/${antenna.id}`, - })), null, { - type: 'link', - to: '/my/antennas', - text: i18n.ts.manageAntennas, - icon: 'fas fa-cog', - }]; - items.value = _items; - }); - os.popupMenu(items, ev.currentTarget ?? ev.target); - }, - }, - mentions: { - title: 'mentions', - icon: 'fas fa-at', - show: computed(() => $i != null), - indicated: computed(() => $i != null && $i.hasUnreadMentions), - to: '/my/mentions', - }, - messages: { - title: 'directNotes', - icon: 'fas fa-envelope', - show: computed(() => $i != null), - indicated: computed(() => $i != null && $i.hasUnreadSpecifiedNotes), - to: '/my/messages', + to: '/my/antennas', }, favorites: { title: 'favorites', @@ -153,21 +97,6 @@ export const menuDef = reactive({ icon: 'fas fa-satellite-dish', to: '/channels', }, - federation: { - title: 'federation', - icon: 'fas fa-globe', - to: '/federation', - }, - emojis: { - title: 'emojis', - icon: 'fas fa-laugh', - to: '/emojis', - }, - scratchpad: { - title: 'scratchpad', - icon: 'fas fa-terminal', - to: '/scratchpad', - }, ui: { title: 'switchUi', icon: 'fas fa-columns', @@ -196,4 +125,11 @@ export const menuDef = reactive({ }], ev.currentTarget ?? ev.target); }, }, + reload: { + title: 'reload', + icon: 'fas fa-refresh', + action: (ev) => { + location.reload(); + }, + }, }); diff --git a/packages/client/src/nirax.ts b/packages/client/src/nirax.ts index d219787448..0ee39bf473 100644 --- a/packages/client/src/nirax.ts +++ b/packages/client/src/nirax.ts @@ -2,13 +2,18 @@ import { EventEmitter } from 'eventemitter3'; import { Ref, Component, ref, shallowRef, ShallowRef } from 'vue'; +import { pleaseLogin } from '@/scripts/please-login'; +import { safeURIDecode } from '@/scripts/safe-uri-decode'; type RouteDef = { path: string; component: Component; query?: Record<string, string>; + loginRequired?: boolean; name?: string; + hash?: string; globalCacheKey?: string; + children?: RouteDef[]; }; type ParsedPath = (string | { @@ -18,6 +23,8 @@ type ParsedPath = (string | { optional?: boolean; })[]; +export type Resolved = { route: RouteDef; props: Map<string, string>; child?: Resolved; }; + function parsePath(path: string): ParsedPath { const res = [] as ParsedPath; @@ -35,7 +42,7 @@ function parsePath(path: string): ParsedPath { wildcard, optional, }); - } else { + } else if (part.length !== 0) { res.push(part); } } @@ -47,8 +54,11 @@ export class Router extends EventEmitter<{ change: (ctx: { beforePath: string; path: string; - route: RouteDef | null; - props: Map<string, string> | null; + resolved: Resolved; + key: string; + }) => void; + replace: (ctx: { + path: string; key: string; }) => void; push: (ctx: { @@ -58,26 +68,33 @@ export class Router extends EventEmitter<{ props: Map<string, string> | null; key: string; }) => void; + same: () => void; }> { private routes: RouteDef[]; + public current: Resolved; + public currentRef: ShallowRef<Resolved> = shallowRef(); + public currentRoute: ShallowRef<RouteDef> = shallowRef(); private currentPath: string; - private currentComponent: Component | null = null; - private currentProps: Map<string, string> | null = null; private currentKey = Date.now().toString(); - public currentRoute: ShallowRef<RouteDef | null> = shallowRef(null); + public navHook: ((path: string, flag?: any) => boolean) | null = null; constructor(routes: Router['routes'], currentPath: Router['currentPath']) { super(); this.routes = routes; this.currentPath = currentPath; - this.navigate(currentPath, null, true); + this.navigate(currentPath, null, false); } - public resolve(path: string): { route: RouteDef; props: Map<string, string>; } | null { + public resolve(path: string): Resolved | null { let queryString: string | null = null; + let hash: string | null = null; if (path[0] === '/') path = path.substring(1); + if (path.includes('#')) { + hash = path.substring(path.indexOf('#') + 1); + path = path.substring(0, path.indexOf('#')); + } if (path.includes('?')) { queryString = path.substring(path.indexOf('?') + 1); path = path.substring(0, path.indexOf('?')); @@ -85,68 +102,108 @@ export class Router extends EventEmitter<{ if (_DEV_) console.log('Routing: ', path, queryString); - forEachRouteLoop: - for (const route of this.routes) { - let parts = path.split('/'); - const props = new Map<string, string>(); + function check(routes: RouteDef[], _parts: string[]): Resolved | null { + forEachRouteLoop: + for (const route of routes) { + let parts = [ ..._parts ]; + const props = new Map<string, string>(); - pathMatchLoop: - for (const p of parsePath(route.path)) { - if (typeof p === 'string') { - if (p === parts[0]) { - parts.shift(); - } else { - continue forEachRouteLoop; - } - } else { - if (parts[0] == null && !p.optional) { - continue forEachRouteLoop; - } - if (p.wildcard) { - if (parts.length !== 0) { - props.set(p.name, parts.join('/')); - parts = []; + pathMatchLoop: + for (const p of parsePath(route.path)) { + if (typeof p === 'string') { + if (p === parts[0]) { + parts.shift(); + } else { + continue forEachRouteLoop; } - break pathMatchLoop; } else { - if (p.startsWith) { - if (parts[0] == null || !parts[0].startsWith(p.startsWith)) continue forEachRouteLoop; - - props.set(p.name, parts[0].substring(p.startsWith.length)); - parts.shift(); + if (parts[0] == null && !p.optional) { + continue forEachRouteLoop; + } + if (p.wildcard) { + if (parts.length !== 0) { + props.set(p.name, safeURIDecode(parts.join('/'))); + parts = []; + } + break pathMatchLoop; } else { - props.set(p.name, parts[0]); - parts.shift(); + if (p.startsWith) { + if (parts[0] == null || !parts[0].startsWith(p.startsWith)) continue forEachRouteLoop; + + props.set(p.name, safeURIDecode(parts[0].substring(p.startsWith.length))); + parts.shift(); + } else { + if (parts[0]) { + props.set(p.name, safeURIDecode(parts[0])); + } + parts.shift(); + } } } } - } - if (parts.length !== 0) continue forEachRouteLoop; - - if (route.query != null && queryString != null) { - const queryObject = [...new URLSearchParams(queryString).entries()] - .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {}); + if (parts.length === 0) { + if (route.children) { + const child = check(route.children, []); + if (child) { + return { + route, + props, + child, + }; + } else { + continue forEachRouteLoop; + } + } - for (const q in route.query) { - const as = route.query[q]; - if (queryObject[q]) { - props.set(as, queryObject[q]); + if (route.hash != null && hash != null) { + props.set(route.hash, safeURIDecode(hash)); + } + + if (route.query != null && queryString != null) { + const queryObject = [...new URLSearchParams(queryString).entries()] + .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {}); + + for (const q in route.query) { + const as = route.query[q]; + if (queryObject[q]) { + props.set(as, safeURIDecode(queryObject[q])); + } + } + } + + return { + route, + props, + }; + } else { + if (route.children) { + const child = check(route.children, parts); + if (child) { + return { + route, + props, + child, + }; + } else { + continue forEachRouteLoop; + } + } else { + continue forEachRouteLoop; } } } - return { - route, - props, - }; + + return null; } - return null; + const _parts = path.split('/').filter(part => part.length !== 0); + + return check(this.routes, _parts); } - private navigate(path: string, key: string | null | undefined, initial = false) { + private navigate(path: string, key: string | null | undefined, emitChange = true) { const beforePath = this.currentPath; - const beforeRoute = this.currentRoute.value; this.currentPath = path; const res = this.resolve(this.currentPath); @@ -155,30 +212,27 @@ export class Router extends EventEmitter<{ throw new Error('no route found for: ' + path); } + if (res.route.loginRequired) { + pleaseLogin('/'); + } + const isSamePath = beforePath === path; if (isSamePath && key == null) key = this.currentKey; - this.currentComponent = res.route.component; - this.currentProps = res.props; + this.current = res; + this.currentRef.value = res; this.currentRoute.value = res.route; - this.currentKey = this.currentRoute.value.globalCacheKey ?? key ?? Date.now().toString(); + this.currentKey = res.route.globalCacheKey ?? key ?? path; - if (!initial) { + if (emitChange) { this.emit('change', { beforePath, path, - route: this.currentRoute.value, - props: this.currentProps, + resolved: res, key: this.currentKey, }); } - } - public getCurrentComponent() { - return this.currentComponent; - } - - public getCurrentProps() { - return this.currentProps; + return res; } public getCurrentPath() { @@ -189,19 +243,33 @@ export class Router extends EventEmitter<{ return this.currentKey; } - public push(path: string) { + public push(path: string, flag?: any) { const beforePath = this.currentPath; - this.navigate(path, null); + if (path === beforePath) { + this.emit('same'); + return; + } + if (this.navHook) { + const cancel = this.navHook(path, flag); + if (cancel) return; + } + const res = this.navigate(path, null); this.emit('push', { beforePath, path, - route: this.currentRoute.value, - props: this.currentProps, + route: res.route, + props: res.props, key: this.currentKey, }); } - public change(path: string, key?: string | null) { + public replace(path: string, key?: string | null, emitEvent = true) { this.navigate(path, key); + if (emitEvent) { + this.emit('replace', { + path, + key: this.currentKey, + }); + } } } diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts index e823d3719c..515fc47819 100644 --- a/packages/client/src/os.ts +++ b/packages/client/src/os.ts @@ -5,8 +5,8 @@ import { EventEmitter } from 'eventemitter3'; import insertTextAtCursor from 'insert-text-at-cursor'; import * as Misskey from 'misskey-js'; import { apiUrl, url } from '@/config'; -import MkPostFormDialog from '@/components/post-form-dialog.vue'; -import MkWaitingDialog from '@/components/waiting-dialog.vue'; +import MkPostFormDialog from '@/components/MkPostFormDialog.vue'; +import MkWaitingDialog from '@/components/MkWaitingDialog.vue'; import { MenuItem } from '@/types/menu'; import { $i } from '@/account'; @@ -52,6 +52,39 @@ export const api = ((endpoint: string, data: Record<string, any> = {}, token?: s return promise; }) as typeof apiClient.request; +export const apiGet = ((endpoint: string, data: Record<string, any> = {}) => { + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const query = new URLSearchParams(data); + + const promise = new Promise((resolve, reject) => { + // Send request + fetch(`${apiUrl}/${endpoint}?${query}`, { + method: 'GET', + credentials: 'omit', + cache: 'default', + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +}) as typeof apiClient.request; + export const apiWithDialog = (( endpoint: string, data: Record<string, any> = {}, @@ -154,19 +187,19 @@ export async function popup(component: Component, props: Record<string, any>, ev } export function pageWindow(path: string) { - popup(defineAsyncComponent(() => import('@/components/page-window.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkPageWindow.vue')), { initialPath: path, }, {}, 'closed'); } export function modalPageWindow(path: string) { - popup(defineAsyncComponent(() => import('@/components/modal-page-window.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkModalPageWindow.vue')), { initialPath: path, }, {}, 'closed'); } export function toast(message: string) { - popup(defineAsyncComponent(() => import('@/components/toast.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkToast.vue')), { message, }, {}, 'closed'); } @@ -177,7 +210,7 @@ export function alert(props: { text?: string | null; }): Promise<void> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/dialog.vue')), props, { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), props, { done: result => { resolve(); }, @@ -191,7 +224,7 @@ export function confirm(props: { text?: string | null; }): Promise<{ canceled: boolean }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { ...props, showCancelButton: true, }, { @@ -212,7 +245,7 @@ export function inputText(props: { canceled: false; result: string; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { title: props.title, text: props.text, input: { @@ -237,7 +270,7 @@ export function inputNumber(props: { canceled: false; result: number; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { title: props.title, text: props.text, input: { @@ -262,7 +295,7 @@ export function inputDate(props: { canceled: false; result: Date; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { title: props.title, text: props.text, input: { @@ -299,7 +332,7 @@ export function select<C = any>(props: { canceled: false; result: C; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { title: props.title, text: props.text, select: { @@ -321,7 +354,7 @@ export function success() { window.setTimeout(() => { showing.value = false; }, 1000); - popup(defineAsyncComponent(() => import('@/components/waiting-dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { success: true, showing: showing, }, { @@ -333,7 +366,7 @@ export function success() { export function waiting() { return new Promise((resolve, reject) => { const showing = ref(true); - popup(defineAsyncComponent(() => import('@/components/waiting-dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { success: false, showing: showing, }, { @@ -344,7 +377,7 @@ export function waiting() { export function form(title, form) { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/form-dialog.vue')), { title, form }, { + popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, { done: result => { resolve(result); }, @@ -354,7 +387,7 @@ export function form(title, form) { export async function selectUser() { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/user-select-dialog.vue')), {}, { + popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {}, { ok: user => { resolve(user); }, @@ -364,7 +397,7 @@ export async function selectUser() { export async function selectDriveFile(multiple: boolean) { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/drive-select-dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { type: 'file', multiple, }, { @@ -379,7 +412,7 @@ export async function selectDriveFile(multiple: boolean) { export async function selectDriveFolder(multiple: boolean) { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/drive-select-dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { type: 'folder', multiple, }, { @@ -394,7 +427,7 @@ export async function selectDriveFolder(multiple: boolean) { export async function pickEmoji(src: HTMLElement | null, opts) { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/emoji-picker-dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { src, ...opts, }, { @@ -409,7 +442,7 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: { aspectRatio: number; }): Promise<Misskey.entities.DriveFile> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/cropper-dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { file: image, aspectRatio: options.aspectRatio, }, { @@ -459,7 +492,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: characterData: false, }); - openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/emoji-picker-window.vue')), { + openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerWindow.vue')), { src, ...opts, }, { @@ -481,7 +514,7 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement }) { return new Promise((resolve, reject) => { let dispose; - popup(defineAsyncComponent(() => import('@/components/ui/popup-menu.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkPopupMenu.vue')), { items, src, width: options?.width, @@ -502,7 +535,7 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) ev.preventDefault(); return new Promise((resolve, reject) => { let dispose; - popup(defineAsyncComponent(() => import('@/components/ui/context-menu.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkContextMenu.vue')), { items, ev, }, { diff --git a/packages/client/src/pages/_empty_.vue b/packages/client/src/pages/_empty_.vue new file mode 100644 index 0000000000..000b6decc9 --- /dev/null +++ b/packages/client/src/pages/_empty_.vue @@ -0,0 +1,7 @@ +<template> +<div></div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +</script> diff --git a/packages/client/src/pages/_error_.vue b/packages/client/src/pages/_error_.vue index 6ac1f4297a..a90a023cb6 100644 --- a/packages/client/src/pages/_error_.vue +++ b/packages/client/src/pages/_error_.vue @@ -20,7 +20,7 @@ <script lang="ts" setup> import { } from 'vue'; import * as misskey from 'misskey-js'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import { version } from '@/config'; import * as os from '@/os'; import { unisonReload } from '@/scripts/unison-reload'; diff --git a/packages/client/src/pages/about-misskey.vue b/packages/client/src/pages/about-misskey.vue index ba85860cda..7bcccea98f 100644 --- a/packages/client/src/pages/about-misskey.vue +++ b/packages/client/src/pages/about-misskey.vue @@ -67,8 +67,8 @@ import { nextTick, onBeforeUnmount } from 'vue'; import { version } from '@/config'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; -import MkButton from '@/components/ui/button.vue'; -import MkLink from '@/components/link.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkLink from '@/components/MkLink.vue'; import { physics } from '@/scripts/physics'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; @@ -204,7 +204,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.aboutMisskey, icon: null, - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/emojis.category.vue b/packages/client/src/pages/about.emojis.vue index 6d915c5843..df64378c01 100644 --- a/packages/client/src/pages/emojis.category.vue +++ b/packages/client/src/pages/about.emojis.vue @@ -30,14 +30,14 @@ <script lang="ts"> import { defineComponent, computed } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +import XEmoji from './emojis.emoji.vue'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; import MkSelect from '@/components/form/select.vue'; -import MkFolder from '@/components/ui/folder.vue'; -import MkTab from '@/components/tab.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkTab from '@/components/MkTab.vue'; import * as os from '@/os'; import { emojiCategories, emojiTags } from '@/instance'; -import XEmoji from './emojis.emoji.vue'; export default defineComponent({ components: { @@ -66,7 +66,7 @@ export default defineComponent({ handler() { this.search(); }, - deep: true + deep: true, }, }, @@ -90,8 +90,8 @@ export default defineComponent({ } else { this.selectedTags.add(tag); } - } - } + }, + }, }); </script> diff --git a/packages/client/src/pages/about.federation.vue b/packages/client/src/pages/about.federation.vue new file mode 100644 index 0000000000..c501a77582 --- /dev/null +++ b/packages/client/src/pages/about.federation.vue @@ -0,0 +1,106 @@ +<template> +<div class="taeiyria"> + <div class="query"> + <MkInput v-model="host" :debounce="true" class=""> + <template #prefix><i class="fas fa-search"></i></template> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + <FormSplit style="margin-top: var(--margin);"> + <MkSelect v-model="state"> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="federating">{{ i18n.ts.federating }}</option> + <option value="subscribing">{{ i18n.ts.subscribing }}</option> + <option value="publishing">{{ i18n.ts.publishing }}</option> + <option value="suspended">{{ i18n.ts.suspended }}</option> + <option value="blocked">{{ i18n.ts.blocked }}</option> + <option value="notResponding">{{ i18n.ts.notResponding }}</option> + </MkSelect> + <MkSelect v-model="sort"> + <template #label>{{ i18n.ts.sort }}</template> + <option value="+pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+notes">{{ i18n.ts.notes }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-notes">{{ i18n.ts.notes }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+users">{{ i18n.ts.users }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-users">{{ i18n.ts.users }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+following">{{ i18n.ts.following }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+lastCommunicatedAt">{{ i18n.ts.lastCommunication }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-lastCommunicatedAt">{{ i18n.ts.lastCommunication }} ({{ i18n.ts.ascendingOrder }})</option> + </MkSelect> + </FormSplit> + </div> + + <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> + <div class="dqokceoi"> + <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Last communicated: ${new Date(instance.lastCommunicatedAt).toLocaleString()}\nStatus: ${getStatus(instance)}`" class="instance" :to="`/instance-info/${instance.host}`"> + <MkInstanceCardMini :instance="instance"/> + </MkA> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; +import FormSplit from '@/components/form/split.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +let host = $ref(''); +let state = $ref('federating'); +let sort = $ref('+pubSub'); +const pagination = { + endpoint: 'federation/instances' as const, + limit: 10, + offsetMode: true, + params: computed(() => ({ + sort: sort, + host: host !== '' ? host : null, + ...( + state === 'federating' ? { federating: true } : + state === 'subscribing' ? { subscribing: true } : + state === 'publishing' ? { publishing: true } : + state === 'suspended' ? { suspended: true } : + state === 'blocked' ? { blocked: true } : + state === 'notResponding' ? { notResponding: true } : + {}), + })), +}; + +function getStatus(instance) { + if (instance.isSuspended) return 'Suspended'; + if (instance.isBlocked) return 'Blocked'; + if (instance.isNotResponding) return 'Error'; + return 'Alive'; +} +</script> + +<style lang="scss" scoped> +.taeiyria { + > .query { + background: var(--bg); + margin-bottom: 16px; + } +} + +.dqokceoi { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + grid-gap: 12px; + + > .instance:hover { + text-decoration: none; + } +} +</style> diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue index 20497c86fc..d124db55a7 100644 --- a/packages/client/src/pages/about.vue +++ b/packages/client/src/pages/about.vue @@ -1,20 +1,20 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20"> <div class="_formRoot"> <div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> <div class="content"> - <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + <img :src="$instance.iconUrl ?? $instance.faviconUrl ?? '/favicon.ico'" alt="" class="icon"/> <div class="name"> - <b>{{ $instance.name || host }}</b> + <b>{{ $instance.name ?? host }}</b> </div> </div> </div> <MkKeyValue class="_formBlock"> - <template #key>{{ $ts.description }}</template> - <template #value>{{ $instance.description }}</template> + <template #key>{{ i18n.ts.description }}</template> + <template #value><div v-html="$instance.description"></div></template> </MkKeyValue> <FormSection> @@ -22,33 +22,35 @@ <template #key>Misskey</template> <template #value>{{ version }}</template> </MkKeyValue> - <FormLink to="/about-misskey">{{ $ts.aboutMisskey }}</FormLink> + <div class="_formBlock" v-html="i18n.t('poweredByMisskeyDescription', { name: $instance.name ?? host })"> + </div> + <FormLink to="/about-misskey">{{ i18n.ts.aboutMisskey }}</FormLink> </FormSection> <FormSection> <FormSplit> <MkKeyValue class="_formBlock"> - <template #key>{{ $ts.administrator }}</template> + <template #key>{{ i18n.ts.administrator }}</template> <template #value>{{ $instance.maintainerName }}</template> </MkKeyValue> <MkKeyValue class="_formBlock"> - <template #key>{{ $ts.contact }}</template> + <template #key>{{ i18n.ts.contact }}</template> <template #value>{{ $instance.maintainerEmail }}</template> </MkKeyValue> </FormSplit> - <FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ $ts.tos }}</FormLink> + <FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ i18n.ts.tos }}</FormLink> </FormSection> <FormSuspense :p="initStats"> <FormSection> - <template #label>{{ $ts.statistics }}</template> + <template #label>{{ i18n.ts.statistics }}</template> <FormSplit> <MkKeyValue class="_formBlock"> - <template #key>{{ $ts.users }}</template> + <template #key>{{ i18n.ts.users }}</template> <template #value>{{ number(stats.originalUsersCount) }}</template> </MkKeyValue> <MkKeyValue class="_formBlock"> - <template #key>{{ $ts.notes }}</template> + <template #key>{{ i18n.ts.notes }}</template> <template #value>{{ number(stats.originalNotesCount) }}</template> </MkKeyValue> </FormSplit> @@ -67,7 +69,13 @@ </FormSection> </div> </MkSpacer> - <MkSpacer v-else-if="tab === 'charts'" :content-max="1200" :margin-min="20"> + <MkSpacer v-else-if="tab === 'emojis'" :content-max="1000" :margin-min="20"> + <XEmojis/> + </MkSpacer> + <MkSpacer v-else-if="tab === 'federation'" :content-max="1000" :margin-min="20"> + <XFederation/> + </MkSpacer> + <MkSpacer v-else-if="tab === 'charts'" :content-max="1000" :margin-min="20"> <MkInstanceStats :chart-limit="500" :detailed="true"/> </MkSpacer> </MkStickyContainer> @@ -75,20 +83,28 @@ <script lang="ts" setup> import { ref, computed } from 'vue'; -import { version, instanceName , host } from '@/config'; +import XEmojis from './about.emojis.vue'; +import XFederation from './about.federation.vue'; +import { version, instanceName, host } from '@/config'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; -import MkKeyValue from '@/components/key-value.vue'; -import MkInstanceStats from '@/components/instance-stats.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkInstanceStats from '@/components/MkInstanceStats.vue'; import * as os from '@/os'; import number from '@/filters/number'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +const props = withDefaults(defineProps<{ + initialTab?: string; +}>(), { + initialTab: 'overview', +}); + let stats = $ref(null); -let tab = $ref('overview'); +let tab = $ref(props.initialTab); const initStats = () => os.api('stats', { }).then((res) => { @@ -98,20 +114,25 @@ const initStats = () => os.api('stats', { const headerActions = $computed(() => []); const headerTabs = $computed(() => [{ - active: tab === 'overview', + key: 'overview', title: i18n.ts.overview, - onClick: () => { tab = 'overview'; }, }, { - active: tab === 'charts', + key: 'emojis', + title: i18n.ts.customEmojis, + icon: 'fas fa-laugh', +}, { + key: 'federation', + title: i18n.ts.federation, + icon: 'fas fa-globe', +}, { + key: 'charts', title: i18n.ts.charts, - icon: 'fas fa-chart-bar', - onClick: () => { tab = 'charts'; }, + icon: 'fas fa-chart-simple', }]); definePageMetadata(computed(() => ({ title: i18n.ts.instanceInfo, icon: 'fas fa-info-circle', - bg: 'var(--bg)', }))); </script> diff --git a/packages/client/src/pages/admin-file.vue b/packages/client/src/pages/admin-file.vue index 2cbe8890a1..a62e0f630f 100644 --- a/packages/client/src/pages/admin-file.vue +++ b/packages/client/src/pages/admin-file.vue @@ -1,46 +1,84 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="500" :margin-min="16" :margin-max="32"> - <div v-if="file" class="cxqhhsmd _formRoot"> - <div class="_formBlock"> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="file" :content-max="600" :margin-min="16" :margin-max="32"> + <div v-if="tab === 'overview'" class="cxqhhsmd _formRoot"> + <a class="_formBlock thumbnail" :href="file.url" target="_blank"> <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> - <div class="info"> - <span style="margin-right: 1em;">{{ file.type }}</span> - <span>{{ bytes(file.size) }}</span> - <MkTime :time="file.createdAt" mode="detail" style="display: block;"/> - </div> + </a> + <div class="_formBlock"> + <MkKeyValue :copy="file.type" oneline style="margin: 1em 0;"> + <template #key>MIME Type</template> + <template #value><span class="_monospace">{{ file.type }}</span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>Size</template> + <template #value><span class="_monospace">{{ bytes(file.size) }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="file.id" oneline style="margin: 1em 0;"> + <template #key>ID</template> + <template #value><span class="_monospace">{{ file.id }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="file.md5" oneline style="margin: 1em 0;"> + <template #key>MD5</template> + <template #value><span class="_monospace">{{ file.md5 }}</span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.createdAt }}</template> + <template #value><span class="_monospace"><MkTime :time="file.createdAt" mode="detail" style="display: block;"/></span></template> + </MkKeyValue> </div> + <MkA v-if="file.user" class="user" :to="`/user-info/${file.user.id}`"> + <MkUserCardMini :user="file.user"/> + </MkA> <div class="_formBlock"> <MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch> </div> - <FormLink class="_formBlock" :to="file.url" :external="true">Open</FormLink> - <FormLink class="_formBlock" :to="`/user-info/${file.userId}`">{{ $ts.user }}</FormLink> <div class="_formBlock"> - <MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton> - </div> - <div v-if="info" class="_formBlock"> - <details class="_content rawdata"> - <pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre> - </details> + <MkButton danger @click="del"><i class="fas fa-trash-alt"></i> {{ i18n.ts.delete }}</MkButton> </div> </div> + <div v-else-if="tab === 'ip' && info" class="_formRoot"> + <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo> + <MkKeyValue v-if="info.requestIp" class="_formBlock _monospace" :copy="info.requestIp" oneline> + <template #key>IP</template> + <template #value>{{ info.requestIp }}</template> + </MkKeyValue> + <FormSection v-if="info.requestHeaders"> + <template #label>Headers</template> + <MkKeyValue v-for="(v, k) in info.requestHeaders" :key="k" class="_formBlock _monospace"> + <template #key>{{ k }}</template> + <template #value>{{ v }}</template> + </MkKeyValue> + </FormSection> + </div> + <div v-else-if="tab === 'raw'" class="_formRoot"> + <MkObjectView v-if="info" tall :value="info"> + </MkObjectView> + </div> </MkSpacer> </MkStickyContainer> </template> <script lang="ts" setup> import { computed } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import MkSwitch from '@/components/form/switch.vue'; -import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; -import FormLink from '@/components/form/link.vue'; +import MkObjectView from '@/components/MkObjectView.vue'; +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormSection from '@/components/form/section.vue'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import MkInfo from '@/components/MkInfo.vue'; import bytes from '@/filters/bytes'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { acct } from '@/filters/user'; +import { iAmAdmin, iAmModerator } from '@/account'; +let tab = $ref('overview'); let file: any = $ref(null); let info: any = $ref(null); let isSensitive: boolean = $ref(false); @@ -74,32 +112,48 @@ async function toggleIsSensitive(v) { isSensitive = v; } -const headerActions = $computed(() => []); +const headerActions = $computed(() => [{ + text: i18n.ts.openInNewTab, + icon: 'fas fa-external-link-alt', + handler: () => { + window.open(file.url, '_blank'); + }, +}]); -const headerTabs = $computed(() => []); +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, + icon: 'fas fa-info-circle', +}, iAmModerator ? { + key: 'ip', + title: 'IP', + icon: 'fas fa-bars-staggered', +} : null, { + key: 'raw', + title: 'Raw data', + icon: 'fas fa-code', +}]); definePageMetadata(computed(() => ({ title: file ? i18n.ts.file + ': ' + file.name : i18n.ts.file, icon: 'fas fa-file', - bg: 'var(--bg)', }))); </script> <style lang="scss" scoped> .cxqhhsmd { - > ._section { + > .thumbnail { + display: block; + > .thumbnail { - height: 150px; + height: 300px; max-width: 100%; } + } - > .info { - text-align: center; - margin-top: 8px; - } - - > .rawdata { - overflow: auto; + > .user { + &:hover { + text-decoration: none; } } } diff --git a/packages/client/src/pages/admin/_header_.vue b/packages/client/src/pages/admin/_header_.vue index 9e11d065d9..bdb41b2d2c 100644 --- a/packages/client/src/pages/admin/_header_.vue +++ b/packages/client/src/pages/admin/_header_.vue @@ -9,17 +9,18 @@ </div> </div> <div class="tabs"> - <button v-for="tab in tabs" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.active }" @click="tab.onClick"> + <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)"> <i v-if="tab.icon" class="icon" :class="tab.icon"></i> <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> </button> + <div ref="tabHighlightEl" class="highlight"></div> </div> </template> <div class="buttons right"> <template v-if="actions"> <template v-for="action in actions"> <MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> - <button v-else v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> + <button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> </template> </template> </div> @@ -27,24 +28,27 @@ </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, ref, inject } from 'vue'; +import { computed, onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue'; import tinycolor from 'tinycolor2'; import { popupMenu } from '@/os'; import { url } from '@/config'; import { scrollToTop } from '@/scripts/scroll'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n'; import { globalEvents } from '@/events'; -import { injectPageMetadata, PageMetadata } from '@/scripts/page-metadata'; +import { injectPageMetadata } from '@/scripts/page-metadata'; + +type Tab = { + key?: string | null; + title: string; + icon?: string; + iconOnly?: boolean; + onClick?: (ev: MouseEvent) => void; +}; const props = defineProps<{ - tabs?: { - title: string; - active: boolean; - icon?: string; - iconOnly?: boolean; - onClick: () => void; - }[]; + tabs?: Tab[]; + tab?: string; actions?: { text: string; icon: string; @@ -54,9 +58,15 @@ const props = defineProps<{ thin?: boolean; }>(); +const emit = defineEmits<{ + (ev: 'update:tab', key: string); +}>(); + const metadata = injectPageMetadata(); const el = ref<HTMLElement>(null); +const tabRefs = {}; +const tabHighlightEl = $ref<HTMLElement | null>(null); const bg = ref(null); const height = ref(0); const hasTabs = computed(() => { @@ -65,13 +75,15 @@ const hasTabs = computed(() => { const showTabsPopup = (ev: MouseEvent) => { if (!hasTabs.value) return; - if (!narrow.value) return; ev.preventDefault(); ev.stopPropagation(); const menu = props.tabs.map(tab => ({ text: tab.title, icon: tab.icon, - action: tab.onClick, + active: tab.key != null && tab.key === props.tab, + action: (ev) => { + onTabClick(tab, ev); + }, })); popupMenu(menu, ev.currentTarget ?? ev.target); }; @@ -84,6 +96,24 @@ const onClick = () => { scrollToTop(el.value, { behavior: 'smooth' }); }; +function onTabMousedown(tab: Tab, ev: MouseEvent): void { + // ユーザビリティの観点からmousedown時にはonClickは呼ばない + if (tab.key) { + emit('update:tab', tab.key); + } +} + +function onTabClick(tab: Tab, ev: MouseEvent): void { + if (tab.onClick) { + ev.preventDefault(); + ev.stopPropagation(); + tab.onClick(ev); + } + if (tab.key) { + emit('update:tab', tab.key); + } +} + const calcBg = () => { const rawBg = metadata?.bg || 'var(--bg)'; const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); @@ -94,6 +124,22 @@ const calcBg = () => { onMounted(() => { calcBg(); globalEvents.on('themeChanged', calcBg); + + watch(() => [props.tab, props.tabs], () => { + nextTick(() => { + const tabEl = tabRefs[props.tab]; + if (tabEl && tabHighlightEl) { + // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabEl.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.style.width = rect.width + 'px'; + tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px'; + } + }); + }, { + immediate: true, + }); }); onUnmounted(() => { @@ -105,9 +151,6 @@ onUnmounted(() => { .fdidabkc { --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)); @@ -176,6 +219,8 @@ onUnmounted(() => { > .icon { margin-right: 8px; + width: 16px; + text-align: center; } > .title { @@ -206,6 +251,7 @@ onUnmounted(() => { } > .tabs { + position: relative; margin-left: 16px; font-size: 0.8em; overflow: auto; @@ -225,25 +271,22 @@ onUnmounted(() => { &.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; } } + + > .highlight { + position: absolute; + bottom: 0; + height: 3px; + background: var(--accent); + border-radius: 999px; + transition: all 0.2s ease; + pointer-events: none; + } } } </style> diff --git a/packages/client/src/pages/admin/abuses.vue b/packages/client/src/pages/admin/abuses.vue index 2b6dadf7c6..9907d4d235 100644 --- a/packages/client/src/pages/admin/abuses.vue +++ b/packages/client/src/pages/admin/abuses.vue @@ -7,31 +7,31 @@ <div class="_content"> <div class="inputs" style="display: flex;"> <MkSelect v-model="state" style="margin: 0; flex: 1;"> - <template #label>{{ $ts.state }}</template> - <option value="all">{{ $ts.all }}</option> - <option value="unresolved">{{ $ts.unresolved }}</option> - <option value="resolved">{{ $ts.resolved }}</option> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="unresolved">{{ i18n.ts.unresolved }}</option> + <option value="resolved">{{ i18n.ts.resolved }}</option> </MkSelect> <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> - <template #label>{{ $ts.reporteeOrigin }}</template> - <option value="combined">{{ $ts.all }}</option> - <option value="local">{{ $ts.local }}</option> - <option value="remote">{{ $ts.remote }}</option> + <template #label>{{ i18n.ts.reporteeOrigin }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> </MkSelect> <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> - <template #label>{{ $ts.reporterOrigin }}</template> - <option value="combined">{{ $ts.all }}</option> - <option value="local">{{ $ts.local }}</option> - <option value="remote">{{ $ts.remote }}</option> + <template #label>{{ i18n.ts.reporterOrigin }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> </MkSelect> </div> <!-- TODO <div class="inputs" style="display: flex; padding-top: 1.2em;"> - <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false"> - <span>{{ $ts.username }}</span> + <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" :spellcheck="false"> + <span>{{ i18n.ts.username }}</span> </MkInput> - <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" :disabled="pagination.params().origin === 'local'"> - <span>{{ $ts.host }}</span> + <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params().origin === 'local'"> + <span>{{ i18n.ts.host }}</span> </MkInput> </div> --> @@ -52,8 +52,8 @@ import { computed } from 'vue'; import XHeader from './_header_.vue'; import MkInput from '@/components/form/input.vue'; import MkSelect from '@/components/form/select.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import XAbuseReport from '@/components/abuse-report.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import XAbuseReport from '@/components/MkAbuseReport.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -87,7 +87,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.abuseReports, icon: 'fas fa-exclamation-circle', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/ads.vue b/packages/client/src/pages/admin/ads.vue index 05557469e7..9a28d2ad61 100644 --- a/packages/client/src/pages/admin/ads.vue +++ b/packages/client/src/pages/admin/ads.vue @@ -49,7 +49,7 @@ <script lang="ts" setup> import { } from 'vue'; import XHeader from './_header_.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; import MkTextarea from '@/components/form/textarea.vue'; import FormRadios from '@/components/form/radios.vue'; @@ -116,7 +116,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.ads, icon: 'fas fa-audio-description', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/announcements.vue b/packages/client/src/pages/admin/announcements.vue index 025897d093..f10693314a 100644 --- a/packages/client/src/pages/admin/announcements.vue +++ b/packages/client/src/pages/admin/announcements.vue @@ -29,7 +29,7 @@ <script lang="ts" setup> import { } from 'vue'; import XHeader from './_header_.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; import MkTextarea from '@/components/form/textarea.vue'; import * as os from '@/os'; @@ -102,7 +102,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.announcements, icon: 'fas fa-broadcast-tower', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/bot-protection.vue b/packages/client/src/pages/admin/bot-protection.vue index d2e7919b4f..484a9d1a1a 100644 --- a/packages/client/src/pages/admin/bot-protection.vue +++ b/packages/client/src/pages/admin/bot-protection.vue @@ -3,41 +3,56 @@ <FormSuspense :p="init"> <div class="_formRoot"> <FormRadios v-model="provider" class="_formBlock"> - <option :value="null">{{ $ts.none }} ({{ $ts.notRecommended }})</option> + <option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option> <option value="hcaptcha">hCaptcha</option> <option value="recaptcha">reCAPTCHA</option> + <option value="turnstile">Turnstile</option> </FormRadios> <template v-if="provider === 'hcaptcha'"> <FormInput v-model="hcaptchaSiteKey" class="_formBlock"> <template #prefix><i class="fas fa-key"></i></template> - <template #label>{{ $ts.hcaptchaSiteKey }}</template> + <template #label>{{ i18n.ts.hcaptchaSiteKey }}</template> </FormInput> <FormInput v-model="hcaptchaSecretKey" class="_formBlock"> <template #prefix><i class="fas fa-key"></i></template> - <template #label>{{ $ts.hcaptchaSecretKey }}</template> + <template #label>{{ i18n.ts.hcaptchaSecretKey }}</template> </FormInput> <FormSlot class="_formBlock"> - <template #label>{{ $ts.preview }}</template> + <template #label>{{ i18n.ts.preview }}</template> <MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> </FormSlot> </template> <template v-else-if="provider === 'recaptcha'"> <FormInput v-model="recaptchaSiteKey" class="_formBlock"> <template #prefix><i class="fas fa-key"></i></template> - <template #label>{{ $ts.recaptchaSiteKey }}</template> + <template #label>{{ i18n.ts.recaptchaSiteKey }}</template> </FormInput> <FormInput v-model="recaptchaSecretKey" class="_formBlock"> <template #prefix><i class="fas fa-key"></i></template> - <template #label>{{ $ts.recaptchaSecretKey }}</template> + <template #label>{{ i18n.ts.recaptchaSecretKey }}</template> </FormInput> <FormSlot v-if="recaptchaSiteKey" class="_formBlock"> - <template #label>{{ $ts.preview }}</template> + <template #label>{{ i18n.ts.preview }}</template> <MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/> </FormSlot> </template> + <template v-else-if="provider === 'turnstile'"> + <FormInput v-model="turnstileSiteKey" class="_formBlock"> + <template #prefix><i class="fas fa-key"></i></template> + <template #label>{{ i18n.ts.turnstileSiteKey }}</template> + </FormInput> + <FormInput v-model="turnstileSecretKey" class="_formBlock"> + <template #prefix><i class="fas fa-key"></i></template> + <template #label>{{ i18n.ts.turnstileSecretKey }}</template> + </FormInput> + <FormSlot class="_formBlock"> + <template #label>{{ i18n.ts.preview }}</template> + <MkCaptcha provider="turnstile" :sitekey="turnstileSiteKey || '1x00000000000000000000AA'"/> + </FormSlot> + </template> - <FormButton primary @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + <FormButton primary @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton> </div> </FormSuspense> </div> @@ -47,43 +62,46 @@ import { defineAsyncComponent } from 'vue'; import FormRadios from '@/components/form/radios.vue'; import FormInput from '@/components/form/input.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import FormSuspense from '@/components/form/suspense.vue'; import FormSlot from '@/components/form/slot.vue'; import * as os from '@/os'; import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; -const MkCaptcha = defineAsyncComponent(() => import('@/components/captcha.vue')); +const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue')); let provider = $ref(null); let hcaptchaSiteKey: string | null = $ref(null); let hcaptchaSecretKey: string | null = $ref(null); let recaptchaSiteKey: string | null = $ref(null); let recaptchaSecretKey: string | null = $ref(null); - -const enableHcaptcha = $computed(() => provider === 'hcaptcha'); -const enableRecaptcha = $computed(() => provider === 'recaptcha'); +let turnstileSiteKey: string | null = $ref(null); +let turnstileSecretKey: string | null = $ref(null); async function init() { const meta = await os.api('admin/meta'); - enableHcaptcha = meta.enableHcaptcha; hcaptchaSiteKey = meta.hcaptchaSiteKey; hcaptchaSecretKey = meta.hcaptchaSecretKey; - enableRecaptcha = meta.enableRecaptcha; recaptchaSiteKey = meta.recaptchaSiteKey; recaptchaSecretKey = meta.recaptchaSecretKey; + turnstileSiteKey = meta.turnstileSiteKey; + turnstileSecretKey = meta.turnstileSecretKey; - provider = enableHcaptcha ? 'hcaptcha' : enableRecaptcha ? 'recaptcha' : null; + provider = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : meta.enableTurnstile ? 'turnstile' : null; } function save() { os.apiWithDialog('admin/update-meta', { - enableHcaptcha, + enableHcaptcha: provider === 'hcaptcha', hcaptchaSiteKey, hcaptchaSecretKey, - enableRecaptcha, + enableRecaptcha: provider === 'recaptcha', recaptchaSiteKey, recaptchaSecretKey, + enableTurnstile: provider === 'turnstile', + turnstileSiteKey, + turnstileSecretKey, }).then(() => { fetchInstance(); }); diff --git a/packages/client/src/pages/admin/database.vue b/packages/client/src/pages/admin/database.vue index b9c5f9e393..1c2656b8f5 100644 --- a/packages/client/src/pages/admin/database.vue +++ b/packages/client/src/pages/admin/database.vue @@ -13,7 +13,7 @@ <script lang="ts" setup> import { } from 'vue'; import FormSuspense from '@/components/form/suspense.vue'; -import MkKeyValue from '@/components/key-value.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; import * as os from '@/os'; import bytes from '@/filters/bytes'; import number from '@/filters/number'; @@ -29,6 +29,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.database, icon: 'fas fa-database', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/email-settings.vue b/packages/client/src/pages/admin/email-settings.vue index c0ff94fad2..64137f0c3e 100644 --- a/packages/client/src/pages/admin/email-settings.vue +++ b/packages/client/src/pages/admin/email-settings.vue @@ -50,7 +50,7 @@ import { } from 'vue'; import XHeader from './_header_.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormInput from '@/components/form/input.vue'; -import FormInfo from '@/components/ui/info.vue'; +import FormInfo from '@/components/MkInfo.vue'; import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; import FormSection from '@/components/form/section.vue'; @@ -122,6 +122,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.emailServer, icon: 'fas fa-envelope', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/emoji-edit-dialog.vue b/packages/client/src/pages/admin/emoji-edit-dialog.vue index d482fa49e6..090dd9afc1 100644 --- a/packages/client/src/pages/admin/emoji-edit-dialog.vue +++ b/packages/client/src/pages/admin/emoji-edit-dialog.vue @@ -1,5 +1,6 @@ <template> -<XModalWindow ref="dialog" +<XModalWindow + ref="dialog" :width="370" :with-ok-button="true" @close="$refs.dialog.close()" @@ -12,16 +13,16 @@ <div class="yigymqpb _section"> <img :src="emoji.url" class="img"/> <MkInput v-model="name" class="_formBlock"> - <template #label>{{ $ts.name }}</template> + <template #label>{{ i18n.ts.name }}</template> </MkInput> <MkInput v-model="category" class="_formBlock" :datalist="categories"> - <template #label>{{ $ts.category }}</template> + <template #label>{{ i18n.ts.category }}</template> </MkInput> <MkInput v-model="aliases" class="_formBlock"> - <template #label>{{ $ts.tags }}</template> - <template #caption>{{ $ts.setMultipleBySeparatingWithSpace }}</template> + <template #label>{{ i18n.ts.tags }}</template> + <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template> </MkInput> - <MkButton danger @click="del()"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton> + <MkButton danger @click="del()"><i class="fas fa-trash-alt"></i> {{ i18n.ts.delete }}</MkButton> </div> </div> </XModalWindow> @@ -29,8 +30,8 @@ <script lang="ts" setup> import { } from 'vue'; -import XModalWindow from '@/components/ui/modal-window.vue'; -import MkButton from '@/components/ui/button.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; import * as os from '@/os'; import { unique } from '@/scripts/array'; @@ -70,7 +71,7 @@ async function update() { name, category, aliases: aliases.split(' '), - } + }, }); dialog.close(); @@ -84,10 +85,10 @@ async function del() { if (canceled) return; os.api('admin/emoji/delete', { - id: props.emoji.id + id: props.emoji.id, }).then(() => { emit('done', { - deleted: true + deleted: true, }); dialog.close(); }); diff --git a/packages/client/src/pages/admin/emojis.vue b/packages/client/src/pages/admin/emojis.vue index 9d6b56dbc5..94f152d7db 100644 --- a/packages/client/src/pages/admin/emojis.vue +++ b/packages/client/src/pages/admin/emojis.vue @@ -1,13 +1,13 @@ <template> <div> <MkStickyContainer> - <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="900"> <div class="ogwlenmc"> <div v-if="tab === 'local'" class="local"> <MkInput v-model="query" :debounce="true" type="search"> <template #prefix><i class="fas fa-search"></i></template> - <template #label>{{ $ts.search }}</template> + <template #label>{{ i18n.ts.search }}</template> </MkInput> <MkSwitch v-model="selectMode" style="margin: 8px 0;"> <template #label>Select mode</template> @@ -21,7 +21,7 @@ <MkButton inline danger @click="delBulk">Delete</MkButton> </div> <MkPagination ref="emojisPaginationComponent" :pagination="pagination"> - <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> <template #default="{items}"> <div class="ldhfsamy"> <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> @@ -40,14 +40,14 @@ <FormSplit> <MkInput v-model="queryRemote" :debounce="true" type="search"> <template #prefix><i class="fas fa-search"></i></template> - <template #label>{{ $ts.search }}</template> + <template #label>{{ i18n.ts.search }}</template> </MkInput> <MkInput v-model="host" :debounce="true"> - <template #label>{{ $ts.host }}</template> + <template #label>{{ i18n.ts.host }}</template> </MkInput> </FormSplit> <MkPagination :pagination="remotePagination"> - <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> <template #default="{items}"> <div class="ldhfsamy"> <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> @@ -70,10 +70,10 @@ <script lang="ts" setup> import { computed, defineAsyncComponent, defineComponent, ref, toRef } from 'vue'; import XHeader from './_header_.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkTab from '@/components/tab.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkTab from '@/components/MkTab.vue'; import MkSwitch from '@/components/form/switch.vue'; import FormSplit from '@/components/form/split.vue'; import { selectFile, selectFiles } from '@/scripts/select-file'; @@ -282,19 +282,16 @@ const headerActions = $computed(() => [{ }]); const headerTabs = $computed(() => [{ - active: tab.value === 'local', + key: 'local', title: i18n.ts.local, - onClick: () => { tab.value = 'local'; }, }, { - active: tab.value === 'remote', + key: 'remote', title: i18n.ts.remote, - onClick: () => { tab.value = 'remote'; }, }]); definePageMetadata(computed(() => ({ title: i18n.ts.customEmojis, icon: 'fas fa-laugh', - bg: 'var(--bg)', }))); </script> diff --git a/packages/client/src/pages/admin/files.vue b/packages/client/src/pages/admin/files.vue index 18bf4f9a8c..2e3a807ba6 100644 --- a/packages/client/src/pages/admin/files.vue +++ b/packages/client/src/pages/admin/files.vue @@ -7,41 +7,24 @@ <div> <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> <MkSelect v-model="origin" style="margin: 0; flex: 1;"> - <template #label>{{ $ts.instance }}</template> - <option value="combined">{{ $ts.all }}</option> - <option value="local">{{ $ts.local }}</option> - <option value="remote">{{ $ts.remote }}</option> + <template #label>{{ i18n.ts.instance }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> </MkSelect> <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'"> - <template #label>{{ $ts.host }}</template> + <template #label>{{ i18n.ts.host }}</template> </MkInput> </div> - <div class="inputs" style="display: flex; padding-top: 1.2em;"> + <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap; padding-top: 1.2em;"> + <MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>User ID</template> + </MkInput> <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> <template #label>MIME type</template> </MkInput> </div> - <MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }"> - <button v-for="file in items" :key="file.id" v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${new Date(file.createdAt).toLocaleString()}\nby ${file.user ? '@' + Acct.toString(file.user) : 'system'}`" class="file _button" @click="show(file, $event)"> - <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> - <div v-if="viewMode === 'list'" class="body"> - <div> - <small style="opacity: 0.7;">{{ file.name }}</small> - </div> - <div> - <MkAcct v-if="file.user" :user="file.user"/> - <div v-else>{{ $ts.system }}</div> - </div> - <div> - <span style="margin-right: 1em;">{{ file.type }}</span> - <span>{{ bytes(file.size) }}</span> - </div> - <div> - <span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span> - </div> - </div> - </button> - </MkPagination> + <MkFileListForAdmin :pagination="pagination" :view-mode="viewMode"/> </div> </div> </MkSpacer> @@ -53,12 +36,10 @@ import { computed, defineAsyncComponent } from 'vue'; import * as Acct from 'misskey-js/built/acct'; import XHeader from './_header_.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; import MkSelect from '@/components/form/select.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkContainer from '@/components/ui/container.vue'; -import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; import bytes from '@/filters/bytes'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -67,12 +48,14 @@ import { definePageMetadata } from '@/scripts/page-metadata'; let origin = $ref('local'); let type = $ref(null); let searchHost = $ref(''); +let userId = $ref(''); let viewMode = $ref('grid'); const pagination = { endpoint: 'admin/drive/files' as const, limit: 10, params: computed(() => ({ type: (type && type !== '') ? type : null, + userId: (userId && userId !== '') ? userId : null, origin: origin, hostname: (searchHost && searchHost !== '') ? searchHost : null, })), @@ -127,61 +110,11 @@ const headerTabs = $computed(() => []); definePageMetadata(computed(() => ({ title: i18n.ts.files, icon: 'fas fa-cloud', - bg: 'var(--bg)', }))); </script> <style lang="scss" scoped> .xrmjdkdw { margin: var(--margin); - - .urempief { - margin-top: var(--margin); - - &.list { - > .file { - display: flex; - width: 100%; - box-sizing: border-box; - text-align: left; - align-items: center; - - &:hover { - color: var(--accent); - } - - > .thumbnail { - width: 128px; - height: 128px; - } - - > .body { - margin-left: 0.3em; - padding: 8px; - flex: 1; - - @media (max-width: 500px) { - font-size: 14px; - } - } - } - } - - &.grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - grid-gap: 12px; - margin: var(--margin) 0; - - > .file { - aspect-ratio: 1; - - > .thumbnail { - width: 100%; - height: 100%; - } - } - } - } } </style> diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue index b91330e1b7..20f82bba28 100644 --- a/packages/client/src/pages/admin/index.vue +++ b/packages/client/src/pages/admin/index.vue @@ -1,23 +1,23 @@ <template> <div ref="el" class="hiyeyicy" :class="{ wide: !narrow }"> - <div v-if="!narrow || initialPage == null" class="nav"> + <div v-if="!narrow || currentPage?.route.name == null" class="nav"> <MkSpacer :content-max="700" :margin-min="16"> <div class="lxpfedzu"> <div class="banner"> <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> </div> - <MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ $ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ $ts.check }}</MkA></MkInfo> - <MkInfo v-if="noMaintainerInformation" warn class="info">{{ $ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ $ts.configure }}</MkA></MkInfo> - <MkInfo v-if="noBotProtection" warn class="info">{{ $ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ $ts.configure }}</MkA></MkInfo> - <MkInfo v-if="noEmailServer" warn class="info">{{ $ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ $ts.configure }}</MkA></MkInfo> + <MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo> + <MkInfo v-if="noMaintainerInformation" warn class="info">{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> - <MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu> + <MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu> </div> </MkSpacer> </div> - <div v-if="!(narrow && initialPage == null)" class="main"> - <component :is="component" :key="initialPage" v-bind="pageProps"/> + <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> + <RouterView/> </div> </div> </template> @@ -25,8 +25,8 @@ <script lang="ts" setup> import { defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, watch } from 'vue'; import { i18n } from '@/i18n'; -import MkSuperMenu from '@/components/ui/super-menu.vue'; -import MkInfo from '@/components/ui/info.vue'; +import MkSuperMenu from '@/components/MkSuperMenu.vue'; +import MkInfo from '@/components/MkInfo.vue'; import { scroll } from '@/scripts/scroll'; import { instance } from '@/instance'; import * as os from '@/os'; @@ -41,27 +41,22 @@ const router = useRouter(); const indexInfo = { title: i18n.ts.controlPanel, icon: 'fas fa-cog', - bg: 'var(--bg)', hideHeader: true, }; -const props = defineProps<{ - initialPage?: string, -}>(); - provide('shouldOmitHeaderTitle', false); let INFO = $ref(indexInfo); let childInfo = $ref(null); -let page = $ref(props.initialPage); let narrow = $ref(false); let view = $ref(null); let el = $ref(null); let pageProps = $ref({}); let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail); -let noBotProtection = !instance.enableHcaptcha && !instance.enableRecaptcha; +let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile; let noEmailServer = !instance.enableEmail; let thereIsUnresolvedAbuseReport = $ref(false); +let currentPage = $computed(() => router.currentRef.value.child); os.api('admin/abuse-user-reports', { state: 'unresolved', @@ -95,47 +90,47 @@ const menuDef = $computed(() => [{ icon: 'fas fa-tachometer-alt', text: i18n.ts.dashboard, to: '/admin/overview', - active: props.initialPage === 'overview', + active: currentPage?.route.name === 'overview', }, { icon: 'fas fa-users', text: i18n.ts.users, to: '/admin/users', - active: props.initialPage === 'users', + active: currentPage?.route.name === 'users', }, { icon: 'fas fa-laugh', text: i18n.ts.customEmojis, to: '/admin/emojis', - active: props.initialPage === 'emojis', + active: currentPage?.route.name === 'emojis', }, { icon: 'fas fa-globe', text: i18n.ts.federation, - to: '/admin/federation', - active: props.initialPage === 'federation', + to: '/about#federation', + active: currentPage?.route.name === 'federation', }, { icon: 'fas fa-clipboard-list', text: i18n.ts.jobQueue, to: '/admin/queue', - active: props.initialPage === 'queue', + active: currentPage?.route.name === 'queue', }, { icon: 'fas fa-cloud', text: i18n.ts.files, to: '/admin/files', - active: props.initialPage === 'files', + active: currentPage?.route.name === 'files', }, { icon: 'fas fa-broadcast-tower', text: i18n.ts.announcements, to: '/admin/announcements', - active: props.initialPage === 'announcements', + active: currentPage?.route.name === 'announcements', }, { icon: 'fas fa-audio-description', text: i18n.ts.ads, to: '/admin/ads', - active: props.initialPage === 'ads', + active: currentPage?.route.name === 'ads', }, { icon: 'fas fa-exclamation-circle', text: i18n.ts.abuseReports, to: '/admin/abuses', - active: props.initialPage === 'abuses', + active: currentPage?.route.name === 'abuses', }], }, { title: i18n.ts.settings, @@ -143,47 +138,47 @@ const menuDef = $computed(() => [{ icon: 'fas fa-cog', text: i18n.ts.general, to: '/admin/settings', - active: props.initialPage === 'settings', + active: currentPage?.route.name === 'settings', }, { icon: 'fas fa-envelope', text: i18n.ts.emailServer, to: '/admin/email-settings', - active: props.initialPage === 'email-settings', + active: currentPage?.route.name === 'email-settings', }, { icon: 'fas fa-cloud', text: i18n.ts.objectStorage, to: '/admin/object-storage', - active: props.initialPage === 'object-storage', + active: currentPage?.route.name === 'object-storage', }, { icon: 'fas fa-lock', text: i18n.ts.security, to: '/admin/security', - active: props.initialPage === 'security', + active: currentPage?.route.name === 'security', }, { icon: 'fas fa-globe', text: i18n.ts.relays, to: '/admin/relays', - active: props.initialPage === 'relays', + active: currentPage?.route.name === 'relays', }, { icon: 'fas fa-share-alt', text: i18n.ts.integration, to: '/admin/integrations', - active: props.initialPage === 'integrations', + active: currentPage?.route.name === 'integrations', }, { icon: 'fas fa-ban', text: i18n.ts.instanceBlocking, to: '/admin/instance-block', - active: props.initialPage === 'instance-block', + active: currentPage?.route.name === 'instance-block', }, { icon: 'fas fa-ghost', text: i18n.ts.proxyAccount, to: '/admin/proxy-account', - active: props.initialPage === 'proxy-account', + active: currentPage?.route.name === 'proxy-account', }, { icon: 'fas fa-cogs', text: i18n.ts.other, to: '/admin/other-settings', - active: props.initialPage === 'other-settings', + active: currentPage?.route.name === 'other-settings', }], }, { title: i18n.ts.info, @@ -191,55 +186,12 @@ const menuDef = $computed(() => [{ icon: 'fas fa-database', text: i18n.ts.database, to: '/admin/database', - active: props.initialPage === 'database', + active: currentPage?.route.name === 'database', }], }]); -const component = $computed(() => { - if (props.initialPage == null) return null; - switch (props.initialPage) { - case 'overview': return defineAsyncComponent(() => import('./overview.vue')); - case 'users': return defineAsyncComponent(() => import('./users.vue')); - case 'emojis': return defineAsyncComponent(() => import('./emojis.vue')); - case 'federation': return defineAsyncComponent(() => import('../federation.vue')); - case 'queue': return defineAsyncComponent(() => import('./queue.vue')); - case 'files': return defineAsyncComponent(() => import('./files.vue')); - case 'announcements': return defineAsyncComponent(() => import('./announcements.vue')); - case 'ads': return defineAsyncComponent(() => import('./ads.vue')); - case 'database': return defineAsyncComponent(() => import('./database.vue')); - case 'abuses': return defineAsyncComponent(() => import('./abuses.vue')); - case 'settings': return defineAsyncComponent(() => import('./settings.vue')); - case 'email-settings': return defineAsyncComponent(() => import('./email-settings.vue')); - case 'object-storage': return defineAsyncComponent(() => import('./object-storage.vue')); - case 'security': return defineAsyncComponent(() => import('./security.vue')); - case 'relays': return defineAsyncComponent(() => import('./relays.vue')); - case 'integrations': return defineAsyncComponent(() => import('./integrations.vue')); - case 'instance-block': return defineAsyncComponent(() => import('./instance-block.vue')); - case 'proxy-account': return defineAsyncComponent(() => import('./proxy-account.vue')); - case 'other-settings': return defineAsyncComponent(() => import('./other-settings.vue')); - } -}); - -watch(component, () => { - pageProps = {}; - - nextTick(() => { - scroll(el, { top: 0 }); - }); -}, { immediate: true }); - -watch(() => props.initialPage, () => { - if (props.initialPage == null && !narrow) { - router.push('/admin/overview'); - } else { - if (props.initialPage == null) { - INFO = indexInfo; - } - } -}); - watch(narrow, () => { - if (props.initialPage == null && !narrow) { + if (currentPage?.route.name == null && !narrow) { router.push('/admin/overview'); } }); @@ -248,7 +200,7 @@ onMounted(() => { ro.observe(el); narrow = el.offsetWidth < NARROW_THRESHOLD; - if (props.initialPage == null && !narrow) { + if (currentPage?.route.name == null && !narrow) { router.push('/admin/overview'); } }); diff --git a/packages/client/src/pages/admin/instance-block.vue b/packages/client/src/pages/admin/instance-block.vue index 1aec151abb..94b740a4d5 100644 --- a/packages/client/src/pages/admin/instance-block.vue +++ b/packages/client/src/pages/admin/instance-block.vue @@ -17,7 +17,7 @@ <script lang="ts" setup> import { } from 'vue'; import XHeader from './_header_.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os'; @@ -47,6 +47,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.instanceBlocking, icon: 'fas fa-ban', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/integrations.discord.vue b/packages/client/src/pages/admin/integrations.discord.vue index 9fdc51a6ca..0ab6ecbb4f 100644 --- a/packages/client/src/pages/admin/integrations.discord.vue +++ b/packages/client/src/pages/admin/integrations.discord.vue @@ -2,7 +2,7 @@ <FormSuspense :p="init"> <div class="_formRoot"> <FormSwitch v-model="enableDiscordIntegration" class="_formBlock"> - <template #label>{{ $ts.enable }}</template> + <template #label>{{ i18n.ts.enable }}</template> </FormSwitch> <template v-if="enableDiscordIntegration"> @@ -19,7 +19,7 @@ </FormInput> </template> - <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton> </div> </FormSuspense> </template> @@ -28,11 +28,12 @@ import { } from 'vue'; import FormSwitch from '@/components/form/switch.vue'; import FormInput from '@/components/form/input.vue'; -import FormButton from '@/components/ui/button.vue'; -import FormInfo from '@/components/ui/info.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os'; import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; let uri: string = $ref(''); let enableDiscordIntegration: boolean = $ref(false); diff --git a/packages/client/src/pages/admin/integrations.github.vue b/packages/client/src/pages/admin/integrations.github.vue index b10ccb8394..34761e9a7b 100644 --- a/packages/client/src/pages/admin/integrations.github.vue +++ b/packages/client/src/pages/admin/integrations.github.vue @@ -2,7 +2,7 @@ <FormSuspense :p="init"> <div class="_formRoot"> <FormSwitch v-model="enableGithubIntegration" class="_formBlock"> - <template #label>{{ $ts.enable }}</template> + <template #label>{{ i18n.ts.enable }}</template> </FormSwitch> <template v-if="enableGithubIntegration"> @@ -19,7 +19,7 @@ </FormInput> </template> - <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton> </div> </FormSuspense> </template> @@ -28,11 +28,12 @@ import { } from 'vue'; import FormSwitch from '@/components/form/switch.vue'; import FormInput from '@/components/form/input.vue'; -import FormButton from '@/components/ui/button.vue'; -import FormInfo from '@/components/ui/info.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os'; import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; let uri: string = $ref(''); let enableGithubIntegration: boolean = $ref(false); diff --git a/packages/client/src/pages/admin/integrations.twitter.vue b/packages/client/src/pages/admin/integrations.twitter.vue index 11b5fd86b2..a870d76a4d 100644 --- a/packages/client/src/pages/admin/integrations.twitter.vue +++ b/packages/client/src/pages/admin/integrations.twitter.vue @@ -2,7 +2,7 @@ <FormSuspense :p="init"> <div class="_formRoot"> <FormSwitch v-model="enableTwitterIntegration" class="_formBlock"> - <template #label>{{ $ts.enable }}</template> + <template #label>{{ i18n.ts.enable }}</template> </FormSwitch> <template v-if="enableTwitterIntegration"> @@ -19,7 +19,7 @@ </FormInput> </template> - <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton> </div> </FormSuspense> </template> @@ -28,11 +28,12 @@ import { defineComponent } from 'vue'; import FormSwitch from '@/components/form/switch.vue'; import FormInput from '@/components/form/input.vue'; -import FormButton from '@/components/ui/button.vue'; -import FormInfo from '@/components/ui/info.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os'; import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; let uri: string = $ref(''); let enableTwitterIntegration: boolean = $ref(false); diff --git a/packages/client/src/pages/admin/integrations.vue b/packages/client/src/pages/admin/integrations.vue index d407d440b9..9964426a68 100644 --- a/packages/client/src/pages/admin/integrations.vue +++ b/packages/client/src/pages/admin/integrations.vue @@ -53,6 +53,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.integration, icon: 'fas fa-share-alt', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/metrics.vue b/packages/client/src/pages/admin/metrics.vue index 7e5f5bb094..e0e47e667f 100644 --- a/packages/client/src/pages/admin/metrics.vue +++ b/packages/client/src/pages/admin/metrics.vue @@ -67,11 +67,11 @@ import { Tooltip, SubTitle } from 'chart.js'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import MkSelect from '@/components/form/select.vue'; import MkInput from '@/components/form/input.vue'; -import MkContainer from '@/components/ui/container.vue'; -import MkFolder from '@/components/ui/folder.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import MkFolder from '@/components/MkFolder.vue'; import MkwFederation from '../../widgets/federation.vue'; import { version, url } from '@/config'; import bytes from '@/filters/bytes'; diff --git a/packages/client/src/pages/admin/object-storage.vue b/packages/client/src/pages/admin/object-storage.vue index bae5277f49..5cc3018532 100644 --- a/packages/client/src/pages/admin/object-storage.vue +++ b/packages/client/src/pages/admin/object-storage.vue @@ -73,7 +73,6 @@ import { } from 'vue'; import XHeader from './_header_.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormInput from '@/components/form/input.vue'; -import FormGroup from '@/components/form/group.vue'; import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; import FormSection from '@/components/form/section.vue'; @@ -145,6 +144,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.objectStorage, icon: 'fas fa-cloud', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/other-settings.vue b/packages/client/src/pages/admin/other-settings.vue index 59b3503c3c..ee4e8edba0 100644 --- a/packages/client/src/pages/admin/other-settings.vue +++ b/packages/client/src/pages/admin/other-settings.vue @@ -40,6 +40,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.other, icon: 'fas fa-cogs', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/overview.federation.vue b/packages/client/src/pages/admin/overview.federation.vue new file mode 100644 index 0000000000..e8cb5867a7 --- /dev/null +++ b/packages/client/src/pages/admin/overview.federation.vue @@ -0,0 +1,100 @@ +<template> +<div class="wbrkwale"> + <MkLoading v-if="fetching"/> + <transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="instances"> + <MkA v-for="(instance, i) in instances" :key="instance.id" :to="`/instance-info/${instance.host}`" class="instance"> + <img v-if="instance.iconUrl" :src="instance.iconUrl" alt=""/> + <div class="body"> + <div class="name">{{ instance.name ?? instance.host }}</div> + <div class="host">{{ instance.host }}</div> + </div> + <MkMiniChart class="chart" :src="charts[i].requests.received"/> + </MkA> + </transition-group> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; + +const instances = ref([]); +const charts = ref([]); +const fetching = ref(true); + +const fetch = async () => { + const fetchedInstances = await os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 5, + }); + const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.apiGet('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); + instances.value = fetchedInstances; + charts.value = fetchedCharts; + fetching.value = false; +}; + +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.wbrkwale { + > .instances { + .chart-move { + transition: transform 1s ease; + } + + > .instance { + display: flex; + align-items: center; + padding: 16px 20px; + + &:not(:last-child) { + border-bottom: solid 0.5px var(--divider); + } + + > img { + display: block; + width: 34px; + height: 34px; + object-fit: cover; + border-radius: 4px; + margin-right: 12px; + } + + > .body { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; + + > .name { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .host { + margin: 0; + font-size: 75%; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + > .chart { + height: 30px; + } + } + } +} +</style> diff --git a/packages/client/src/pages/admin/overview.pie.vue b/packages/client/src/pages/admin/overview.pie.vue new file mode 100644 index 0000000000..d3b2032876 --- /dev/null +++ b/packages/client/src/pages/admin/overview.pie.vue @@ -0,0 +1,108 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + DoughnutController, +} from 'chart.js'; +import number from '@/filters/number'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + DoughnutController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + data: { name: string; value: number; color: string; onClick?: () => void }[]; +}>(); + +const chartEl = ref<HTMLCanvasElement>(null); + +// フォントカラー +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +onMounted(() => { + chartInstance = new Chart(chartEl.value, { + type: 'doughnut', + data: { + labels: props.data.map(x => x.name), + datasets: [{ + backgroundColor: props.data.map(x => x.color), + borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'), + borderWidth: 2, + hoverOffset: 0, + data: props.data.map(x => x.value), + }], + }, + options: { + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 16, + }, + }, + onClick: (ev) => { + const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; + if (hit && props.data[hit.index].onClick) { + props.data[hit.index].onClick(); + } + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + }); +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/admin/overview.queue-chart.vue b/packages/client/src/pages/admin/overview.queue-chart.vue new file mode 100644 index 0000000000..a2b748ad38 --- /dev/null +++ b/packages/client/src/pages/admin/overview.queue-chart.vue @@ -0,0 +1,211 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + domain: string; + connection: any; +}>(); + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +const chartEl = ref<HTMLCanvasElement>(null); + +const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + +// フォントカラー +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +const onStats = (stats) => { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); + chartInstance.data.datasets[1].data.push(stats[props.domain].active); + chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); + chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); + if (chartInstance.data.datasets[0].data.length > 100) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + chartInstance.data.datasets[1].data.shift(); + chartInstance.data.datasets[2].data.shift(); + chartInstance.data.datasets[3].data.shift(); + } + chartInstance.update(); +}; + +const onStatsLog = (statsLog) => { + for (const stats of [...statsLog].reverse()) { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); + chartInstance.data.datasets[1].data.push(stats[props.domain].active); + chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); + chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); + if (chartInstance.data.datasets[0].data.length > 100) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + chartInstance.data.datasets[1].data.shift(); + chartInstance.data.datasets[2].data.shift(); + chartInstance.data.datasets[3].data.shift(); + } + } + chartInstance.update(); +}; + +onMounted(() => { + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Process', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#00E396', + backgroundColor: alpha('#00E396', 0.1), + data: [], + }, { + label: 'Active', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#00BCD4', + backgroundColor: alpha('#00BCD4', 0.1), + data: [], + }, { + label: 'Waiting', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#FFB300', + backgroundColor: alpha('#FFB300', 0.1), + data: [], + }, { + label: 'Delayed', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#E53935', + borderDash: [5, 5], + fill: false, + data: [], + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + display: false, + grid: { + display: false, + }, + ticks: { + display: false, + maxTicksLimit: 10, + }, + }, + y: { + display: false, + min: 0, + grid: { + display: false, + }, + ticks: { + display: false, + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + }); + + props.connection.on('stats', onStats); + props.connection.on('statsLog', onStatsLog); +}); + +onUnmounted(() => { + props.connection.off('stats', onStats); + props.connection.off('statsLog', onStatsLog); +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/admin/overview.user.vue b/packages/client/src/pages/admin/overview.user.vue new file mode 100644 index 0000000000..0dd4a749ba --- /dev/null +++ b/packages/client/src/pages/admin/overview.user.vue @@ -0,0 +1,76 @@ +<template> +<MkA :class="[$style.root]" :to="`/user-info/${user.id}`"> + <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/> + <div class="body"> + <span class="name"><MkUserName class="name" :user="user"/></span> + <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> + </div> + <MkMiniChart v-if="chart" class="chart" :src="chart.inc"/> +</MkA> +</template> + +<script lang="ts" setup> +import * as misskey from 'misskey-js'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import { acct } from '@/filters/user'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +let chart = $ref(null); + +os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16, span: 'day' }).then(res => { + chart = res; +}); +</script> + +<style lang="scss" module> +.root { + $bodyTitleHieght: 18px; + $bodyInfoHieght: 16px; + + display: flex; + align-items: center; + + > :global(.avatar) { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + margin-right: 12px; + } + + > :global(.body) { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; + + > :global(.name) { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $bodyTitleHieght; + } + + > :global(.sub) { + display: block; + width: 100%; + font-size: 95%; + opacity: 0.7; + line-height: $bodyInfoHieght; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + > :global(.chart) { + height: 30px; + } +} +</style> diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue index 82b3c33852..e532a908f1 100644 --- a/packages/client/src/pages/admin/overview.vue +++ b/packages/client/src/pages/admin/overview.vue @@ -1,112 +1,458 @@ <template> -<div v-size="{ max: [740] }" class="edbbcaef"> - <div v-if="stats" class="cfcdecdf" style="margin: var(--margin)"> - <div class="number _panel"> - <div class="label">Users</div> - <div class="value _monospace"> - {{ number(stats.originalUsersCount) }} - <MkNumberDiff v-if="usersComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff> +<MkSpacer :content-max="900"> + <div ref="rootEl" v-size="{ max: [740] }" class="edbbcaef"> + <div class="left"> + <div v-if="stats" class="container stats"> + <div class="title">Stats</div> + <div class="body"> + <div class="number _panel"> + <div class="label">Users</div> + <div class="value _monospace"> + {{ number(stats.originalUsersCount) }} + <MkNumberDiff v-if="usersComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + <div class="number _panel"> + <div class="label">Notes</div> + <div class="value _monospace"> + {{ number(stats.originalNotesCount) }} + <MkNumberDiff v-if="notesComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + </div> </div> - </div> - <div class="number _panel"> - <div class="label">Notes</div> - <div class="value _monospace"> - {{ number(stats.originalNotesCount) }} - <MkNumberDiff v-if="notesComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff> - </div> - </div> - </div> - <MkContainer :foldable="true" class="charts"> - <template #header><i class="fas fa-chart-bar"></i>{{ i18n.ts.charts }}</template> - <div style="padding: 12px;"> - <MkInstanceStats :chart-limit="500" :detailed="true"/> - </div> - </MkContainer> + <div class="container queue"> + <div class="title">Job queue</div> + <div class="body"> + <div class="chart deliver"> + <div class="title">Deliver</div> + <XQueueChart :connection="queueStatsConnection" domain="deliver"/> + </div> + <div class="chart inbox"> + <div class="title">Inbox</div> + <XQueueChart :connection="queueStatsConnection" domain="inbox"/> + </div> + </div> + </div> - <div class="queue"> - <MkContainer :foldable="true" :thin="true" class="deliver"> - <template #header>Queue: deliver</template> - <MkQueueChart :connection="queueStatsConnection" domain="deliver"/> - </MkContainer> - <MkContainer :foldable="true" :thin="true" class="inbox"> - <template #header>Queue: inbox</template> - <MkQueueChart :connection="queueStatsConnection" domain="inbox"/> - </MkContainer> - </div> + <div class="container users"> + <div class="title">New users</div> + <div v-if="newUsers" class="body"> + <XUser v-for="user in newUsers" :key="user.id" class="user" :user="user"/> + </div> + </div> - <!--<XMetrics/>--> + <div class="container files"> + <div class="title">Recent files</div> + <div class="body"> + <MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/> + </div> + </div> - <MkFolder style="margin: var(--margin)"> - <template #header><i class="fas fa-info-circle"></i> {{ i18n.ts.info }}</template> - <div class="cfcdecdf"> - <div class="number _panel"> - <div class="label">Misskey</div> - <div class="value _monospace">{{ version }}</div> + <div class="container env"> + <div class="title">Enviroment</div> + <div class="body"> + <div class="number _panel"> + <div class="label">Misskey</div> + <div class="value _monospace">{{ version }}</div> + </div> + <div v-if="serverInfo" class="number _panel"> + <div class="label">Node.js</div> + <div class="value _monospace">{{ serverInfo.node }}</div> + </div> + <div v-if="serverInfo" class="number _panel"> + <div class="label">PostgreSQL</div> + <div class="value _monospace">{{ serverInfo.psql }}</div> + </div> + <div v-if="serverInfo" class="number _panel"> + <div class="label">Redis</div> + <div class="value _monospace">{{ serverInfo.redis }}</div> + </div> + <div class="number _panel"> + <div class="label">Vue</div> + <div class="value _monospace">{{ vueVersion }}</div> + </div> + </div> + </div> + </div> + <div class="right"> + <div class="container charts"> + <div class="title">Active users</div> + <div class="body"> + <canvas ref="chartEl"></canvas> + </div> </div> - <div v-if="serverInfo" class="number _panel"> - <div class="label">Node.js</div> - <div class="value _monospace">{{ serverInfo.node }}</div> + <div class="container federation"> + <div class="title">Active instances</div> + <div class="body"> + <XFederation/> + </div> </div> - <div v-if="serverInfo" class="number _panel"> - <div class="label">PostgreSQL</div> - <div class="value _monospace">{{ serverInfo.psql }}</div> + <div v-if="stats" class="container federationStats"> + <div class="title">Federation</div> + <div class="body"> + <div class="number _panel"> + <div class="label">Sub</div> + <div class="value _monospace"> + {{ number(federationSubActive) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + <div class="number _panel"> + <div class="label">Pub</div> + <div class="value _monospace"> + {{ number(federationPubActive) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + </div> </div> - <div v-if="serverInfo" class="number _panel"> - <div class="label">Redis</div> - <div class="value _monospace">{{ serverInfo.redis }}</div> + <div class="container tagCloud"> + <div class="body"> + <MkTagCloud v-if="activeInstances"> + <li v-for="instance in activeInstances"> + <a @click.prevent="onInstanceClick(instance)"> + <img style="width: 32px;" :src="instance.iconUrl"> + </a> + </li> + </MkTagCloud> + </div> </div> - <div class="number _panel"> - <div class="label">Vue</div> - <div class="value _monospace">{{ vueVersion }}</div> + <div v-if="topSubInstancesForPie && topPubInstancesForPie" class="container federationPies"> + <div class="body"> + <div class="chart deliver"> + <div class="title">Sub</div> + <XPie :data="topSubInstancesForPie"/> + <div class="subTitle">Top 10</div> + </div> + <div class="chart inbox"> + <div class="title">Pub</div> + <XPie :data="topPubInstancesForPie"/> + <div class="subTitle">Top 10</div> + </div> + </div> </div> </div> - </MkFolder> -</div> + </div> +</MkSpacer> </template> <script lang="ts" setup> import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import { enUS } from 'date-fns/locale'; +import tinycolor from 'tinycolor2'; +import MagicGrid from 'magic-grid'; import XMetrics from './metrics.vue'; -import MkInstanceStats from '@/components/instance-stats.vue'; -import MkNumberDiff from '@/components/number-diff.vue'; -import MkContainer from '@/components/ui/container.vue'; -import MkFolder from '@/components/ui/folder.vue'; -import MkQueueChart from '@/components/queue-chart.vue'; +import XFederation from './overview.federation.vue'; +import XQueueChart from './overview.queue-chart.vue'; +import XUser from './overview.user.vue'; +import XPie from './overview.pie.vue'; +import MkNumberDiff from '@/components/MkNumberDiff.vue'; +import MkTagCloud from '@/components/MkTagCloud.vue'; import { version, url } from '@/config'; import number from '@/filters/number'; import * as os from '@/os'; import { stream } from '@/stream'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import 'chartjs-adapter-date-fns'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + //gradient, +); +const rootEl = $ref<HTMLElement>(); +const chartEl = $ref<HTMLCanvasElement>(null); let stats: any = $ref(null); let serverInfo: any = $ref(null); +let topSubInstancesForPie: any = $ref(null); +let topPubInstancesForPie: any = $ref(null); let usersComparedToThePrevDay: any = $ref(null); let notesComparedToThePrevDay: any = $ref(null); +let federationPubActive = $ref<number | null>(null); +let federationPubActiveDiff = $ref<number | null>(null); +let federationSubActive = $ref<number | null>(null); +let federationSubActiveDiff = $ref<number | null>(null); +let newUsers = $ref(null); +let activeInstances = $shallowRef(null); const queueStatsConnection = markRaw(stream.useChannel('queueStats')); +const now = new Date(); +let chartInstance: Chart = null; +const chartLimit = 30; +const filesPagination = { + endpoint: 'admin/drive/files' as const, + limit: 9, + noPaging: true, +}; + +const { handler: externalTooltipHandler } = useChartTooltip(); + +async function renderChart() { + if (chartInstance) { + chartInstance.destroy(); + } + + const getDate = (ago: number) => { + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + + return new Date(y, m, d - ago); + }; + + const format = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: v, + })); + }; + + const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); + + const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + // フォントカラー + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + const color = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent')); + + chartInstance = new Chart(chartEl, { + type: 'bar', + data: { + //labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(), + datasets: [{ + parsing: false, + label: 'a', + data: format(raw.readWrite).slice().reverse(), + tension: 0.3, + pointRadius: 0, + borderWidth: 0, + borderJoinStyle: 'round', + borderRadius: 3, + backgroundColor: color, + /*gradient: props.bar ? undefined : { + backgroundColor: { + axis: 'y', + colors: { + 0: alpha(x.color ? x.color : getColor(i), 0), + [maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.2), + }, + }, + },*/ + barPercentage: 0.9, + categoryPercentage: 0.9, + clip: 8, + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + display: false, + stacked: true, + offset: false, + time: { + stepSize: 1, + unit: 'month', + }, + grid: { + display: false, + }, + ticks: { + display: false, + }, + adapters: { + date: { + locale: enUS, + }, + }, + min: getDate(chartLimit).getTime(), + }, + y: { + display: false, + position: 'left', + stacked: true, + grid: { + display: false, + }, + ticks: { + display: false, + //mirror: true, + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + elements: { + point: { + hoverRadius: 5, + hoverBorderWidth: 2, + }, + }, + animation: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + //gradient, + }, + }, + plugins: [{ + id: 'vLine', + beforeDraw(chart, args, options) { + if (chart.tooltip?._active?.length) { + const activePoint = chart.tooltip._active[0]; + const ctx = chart.ctx; + const x = activePoint.element.x; + const topY = chart.scales.y.top; + const bottomY = chart.scales.y.bottom; + + ctx.save(); + ctx.beginPath(); + ctx.moveTo(x, bottomY); + ctx.lineTo(x, topY); + ctx.lineWidth = 1; + ctx.strokeStyle = vLineColor; + ctx.stroke(); + ctx.restore(); + } + }, + }], + }); +} + +function onInstanceClick(i) { + os.pageWindow(`/instance-info/${i.host}`); +} + +onMounted(async () => { + /* + const magicGrid = new MagicGrid({ + container: rootEl, + static: true, + animate: true, + }); + + magicGrid.listen(); + */ + + renderChart(); -onMounted(async () => { os.api('stats', {}).then(statsResponse => { stats = statsResponse; - os.api('charts/users', { limit: 2, span: 'day' }).then(chart => { + os.apiGet('charts/users', { limit: 2, span: 'day' }).then(chart => { usersComparedToThePrevDay = stats.originalUsersCount - chart.local.total[1]; }); - os.api('charts/notes', { limit: 2, span: 'day' }).then(chart => { + os.apiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => { notesComparedToThePrevDay = stats.originalNotesCount - chart.local.total[1]; }); }); + os.apiGet('charts/federation', { limit: 2, span: 'day' }).then(chart => { + federationPubActive = chart.pubActive[0]; + federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1]; + federationSubActive = chart.subActive[0]; + federationSubActiveDiff = chart.subActive[0] - chart.subActive[1]; + }); + + os.apiGet('federation/stats', { limit: 10 }).then(res => { + topSubInstancesForPie = res.topSubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followersCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]); + topPubInstancesForPie = res.topPubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followingCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]); + }); + os.api('admin/server-info').then(serverInfoResponse => { serverInfo = serverInfoResponse; }); + os.api('admin/show-users', { + limit: 5, + sort: '+createdAt', + }).then(res => { + newUsers = res; + }); + + os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 25, + }).then(res => { + activeInstances = res; + }); + nextTick(() => { queueStatsConnection.send('requestLog', { id: Math.random().toString().substr(2, 8), - length: 200, + length: 100, }); }); }); @@ -122,69 +468,170 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.dashboard, icon: 'fas fa-tachometer-alt', - bg: 'var(--bg)', }); </script> <style lang="scss" scoped> .edbbcaef { - .cfcdecdf { - display: grid; - grid-gap: 8px; - grid-template-columns: repeat(auto-fill,minmax(150px,1fr)); + display: flex; - > .number { - padding: 12px 16px; + > .left, > .right { + box-sizing: border-box; + width: 50%; - > .label { - opacity: 0.7; - font-size: 0.8em; - } + > .container { + margin: 32px 0; - > .value { + > .title { font-weight: bold; - font-size: 1.2em; + margin-bottom: 16px; + } - > .diff { - font-size: 0.8em; + &.stats, &.federationStats { + > .body { + display: grid; + grid-gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + + > .number { + padding: 14px 20px; + + > .label { + opacity: 0.7; + font-size: 0.8em; + } + + > .value { + font-weight: bold; + font-size: 1.5em; + + > .diff { + font-size: 0.7em; + } + } + } } } - } - } - > .charts { - margin: var(--margin); - } + &.env { + > .body { + display: grid; + grid-gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - > .queue { - margin: var(--margin); - display: flex; + > .number { + padding: 14px 20px; - > .deliver, - > .inbox { - flex: 1; - width: 50%; + > .label { + opacity: 0.7; + font-size: 0.8em; + } - &:not(:first-child) { - margin-left: var(--margin); + > .value { + font-size: 1.1em; + } + } + } } - } - } - &.max-width_740px { - > .queue { - display: block; + &.charts { + > .body { + padding: 32px; + background: var(--panel); + border-radius: var(--radius); + } + } + + &.users { + > .body { + background: var(--panel); + border-radius: var(--radius); - > .deliver, - > .inbox { - width: 100%; + > .user { + padding: 16px 20px; - &:not(:first-child) { - margin-top: var(--margin); - margin-left: 0; + &:not(:last-child) { + border-bottom: solid 0.5px var(--divider); + } + } + } + } + + &.federation { + > .body { + background: var(--panel); + border-radius: var(--radius); + overflow: clip; + } + } + + &.queue { + > .body { + display: grid; + grid-gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + + > .chart { + position: relative; + padding: 20px; + background: var(--panel); + border-radius: var(--radius); + + > .title { + position: absolute; + top: 20px; + left: 20px; + font-size: 90%; + } + } + } + } + + &.federationPies { + > .body { + display: grid; + grid-gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + + > .chart { + position: relative; + padding: 20px; + background: var(--panel); + border-radius: var(--radius); + + > .title { + position: absolute; + top: 20px; + left: 20px; + font-size: 90%; + } + + > .subTitle { + position: absolute; + bottom: 20px; + right: 20px; + font-size: 85%; + } + } + } + } + + &.tagCloud { + > .body { + background: var(--panel); + border-radius: var(--radius); + overflow: clip; } } } } + + > .left { + padding-right: 16px; + } + + > .right { + padding-left: 16px; + } } </style> diff --git a/packages/client/src/pages/admin/proxy-account.vue b/packages/client/src/pages/admin/proxy-account.vue index 0c5bb1bc9f..fe61909e80 100644 --- a/packages/client/src/pages/admin/proxy-account.vue +++ b/packages/client/src/pages/admin/proxy-account.vue @@ -15,9 +15,9 @@ <script lang="ts" setup> import { } from 'vue'; -import MkKeyValue from '@/components/key-value.vue'; -import FormButton from '@/components/ui/button.vue'; -import MkInfo from '@/components/ui/info.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os'; import { fetchInstance } from '@/instance'; @@ -58,6 +58,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.proxyAccount, icon: 'fas fa-ghost', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/queue.chart.chart.vue b/packages/client/src/pages/admin/queue.chart.chart.vue new file mode 100644 index 0000000000..96156f8e67 --- /dev/null +++ b/packages/client/src/pages/admin/queue.chart.chart.vue @@ -0,0 +1,181 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { watch, onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + type: string; +}>(); + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +const chartEl = ref<HTMLCanvasElement>(null); + +const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + +// フォントカラー +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +function setData(values) { + if (chartInstance == null) return; + for (const value of values) { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + } + chartInstance.update(); +} + +function pushData(value) { + if (chartInstance == null) return; + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + chartInstance.update(); +} + +const label = + props.type === 'process' ? 'Process' : + props.type === 'active' ? 'Active' : + props.type === 'delayed' ? 'Delayed' : + props.type === 'waiting' ? 'Waiting' : + '?' as never; + +const color = + props.type === 'process' ? '#00E396' : + props.type === 'active' ? '#00BCD4' : + props.type === 'delayed' ? '#E53935' : + props.type === 'waiting' ? '#FFB300' : + '?' as never; + +onMounted(() => { + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: label, + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: color, + backgroundColor: alpha(color, 0.1), + data: [], + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + maxTicksLimit: 10, + }, + }, + y: { + min: 0, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + }); +}); + +defineExpose({ + setData, + pushData, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/admin/queue.chart.vue b/packages/client/src/pages/admin/queue.chart.vue index be63830bdd..186a22c43e 100644 --- a/packages/client/src/pages/admin/queue.chart.vue +++ b/packages/client/src/pages/admin/queue.chart.vue @@ -1,80 +1,149 @@ <template> -<div class="_debobigegoItem"> - <div class="_debobigegoLabel"><slot name="title"></slot></div> - <div class="_debobigegoPanel pumxzjhg"> - <div class="_table status"> - <div class="_row"> - <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> - <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> - <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> - <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> - </div> +<div class="pumxzjhg"> + <div class="_table status"> + <div class="_row"> + <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> + <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> + <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> + <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> + </div> + </div> + <div class="charts"> + <div class="chart"> + <div class="title">Process</div> + <XChart ref="chartProcess" type="process"/> </div> - <div class=""> - <MkQueueChart :domain="domain" :connection="connection"/> + <div class="chart"> + <div class="title">Active</div> + <XChart ref="chartActive" type="active"/> </div> - <div class="jobs"> - <div v-if="jobs.length > 0"> - <div v-for="job in jobs" :key="job[0]"> - <span>{{ job[0] }}</span> - <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> - </div> + <div class="chart"> + <div class="title">Delayed</div> + <XChart ref="chartDelayed" type="delayed"/> + </div> + <div class="chart"> + <div class="title">Waiting</div> + <XChart ref="chartWaiting" type="waiting"/> + </div> + </div> + <div class="jobs"> + <div v-if="jobs.length > 0"> + <div v-for="job in jobs" :key="job[0]"> + <span>{{ job[0] }}</span> + <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> </div> - <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span> </div> + <span v-else style="opacity: 0.5;">{{ i18n.ts.noJobs }}</span> </div> </div> </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref } from 'vue'; +import { markRaw, onMounted, onUnmounted, ref } from 'vue'; +import XChart from './queue.chart.chart.vue'; import number from '@/filters/number'; -import MkQueueChart from '@/components/queue-chart.vue'; import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; + +const connection = markRaw(stream.useChannel('queueStats')); const activeSincePrevTick = ref(0); const active = ref(0); -const waiting = ref(0); const delayed = ref(0); +const waiting = ref(0); const jobs = ref([]); +let chartProcess = $ref<InstanceType<typeof XChart>>(); +let chartActive = $ref<InstanceType<typeof XChart>>(); +let chartDelayed = $ref<InstanceType<typeof XChart>>(); +let chartWaiting = $ref<InstanceType<typeof XChart>>(); const props = defineProps<{ - domain: string, - connection: any, + domain: string; }>(); +const onStats = (stats) => { + activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; + active.value = stats[props.domain].active; + delayed.value = stats[props.domain].delayed; + waiting.value = stats[props.domain].waiting; + + chartProcess.pushData(stats[props.domain].activeSincePrevTick); + chartActive.pushData(stats[props.domain].active); + chartDelayed.pushData(stats[props.domain].delayed); + chartWaiting.pushData(stats[props.domain].waiting); +}; + +const onStatsLog = (statsLog) => { + const dataProcess = []; + const dataActive = []; + const dataDelayed = []; + const dataWaiting = []; + + for (const stats of [...statsLog].reverse()) { + dataProcess.push(stats[props.domain].activeSincePrevTick); + dataActive.push(stats[props.domain].active); + dataDelayed.push(stats[props.domain].delayed); + dataWaiting.push(stats[props.domain].waiting); + } + + chartProcess.setData(dataProcess); + chartActive.setData(dataActive); + chartDelayed.setData(dataDelayed); + chartWaiting.setData(dataWaiting); +}; + onMounted(() => { os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => { jobs.value = result; }); - const onStats = (stats) => { - activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; - active.value = stats[props.domain].active; - waiting.value = stats[props.domain].waiting; - delayed.value = stats[props.domain].delayed; - }; - - props.connection.on('stats', onStats); - - onUnmounted(() => { - props.connection.off('stats', onStats); + connection.on('stats', onStats); + connection.on('statsLog', onStatsLog); + connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200, }); }); + +onUnmounted(() => { + connection.off('stats', onStats); + connection.off('statsLog', onStatsLog); + connection.dispose(); +}); </script> <style lang="scss" scoped> .pumxzjhg { > .status { padding: 16px; - border-bottom: solid 0.5px var(--divider); + } + + > .charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + + > .chart { + min-width: 0; + padding: 16px; + background: var(--panel); + border-radius: var(--radius); + + > .title { + margin-bottom: 8px; + } + } } > .jobs { + margin-top: 16px; padding: 16px; - border-top: solid 0.5px var(--divider); max-height: 180px; overflow: auto; + background: var(--panel); + border-radius: var(--radius); } + } </style> diff --git a/packages/client/src/pages/admin/queue.vue b/packages/client/src/pages/admin/queue.vue index c2865525ab..a6fc75aea8 100644 --- a/packages/client/src/pages/admin/queue.vue +++ b/packages/client/src/pages/admin/queue.vue @@ -1,14 +1,9 @@ <template> <MkStickyContainer> - <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="800"> - <XQueue :connection="connection" domain="inbox"> - <template #title>In</template> - </XQueue> - <XQueue :connection="connection" domain="deliver"> - <template #title>Out</template> - </XQueue> - <MkButton danger @click="clear()"><i class="fas fa-trash-alt"></i> {{ i18n.ts.clearQueue }}</MkButton> + <XQueue v-if="tab === 'deliver'" domain="deliver"/> + <XQueue v-else-if="tab === 'inbox'" domain="inbox"/> </MkSpacer> </MkStickyContainer> </template> @@ -17,14 +12,13 @@ import { markRaw, onMounted, onBeforeUnmount, nextTick } from 'vue'; import XQueue from './queue.chart.vue'; import XHeader from './_header_.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; -import { stream } from '@/stream'; import * as config from '@/config'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; -const connection = markRaw(stream.useChannel('queueStats')); +let tab = $ref('deliver'); function clear() { os.confirm({ @@ -38,19 +32,6 @@ function clear() { }); } -onMounted(() => { - nextTick(() => { - connection.send('requestLog', { - id: Math.random().toString().substr(2, 8), - length: 200, - }); - }); -}); - -onBeforeUnmount(() => { - connection.dispose(); -}); - const headerActions = $computed(() => [{ asFullButton: true, icon: 'fas fa-up-right-from-square', @@ -60,11 +41,16 @@ const headerActions = $computed(() => [{ }, }]); -const headerTabs = $computed(() => []); +const headerTabs = $computed(() => [{ + key: 'deliver', + title: 'Deliver', +}, { + key: 'inbox', + title: 'Inbox', +}]); definePageMetadata({ title: i18n.ts.jobQueue, icon: 'fas fa-clipboard-list', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/relays.vue b/packages/client/src/pages/admin/relays.vue index 1ca4f2df09..e6f7f4ead1 100644 --- a/packages/client/src/pages/admin/relays.vue +++ b/packages/client/src/pages/admin/relays.vue @@ -19,7 +19,7 @@ <script lang="ts" setup> import { } from 'vue'; import XHeader from './_header_.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -78,7 +78,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.relays, icon: 'fas fa-globe', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/security.vue b/packages/client/src/pages/admin/security.vue index 65b08565cd..65d079c2cf 100644 --- a/packages/client/src/pages/admin/security.vue +++ b/packages/client/src/pages/admin/security.vue @@ -9,12 +9,81 @@ <template #label>{{ i18n.ts.botProtection }}</template> <template v-if="enableHcaptcha" #suffix>hCaptcha</template> <template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template> + <template v-else-if="enableTurnstile" #suffix>Turnstile</template> <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template> <XBotProtection/> </FormFolder> <FormFolder class="_formBlock"> + <template #icon><i class="fas fa-eye-slash"></i></template> + <template #label>{{ i18n.ts.sensitiveMediaDetection }}</template> + <template v-if="sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template> + <template v-else-if="sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template> + <template v-else-if="sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template> + <template v-else #suffix>{{ i18n.ts.none }}</template> + + <div class="_formRoot"> + <span class="_formBlock">{{ i18n.ts._sensitiveMediaDetection.description }}</span> + + <FormRadios v-model="sensitiveMediaDetection" class="_formBlock"> + <option value="none">{{ i18n.ts.none }}</option> + <option value="all">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.localOnly }}</option> + <option value="remote">{{ i18n.ts.remoteOnly }}</option> + </FormRadios> + + <FormRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :text-converter="(v) => `${v + 1}`" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template> + </FormRange> + + <FormSwitch v-model="enableSensitiveMediaDetectionForVideos" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template> + </FormSwitch> + + <FormSwitch v-model="setSensitiveFlagAutomatically" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template> + </FormSwitch> + + <!-- 現状 false positive が多すぎて実用に耐えない + <FormSwitch v-model="disallowUploadWhenPredictedAsPorn" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template> + </FormSwitch> + --> + + <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #label>Active Email Validation</template> + <template v-if="enableActiveEmailValidation" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + + <div class="_formRoot"> + <span class="_formBlock">{{ i18n.ts.activeEmailValidationDescription }}</span> + <FormSwitch v-model="enableActiveEmailValidation" class="_formBlock" @update:modelValue="save"> + <template #label>Enable</template> + </FormSwitch> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #label>Log IP address</template> + <template v-if="enableIpLogging" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + + <div class="_formRoot"> + <FormSwitch v-model="enableIpLogging" class="_formBlock" @update:modelValue="save"> + <template #label>Enable</template> + </FormSwitch> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> <template #label>Summaly Proxy</template> <div class="_formRoot"> @@ -37,12 +106,13 @@ import { } from 'vue'; import XBotProtection from './bot-protection.vue'; import XHeader from './_header_.vue'; import FormFolder from '@/components/form/folder.vue'; +import FormRadios from '@/components/form/radios.vue'; import FormSwitch from '@/components/form/switch.vue'; -import FormInfo from '@/components/ui/info.vue'; +import FormInfo from '@/components/MkInfo.vue'; import FormSuspense from '@/components/form/suspense.vue'; -import FormSection from '@/components/form/section.vue'; +import FormRange from '@/components/form/range.vue'; import FormInput from '@/components/form/input.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; @@ -51,17 +121,48 @@ import { definePageMetadata } from '@/scripts/page-metadata'; let summalyProxy: string = $ref(''); let enableHcaptcha: boolean = $ref(false); let enableRecaptcha: boolean = $ref(false); +let enableTurnstile: boolean = $ref(false); +let sensitiveMediaDetection: string = $ref('none'); +let sensitiveMediaDetectionSensitivity: number = $ref(0); +let setSensitiveFlagAutomatically: boolean = $ref(false); +let enableSensitiveMediaDetectionForVideos: boolean = $ref(false); +let enableIpLogging: boolean = $ref(false); +let enableActiveEmailValidation: boolean = $ref(false); async function init() { const meta = await os.api('admin/meta'); summalyProxy = meta.summalyProxy; enableHcaptcha = meta.enableHcaptcha; enableRecaptcha = meta.enableRecaptcha; + enableTurnstile = meta.enableTurnstile; + sensitiveMediaDetection = meta.sensitiveMediaDetection; + sensitiveMediaDetectionSensitivity = + meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 : + meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 : + meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 : + meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 : + meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0; + setSensitiveFlagAutomatically = meta.setSensitiveFlagAutomatically; + enableSensitiveMediaDetectionForVideos = meta.enableSensitiveMediaDetectionForVideos; + enableIpLogging = meta.enableIpLogging; + enableActiveEmailValidation = meta.enableActiveEmailValidation; } function save() { os.apiWithDialog('admin/update-meta', { summalyProxy, + sensitiveMediaDetection, + sensitiveMediaDetectionSensitivity: + sensitiveMediaDetectionSensitivity === 0 ? 'veryLow' : + sensitiveMediaDetectionSensitivity === 1 ? 'low' : + sensitiveMediaDetectionSensitivity === 2 ? 'medium' : + sensitiveMediaDetectionSensitivity === 3 ? 'high' : + sensitiveMediaDetectionSensitivity === 4 ? 'veryHigh' : + 0, + setSensitiveFlagAutomatically, + enableSensitiveMediaDetectionForVideos, + enableIpLogging, + enableActiveEmailValidation, }).then(() => { fetchInstance(); }); @@ -74,6 +175,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.security, icon: 'fas fa-lock', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue index a5767cc2c2..cf6b1f17e9 100644 --- a/packages/client/src/pages/admin/settings.vue +++ b/packages/client/src/pages/admin/settings.vue @@ -153,7 +153,7 @@ import XHeader from './_header_.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormInput from '@/components/form/input.vue'; import FormTextarea from '@/components/form/textarea.vue'; -import FormInfo from '@/components/ui/info.vue'; +import FormInfo from '@/components/MkInfo.vue'; import FormSection from '@/components/form/section.vue'; import FormSplit from '@/components/form/split.vue'; import FormSuspense from '@/components/form/suspense.vue'; @@ -258,6 +258,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.general, icon: 'fas fa-cog', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/admin/users.vue b/packages/client/src/pages/admin/users.vue index dccf952ba9..eeb335a430 100644 --- a/packages/client/src/pages/admin/users.vue +++ b/packages/client/src/pages/admin/users.vue @@ -7,59 +7,43 @@ <div class="users"> <div class="inputs"> <MkSelect v-model="sort" style="flex: 1;"> - <template #label>{{ $ts.sort }}</template> - <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option> - <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option> - <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option> - <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option> + <template #label>{{ i18n.ts.sort }}</template> + <option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.descendingOrder }})</option> </MkSelect> <MkSelect v-model="state" style="flex: 1;"> - <template #label>{{ $ts.state }}</template> - <option value="all">{{ $ts.all }}</option> - <option value="available">{{ $ts.normal }}</option> - <option value="admin">{{ $ts.administrator }}</option> - <option value="moderator">{{ $ts.moderator }}</option> - <option value="silenced">{{ $ts.silence }}</option> - <option value="suspended">{{ $ts.suspend }}</option> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="available">{{ i18n.ts.normal }}</option> + <option value="admin">{{ i18n.ts.administrator }}</option> + <option value="moderator">{{ i18n.ts.moderator }}</option> + <option value="silenced">{{ i18n.ts.silence }}</option> + <option value="suspended">{{ i18n.ts.suspend }}</option> </MkSelect> <MkSelect v-model="origin" style="flex: 1;"> - <template #label>{{ $ts.instance }}</template> - <option value="combined">{{ $ts.all }}</option> - <option value="local">{{ $ts.local }}</option> - <option value="remote">{{ $ts.remote }}</option> + <template #label>{{ i18n.ts.instance }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> </MkSelect> </div> <div class="inputs"> - <MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()"> + <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:modelValue="$refs.users.reload()"> <template #prefix>@</template> - <template #label>{{ $ts.username }}</template> + <template #label>{{ i18n.ts.username }}</template> </MkInput> - <MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()"> + <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()"> <template #prefix>@</template> - <template #label>{{ $ts.host }}</template> + <template #label>{{ i18n.ts.host }}</template> </MkInput> </div> <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users"> - <button v-for="user in items" :key="user.id" class="user _panel _button _gap" @click="show(user)"> - <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/> - <div class="body"> - <header> - <MkUserName class="name" :user="user"/> - <span class="acct">@{{ acct(user) }}</span> - <span v-if="user.isAdmin" class="staff"><i class="fas fa-bookmark"></i></span> - <span v-if="user.isModerator" class="staff"><i class="far fa-bookmark"></i></span> - <span v-if="user.isSilenced" class="punished"><i class="fas fa-microphone-slash"></i></span> - <span v-if="user.isSuspended" class="punished"><i class="fas fa-snowflake"></i></span> - </header> - <div> - <span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span> - </div> - <div> - <span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span> - </div> - </div> - </button> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`"> + <MkUserCardMini :user="user"/> + </MkA> </MkPagination> </div> </div> @@ -73,12 +57,12 @@ import { computed } from 'vue'; import XHeader from './_header_.vue'; import MkInput from '@/components/form/input.vue'; import MkSelect from '@/components/form/select.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import { acct } from '@/filters/user'; +import MkPagination from '@/components/MkPagination.vue'; import * as os from '@/os'; import { lookupUser } from '@/scripts/lookup-user'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; let paginationComponent = $ref<InstanceType<typeof MkPagination>>(); @@ -151,7 +135,6 @@ const headerTabs = $computed(() => []); definePageMetadata(computed(() => ({ title: i18n.ts.users, icon: 'fas fa-users', - bg: 'var(--bg)', }))); </script> @@ -174,54 +157,12 @@ definePageMetadata(computed(() => ({ > .users { margin-top: var(--margin); + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + grid-gap: 12px; - > .user { - display: flex; - width: 100%; - box-sizing: border-box; - text-align: left; - align-items: center; - padding: 16px; - - &:hover { - color: var(--accent); - } - - > .avatar { - width: 60px; - height: 60px; - } - - > .body { - margin-left: 0.3em; - padding: 0 8px; - flex: 1; - - @media (max-width: 500px) { - font-size: 14px; - } - - > header { - > .name { - font-weight: bold; - } - - > .acct { - margin-left: 8px; - opacity: 0.7; - } - - > .staff { - margin-left: 0.5em; - color: var(--badge); - } - - > .punished { - margin-left: 0.5em; - color: #4dabf7; - } - } - } + > .user:hover { + text-decoration: none; } } } diff --git a/packages/client/src/pages/announcements.vue b/packages/client/src/pages/announcements.vue index 9afaa7fd1f..5f66596997 100644 --- a/packages/client/src/pages/announcements.vue +++ b/packages/client/src/pages/announcements.vue @@ -20,8 +20,8 @@ <script lang="ts" setup> import { } from 'vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -47,7 +47,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.announcements, icon: 'fas fa-broadcast-tower', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/antenna-timeline.vue b/packages/client/src/pages/antenna-timeline.vue index 29b6066fc9..500cb3a7c5 100644 --- a/packages/client/src/pages/antenna-timeline.vue +++ b/packages/client/src/pages/antenna-timeline.vue @@ -1,27 +1,30 @@ <template> -<div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks"> - <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> - <div class="tl _block"> - <XTimeline - ref="tlEl" :key="antennaId" - class="tl" - src="antenna" - :antenna="antennaId" - :sound="true" - @queue="queueUpdated" - /> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks"> + <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline + ref="tlEl" :key="antennaId" + class="tl" + src="antenna" + :antenna="antennaId" + :sound="true" + @queue="queueUpdated" + /> + </div> </div> -</div> +</MkStickyContainer> </template> <script lang="ts" setup> import { computed, inject, watch } from 'vue'; -import XTimeline from '@/components/timeline.vue'; +import XTimeline from '@/components/MkTimeline.vue'; import { scroll } from '@/scripts/scroll'; import * as os from '@/os'; import { useRouter } from '@/router'; import { definePageMetadata } from '@/scripts/page-metadata'; -import i18n from '@/components/global/i18n'; +import { i18n } from '@/i18n'; const router = useRouter(); @@ -68,23 +71,21 @@ watch(() => props.antennaId, async () => { }); }, { immediate: true }); -const headerActions = $computed(() => []); +const headerActions = $computed(() => antenna ? [{ + icon: 'fas fa-calendar-alt', + text: i18n.ts.jumpToSpecifiedDate, + handler: timetravel, +}, { + icon: 'fas fa-cog', + text: i18n.ts.settings, + handler: settings, +}] : []); const headerTabs = $computed(() => []); definePageMetadata(computed(() => antenna ? { title: antenna.name, icon: 'fas fa-satellite', - bg: 'var(--bg)', - actions: [{ - icon: 'fas fa-calendar-alt', - text: i18n.ts.jumpToSpecifiedDate, - handler: timetravel, - }, { - icon: 'fas fa-cog', - text: i18n.ts.settings, - handler: settings, - }], } : null)); </script> diff --git a/packages/client/src/pages/api-console.vue b/packages/client/src/pages/api-console.vue index 2f8eeadff1..0064e4c3f1 100644 --- a/packages/client/src/pages/api-console.vue +++ b/packages/client/src/pages/api-console.vue @@ -32,7 +32,7 @@ import { ref } from 'vue'; import JSON5 from 'json5'; import { Endpoints } from 'misskey-js'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; import MkTextarea from '@/components/form/textarea.vue'; import MkSwitch from '@/components/form/switch.vue'; diff --git a/packages/client/src/pages/auth.form.vue b/packages/client/src/pages/auth.form.vue index 5feff0149a..024a7a2c5b 100644 --- a/packages/client/src/pages/auth.form.vue +++ b/packages/client/src/pages/auth.form.vue @@ -21,7 +21,7 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; export default defineComponent({ diff --git a/packages/client/src/pages/auth.vue b/packages/client/src/pages/auth.vue index 9457cd6b2f..bb55881a22 100644 --- a/packages/client/src/pages/auth.vue +++ b/packages/client/src/pages/auth.vue @@ -31,7 +31,7 @@ <script lang="ts"> import { defineComponent } from 'vue'; import XForm from './auth.form.vue'; -import MkSignin from '@/components/signin.vue'; +import MkSignin from '@/components/MkSignin.vue'; import * as os from '@/os'; import { login } from '@/account'; diff --git a/packages/client/src/pages/channel-editor.vue b/packages/client/src/pages/channel-editor.vue index 2065bd6689..3e94b5f041 100644 --- a/packages/client/src/pages/channel-editor.vue +++ b/packages/client/src/pages/channel-editor.vue @@ -4,22 +4,22 @@ <MkSpacer :content-max="700"> <div class="_formRoot"> <MkInput v-model="name" class="_formBlock"> - <template #label>{{ $ts.name }}</template> + <template #label>{{ i18n.ts.name }}</template> </MkInput> <MkTextarea v-model="description" class="_formBlock"> - <template #label>{{ $ts.description }}</template> + <template #label>{{ i18n.ts.description }}</template> </MkTextarea> <div class="banner"> - <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ $ts._channel.setBanner }}</MkButton> + <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ i18n.ts._channel.setBanner }}</MkButton> <div v-else-if="bannerUrl"> <img :src="bannerUrl" style="width: 100%;"/> - <MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ $ts._channel.removeBanner }}</MkButton> + <MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ i18n.ts._channel.removeBanner }}</MkButton> </div> </div> <div class="_formBlock"> - <MkButton primary @click="save()"><i class="fas fa-save"></i> {{ channelId ? $ts.save : $ts.create }}</MkButton> + <MkButton primary @click="save()"><i class="fas fa-save"></i> {{ channelId ? i18n.ts.save : i18n.ts.create }}</MkButton> </div> </div> </MkSpacer> @@ -29,7 +29,7 @@ <script lang="ts" setup> import { computed, inject, watch } from 'vue'; import MkTextarea from '@/components/form/textarea.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; import { selectFile } from '@/scripts/select-file'; import * as os from '@/os'; @@ -111,11 +111,9 @@ const headerTabs = $computed(() => []); definePageMetadata(computed(() => props.channelId ? { title: i18n.ts._channel.edit, icon: 'fas fa-satellite-dish', - bg: 'var(--bg)', } : { title: i18n.ts._channel.create, icon: 'fas fa-satellite-dish', - bg: 'var(--bg)', })); </script> diff --git a/packages/client/src/pages/channel.vue b/packages/client/src/pages/channel.vue index 003ad5cce9..380c3efc8e 100644 --- a/packages/client/src/pages/channel.vue +++ b/packages/client/src/pages/channel.vue @@ -13,8 +13,8 @@ </div> <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner"> <div class="status"> - <div><i class="fas fa-users fa-fw"></i><I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div> - <div><i class="fas fa-pencil-alt fa-fw"></i><I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div> + <div><i class="fas fa-users fa-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div> + <div><i class="fas fa-pencil-alt fa-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div> </div> <div class="fade"></div> </div> @@ -33,10 +33,10 @@ <script lang="ts" setup> import { computed, inject, watch } from 'vue'; -import MkContainer from '@/components/ui/container.vue'; -import XPostForm from '@/components/post-form.vue'; -import XTimeline from '@/components/timeline.vue'; -import XChannelFollowButton from '@/components/channel-follow-button.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import XPostForm from '@/components/MkPostForm.vue'; +import XTimeline from '@/components/MkTimeline.vue'; +import XChannelFollowButton from '@/components/MkChannelFollowButton.vue'; import * as os from '@/os'; import { useRouter } from '@/router'; import { $i } from '@/account'; @@ -80,7 +80,6 @@ const headerTabs = $computed(() => []); definePageMetadata(computed(() => channel ? { title: channel.name, icon: 'fas fa-satellite-dish', - bg: 'var(--bg)', } : null)); </script> diff --git a/packages/client/src/pages/channels.vue b/packages/client/src/pages/channels.vue index 89d23350f2..56ea98d15e 100644 --- a/packages/client/src/pages/channels.vue +++ b/packages/client/src/pages/channels.vue @@ -1,6 +1,6 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="700"> <div v-if="tab === 'featured'" class="_content grwlizim featured"> <MkPagination v-slot="{items}" :pagination="featuredPagination"> @@ -24,9 +24,9 @@ <script lang="ts" setup> import { computed, defineComponent, inject } from 'vue'; -import MkChannelPreview from '@/components/channel-preview.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkChannelPreview from '@/components/MkChannelPreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; import { useRouter } from '@/router'; import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; @@ -59,25 +59,21 @@ const headerActions = $computed(() => [{ }]); const headerTabs = $computed(() => [{ - active: tab === 'featured', + key: 'featured', title: i18n.ts._channel.featured, icon: 'fas fa-fire-alt', - onClick: () => { tab = 'featured'; }, }, { - active: tab === 'following', + key: 'following', title: i18n.ts._channel.following, icon: 'fas fa-heart', - onClick: () => { tab = 'following'; }, }, { - active: tab === 'owned', + key: 'owned', title: i18n.ts._channel.owned, icon: 'fas fa-edit', - onClick: () => { tab = 'owned'; }, }]); definePageMetadata(computed(() => ({ title: i18n.ts.channel, icon: 'fas fa-satellite-dish', - bg: 'var(--bg)', }))); </script> diff --git a/packages/client/src/pages/clip.vue b/packages/client/src/pages/clip.vue index ce21b4c809..5b56651bdd 100644 --- a/packages/client/src/pages/clip.vue +++ b/packages/client/src/pages/clip.vue @@ -21,7 +21,7 @@ <script lang="ts" setup> import { computed, watch, provide } from 'vue'; import * as misskey from 'misskey-js'; -import XNotes from '@/components/notes.vue'; +import XNotes from '@/components/MkNotes.vue'; import { $i } from '@/account'; import { i18n } from '@/i18n'; import * as os from '@/os'; @@ -102,7 +102,6 @@ const headerActions = $computed(() => clip && isOwned ? [{ definePageMetadata(computed(() => clip ? { title: clip.name, icon: 'fas fa-paperclip', - bg: 'var(--bg)', } : null)); </script> diff --git a/packages/client/src/pages/drive.vue b/packages/client/src/pages/drive.vue index c7bc31135c..088f0eacdc 100644 --- a/packages/client/src/pages/drive.vue +++ b/packages/client/src/pages/drive.vue @@ -6,7 +6,7 @@ <script lang="ts" setup> import { computed } from 'vue'; -import XDrive from '@/components/drive.vue'; +import XDrive from '@/components/MkDrive.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -20,7 +20,6 @@ const headerTabs = $computed(() => []); definePageMetadata(computed(() => ({ title: folder ? folder.name : i18n.ts.drive, icon: 'fas fa-cloud', - bg: 'var(--bg)', hideHeader: true, }))); </script> diff --git a/packages/client/src/pages/emojis.vue b/packages/client/src/pages/emojis.vue deleted file mode 100644 index 1592995844..0000000000 --- a/packages/client/src/pages/emojis.vue +++ /dev/null @@ -1,60 +0,0 @@ -<template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <div :class="$style.root"> - <XCategory v-if="tab === 'category'"/> - </div> -</MkStickyContainer> -</template> - -<script lang="ts" setup> -import { ref, computed } from 'vue'; -import XCategory from './emojis.category.vue'; -import * as os from '@/os'; -import { i18n } from '@/i18n'; -import { definePageMetadata } from '@/scripts/page-metadata'; - -const tab = ref('category'); - -function menu(ev) { - os.popupMenu([{ - icon: 'fas fa-download', - text: i18n.ts.export, - action: async () => { - os.api('export-custom-emojis', { - }) - .then(() => { - os.alert({ - type: 'info', - text: i18n.ts.exportRequested, - }); - }).catch((err) => { - os.alert({ - type: 'error', - text: err.message, - }); - }); - }, - }], ev.currentTarget ?? ev.target); -} - -const headerActions = $computed(() => [{ - icon: 'fas fa-ellipsis-h', - handler: menu, -}]); - -const headerTabs = $computed(() => []); - -definePageMetadata({ - title: i18n.ts.customEmojis, - icon: 'fas fa-laugh', - bg: 'var(--bg)', -}); -</script> - -<style lang="scss" module> -.root { - max-width: 1000px; - margin: 0 auto; -} -</style> diff --git a/packages/client/src/pages/explore.featured.vue b/packages/client/src/pages/explore.featured.vue new file mode 100644 index 0000000000..18a371a086 --- /dev/null +++ b/packages/client/src/pages/explore.featured.vue @@ -0,0 +1,30 @@ +<template> +<MkSpacer :content-max="800"> + <MkTab v-model="tab" style="margin-bottom: var(--margin);"> + <option value="notes">{{ i18n.ts.notes }}</option> + <option value="polls">{{ i18n.ts.poll }}</option> + </MkTab> + <XNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/> + <XNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> +</MkSpacer> +</template> + +<script lang="ts" setup> +import XNotes from '@/components/MkNotes.vue'; +import MkTab from '@/components/MkTab.vue'; +import { i18n } from '@/i18n'; + +const paginationForNotes = { + endpoint: 'notes/featured' as const, + limit: 10, + offsetMode: true, +}; + +const paginationForPolls = { + endpoint: 'notes/polls/recommendation' as const, + limit: 10, + offsetMode: true, +}; + +let tab = $ref('notes'); +</script> diff --git a/packages/client/src/pages/explore.users.vue b/packages/client/src/pages/explore.users.vue new file mode 100644 index 0000000000..e16e40b8ed --- /dev/null +++ b/packages/client/src/pages/explore.users.vue @@ -0,0 +1,148 @@ +<template> +<MkSpacer :content-max="1200"> + <MkTab v-model="origin" style="margin-bottom: var(--margin);"> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkTab> + <div v-if="origin === 'local'"> + <template v-if="tag == null"> + <MkFolder class="_gap" persist-key="explore-pinned-users"> + <template #header><i class="fas fa-bookmark fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template> + <XUserList :pagination="pinnedUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-popular-users"> + <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template> + <XUserList :pagination="popularUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-recently-updated-users"> + <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template> + <XUserList :pagination="recentlyUpdatedUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-recently-registered-users"> + <template #header><i class="fas fa-plus fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template> + <XUserList :pagination="recentlyRegisteredUsers"/> + </MkFolder> + </template> + </div> + <div v-else> + <MkFolder ref="tagsEl" :foldable="true" :expanded="false" class="_gap"> + <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template> + + <div class="vxjfqztj"> + <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA> + <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA> + </div> + </MkFolder> + + <MkFolder v-if="tag != null" :key="`${tag}`" class="_gap"> + <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template> + <XUserList :pagination="tagUsers"/> + </MkFolder> + + <template v-if="tag == null"> + <MkFolder class="_gap"> + <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template> + <XUserList :pagination="popularUsersF"/> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template> + <XUserList :pagination="recentlyUpdatedUsersF"/> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-rocket fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template> + <XUserList :pagination="recentlyRegisteredUsersF"/> + </MkFolder> + </template> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import XUserList from '@/components/MkUserList.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkTab from '@/components/MkTab.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; + +const props = defineProps<{ + tag?: string; +}>(); + +let origin = $ref('local'); +let tagsEl = $ref<InstanceType<typeof MkFolder>>(); +let tagsLocal = $ref([]); +let tagsRemote = $ref([]); + +watch(() => props.tag, () => { + if (tagsEl) tagsEl.toggleContent(props.tag == null); +}); + +const tagUsers = $computed(() => ({ + endpoint: 'hashtags/users' as const, + limit: 30, + params: { + tag: props.tag, + origin: 'combined', + sort: '+follower', + }, +})); + +const pinnedUsers = { endpoint: 'pinned-users' }; +const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'local', + sort: '+follower', +} }; +const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + sort: '+updatedAt', +} }; +const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + state: 'alive', + sort: '+createdAt', +} }; +const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'remote', + sort: '+follower', +} }; +const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+updatedAt', +} }; +const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+createdAt', +} }; + +os.api('hashtags/list', { + sort: '+attachedLocalUsers', + attachedToLocalUserOnly: true, + limit: 30, +}).then(tags => { + tagsLocal = tags; +}); +os.api('hashtags/list', { + sort: '+attachedRemoteUsers', + attachedToRemoteUserOnly: true, + limit: 30, +}).then(tags => { + tagsRemote = tags; +}); +</script> + +<style lang="scss" scoped> +.vxjfqztj { + > * { + margin-right: 16px; + + &.local { + font-weight: bold; + } + } +} +</style> diff --git a/packages/client/src/pages/explore.vue b/packages/client/src/pages/explore.vue index 26e201cd99..279960d139 100644 --- a/packages/client/src/pages/explore.vue +++ b/packages/client/src/pages/explore.vue @@ -1,91 +1,39 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="1200"> - <div class="lznhrdub"> - <div v-if="tab === 'local'"> - <div v-if="instance && stats && tag == null" class="localfedi7 _block _isolated" :style="{ backgroundImage: instance.bannerUrl ? `url(${instance.bannerUrl})` : null }"> - <header><span>{{ $t('explore', { host: instance.name || 'Misskey' }) }}</span></header> - <div><span>{{ $t('exploreUsersCount', { count: number(stats.originalUsersCount) }) }}</span></div> - </div> - - <template v-if="tag == null"> - <MkFolder class="_gap" persist-key="explore-pinned-users"> - <template #header><i class="fas fa-bookmark fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.pinnedUsers }}</template> - <XUserList :pagination="pinnedUsers"/> - </MkFolder> - <MkFolder class="_gap" persist-key="explore-popular-users"> - <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template> - <XUserList :pagination="popularUsers"/> - </MkFolder> - <MkFolder class="_gap" persist-key="explore-recently-updated-users"> - <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template> - <XUserList :pagination="recentlyUpdatedUsers"/> - </MkFolder> - <MkFolder class="_gap" persist-key="explore-recently-registered-users"> - <template #header><i class="fas fa-plus fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyRegisteredUsers }}</template> - <XUserList :pagination="recentlyRegisteredUsers"/> - </MkFolder> - </template> - </div> - <div v-else-if="tab === 'remote'"> - <div v-if="tag == null" class="localfedi7 _block _isolated" :style="{ backgroundImage: `url(/client-assets/fedi.jpg)` }"> - <header><span>{{ $ts.exploreFediverse }}</span></header> - </div> - - <MkFolder ref="tagsEl" :foldable="true" :expanded="false" class="_gap"> - <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularTags }}</template> - - <div class="vxjfqztj"> - <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA> - <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA> - </div> - </MkFolder> - - <MkFolder v-if="tag != null" :key="`${tag}`" class="_gap"> - <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template> - <XUserList :pagination="tagUsers"/> - </MkFolder> - - <template v-if="tag == null"> - <MkFolder class="_gap"> - <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template> - <XUserList :pagination="popularUsersF"/> - </MkFolder> - <MkFolder class="_gap"> - <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template> - <XUserList :pagination="recentlyUpdatedUsersF"/> - </MkFolder> - <MkFolder class="_gap"> - <template #header><i class="fas fa-rocket fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyDiscoveredUsers }}</template> - <XUserList :pagination="recentlyRegisteredUsersF"/> - </MkFolder> - </template> - </div> - <div v-else-if="tab === 'search'"> - <div class="_isolated"> - <MkInput v-model="searchQuery" :debounce="true" type="search"> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <div class="lznhrdub"> + <div v-if="tab === 'featured'"> + <XFeatured/> + </div> + <div v-else-if="tab === 'users'"> + <XUsers/> + </div> + <div v-else-if="tab === 'search'"> + <MkSpacer :content-max="1200"> + <div> + <MkInput v-model="searchQuery" :debounce="true" type="search" class="_formBlock"> <template #prefix><i class="fas fa-search"></i></template> - <template #label>{{ $ts.searchUser }}</template> + <template #label>{{ i18n.ts.searchUser }}</template> </MkInput> - <MkRadios v-model="searchOrigin"> - <option value="combined">{{ $ts.all }}</option> - <option value="local">{{ $ts.local }}</option> - <option value="remote">{{ $ts.remote }}</option> + <MkRadios v-model="searchOrigin" class="_formBlock"> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> </MkRadios> </div> <XUserList v-if="searchQuery" ref="searchEl" class="_gap" :pagination="searchPagination"/> - </div> + </MkSpacer> </div> - </MkSpacer> + </div> </MkStickyContainer> </template> <script lang="ts" setup> -import { computed, defineComponent, watch } from 'vue'; -import XUserList from '@/components/user-list.vue'; -import MkFolder from '@/components/ui/folder.vue'; +import { computed, watch } from 'vue'; +import XFeatured from './explore.featured.vue'; +import XUsers from './explore.users.vue'; +import MkFolder from '@/components/MkFolder.vue'; import MkInput from '@/components/form/input.vue'; import MkRadios from '@/components/form/radios.vue'; import number from '@/filters/number'; @@ -93,16 +41,14 @@ import * as os from '@/os'; import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; +import XUserList from '@/components/MkUserList.vue'; const props = defineProps<{ tag?: string; }>(); -let tab = $ref('local'); +let tab = $ref('featured'); let tagsEl = $ref<InstanceType<typeof MkFolder>>(); -let tagsLocal = $ref([]); -let tagsRemote = $ref([]); -let stats = $ref(null); let searchQuery = $ref(null); let searchOrigin = $ref('combined'); @@ -110,44 +56,6 @@ watch(() => props.tag, () => { if (tagsEl) tagsEl.toggleContent(props.tag == null); }); -const tagUsers = $computed(() => ({ - endpoint: 'hashtags/users' as const, - limit: 30, - params: { - tag: props.tag, - origin: 'combined', - sort: '+follower', - }, -})); - -const pinnedUsers = { endpoint: 'pinned-users' }; -const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { - state: 'alive', - origin: 'local', - sort: '+follower', -} }; -const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { - origin: 'local', - sort: '+updatedAt', -} }; -const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { - origin: 'local', - state: 'alive', - sort: '+createdAt', -} }; -const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { - state: 'alive', - origin: 'remote', - sort: '+follower', -} }; -const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { - origin: 'combined', - sort: '+updatedAt', -} }; -const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { - origin: 'combined', - sort: '+createdAt', -} }; const searchPagination = { endpoint: 'users/search' as const, limit: 10, @@ -157,86 +65,23 @@ const searchPagination = { } : null), }; -os.api('hashtags/list', { - sort: '+attachedLocalUsers', - attachedToLocalUserOnly: true, - limit: 30, -}).then(tags => { - tagsLocal = tags; -}); -os.api('hashtags/list', { - sort: '+attachedRemoteUsers', - attachedToRemoteUserOnly: true, - limit: 30, -}).then(tags => { - tagsRemote = tags; -}); -os.api('stats').then(_stats => { - stats = _stats; -}); - const headerActions = $computed(() => []); const headerTabs = $computed(() => [{ - active: tab === 'local', - title: i18n.ts.local, - onClick: () => { tab = 'local'; }, + key: 'featured', + icon: 'fas fa-bolt', + title: i18n.ts.featured, }, { - active: tab === 'remote', - title: i18n.ts.remote, - onClick: () => { tab = 'remote'; }, + key: 'users', + icon: 'fas fa-users', + title: i18n.ts.users, }, { - active: tab === 'search', + key: 'search', title: i18n.ts.search, - onClick: () => { tab = 'search'; }, }]); definePageMetadata(computed(() => ({ title: i18n.ts.explore, icon: 'fas fa-hashtag', - bg: 'var(--bg)', }))); </script> - -<style lang="scss" scoped> -.localfedi7 { - color: #fff; - padding: 16px; - height: 80px; - background-position: 50%; - background-size: cover; - margin-bottom: var(--margin); - - > * { - &:not(:last-child) { - margin-bottom: 8px; - } - - > span { - display: inline-block; - padding: 6px 8px; - background: rgba(0, 0, 0, 0.7); - } - } - - > header { - font-size: 20px; - font-weight: bold; - } - - > div { - font-size: 14px; - opacity: 0.8; - } -} - -.vxjfqztj { - > * { - margin-right: 16px; - - &.local { - font-weight: bold; - } - } -} -</style> diff --git a/packages/client/src/pages/favorites.vue b/packages/client/src/pages/favorites.vue index 6efca4c221..32a1dbf592 100644 --- a/packages/client/src/pages/favorites.vue +++ b/packages/client/src/pages/favorites.vue @@ -6,7 +6,7 @@ <template #empty> <div class="_fullinfo"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> - <div>{{ $ts.noNotes }}</div> + <div>{{ i18n.ts.noNotes }}</div> </div> </template> @@ -22,9 +22,9 @@ <script lang="ts" setup> import { ref } from 'vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import XNote from '@/components/note.vue'; -import XList from '@/components/date-separated-list.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import XNote from '@/components/MkNote.vue'; +import XList from '@/components/MkDateSeparatedList.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -38,7 +38,6 @@ const pagingComponent = ref<InstanceType<typeof MkPagination>>(); definePageMetadata({ title: i18n.ts.favorites, icon: 'fas fa-star', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/featured.vue b/packages/client/src/pages/featured.vue deleted file mode 100644 index 4e3f67c76c..0000000000 --- a/packages/client/src/pages/featured.vue +++ /dev/null @@ -1,26 +0,0 @@ -<template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> - <MkSpacer :content-max="800"> - <XNotes ref="notes" :pagination="pagination"/> - </MkSpacer> -</MkStickyContainer> -</template> - -<script lang="ts" setup> -import XNotes from '@/components/notes.vue'; -import { i18n } from '@/i18n'; -import { definePageMetadata } from '@/scripts/page-metadata'; - -const pagination = { - endpoint: 'notes/featured' as const, - limit: 10, - offsetMode: true, -}; - -definePageMetadata({ - title: i18n.ts.featured, - icon: 'fas fa-fire-alt', - bg: 'var(--bg)', -}); -</script> diff --git a/packages/client/src/pages/federation.vue b/packages/client/src/pages/federation.vue deleted file mode 100644 index 38d42f2be4..0000000000 --- a/packages/client/src/pages/federation.vue +++ /dev/null @@ -1,122 +0,0 @@ -<template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="1000"> - <div class="taeiyria"> - <div class="query"> - <MkInput v-model="host" :debounce="true" class=""> - <template #prefix><i class="fas fa-search"></i></template> - <template #label>{{ $ts.host }}</template> - </MkInput> - <FormSplit style="margin-top: var(--margin);"> - <MkSelect v-model="state"> - <template #label>{{ $ts.state }}</template> - <option value="all">{{ $ts.all }}</option> - <option value="federating">{{ $ts.federating }}</option> - <option value="subscribing">{{ $ts.subscribing }}</option> - <option value="publishing">{{ $ts.publishing }}</option> - <option value="suspended">{{ $ts.suspended }}</option> - <option value="blocked">{{ $ts.blocked }}</option> - <option value="notResponding">{{ $ts.notResponding }}</option> - </MkSelect> - <MkSelect v-model="sort"> - <template #label>{{ $ts.sort }}</template> - <option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option> - <option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option> - <option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option> - <option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option> - <option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option> - <option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option> - <option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option> - <option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option> - <option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option> - <option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option> - <option value="+caughtAt">{{ $ts.registeredAt }} ({{ $ts.descendingOrder }})</option> - <option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option> - <option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option> - <option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option> - </MkSelect> - </FormSplit> - </div> - - <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> - <div class="dqokceoi"> - <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Last communicated: ${new Date(instance.lastCommunicatedAt).toLocaleString()}\nStatus: ${getStatus(instance)}`" class="instance" :to="`/instance-info/${instance.host}`" :behavior="'window'"> - <MkInstanceInfo :instance="instance"/> - </MkA> - </div> - </MkPagination> - </div> - </MkSpacer> -</MkStickyContainer> -</template> - -<script lang="ts" setup> -import { computed } from 'vue'; -import MkButton from '@/components/ui/button.vue'; -import MkInput from '@/components/form/input.vue'; -import MkSelect from '@/components/form/select.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkInstanceInfo from '@/components/instance-info.vue'; -import FormSplit from '@/components/form/split.vue'; -import * as os from '@/os'; -import { i18n } from '@/i18n'; -import { definePageMetadata } from '@/scripts/page-metadata'; - -let host = $ref(''); -let state = $ref('federating'); -let sort = $ref('+pubSub'); -const pagination = { - endpoint: 'federation/instances' as const, - limit: 10, - offsetMode: true, - params: computed(() => ({ - sort: sort, - host: host !== '' ? host : null, - ...( - state === 'federating' ? { federating: true } : - state === 'subscribing' ? { subscribing: true } : - state === 'publishing' ? { publishing: true } : - state === 'suspended' ? { suspended: true } : - state === 'blocked' ? { blocked: true } : - state === 'notResponding' ? { notResponding: true } : - {}), - })), -}; - -function getStatus(instance) { - if (instance.isSuspended) return 'Suspended'; - if (instance.isBlocked) return 'Blocked'; - if (instance.isNotResponding) return 'Error'; - return 'Alive'; -} - -const headerActions = $computed(() => []); - -const headerTabs = $computed(() => []); - -definePageMetadata({ - title: i18n.ts.federation, - icon: 'fas fa-globe', - bg: 'var(--bg)', -}); -</script> - -<style lang="scss" scoped> -.taeiyria { - > .query { - background: var(--bg); - margin-bottom: 16px; - } -} - -.dqokceoi { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); - grid-gap: 12px; - - > .instance:hover { - text-decoration: none; - } -} -</style> diff --git a/packages/client/src/pages/follow-requests.vue b/packages/client/src/pages/follow-requests.vue index e6f9a9a5d9..82d7164260 100644 --- a/packages/client/src/pages/follow-requests.vue +++ b/packages/client/src/pages/follow-requests.vue @@ -1,39 +1,42 @@ <template> -<div> - <MkPagination ref="paginationComponent" :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> - <div>{{ $ts.noFollowRequests }}</div> - </div> - </template> - <template #default="{items}"> - <div class="mk-follow-requests"> - <div v-for="req in items" :key="req.id" class="user _panel"> - <MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/> - <div class="body"> - <div class="name"> - <MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA> - <p class="acct">@{{ acct(req.follower) }}</p> - </div> - <div v-if="req.follower.description" class="description" :title="req.follower.description"> - <Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/> - </div> - <div class="actions"> - <button class="_button" @click="accept(req.follower)"><i class="fas fa-check"></i></button> - <button class="_button" @click="reject(req.follower)"><i class="fas fa-times"></i></button> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :content-max="800"> + <MkPagination ref="paginationComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noFollowRequests }}</div> + </div> + </template> + <template #default="{items}"> + <div class="mk-follow-requests"> + <div v-for="req in items" :key="req.id" class="user _panel"> + <MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/> + <div class="body"> + <div class="name"> + <MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA> + <p class="acct">@{{ acct(req.follower) }}</p> + </div> + <div v-if="req.follower.description" class="description" :title="req.follower.description"> + <Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/> + </div> + <div class="actions"> + <button class="_button" @click="accept(req.follower)"><i class="fas fa-check"></i></button> + <button class="_button" @click="reject(req.follower)"><i class="fas fa-times"></i></button> + </div> </div> </div> </div> - </div> - </template> - </MkPagination> -</div> + </template> + </MkPagination> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; -import MkPagination from '@/components/ui/pagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import { userPage, acct } from '@/filters/user'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -65,7 +68,6 @@ const headerTabs = $computed(() => []); definePageMetadata(computed(() => ({ title: i18n.ts.followRequests, icon: 'fas fa-user-clock', - bg: 'var(--bg)', }))); </script> diff --git a/packages/client/src/pages/follow.vue b/packages/client/src/pages/follow.vue index 0c1cb7733b..828246d678 100644 --- a/packages/client/src/pages/follow.vue +++ b/packages/client/src/pages/follow.vue @@ -3,63 +3,60 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import * as Acct from 'misskey-js/built/acct'; import * as os from '@/os'; import { mainRouter } from '@/router'; +import { i18n } from '@/i18n'; -export default defineComponent({ - created() { - const acct = new URL(location.href).searchParams.get('acct'); - if (acct == null) return; +async function follow(user): Promise<void> { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('followConfirm', { name: user.name || user.username }), + }); - let promise; + if (canceled) { + window.close(); + return; + } + + os.apiWithDialog('following/create', { + userId: user.id, + }); +} - if (acct.startsWith('https://')) { - promise = os.api('ap/show', { - uri: acct, - }); - promise.then(res => { - if (res.type === 'User') { - this.follow(res.object); - } else if (res.type === 'Note') { - mainRouter.push(`/notes/${res.object.id}`); - } else { - os.alert({ - type: 'error', - text: 'Not a user', - }).then(() => { - window.close(); - }); - } - }); - } else { - promise = os.api('users/show', Acct.parse(acct)); - promise.then(user => { - this.follow(user); - }); - } +const acct = new URL(location.href).searchParams.get('acct'); +if (acct == null) { + throw new Error('acct required'); +} - os.promiseDialog(promise, null, null, this.$ts.fetchingAsApObject); - }, +let promise; - methods: { - async follow(user) { - const { canceled } = await os.confirm({ - type: 'question', - text: this.$t('followConfirm', { name: user.name || user.username }), - }); - - if (canceled) { +if (acct.startsWith('https://')) { + promise = os.api('ap/show', { + uri: acct, + }); + promise.then(res => { + if (res.type === 'User') { + follow(res.object); + } else if (res.type === 'Note') { + mainRouter.push(`/notes/${res.object.id}`); + } else { + os.alert({ + type: 'error', + text: 'Not a user', + }).then(() => { window.close(); - return; - } - - os.apiWithDialog('following/create', { - userId: user.id, }); - }, - }, -}); + } + }); +} else { + promise = os.api('users/show', Acct.parse(acct)); + promise.then(user => { + follow(user); + }); +} + +os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); </script> diff --git a/packages/client/src/pages/gallery/edit.vue b/packages/client/src/pages/gallery/edit.vue index 6d1140ba3a..8f716d9eb3 100644 --- a/packages/client/src/pages/gallery/edit.vue +++ b/packages/client/src/pages/gallery/edit.vue @@ -1,39 +1,41 @@ <template> -<div> - <FormSuspense :p="init"> - <FormInput v-model="title"> - <template #label>{{ $ts.title }}</template> - </FormInput> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <FormInput v-model="title"> + <template #label>{{ i18n.ts.title }}</template> + </FormInput> - <FormTextarea v-model="description" :max="500"> - <template #label>{{ $ts.description }}</template> - </FormTextarea> + <FormTextarea v-model="description" :max="500"> + <template #label>{{ i18n.ts.description }}</template> + </FormTextarea> - <FormGroup> - <div v-for="file in files" :key="file.id" class="_formGroup wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }"> - <div class="name">{{ file.name }}</div> - <button v-tooltip="$ts.remove" class="remove _button" @click="remove(file)"><i class="fas fa-times"></i></button> + <div class=""> + <div v-for="file in files" :key="file.id" class="wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }"> + <div class="name">{{ file.name }}</div> + <button v-tooltip="i18n.ts.remove" class="remove _button" @click="remove(file)"><i class="fas fa-times"></i></button> + </div> + <FormButton primary @click="selectFile"><i class="fas fa-plus"></i> {{ i18n.ts.attachFile }}</FormButton> </div> - <FormButton primary @click="selectFile"><i class="fas fa-plus"></i> {{ $ts.attachFile }}</FormButton> - </FormGroup> - <FormSwitch v-model="isSensitive">{{ $ts.markAsSensitive }}</FormSwitch> + <FormSwitch v-model="isSensitive">{{ i18n.ts.markAsSensitive }}</FormSwitch> - <FormButton v-if="postId" primary @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> - <FormButton v-else primary @click="save"><i class="fas fa-save"></i> {{ $ts.publish }}</FormButton> + <FormButton v-if="postId" primary @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton> + <FormButton v-else primary @click="save"><i class="fas fa-save"></i> {{ i18n.ts.publish }}</FormButton> - <FormButton v-if="postId" danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</FormButton> - </FormSuspense> -</div> + <FormButton v-if="postId" danger @click="del"><i class="fas fa-trash-alt"></i> {{ i18n.ts.delete }}</FormButton> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { computed, inject, watch } from 'vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import FormInput from '@/components/form/input.vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormSwitch from '@/components/form/switch.vue'; -import FormGroup from '@/components/form/group.vue'; import FormSuspense from '@/components/form/suspense.vue'; import { selectFiles } from '@/scripts/select-file'; import * as os from '@/os'; @@ -72,7 +74,7 @@ async function save() { fileIds: files.map(file => file.id), isSensitive: isSensitive, }); - mainRouter.push(`/gallery/${props.postId}`); + router.push(`/gallery/${props.postId}`); } else { const created = await os.apiWithDialog('gallery/posts/create', { title: title, @@ -93,7 +95,7 @@ async function del() { await os.apiWithDialog('gallery/posts/delete', { postId: props.postId, }); - mainRouter.push('/gallery'); + router.push('/gallery'); } watch(() => props.postId, () => { diff --git a/packages/client/src/pages/gallery/index.vue b/packages/client/src/pages/gallery/index.vue index b26470dbe9..598383217e 100644 --- a/packages/client/src/pages/gallery/index.vue +++ b/packages/client/src/pages/gallery/index.vue @@ -1,17 +1,11 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="1400"> <div class="_root"> - <MkTab v-if="$i" v-model="tab"> - <option value="explore"><i class="fas fa-icons"></i> {{ $ts.gallery }}</option> - <option value="liked"><i class="fas fa-heart"></i> {{ $ts._gallery.liked }}</option> - <option value="my"><i class="fas fa-edit"></i> {{ $ts._gallery.my }}</option> - </MkTab> - <div v-if="tab === 'explore'"> <MkFolder class="_gap"> - <template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template> + <template #header><i class="fas fa-clock"></i>{{ i18n.ts.recentPosts }}</template> <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true"> <div class="vfpdbgtk"> <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> @@ -19,7 +13,7 @@ </MkPagination> </MkFolder> <MkFolder class="_gap"> - <template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template> + <template #header><i class="fas fa-fire-alt"></i>{{ i18n.ts.popularPosts }}</template> <MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true"> <div class="vfpdbgtk"> <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> @@ -35,7 +29,7 @@ </MkPagination> </div> <div v-else-if="tab === 'my'"> - <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA> + <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ i18n.ts.postToGallery }}</MkA> <MkPagination v-slot="{items}" :pagination="myPostsPagination"> <div class="vfpdbgtk"> <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> @@ -49,17 +43,20 @@ <script lang="ts" setup> import { computed, defineComponent, watch } from 'vue'; -import XUserList from '@/components/user-list.vue'; -import MkFolder from '@/components/ui/folder.vue'; +import XUserList from '@/components/MkUserList.vue'; +import MkFolder from '@/components/MkFolder.vue'; import MkInput from '@/components/form/input.vue'; -import MkButton from '@/components/ui/button.vue'; -import MkTab from '@/components/tab.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkGalleryPostPreview from '@/components/gallery-post-preview.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; import number from '@/filters/number'; import * as os from '@/os'; import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; +import { useRouter } from '@/router'; + +const router = useRouter(); const props = defineProps<{ tag?: string; @@ -100,14 +97,31 @@ watch(() => props.tag, () => { if (tagsRef) tagsRef.tags.toggleContent(props.tag == null); }); -const headerActions = $computed(() => []); +const headerActions = $computed(() => [{ + icon: 'fas fa-plus', + text: i18n.ts.create, + handler: () => { + router.push('/gallery/new'); + }, +}]); -const headerTabs = $computed(() => []); +const headerTabs = $computed(() => [{ + key: 'explore', + title: i18n.ts.gallery, + icon: 'fas fa-icons', +}, { + key: 'liked', + title: i18n.ts._gallery.liked, + icon: 'fas fa-heart', +}, { + key: 'my', + title: i18n.ts._gallery.my, + icon: 'fas fa-edit', +}]); definePageMetadata({ title: i18n.ts.gallery, icon: 'fas fa-icons', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/gallery/post.vue b/packages/client/src/pages/gallery/post.vue index d5f3253b3e..3804bcdcf5 100644 --- a/packages/client/src/pages/gallery/post.vue +++ b/packages/client/src/pages/gallery/post.vue @@ -1,63 +1,68 @@ <template> -<div class="_root"> - <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> - <div v-if="post" class="rkxwuolj"> - <div class="files"> - <div v-for="file in post.files" :key="file.id" class="file"> - <img :src="file.url"/> - </div> - </div> - <div class="body _block"> - <div class="title">{{ post.title }}</div> - <div class="description"><Mfm :text="post.description"/></div> - <div class="info"> - <i class="fas fa-clock"></i> <MkTime :time="post.createdAt" mode="detail"/> - </div> - <div class="actions"> - <div class="like"> - <MkButton v-if="post.isLiked" v-tooltip="$ts._gallery.unlike" class="button" primary @click="unlike()"><i class="fas fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton> - <MkButton v-else v-tooltip="$ts._gallery.like" class="button" @click="like()"><i class="far fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000" :margin-min="16" :margin-max="32"> + <div class="_root"> + <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <div v-if="post" class="rkxwuolj"> + <div class="files"> + <div v-for="file in post.files" :key="file.id" class="file"> + <img :src="file.url"/> + </div> </div> - <div class="other"> - <button v-if="$i && $i.id === post.user.id" v-tooltip="$ts.edit" v-click-anime class="_button" @click="edit"><i class="fas fa-pencil-alt fa-fw"></i></button> - <button v-tooltip="$ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="fas fa-retweet fa-fw"></i></button> - <button v-tooltip="$ts.share" v-click-anime class="_button" @click="share"><i class="fas fa-share-alt fa-fw"></i></button> - </div> - </div> - <div class="user"> - <MkAvatar :user="post.user" class="avatar"/> - <div class="name"> - <MkUserName :user="post.user" style="display: block;"/> - <MkAcct :user="post.user"/> + <div class="body _block"> + <div class="title">{{ post.title }}</div> + <div class="description"><Mfm :text="post.description"/></div> + <div class="info"> + <i class="fas fa-clock"></i> <MkTime :time="post.createdAt" mode="detail"/> + </div> + <div class="actions"> + <div class="like"> + <MkButton v-if="post.isLiked" v-tooltip="i18n.ts._gallery.unlike" class="button" primary @click="unlike()"><i class="fas fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts._gallery.like" class="button" @click="like()"><i class="far fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton> + </div> + <div class="other"> + <button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="fas fa-pencil-alt fa-fw"></i></button> + <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="fas fa-retweet fa-fw"></i></button> + <button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="fas fa-share-alt fa-fw"></i></button> + </div> + </div> + <div class="user"> + <MkAvatar :user="post.user" class="avatar"/> + <div class="name"> + <MkUserName :user="post.user" style="display: block;"/> + <MkAcct :user="post.user"/> + </div> + <MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + </div> </div> - <MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + <MkAd :prefer="['horizontal', 'horizontal-big']"/> + <MkContainer :max-height="300" :foldable="true" class="other"> + <template #header><i class="fas fa-clock"></i> {{ i18n.ts.recentPosts }}</template> + <MkPagination v-slot="{items}" :pagination="otherPostsPagination"> + <div class="sdrarzaf"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> + </MkContainer> </div> - </div> - <MkAd :prefer="['horizontal', 'horizontal-big']"/> - <MkContainer :max-height="300" :foldable="true" class="other"> - <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template> - <MkPagination v-slot="{items}" :pagination="otherPostsPagination"> - <div class="sdrarzaf"> - <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> - </div> - </MkPagination> - </MkContainer> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> </div> - <MkError v-else-if="error" @retry="fetch()"/> - <MkLoading v-else/> - </transition> -</div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { computed, defineComponent, inject, watch } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; -import MkContainer from '@/components/ui/container.vue'; -import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkGalleryPostPreview from '@/components/gallery-post-preview.vue'; -import MkFollowButton from '@/components/follow-button.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; +import MkFollowButton from '@/components/MkFollowButton.vue'; import { url } from '@/config'; import { useRouter } from '@/router'; import { i18n } from '@/i18n'; @@ -69,8 +74,8 @@ const props = defineProps<{ postId: string; }>(); -const post = $ref(null); -const error = $ref(null); +let post = $ref(null); +let error = $ref(null); const otherPostsPagination = { endpoint: 'users/gallery/posts' as const, limit: 6, @@ -133,23 +138,17 @@ function edit() { watch(() => props.postId, fetchPost, { immediate: true }); -const headerActions = $computed(() => []); +const headerActions = $computed(() => [{ + icon: 'fas fa-pencil-alt', + text: i18n.ts.edit, + handler: edit, +}]); const headerTabs = $computed(() => []); definePageMetadata(computed(() => post ? { title: post.title, avatar: post.user, - path: `/gallery/${post.id}`, - share: { - title: post.title, - text: post.description, - }, - actions: [{ - icon: 'fas fa-pencil-alt', - text: i18n.ts.edit, - handler: edit, - }], } : null)); </script> diff --git a/packages/client/src/pages/instance-info.vue b/packages/client/src/pages/instance-info.vue index 11e3618574..6e8560ef42 100644 --- a/packages/client/src/pages/instance-info.vue +++ b/packages/client/src/pages/instance-info.vue @@ -1,66 +1,67 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer v-if="instance" :content-max="600" :margin-min="16" :margin-max="32"> <div v-if="tab === 'overview'" class="_formRoot"> <div class="fnfelxur"> <img :src="instance.iconUrl || instance.faviconUrl" alt="" class="icon"/> + <span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span> </div> <MkKeyValue :copy="host" oneline style="margin: 1em 0;"> <template #key>Host</template> <template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template> </MkKeyValue> <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>Name</template> - <template #value>{{ instance.name || `(${$ts.unknown})` }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ $ts.description }}</template> - <template #value>{{ instance.description }}</template> + <template #key>{{ i18n.ts.software }}</template> + <template #value><span class="_monospace">{{ instance.softwareName || `(${i18n.ts.unknown})` }} / {{ instance.softwareVersion || `(${i18n.ts.unknown})` }}</span></template> </MkKeyValue> <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.software }}</template> - <template #value><span class="_monospace">{{ instance.softwareName || `(${$ts.unknown})` }} / {{ instance.softwareVersion || `(${$ts.unknown})` }}</span></template> + <template #key>{{ i18n.ts.administrator }}</template> + <template #value>{{ instance.maintainerName || `(${i18n.ts.unknown})` }} ({{ instance.maintainerEmail || `(${i18n.ts.unknown})` }})</template> </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.administrator }}</template> - <template #value>{{ instance.maintainerName || `(${$ts.unknown})` }} ({{ instance.maintainerEmail || `(${$ts.unknown})` }})</template> + <MkKeyValue> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ instance.description }}</template> </MkKeyValue> <FormSection v-if="iAmModerator"> <template #label>Moderation</template> - <FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.stopActivityDelivery }}</FormSwitch> - <FormSwitch v-model="isBlocked" class="_formBlock" @update:modelValue="toggleBlock">{{ $ts.blockThisInstance }}</FormSwitch> - <MkButton @click="refreshMetadata">Refresh metadata</MkButton> + <FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</FormSwitch> + <FormSwitch v-model="isBlocked" class="_formBlock" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</FormSwitch> + <MkButton @click="refreshMetadata"><i class="fas fa-refresh"></i> Refresh metadata</MkButton> </FormSection> <FormSection> <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.registeredAt }}</template> + <template #key>{{ i18n.ts.registeredAt }}</template> <template #value><MkTime mode="detail" :time="instance.caughtAt"/></template> </MkKeyValue> <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.updatedAt }}</template> + <template #key>{{ i18n.ts.updatedAt }}</template> <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template> </MkKeyValue> <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.latestRequestSentAt }}</template> + <template #key>{{ i18n.ts.latestRequestSentAt }}</template> <template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template> </MkKeyValue> <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.latestStatus }}</template> + <template #key>{{ i18n.ts.latestStatus }}</template> <template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template> </MkKeyValue> <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.latestRequestReceivedAt }}</template> + <template #key>{{ i18n.ts.latestRequestReceivedAt }}</template> <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template> </MkKeyValue> </FormSection> <FormSection> <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>Open Registrations</template> - <template #value>{{ instance.openRegistrations ? $ts.yes : $ts.no }}</template> + <template #key>Following (Pub)</template> + <template #value>{{ number(instance.followingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>Followers (Sub)</template> + <template #value>{{ number(instance.followersCount) }}</template> </MkKeyValue> </FormSection> @@ -73,21 +74,21 @@ <FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink> </FormSection> </div> - <div v-if="tab === 'chart'" class="_formRoot"> + <div v-else-if="tab === 'chart'" class="_formRoot"> <div class="cmhjzshl"> <div class="selects"> <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> - <option value="instance-requests">{{ $ts._instanceCharts.requests }}</option> - <option value="instance-users">{{ $ts._instanceCharts.users }}</option> - <option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option> - <option value="instance-notes">{{ $ts._instanceCharts.notes }}</option> - <option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option> - <option value="instance-ff">{{ $ts._instanceCharts.ff }}</option> - <option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option> - <option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option> - <option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option> - <option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option> - <option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option> + <option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option> + <option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option> + <option value="instance-users-total">{{ i18n.ts._instanceCharts.usersTotal }}</option> + <option value="instance-notes">{{ i18n.ts._instanceCharts.notes }}</option> + <option value="instance-notes-total">{{ i18n.ts._instanceCharts.notesTotal }}</option> + <option value="instance-ff">{{ i18n.ts._instanceCharts.ff }}</option> + <option value="instance-ff-total">{{ i18n.ts._instanceCharts.ffTotal }}</option> + <option value="instance-drive-usage">{{ i18n.ts._instanceCharts.cacheSize }}</option> + <option value="instance-drive-usage-total">{{ i18n.ts._instanceCharts.cacheSizeTotal }}</option> + <option value="instance-drive-files">{{ i18n.ts._instanceCharts.files }}</option> + <option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option> </MkSelect> </div> <div class="charts"> @@ -98,7 +99,14 @@ </div> </div> </div> - <div v-if="tab === 'raw'" class="_formRoot"> + <div v-else-if="tab === 'users'" class="_formRoot"> + <MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;"> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`"> + <MkUserCardMini :user="user"/> + </MkA> + </MkPagination> + </div> + <div v-else-if="tab === 'raw'" class="_formRoot"> <MkObjectView tall :value="instance"> </MkObjectView> </div> @@ -109,13 +117,13 @@ <script lang="ts" setup> import { } from 'vue'; import * as misskey from 'misskey-js'; -import MkChart from '@/components/chart.vue'; -import MkObjectView from '@/components/object-view.vue'; +import MkChart from '@/components/MkChart.vue'; +import MkObjectView from '@/components/MkObjectView.vue'; import FormLink from '@/components/form/link.vue'; -import MkLink from '@/components/link.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkLink from '@/components/MkLink.vue'; +import MkButton from '@/components/MkButton.vue'; import FormSection from '@/components/form/section.vue'; -import MkKeyValue from '@/components/key-value.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; import MkSelect from '@/components/form/select.vue'; import FormSwitch from '@/components/form/switch.vue'; import * as os from '@/os'; @@ -124,30 +132,37 @@ import bytes from '@/filters/bytes'; import { iAmModerator } from '@/account'; import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import MkPagination from '@/components/MkPagination.vue'; const props = defineProps<{ host: string; }>(); let tab = $ref('overview'); +let chartSrc = $ref('instance-requests'); let meta = $ref<misskey.entities.DetailedInstanceMetadata | null>(null); let instance = $ref<misskey.entities.Instance | null>(null); let suspended = $ref(false); let isBlocked = $ref(false); -let chartSrc = $ref('instance-requests'); -async function fetch() { - if (iAmModerator) { - // suspended and blocked information is only displayed to moderators. - // otherwise the API will error anyway +const usersPagination = { + endpoint: iAmModerator ? 'admin/show-users' : 'users' as const, + limit: 10, + params: { + sort: '+updatedAt', + state: 'all', + hostname: props.host, + }, + offsetMode: true, +}; - meta = await os.api('admin/meta', { detail: true }); - instance = await os.api('federation/show-instance', { - host: props.host, - }); - suspended = instance.isSuspended; - isBlocked = meta.blockedHosts.includes(instance.host); - } +async function fetch() { + instance = await os.api('federation/show-instance', { + host: props.host, + }); + suspended = instance.isSuspended; + isBlocked = instance.isBlocked; } async function toggleBlock(ev) { @@ -184,37 +199,44 @@ const headerActions = $computed(() => [{ }]); const headerTabs = $computed(() => [{ - active: tab === 'overview', + key: 'overview', title: i18n.ts.overview, icon: 'fas fa-info-circle', - onClick: () => { tab = 'overview'; }, }, { - active: tab === 'chart', + key: 'chart', title: i18n.ts.charts, icon: 'fas fa-chart-simple', - onClick: () => { tab = 'chart'; }, }, { - active: tab === 'raw', - title: 'Raw data', + key: 'users', + title: i18n.ts.users, + icon: 'fas fa-users', +}, { + key: 'raw', + title: 'Raw', icon: 'fas fa-code', - onClick: () => { tab = 'raw'; }, }]); definePageMetadata({ title: props.host, icon: 'fas fa-server', - bg: 'var(--bg)', }); </script> <style lang="scss" scoped> .fnfelxur { + display: flex; + align-items: center; + > .icon { display: block; - margin: 0; + margin: 0 16px 0 0; height: 64px; border-radius: 8px; } + + > .name { + word-break: break-all; + } } .cmhjzshl { diff --git a/packages/client/src/pages/mentions.vue b/packages/client/src/pages/mentions.vue deleted file mode 100644 index 0835f1f019..0000000000 --- a/packages/client/src/pages/mentions.vue +++ /dev/null @@ -1,27 +0,0 @@ -<template><MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800"> - <XNotes :pagination="pagination"/> -</MkSpacer></MkStickyContainer> -</template> - -<script lang="ts" setup> -import XNotes from '@/components/notes.vue'; -import { i18n } from '@/i18n'; -import { definePageMetadata } from '@/scripts/page-metadata'; - -const pagination = { - endpoint: 'notes/mentions' as const, - limit: 10, -}; - -const headerActions = $computed(() => []); - -const headerTabs = $computed(() => []); - -definePageMetadata({ - title: i18n.ts.mentions, - icon: 'fas fa-at', - bg: 'var(--bg)', -}); -</script> diff --git a/packages/client/src/pages/messages.vue b/packages/client/src/pages/messages.vue deleted file mode 100644 index e443b5c461..0000000000 --- a/packages/client/src/pages/messages.vue +++ /dev/null @@ -1,30 +0,0 @@ -<template><MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800"> - <XNotes :pagination="pagination"/> -</MkSpacer></MkStickyContainer> -</template> - -<script lang="ts" setup> -import XNotes from '@/components/notes.vue'; -import { i18n } from '@/i18n'; -import { definePageMetadata } from '@/scripts/page-metadata'; - -const pagination = { - endpoint: 'notes/mentions' as const, - limit: 10, - params: { - visibility: 'specified', - }, -}; - -const headerActions = $computed(() => []); - -const headerTabs = $computed(() => []); - -definePageMetadata({ - title: i18n.ts.directNotes, - icon: 'fas fa-envelope', - bg: 'var(--bg)', -}); -</script> diff --git a/packages/client/src/pages/messaging/index.vue b/packages/client/src/pages/messaging/index.vue index bf9ac056cf..56d852fe3d 100644 --- a/packages/client/src/pages/messaging/index.vue +++ b/packages/client/src/pages/messaging/index.vue @@ -45,7 +45,7 @@ <script lang="ts" setup> import { defineAsyncComponent, defineComponent, inject, markRaw, onMounted, onUnmounted } from 'vue'; import * as Acct from 'misskey-js/built/acct'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import { acct } from '@/filters/user'; import * as os from '@/os'; import { stream } from '@/stream'; @@ -159,7 +159,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.messaging, icon: 'fas fa-comments', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/messaging/messaging-room.form.vue b/packages/client/src/pages/messaging/messaging-room.form.vue index 38bab90502..4589069df0 100644 --- a/packages/client/src/pages/messaging/messaging-room.form.vue +++ b/packages/client/src/pages/messaging/messaging-room.form.vue @@ -93,7 +93,22 @@ function onDragover(ev: DragEvent) { const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; if (isFile || isDriveFile) { ev.preventDefault(); - ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; + switch (ev.dataTransfer.effectAllowed) { + case 'all': + case 'uninitialized': + case 'copy': + case 'copyLink': + case 'copyMove': + ev.dataTransfer.dropEffect = 'copy'; + break; + case 'linkMove': + case 'move': + ev.dataTransfer.dropEffect = 'move'; + break; + default: + ev.dataTransfer.dropEffect = 'none'; + break; + } } } diff --git a/packages/client/src/pages/messaging/messaging-room.message.vue b/packages/client/src/pages/messaging/messaging-room.message.vue index 393d2a17b2..2b5a9569a1 100644 --- a/packages/client/src/pages/messaging/messaging-room.message.vue +++ b/packages/client/src/pages/messaging/messaging-room.message.vue @@ -40,7 +40,7 @@ import { } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; -import MkUrlPreview from '@/components/url-preview.vue'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; import * as os from '@/os'; import { $i } from '@/account'; diff --git a/packages/client/src/pages/messaging/messaging-room.vue b/packages/client/src/pages/messaging/messaging-room.vue index 2e00c3ab19..37d4145ee8 100644 --- a/packages/client/src/pages/messaging/messaging-room.vue +++ b/packages/client/src/pages/messaging/messaging-room.vue @@ -55,8 +55,8 @@ import * as Misskey from 'misskey-js'; import * as Acct from 'misskey-js/built/acct'; import XMessage from './messaging-room.message.vue'; import XForm from './messaging-room.form.vue'; -import XList from '@/components/date-separated-list.vue'; -import MkPagination, { Paging } from '@/components/ui/pagination.vue'; +import XList from '@/components/MkDateSeparatedList.vue'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll'; import * as os from '@/os'; import { stream } from '@/stream'; @@ -154,7 +154,22 @@ function onDragover(ev: DragEvent) { const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; if (isFile || isDriveFile) { - ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; + switch (ev.dataTransfer.effectAllowed) { + case 'all': + case 'uninitialized': + case 'copy': + case 'copyLink': + case 'copyMove': + ev.dataTransfer.dropEffect = 'copy'; + break; + case 'linkMove': + case 'move': + ev.dataTransfer.dropEffect = 'move'; + break; + default: + ev.dataTransfer.dropEffect = 'none'; + break; + } } else { ev.dataTransfer.dropEffect = 'none'; } @@ -292,6 +307,7 @@ definePageMetadata(computed(() => !fetching ? user ? { <style lang="scss" scoped> .mk-messaging-room { position: relative; + overflow: auto; > .body { .more { @@ -335,10 +351,7 @@ definePageMetadata(computed(() => !fetching ? user ? { z-index: 2; bottom: 0; padding-top: 8px; - - @media (max-width: 500px) { - bottom: calc(env(safe-area-inset-bottom, 0px) + 92px); - } + bottom: calc(env(safe-area-inset-bottom, 0px) + 8px); > .new-message { width: 100%; diff --git a/packages/client/src/pages/mfm-cheat-sheet.vue b/packages/client/src/pages/mfm-cheat-sheet.vue index 2b92a69c4b..bd8ae4e0b6 100644 --- a/packages/client/src/pages/mfm-cheat-sheet.vue +++ b/packages/client/src/pages/mfm-cheat-sheet.vue @@ -1,301 +1,313 @@ <template> <MkStickyContainer> <template #header><MkPageHeader/></template> - <div class="mwysmxbg"> - <div class="_isolated">{{ $ts._mfm.intro }}</div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.mention }}</div> - <div class="content"> - <p>{{ $ts._mfm.mentionDescription }}</p> - <div class="preview"> - <Mfm :text="preview_mention"/> - <MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea> + <MkSpacer :content-max="800"> + <div class="mwysmxbg"> + <div>{{ i18n.ts._mfm.intro }}</div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.mention }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.mentionDescription }}</p> + <div class="preview"> + <Mfm :text="preview_mention"/> + <MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.hashtag }}</div> - <div class="content"> - <p>{{ $ts._mfm.hashtagDescription }}</p> - <div class="preview"> - <Mfm :text="preview_hashtag"/> - <MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.hashtag }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.hashtagDescription }}</p> + <div class="preview"> + <Mfm :text="preview_hashtag"/> + <MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.url }}</div> - <div class="content"> - <p>{{ $ts._mfm.urlDescription }}</p> - <div class="preview"> - <Mfm :text="preview_url"/> - <MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.url }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.urlDescription }}</p> + <div class="preview"> + <Mfm :text="preview_url"/> + <MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.link }}</div> - <div class="content"> - <p>{{ $ts._mfm.linkDescription }}</p> - <div class="preview"> - <Mfm :text="preview_link"/> - <MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.link }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.linkDescription }}</p> + <div class="preview"> + <Mfm :text="preview_link"/> + <MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.emoji }}</div> - <div class="content"> - <p>{{ $ts._mfm.emojiDescription }}</p> - <div class="preview"> - <Mfm :text="preview_emoji"/> - <MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.emoji }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.emojiDescription }}</p> + <div class="preview"> + <Mfm :text="preview_emoji"/> + <MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.bold }}</div> - <div class="content"> - <p>{{ $ts._mfm.boldDescription }}</p> - <div class="preview"> - <Mfm :text="preview_bold"/> - <MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.bold }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.boldDescription }}</p> + <div class="preview"> + <Mfm :text="preview_bold"/> + <MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.small }}</div> - <div class="content"> - <p>{{ $ts._mfm.smallDescription }}</p> - <div class="preview"> - <Mfm :text="preview_small"/> - <MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.small }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.smallDescription }}</p> + <div class="preview"> + <Mfm :text="preview_small"/> + <MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.quote }}</div> - <div class="content"> - <p>{{ $ts._mfm.quoteDescription }}</p> - <div class="preview"> - <Mfm :text="preview_quote"/> - <MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.quote }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.quoteDescription }}</p> + <div class="preview"> + <Mfm :text="preview_quote"/> + <MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.center }}</div> - <div class="content"> - <p>{{ $ts._mfm.centerDescription }}</p> - <div class="preview"> - <Mfm :text="preview_center"/> - <MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.center }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.centerDescription }}</p> + <div class="preview"> + <Mfm :text="preview_center"/> + <MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.inlineCode }}</div> - <div class="content"> - <p>{{ $ts._mfm.inlineCodeDescription }}</p> - <div class="preview"> - <Mfm :text="preview_inlineCode"/> - <MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.inlineCode }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.inlineCodeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_inlineCode"/> + <MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.blockCode }}</div> - <div class="content"> - <p>{{ $ts._mfm.blockCodeDescription }}</p> - <div class="preview"> - <Mfm :text="preview_blockCode"/> - <MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.blockCode }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.blockCodeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_blockCode"/> + <MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.inlineMath }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.inlineMathDescription }}</p> + <div class="preview"> + <Mfm :text="preview_inlineMath"/> + <MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <!-- deprecated <div class="section _block"> - <div class="title">{{ $ts._mfm.inlineMath }}</div> + <div class="title">{{ i18n.ts._mfm.search }}</div> <div class="content"> - <p>{{ $ts._mfm.inlineMathDescription }}</p> + <p>{{ i18n.ts._mfm.searchDescription }}</p> <div class="preview"> - <Mfm :text="preview_inlineMath"/> - <MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea> + <Mfm :text="preview_search"/> + <MkTextarea v-model="preview_search"><template #label>MFM</template></MkTextarea> </div> </div> </div> - <!-- deprecated - <div class="section _block"> - <div class="title">{{ $ts._mfm.search }}</div> - <div class="content"> - <p>{{ $ts._mfm.searchDescription }}</p> - <div class="preview"> - <Mfm :text="preview_search"/> - <MkTextarea v-model="preview_search"><template #label>MFM</template></MkTextarea> + --> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.flip }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.flipDescription }}</p> + <div class="preview"> + <Mfm :text="preview_flip"/> + <MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea> + </div> + </div> </div> - </div> - </div> - --> - <div class="section _block"> - <div class="title">{{ $ts._mfm.flip }}</div> - <div class="content"> - <p>{{ $ts._mfm.flipDescription }}</p> - <div class="preview"> - <Mfm :text="preview_flip"/> - <MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.font }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.fontDescription }}</p> + <div class="preview"> + <Mfm :text="preview_font"/> + <MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.font }}</div> - <div class="content"> - <p>{{ $ts._mfm.fontDescription }}</p> - <div class="preview"> - <Mfm :text="preview_font"/> - <MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.x2 }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.x2Description }}</p> + <div class="preview"> + <Mfm :text="preview_x2"/> + <MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.x2 }}</div> - <div class="content"> - <p>{{ $ts._mfm.x2Description }}</p> - <div class="preview"> - <Mfm :text="preview_x2"/> - <MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.x3 }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.x3Description }}</p> + <div class="preview"> + <Mfm :text="preview_x3"/> + <MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.x3 }}</div> - <div class="content"> - <p>{{ $ts._mfm.x3Description }}</p> - <div class="preview"> - <Mfm :text="preview_x3"/> - <MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.x4 }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.x4Description }}</p> + <div class="preview"> + <Mfm :text="preview_x4"/> + <MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.x4 }}</div> - <div class="content"> - <p>{{ $ts._mfm.x4Description }}</p> - <div class="preview"> - <Mfm :text="preview_x4"/> - <MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.blur }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.blurDescription }}</p> + <div class="preview"> + <Mfm :text="preview_blur"/> + <MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.blur }}</div> - <div class="content"> - <p>{{ $ts._mfm.blurDescription }}</p> - <div class="preview"> - <Mfm :text="preview_blur"/> - <MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.jelly }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.jellyDescription }}</p> + <div class="preview"> + <Mfm :text="preview_jelly"/> + <MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.jelly }}</div> - <div class="content"> - <p>{{ $ts._mfm.jellyDescription }}</p> - <div class="preview"> - <Mfm :text="preview_jelly"/> - <MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.tada }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.tadaDescription }}</p> + <div class="preview"> + <Mfm :text="preview_tada"/> + <MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.tada }}</div> - <div class="content"> - <p>{{ $ts._mfm.tadaDescription }}</p> - <div class="preview"> - <Mfm :text="preview_tada"/> - <MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.jump }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.jumpDescription }}</p> + <div class="preview"> + <Mfm :text="preview_jump"/> + <MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.jump }}</div> - <div class="content"> - <p>{{ $ts._mfm.jumpDescription }}</p> - <div class="preview"> - <Mfm :text="preview_jump"/> - <MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.bounce }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.bounceDescription }}</p> + <div class="preview"> + <Mfm :text="preview_bounce"/> + <MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.bounce }}</div> - <div class="content"> - <p>{{ $ts._mfm.bounceDescription }}</p> - <div class="preview"> - <Mfm :text="preview_bounce"/> - <MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.spin }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.spinDescription }}</p> + <div class="preview"> + <Mfm :text="preview_spin"/> + <MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.spin }}</div> - <div class="content"> - <p>{{ $ts._mfm.spinDescription }}</p> - <div class="preview"> - <Mfm :text="preview_spin"/> - <MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.shake }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.shakeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_shake"/> + <MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.shake }}</div> - <div class="content"> - <p>{{ $ts._mfm.shakeDescription }}</p> - <div class="preview"> - <Mfm :text="preview_shake"/> - <MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.twitch }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.twitchDescription }}</p> + <div class="preview"> + <Mfm :text="preview_twitch"/> + <MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.twitch }}</div> - <div class="content"> - <p>{{ $ts._mfm.twitchDescription }}</p> - <div class="preview"> - <Mfm :text="preview_twitch"/> - <MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.rainbow }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.rainbowDescription }}</p> + <div class="preview"> + <Mfm :text="preview_rainbow"/> + <MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.rainbow }}</div> - <div class="content"> - <p>{{ $ts._mfm.rainbowDescription }}</p> - <div class="preview"> - <Mfm :text="preview_rainbow"/> - <MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.sparkle }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.sparkleDescription }}</p> + <div class="preview"> + <Mfm :text="preview_sparkle"/> + <MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.sparkle }}</div> - <div class="content"> - <p>{{ $ts._mfm.sparkleDescription }}</p> - <div class="preview"> - <Mfm :text="preview_sparkle"/> - <MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.rotate }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.rotateDescription }}</p> + <div class="preview"> + <Mfm :text="preview_rotate"/> + <MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.rotate }}</div> - <div class="content"> - <p>{{ $ts._mfm.rotateDescription }}</p> - <div class="preview"> - <Mfm :text="preview_rotate"/> - <MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.plain }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.plainDescription }}</p> + <div class="preview"> + <Mfm :text="preview_plain"/> + <MkTextarea v-model="preview_plain"><span>MFM</span></MkTextarea> + </div> </div> </div> </div> - </div> + </MkSpacer> </MkStickyContainer> </template> @@ -306,35 +318,36 @@ import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; -const preview_mention = '@example'; -const preview_hashtag = '#test'; -const preview_url = 'https://example.com'; -const preview_link = `[${i18n.ts._mfm.dummy}](https://example.com)`; -const preview_emoji = instance.emojis.length ? `:${instance.emojis[0].name}:` : ':emojiname:'; -const preview_bold = `**${i18n.ts._mfm.dummy}**`; -const preview_small = `<small>${i18n.ts._mfm.dummy}</small>`; -const preview_center = `<center>${i18n.ts._mfm.dummy}</center>`; -const preview_inlineCode = '`<: "Hello, world!"`'; -const preview_blockCode = '```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```'; -const preview_inlineMath = '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)'; -const preview_quote = `> ${i18n.ts._mfm.dummy}`; -const preview_search = `${i18n.ts._mfm.dummy} 検索`; -const preview_jelly = '$[jelly 🍮] $[jelly.speed=5s 🍮]'; -const preview_tada = '$[tada 🍮] $[tada.speed=5s 🍮]'; -const preview_jump = '$[jump 🍮] $[jump.speed=5s 🍮]'; -const preview_bounce = '$[bounce 🍮] $[bounce.speed=5s 🍮]'; -const preview_shake = '$[shake 🍮] $[shake.speed=5s 🍮]'; -const preview_twitch = '$[twitch 🍮] $[twitch.speed=5s 🍮]'; -const preview_spin = '$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]'; -const preview_flip = `$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`; -const preview_font = `$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]\n$[font.cursive ${i18n.ts._mfm.dummy}]\n$[font.fantasy ${i18n.ts._mfm.dummy}]`; -const preview_x2 = '$[x2 🍮]'; -const preview_x3 = '$[x3 🍮]'; -const preview_x4 = '$[x4 🍮]'; -const preview_blur = `$[blur ${i18n.ts._mfm.dummy}]`; -const preview_rainbow = '$[rainbow 🍮] $[rainbow.speed=5s 🍮]'; -const preview_sparkle = '$[sparkle 🍮]'; -const preview_rotate = '$[rotate 🍮]'; +let preview_mention = $ref('@example'); +let preview_hashtag = $ref('#test'); +let preview_url = $ref('https://example.com'); +let preview_link = $ref(`[${i18n.ts._mfm.dummy}](https://example.com)`); +let preview_emoji = $ref(instance.emojis.length ? `:${instance.emojis[0].name}:` : ':emojiname:'); +let preview_bold = $ref(`**${i18n.ts._mfm.dummy}**`); +let preview_small = $ref(`<small>${i18n.ts._mfm.dummy}</small>`); +let preview_center = $ref(`<center>${i18n.ts._mfm.dummy}</center>`); +let preview_inlineCode = $ref('`<: "Hello, world!"`'); +let preview_blockCode = $ref('```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```'); +let preview_inlineMath = $ref('\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)'); +let preview_quote = $ref(`> ${i18n.ts._mfm.dummy}`); +let preview_search = $ref(`${i18n.ts._mfm.dummy} 検索`); +let preview_jelly = $ref('$[jelly 🍮] $[jelly.speed=5s 🍮]'); +let preview_tada = $ref('$[tada 🍮] $[tada.speed=5s 🍮]'); +let preview_jump = $ref('$[jump 🍮] $[jump.speed=5s 🍮]'); +let preview_bounce = $ref('$[bounce 🍮] $[bounce.speed=5s 🍮]'); +let preview_shake = $ref('$[shake 🍮] $[shake.speed=5s 🍮]'); +let preview_twitch = $ref('$[twitch 🍮] $[twitch.speed=5s 🍮]'); +let preview_spin = $ref('$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]'); +let preview_flip = $ref(`$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`); +let preview_font = $ref(`$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]\n$[font.cursive ${i18n.ts._mfm.dummy}]\n$[font.fantasy ${i18n.ts._mfm.dummy}]`); +let preview_x2 = $ref('$[x2 🍮]'); +let preview_x3 = $ref('$[x3 🍮]'); +let preview_x4 = $ref('$[x4 🍮]'); +let preview_blur = $ref(`$[blur ${i18n.ts._mfm.dummy}]`); +let preview_rainbow = $ref('$[rainbow 🍮] $[rainbow.speed=5s 🍮]'); +let preview_sparkle = $ref('$[sparkle 🍮]'); +let preview_rotate = $ref('$[rotate 🍮]'); +let preview_plain = $ref('<plain>**bold** @mention #hashtag `code` $[x2 🍮]</plain>'); definePageMetadata({ title: i18n.ts._mfm.cheatSheet, diff --git a/packages/client/src/pages/miauth.vue b/packages/client/src/pages/miauth.vue index 4b3ac7761e..5de072cbfa 100644 --- a/packages/client/src/pages/miauth.vue +++ b/packages/client/src/pages/miauth.vue @@ -1,85 +1,88 @@ <template> -<div v-if="$i"> - <div v-if="state == 'waiting'" class="waiting _section"> - <div class="_content"> - <MkLoading/> +<MkSpacer :content-max="800"> + <div v-if="$i"> + <div v-if="state == 'waiting'" class="waiting _section"> + <div class="_content"> + <MkLoading/> + </div> </div> - </div> - <div v-if="state == 'denied'" class="denied _section"> - <div class="_content"> - <p>{{ $ts._auth.denied }}</p> + <div v-if="state == 'denied'" class="denied _section"> + <div class="_content"> + <p>{{ i18n.ts._auth.denied }}</p> + </div> </div> - </div> - <div v-else-if="state == 'accepted'" class="accepted _section"> - <div class="_content"> - <p v-if="callback">{{ $ts._auth.callback }}<MkEllipsis/></p> - <p v-else>{{ $ts._auth.pleaseGoBack }}</p> + <div v-else-if="state == 'accepted'" class="accepted _section"> + <div class="_content"> + <p v-if="callback">{{ i18n.ts._auth.callback }}<MkEllipsis/></p> + <p v-else>{{ i18n.ts._auth.pleaseGoBack }}</p> + </div> </div> - </div> - <div v-else class="_section"> - <div v-if="name" class="_title">{{ $t('_auth.shareAccess', { name: name }) }}</div> - <div v-else class="_title">{{ $ts._auth.shareAccessAsk }}</div> - <div class="_content"> - <p>{{ $ts._auth.permissionAsk }}</p> - <ul> - <li v-for="p in permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> - </ul> - </div> - <div class="_footer"> - <MkButton inline @click="deny">{{ $ts.cancel }}</MkButton> - <MkButton inline primary @click="accept">{{ $ts.accept }}</MkButton> + <div v-else class="_section"> + <div v-if="name" class="_title">{{ $t('_auth.shareAccess', { name: name }) }}</div> + <div v-else class="_title">{{ i18n.ts._auth.shareAccessAsk }}</div> + <div class="_content"> + <p>{{ i18n.ts._auth.permissionAsk }}</p> + <ul> + <li v-for="p in _permissions" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </div> + <div class="_footer"> + <MkButton inline @click="deny">{{ i18n.ts.cancel }}</MkButton> + <MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton> + </div> </div> </div> -</div> -<div v-else class="signin"> - <MkSignin @login="onLogin"/> -</div> + <div v-else class="signin"> + <MkSignin @login="onLogin"/> + </div> +</MkSpacer> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkSignin from '@/components/signin.vue'; -import MkButton from '@/components/ui/button.vue'; +<script lang="ts" setup> +import { } from 'vue'; +import MkSignin from '@/components/MkSignin.vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; -import { login } from '@/account'; +import { $i, login } from '@/account'; import { appendQuery, query } from '@/scripts/url'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + session: string; + callback?: string; + name: string; + icon: string; + permission: string; // コンマ区切り +}>(); + +const _permissions = props.permission.split(','); + +let state = $ref<string | null>(null); + +async function accept(): Promise<void> { + state = 'waiting'; + await os.api('miauth/gen-token', { + session: props.session, + name: props.name, + iconUrl: props.icon, + permission: _permissions, + }); + + state = 'accepted'; + if (props.callback) { + location.href = appendQuery(props.callback, query({ + session: props.session, + })); + } +} -export default defineComponent({ - components: { - MkSignin, - MkButton, - }, - props: ['session', 'callback', 'name', 'icon', 'permission'], - data() { - return { - state: null, - }; - }, - methods: { - async accept() { - this.state = 'waiting'; - await os.api('miauth/gen-token', { - session: this.session, - name: this.name, - iconUrl: this.icon, - permission: this.permission, - }); +function deny(): void { + state = 'denied'; +} - this.state = 'accepted'; - if (this.callback) { - location.href = appendQuery(this.callback, query({ - session: this.session, - })); - } - }, - deny() { - this.state = 'denied'; - }, - onLogin(res) { - login(res.i); - }, - }, -}); +function onLogin(res): void { + login(res.i); +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/my-antennas/create.vue b/packages/client/src/pages/my-antennas/create.vue index e792834a89..dc10bece81 100644 --- a/packages/client/src/pages/my-antennas/create.vue +++ b/packages/client/src/pages/my-antennas/create.vue @@ -38,7 +38,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.manageAntennas, icon: 'fas fa-satellite', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/my-antennas/editor.vue b/packages/client/src/pages/my-antennas/editor.vue index 6f3c4afbfe..054053fbfb 100644 --- a/packages/client/src/pages/my-antennas/editor.vue +++ b/packages/client/src/pages/my-antennas/editor.vue @@ -2,51 +2,52 @@ <div class="shaynizk"> <div class="form"> <MkInput v-model="name" class="_formBlock"> - <template #label>{{ $ts.name }}</template> + <template #label>{{ i18n.ts.name }}</template> </MkInput> <MkSelect v-model="src" class="_formBlock"> - <template #label>{{ $ts.antennaSource }}</template> - <option value="all">{{ $ts._antennaSources.all }}</option> - <!--<option value="home">{{ $ts._antennaSources.homeTimeline }}</option>--> - <option value="users">{{ $ts._antennaSources.users }}</option> - <!--<option value="list">{{ $ts._antennaSources.userList }}</option>--> - <!--<option value="group">{{ $ts._antennaSources.userGroup }}</option>--> + <template #label>{{ i18n.ts.antennaSource }}</template> + <option value="all">{{ i18n.ts._antennaSources.all }}</option> + <!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>--> + <option value="users">{{ i18n.ts._antennaSources.users }}</option> + <!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>--> + <!--<option value="group">{{ i18n.ts._antennaSources.userGroup }}</option>--> </MkSelect> <MkSelect v-if="src === 'list'" v-model="userListId" class="_formBlock"> - <template #label>{{ $ts.userList }}</template> + <template #label>{{ i18n.ts.userList }}</template> <option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option> </MkSelect> <MkSelect v-else-if="src === 'group'" v-model="userGroupId" class="_formBlock"> - <template #label>{{ $ts.userGroup }}</template> + <template #label>{{ i18n.ts.userGroup }}</template> <option v-for="group in userGroups" :key="group.id" :value="group.id">{{ group.name }}</option> </MkSelect> <MkTextarea v-else-if="src === 'users'" v-model="users" class="_formBlock"> - <template #label>{{ $ts.users }}</template> - <template #caption>{{ $ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ $ts.addUser }}</button></template> + <template #label>{{ i18n.ts.users }}</template> + <template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template> </MkTextarea> - <MkSwitch v-model="withReplies" class="_formBlock">{{ $ts.withReplies }}</MkSwitch> + <MkSwitch v-model="withReplies" class="_formBlock">{{ i18n.ts.withReplies }}</MkSwitch> <MkTextarea v-model="keywords" class="_formBlock"> - <template #label>{{ $ts.antennaKeywords }}</template> - <template #caption>{{ $ts.antennaKeywordsDescription }}</template> + <template #label>{{ i18n.ts.antennaKeywords }}</template> + <template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template> </MkTextarea> <MkTextarea v-model="excludeKeywords" class="_formBlock"> - <template #label>{{ $ts.antennaExcludeKeywords }}</template> - <template #caption>{{ $ts.antennaKeywordsDescription }}</template> + <template #label>{{ i18n.ts.antennaExcludeKeywords }}</template> + <template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template> </MkTextarea> - <MkSwitch v-model="caseSensitive" class="_formBlock">{{ $ts.caseSensitive }}</MkSwitch> - <MkSwitch v-model="withFile" class="_formBlock">{{ $ts.withFileAntenna }}</MkSwitch> - <MkSwitch v-model="notify" class="_formBlock">{{ $ts.notifyAntenna }}</MkSwitch> + <MkSwitch v-model="caseSensitive" class="_formBlock">{{ i18n.ts.caseSensitive }}</MkSwitch> + <MkSwitch v-model="withFile" class="_formBlock">{{ i18n.ts.withFileAntenna }}</MkSwitch> + <MkSwitch v-model="notify" class="_formBlock">{{ i18n.ts.notifyAntenna }}</MkSwitch> </div> <div class="actions"> - <MkButton inline primary @click="saveAntenna()"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - <MkButton v-if="antenna.id != null" inline danger @click="deleteAntenna()"><i class="fas fa-trash"></i> {{ $ts.delete }}</MkButton> + <MkButton inline primary @click="saveAntenna()"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton> + <MkButton v-if="antenna.id != null" inline danger @click="deleteAntenna()"><i class="fas fa-trash"></i> {{ i18n.ts.delete }}</MkButton> </div> </div> </template> <script lang="ts" setup> import { watch } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +import * as Acct from 'misskey-js/built/acct'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; import MkTextarea from '@/components/form/textarea.vue'; import MkSelect from '@/components/form/select.vue'; diff --git a/packages/client/src/pages/my-antennas/index.vue b/packages/client/src/pages/my-antennas/index.vue index 2cdb26031f..dc73ba674e 100644 --- a/packages/client/src/pages/my-antennas/index.vue +++ b/packages/client/src/pages/my-antennas/index.vue @@ -17,8 +17,8 @@ <script lang="ts" setup> import { } from 'vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -34,7 +34,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.manageAntennas, icon: 'fas fa-satellite', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/my-clips/index.vue b/packages/client/src/pages/my-clips/index.vue index 6434b0c004..68330d6db4 100644 --- a/packages/client/src/pages/my-clips/index.vue +++ b/packages/client/src/pages/my-clips/index.vue @@ -1,23 +1,25 @@ -<template><MkStickyContainer> +<template> +<MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> - <div class="qtcaoidl"> - <MkButton primary class="add" @click="create"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> + <MkSpacer :content-max="700"> + <div class="qtcaoidl"> + <MkButton primary class="add" @click="create"><i class="fas fa-plus"></i> {{ i18n.ts.add }}</MkButton> - <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="list"> - <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> - <b>{{ item.name }}</b> - <div v-if="item.description" class="description">{{ item.description }}</div> - </MkA> - </MkPagination> - </div> -</MkSpacer></MkStickyContainer> + <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="list"> + <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + </MkA> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { } from 'vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -69,7 +71,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.clip, icon: 'fas fa-paperclip', - bg: 'var(--bg)', action: { icon: 'fas fa-plus', handler: create, diff --git a/packages/client/src/pages/my-lists/index.vue b/packages/client/src/pages/my-lists/index.vue index 411826a950..9af7c0d105 100644 --- a/packages/client/src/pages/my-lists/index.vue +++ b/packages/client/src/pages/my-lists/index.vue @@ -1,24 +1,26 @@ -<template><MkStickyContainer> +<template> +<MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> - <div class="qkcjvfiv"> - <MkButton primary class="add" @click="create"><i class="fas fa-plus"></i> {{ $ts.createList }}</MkButton> + <MkSpacer :content-max="700"> + <div class="qkcjvfiv"> + <MkButton primary class="add" @click="create"><i class="fas fa-plus"></i> {{ i18n.ts.createList }}</MkButton> - <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="lists _content"> - <MkA v-for="list in items" :key="list.id" class="list _panel" :to="`/my/lists/${ list.id }`"> - <div class="name">{{ list.name }}</div> - <MkAvatars :user-ids="list.userIds"/> - </MkA> - </MkPagination> - </div> -</MkSpacer></MkStickyContainer> + <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="lists _content"> + <MkA v-for="list in items" :key="list.id" class="list _panel" :to="`/my/lists/${ list.id }`"> + <div class="name">{{ list.name }}</div> + <MkAvatars :user-ids="list.userIds"/> + </MkA> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { } from 'vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkButton from '@/components/ui/button.vue'; -import MkAvatars from '@/components/avatars.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkAvatars from '@/components/MkAvatars.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -46,7 +48,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.manageLists, icon: 'fas fa-list-ul', - bg: 'var(--bg)', action: { icon: 'fas fa-plus', handler: create, diff --git a/packages/client/src/pages/my-lists/list.vue b/packages/client/src/pages/my-lists/list.vue index 6e76c4a7d5..d90453526e 100644 --- a/packages/client/src/pages/my-lists/list.vue +++ b/packages/client/src/pages/my-lists/list.vue @@ -1,46 +1,49 @@ -<template><MkStickyContainer> +<template> +<MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="700"> - <div class="mk-list-page"> - <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> - <div v-if="list" class="_section"> - <div class="_content"> - <MkButton inline @click="addUser()">{{ $ts.addUser }}</MkButton> - <MkButton inline @click="renameList()">{{ $ts.rename }}</MkButton> - <MkButton inline @click="deleteList()">{{ $ts.delete }}</MkButton> + <MkSpacer :content-max="700"> + <div class="mk-list-page"> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <div v-if="list" class="_section"> + <div class="_content"> + <MkButton inline @click="addUser()">{{ i18n.ts.addUser }}</MkButton> + <MkButton inline @click="renameList()">{{ i18n.ts.rename }}</MkButton> + <MkButton inline @click="deleteList()">{{ i18n.ts.delete }}</MkButton> + </div> </div> - </div> - </transition> + </transition> - <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> - <div v-if="list" class="_section members _gap"> - <div class="_title">{{ $ts.members }}</div> - <div class="_content"> - <div class="users"> - <div v-for="user in users" :key="user.id" class="user _panel"> - <MkAvatar :user="user" class="avatar" :show-indicator="true"/> - <div class="body"> - <MkUserName :user="user" class="name"/> - <MkAcct :user="user" class="acct"/> - </div> - <div class="action"> - <button class="_button" @click="removeUser(user)"><i class="fas fa-times"></i></button> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <div v-if="list" class="_section members _gap"> + <div class="_title">{{ i18n.ts.members }}</div> + <div class="_content"> + <div class="users"> + <div v-for="user in users" :key="user.id" class="user _panel"> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> + </div> + <div class="action"> + <button class="_button" @click="removeUser(user)"><i class="fas fa-times"></i></button> + </div> </div> </div> </div> </div> - </div> - </transition> - </div> -</MkSpacer></MkStickyContainer> + </transition> + </div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> -import { computed, defineComponent, watch } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +import { computed, watch } from 'vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { mainRouter } from '@/router'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; const props = defineProps<{ listId: string; @@ -120,7 +123,6 @@ const headerTabs = $computed(() => []); definePageMetadata(computed(() => list ? { title: list.name, icon: 'fas fa-list-ul', - bg: 'var(--bg)', } : null)); </script> diff --git a/packages/client/src/pages/not-found.vue b/packages/client/src/pages/not-found.vue index 955fbbccfd..253ecdb235 100644 --- a/packages/client/src/pages/not-found.vue +++ b/packages/client/src/pages/not-found.vue @@ -2,7 +2,7 @@ <div class="ipledcug"> <div class="_fullinfo"> <img src="https://xn--931a.moe/assets/not-found.jpg" class="_ghost"/> - <div>{{ $ts.notFoundDescription }}</div> + <div>{{ i18n.ts.notFoundDescription }}</div> </div> </div> </template> @@ -18,6 +18,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.notFound, icon: 'fas fa-exclamation-triangle', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/note.vue b/packages/client/src/pages/note.vue index 852b821573..6509cb306e 100644 --- a/packages/client/src/pages/note.vue +++ b/packages/client/src/pages/note.vue @@ -1,51 +1,53 @@ -<template><MkStickyContainer> +<template> +<MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800"> - <div class="fcuexfpr"> - <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> - <div v-if="note" class="note"> - <div v-if="showNext" class="_gap"> - <XNotes class="_content" :pagination="nextPagination" :no-gap="true"/> - </div> - - <div class="main _gap"> - <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="fas fa-chevron-up"></i></MkButton> - <div class="note _gap"> - <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri" class="_isolated"/> - <XNoteDetailed :key="note.id" v-model:note="note" class="_isolated note"/> + <MkSpacer :content-max="800"> + <div class="fcuexfpr"> + <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <div v-if="note" class="note"> + <div v-if="showNext" class="_gap"> + <XNotes class="_content" :pagination="nextPagination" :no-gap="true"/> </div> - <div v-if="clips && clips.length > 0" class="_content clips _gap"> - <div class="title">{{ $ts.clip }}</div> - <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> - <b>{{ item.name }}</b> - <div v-if="item.description" class="description">{{ item.description }}</div> - <div class="user"> - <MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/> - </div> - </MkA> + + <div class="main _gap"> + <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="fas fa-chevron-up"></i></MkButton> + <div class="note _gap"> + <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> + <XNoteDetailed :key="note.id" v-model:note="note" class="note"/> + </div> + <div v-if="clips && clips.length > 0" class="_content clips _gap"> + <div class="title">{{ i18n.ts.clip }}</div> + <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + <div class="user"> + <MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/> + </div> + </MkA> + </div> + <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="fas fa-chevron-down"></i></MkButton> </div> - <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="fas fa-chevron-down"></i></MkButton> - </div> - <div v-if="showPrev" class="_gap"> - <XNotes class="_content" :pagination="prevPagination" :no-gap="true"/> + <div v-if="showPrev" class="_gap"> + <XNotes class="_content" :pagination="prevPagination" :no-gap="true"/> + </div> </div> - </div> - <MkError v-else-if="error" @retry="fetch()"/> - <MkLoading v-else/> - </transition> - </div> -</MkSpacer></MkStickyContainer> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> + </div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { computed, defineComponent, watch } from 'vue'; import * as misskey from 'misskey-js'; -import XNote from '@/components/note.vue'; -import XNoteDetailed from '@/components/note-detailed.vue'; -import XNotes from '@/components/notes.vue'; -import MkRemoteCaution from '@/components/remote-caution.vue'; -import MkButton from '@/components/ui/button.vue'; +import XNote from '@/components/MkNote.vue'; +import XNoteDetailed from '@/components/MkNoteDetailed.vue'; +import XNotes from '@/components/MkNotes.vue'; +import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; @@ -132,7 +134,6 @@ definePageMetadata(computed(() => note ? { title: i18n.t('noteOf', { user: note.user.name }), text: note.text, }, - bg: 'var(--bg)', } : null)); </script> diff --git a/packages/client/src/pages/notifications.vue b/packages/client/src/pages/notifications.vue index 3d1014b3cd..dd57060fda 100644 --- a/packages/client/src/pages/notifications.vue +++ b/packages/client/src/pages/notifications.vue @@ -1,9 +1,15 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="800"> - <div class="clupoqwt"> - <XNotifications class="notifications" :include-types="includeTypes" :unread-only="tab === 'unread'"/> + <div v-if="tab === 'all' || tab === 'unread'"> + <XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/> + </div> + <div v-else-if="tab === 'mentions'"> + <XNotes :pagination="mentionsPagination"/> + </div> + <div v-else-if="tab === 'directNotes'"> + <XNotes :pagination="directNotesPagination"/> </div> </MkSpacer> </MkStickyContainer> @@ -12,13 +18,28 @@ <script lang="ts" setup> import { computed } from 'vue'; import { notificationTypes } from 'misskey-js'; -import XNotifications from '@/components/notifications.vue'; +import XNotifications from '@/components/MkNotifications.vue'; +import XNotes from '@/components/MkNotes.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; let tab = $ref('all'); let includeTypes = $ref<string[] | null>(null); +let unreadOnly = $computed(() => tab === 'unread'); + +const mentionsPagination = { + endpoint: 'notes/mentions' as const, + limit: 10, +}; + +const directNotesPagination = { + endpoint: 'notes/mentions' as const, + limit: 10, + params: { + visibility: 'specified', + }, +}; function setFilter(ev) { const typeItems = notificationTypes.map(t => ({ @@ -38,37 +59,37 @@ function setFilter(ev) { os.popupMenu(items, ev.currentTarget ?? ev.target); } -const headerActions = $computed(() => [{ +const headerActions = $computed(() => [tab === 'all' ? { text: i18n.ts.filter, icon: 'fas fa-filter', highlighted: includeTypes != null, handler: setFilter, -}, { +} : undefined, tab === 'all' ? { text: i18n.ts.markAllAsRead, icon: 'fas fa-check', handler: () => { os.apiWithDialog('notifications/mark-all-as-read'); }, -}]); +} : undefined].filter(x => x !== undefined)); const headerTabs = $computed(() => [{ - active: tab === 'all', + key: 'all', title: i18n.ts.all, - onClick: () => { tab = 'all'; }, }, { - active: tab === 'unread', + key: 'unread', title: i18n.ts.unread, - onClick: () => { tab = 'unread'; }, +}, { + key: 'mentions', + title: i18n.ts.mentions, + icon: 'fas fa-at', +}, { + key: 'directNotes', + title: i18n.ts.directNotes, + icon: 'fas fa-envelope', }]); definePageMetadata(computed(() => ({ title: i18n.ts.notifications, icon: 'fas fa-bell', - bg: 'var(--bg)', }))); </script> - -<style lang="scss" scoped> -.clupoqwt { -} -</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.image.vue b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue index b22bf1cb34..4d471e7b94 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.image.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue @@ -18,7 +18,7 @@ /* eslint-disable vue/no-mutating-props */ import { onMounted } from 'vue'; import XContainer from '../page-editor.container.vue'; -import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import * as os from '@/os'; const props = withDefaults(defineProps<{ diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.note.vue b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue index 27f9f961f3..5e494ee23b 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.note.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue @@ -22,8 +22,8 @@ import { watch } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkInput from '@/components/form/input.vue'; import MkSwitch from '@/components/form/switch.vue'; -import XNote from '@/components/note.vue'; -import XNoteDetailed from '@/components/note-detailed.vue'; +import XNote from '@/components/MkNote.vue'; +import XNoteDetailed from '@/components/MkNoteDetailed.vue'; import * as os from '@/os'; const props = withDefaults(defineProps<{ diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue index 183e60a69a..4b28f120a9 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue @@ -32,7 +32,7 @@ const props = withDefaults(defineProps<{ let values: string = $ref(props.value.values.join('\n')); watch(values, () => { - props.value.values = values.split('\n') + props.value.values = values.split('\n'); }, { deep: true }); diff --git a/packages/client/src/pages/page-editor/page-editor.vue b/packages/client/src/pages/page-editor/page-editor.vue index c09d9af734..591d13053a 100644 --- a/packages/client/src/pages/page-editor/page-editor.vue +++ b/packages/client/src/pages/page-editor/page-editor.vue @@ -1,6 +1,6 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="700"> <div class="jqqmcavi"> <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="fas fa-external-link-square-alt"></i> {{ $ts._pages.viewPage }}</MkButton> @@ -82,7 +82,7 @@ </template> <script lang="ts" setup> -import { defineComponent, defineAsyncComponent, computed, provide, watch } from 'vue'; +import { defineAsyncComponent, computed, provide, watch } from 'vue'; import 'prismjs'; import { highlight, languages } from 'prismjs/components/prism-core'; import 'prismjs/components/prism-clike'; @@ -93,8 +93,7 @@ import { v4 as uuid } from 'uuid'; import XVariable from './page-editor.script-block.vue'; import XBlocks from './page-editor.blocks.vue'; import MkTextarea from '@/components/form/textarea.vue'; -import MkContainer from '@/components/ui/container.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import MkSelect from '@/components/form/select.vue'; import MkSwitch from '@/components/form/switch.vue'; import MkInput from '@/components/form/input.vue'; @@ -168,15 +167,15 @@ function save() { const options = getSaveOptions(); const onError = err => { - if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') { - if (err.info.param == 'name') { + if (err.id === '3d81ceae-475f-4600-b2a8-2bc116157532') { + if (err.info.param === 'name') { os.alert({ type: 'error', title: i18n.ts._pages.invalidNameTitle, text: i18n.ts._pages.invalidNameText, }); } - } else if (err.code == 'NAME_ALREADY_EXISTS') { + } else if (err.code === 'NAME_ALREADY_EXISTS') { os.alert({ type: 'error', text: i18n.ts._pages.nameAlreadyExists, @@ -310,7 +309,7 @@ function getPageBlockList() { function getScriptBlockList(type: string = null) { const list = []; - const blocks = blockDefs.filter(block => type === null || block.out === null || block.out === type || typeof block.out === 'number'); + const blocks = blockDefs.filter(block => type == null || block.out == null || block.out === type || typeof block.out === 'number'); for (const block of blocks) { const category = list.find(x => x.category === block.category); @@ -345,8 +344,8 @@ function getScriptBlockList(type: string = null) { return list; } -function setEyeCatchingImage(e) { - selectFile(e.currentTarget ?? e.target, null).then(file => { +function setEyeCatchingImage(img) { + selectFile(img.currentTarget ?? img.target, null).then(file => { eyeCatchingImageId = file.id; }); } @@ -411,25 +410,21 @@ init(); const headerActions = $computed(() => []); const headerTabs = $computed(() => [{ - active: tab === 'settings', + key: 'settings', title: i18n.ts._pages.pageSetting, icon: 'fas fa-cog', - onClick: () => { tab = 'settings'; }, }, { - active: tab === 'contents', + key: 'contents', title: i18n.ts._pages.contents, icon: 'fas fa-sticky-note', - onClick: () => { tab = 'contents'; }, }, { - active: tab === 'variables', + key: 'variables', title: i18n.ts._pages.variables, icon: 'fas fa-magic', - onClick: () => { tab = 'variables'; }, }, { - active: tab === 'script', + key: 'script', title: i18n.ts.script, icon: 'fas fa-code', - onClick: () => { tab = 'script'; }, }]); definePageMetadata(computed(() => { @@ -443,8 +438,7 @@ definePageMetadata(computed(() => { return { title: title, icon: 'fas fa-pencil-alt', - bg: 'var(--bg)', - }; + }; })); </script> diff --git a/packages/client/src/pages/page.vue b/packages/client/src/pages/page.vue index c60b7069e9..fb0e6a4914 100644 --- a/packages/client/src/pages/page.vue +++ b/packages/client/src/pages/page.vue @@ -18,12 +18,12 @@ </div> <div class="actions"> <div class="like"> - <MkButton v-if="page.isLiked" v-tooltip="$ts._pages.unlike" class="button" primary @click="unlike()"><i class="fas fa-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> - <MkButton v-else v-tooltip="$ts._pages.like" class="button" @click="like()"><i class="far fa-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> + <MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" primary @click="unlike()"><i class="fas fa-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" @click="like()"><i class="far fa-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> </div> <div class="other"> - <button v-tooltip="$ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="fas fa-retweet fa-fw"></i></button> - <button v-tooltip="$ts.share" v-click-anime class="_button" @click="share"><i class="fas fa-share-alt fa-fw"></i></button> + <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="fas fa-retweet fa-fw"></i></button> + <button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="fas fa-share-alt fa-fw"></i></button> </div> </div> <div class="user"> @@ -35,21 +35,21 @@ <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> </div> <div class="links"> - <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ $ts._pages.viewSource }}</MkA> + <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA> <template v-if="$i && $i.id === page.userId"> - <MkA :to="`/pages/edit/${page.id}`" class="link">{{ $ts._pages.editThisPage }}</MkA> - <button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ $ts.unpin }}</button> - <button v-else class="link _textButton" @click="pin(true)">{{ $ts.pin }}</button> + <MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA> + <button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button> + <button v-else class="link _textButton" @click="pin(true)">{{ i18n.ts.pin }}</button> </template> </div> </div> <div class="footer"> - <div><i class="far fa-clock"></i> {{ $ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> - <div v-if="page.createdAt != page.updatedAt"><i class="far fa-clock"></i> {{ $ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> + <div><i class="far fa-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> + <div v-if="page.createdAt != page.updatedAt"><i class="far fa-clock"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> </div> <MkAd :prefer="['horizontal', 'horizontal-big']"/> <MkContainer :max-height="300" :foldable="true" class="other"> - <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template> + <template #header><i class="fas fa-clock"></i> {{ i18n.ts.recentPosts }}</template> <MkPagination v-slot="{items}" :pagination="otherPostsPagination"> <MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/> </MkPagination> @@ -65,13 +65,13 @@ <script lang="ts" setup> import { computed, watch } from 'vue'; import XPage from '@/components/page/page.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { url } from '@/config'; -import MkFollowButton from '@/components/follow-button.vue'; -import MkContainer from '@/components/ui/container.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkPagePreview from '@/components/page-preview.vue'; +import MkFollowButton from '@/components/MkFollowButton.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkPagePreview from '@/components/MkPagePreview.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; diff --git a/packages/client/src/pages/pages.vue b/packages/client/src/pages/pages.vue index 541c968ff4..02b05c57ba 100644 --- a/packages/client/src/pages/pages.vue +++ b/packages/client/src/pages/pages.vue @@ -1,6 +1,6 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="700"> <div v-if="tab === 'featured'" class="rknalgpo"> <MkPagination v-slot="{items}" :pagination="featuredPagesPagination"> @@ -26,9 +26,9 @@ <script lang="ts" setup> import { computed, inject } from 'vue'; -import MkPagePreview from '@/components/page-preview.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkPagePreview from '@/components/MkPagePreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; import { useRouter } from '@/router'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -61,26 +61,22 @@ const headerActions = $computed(() => [{ }]); const headerTabs = $computed(() => [{ - active: tab === 'featured', + key: 'featured', title: i18n.ts._pages.featured, icon: 'fas fa-fire-alt', - onClick: () => { tab = 'featured'; }, }, { - active: tab === 'my', + key: 'my', title: i18n.ts._pages.my, icon: 'fas fa-edit', - onClick: () => { tab = 'my'; }, }, { - active: tab === 'liked', + key: 'liked', title: i18n.ts._pages.liked, icon: 'fas fa-heart', - onClick: () => { tab = 'liked'; }, }]); definePageMetadata(computed(() => ({ title: i18n.ts.pages, icon: 'fas fa-sticky-note', - bg: 'var(--bg)', }))); </script> diff --git a/packages/client/src/pages/preview.vue b/packages/client/src/pages/preview.vue index cba7589a38..efbe53a523 100644 --- a/packages/client/src/pages/preview.vue +++ b/packages/client/src/pages/preview.vue @@ -6,7 +6,7 @@ <script lang="ts" setup> import { computed } from 'vue'; -import MkSample from '@/components/sample.vue'; +import MkSample from '@/components/MkSample.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -17,7 +17,6 @@ const headerTabs = $computed(() => []); definePageMetadata(computed(() => ({ title: i18n.ts.preview, icon: 'fas fa-eye', - bg: 'var(--bg)', }))); </script> diff --git a/packages/client/src/pages/registry.keys.vue b/packages/client/src/pages/registry.keys.vue new file mode 100644 index 0000000000..ac586b4e72 --- /dev/null +++ b/packages/client/src/pages/registry.keys.vue @@ -0,0 +1,96 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600" :margin-min="16"> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.domain }}</template> + <template #value>{{ i18n.ts.system }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.scope }}</template> + <template #value>{{ scope.join('/') }}</template> + </MkKeyValue> + </FormSplit> + + <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> + + <FormSection v-if="keys"> + <template #label>{{ i18n.ts.keys }}</template> + <div class="_formLinks"> + <FormLink v-for="key in keys" :to="`/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink> + </div> + </FormSection> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import JSON5 from 'json5'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormSplit from '@/components/form/split.vue'; + +const props = defineProps<{ + path: string; +}>(); + +const scope = $computed(() => props.path.split('/')); + +let keys = $ref(null); + +function fetchKeys() { + os.api('i/registry/keys-with-type', { + scope: scope, + }).then(res => { + keys = Object.entries(res).sort((a, b) => a[0].localeCompare(b[0])); + }); +} + +async function createKey() { + const { canceled, result } = await os.form(i18n.ts._registry.createKey, { + key: { + type: 'string', + label: i18n.ts._registry.key, + }, + value: { + type: 'string', + multiline: true, + label: i18n.ts.value, + }, + scope: { + type: 'string', + label: i18n.ts._registry.scope, + default: scope.join('/'), + }, + }); + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: result.scope.split('/'), + key: result.key, + value: JSON5.parse(result.value), + }).then(() => { + fetchKeys(); + }); +} + +watch(() => props.path, fetchKeys, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.registry, + icon: 'fas fa-cogs', +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/client/src/pages/registry.value.vue b/packages/client/src/pages/registry.value.vue new file mode 100644 index 0000000000..b6f3d73bb6 --- /dev/null +++ b/packages/client/src/pages/registry.value.vue @@ -0,0 +1,123 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600" :margin-min="16"> + <FormInfo warn>{{ i18n.ts.editTheseSettingsMayBreakAccount }}</FormInfo> + + <template v-if="value"> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.domain }}</template> + <template #value>{{ i18n.ts.system }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.scope }}</template> + <template #value>{{ scope.join('/') }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.key }}</template> + <template #value>{{ key }}</template> + </MkKeyValue> + </FormSplit> + + <FormTextarea v-model="valueForEditor" tall class="_formBlock _monospace"> + <template #label>{{ i18n.ts.value }} (JSON)</template> + </FormTextarea> + + <MkButton class="_formBlock" primary @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton> + + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.updatedAt }}</template> + <template #value><MkTime :time="value.updatedAt" mode="detail"/></template> + </MkKeyValue> + + <MkButton danger @click="del"><i class="fas fa-trash"></i> {{ i18n.ts.delete }}</MkButton> + </template> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import JSON5 from 'json5'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormInfo from '@/components/MkInfo.vue'; + +const props = defineProps<{ + path: string; +}>(); + +const scope = $computed(() => props.path.split('/').slice(0, -1)); +const key = $computed(() => props.path.split('/').at(-1)); + +let value = $ref(null); +let valueForEditor = $ref(null); + +function fetchValue() { + os.api('i/registry/get-detail', { + scope, + key, + }).then(res => { + value = res; + valueForEditor = JSON5.stringify(res.value, null, '\t'); + }); +} + +async function save() { + try { + JSON5.parse(valueForEditor); + } catch (err) { + os.alert({ + type: 'error', + text: i18n.ts.invalidValue, + }); + return; + } + os.confirm({ + type: 'warning', + text: i18n.ts.saveConfirm, + }).then(({ canceled }) => { + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope, + key, + value: JSON5.parse(valueForEditor), + }); + }); +} + +function del() { + os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }).then(({ canceled }) => { + if (canceled) return; + os.apiWithDialog('i/registry/remove', { + scope, + key, + }); + }); +} + +watch(() => props.path, fetchValue, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.registry, + icon: 'fas fa-cogs', +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/client/src/pages/registry.vue b/packages/client/src/pages/registry.vue new file mode 100644 index 0000000000..80a44d5589 --- /dev/null +++ b/packages/client/src/pages/registry.vue @@ -0,0 +1,74 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600" :margin-min="16"> + <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> + + <FormSection v-if="scopes"> + <template #label>{{ i18n.ts.system }}</template> + <div class="_formLinks"> + <FormLink v-for="scope in scopes" :to="`/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink> + </div> + </FormSection> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import JSON5 from 'json5'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; + +let scopes = $ref(null); + +function fetchScopes() { + os.api('i/registry/scopes').then(res => { + scopes = res.slice().sort((a, b) => a.join('/').localeCompare(b.join('/'))); + }); +} + +async function createKey() { + const { canceled, result } = await os.form(i18n.ts._registry.createKey, { + key: { + type: 'string', + label: i18n.ts._registry.key, + }, + value: { + type: 'string', + multiline: true, + label: i18n.ts.value, + }, + scope: { + type: 'string', + label: i18n.ts._registry.scope, + }, + }); + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: result.scope.split('/'), + key: result.key, + value: JSON5.parse(result.value), + }).then(() => { + fetchScopes(); + }); +} + +fetchScopes(); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.registry, + icon: 'fas fa-cogs', +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/client/src/pages/reset-password.vue b/packages/client/src/pages/reset-password.vue index 39a1191caf..38f2cf289d 100644 --- a/packages/client/src/pages/reset-password.vue +++ b/packages/client/src/pages/reset-password.vue @@ -17,7 +17,7 @@ <script lang="ts" setup> import { defineAsyncComponent, onMounted } from 'vue'; import FormInput from '@/components/form/input.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { mainRouter } from '@/router'; @@ -39,7 +39,7 @@ async function save() { onMounted(() => { if (props.token == null) { - os.popup(defineAsyncComponent(() => import('@/components/forgot-password.vue')), {}, {}, 'closed'); + os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {}, 'closed'); mainRouter.push('/'); } }); @@ -51,7 +51,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.resetPassword, icon: 'fas fa-lock', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/scratchpad.vue b/packages/client/src/pages/scratchpad.vue index d437601475..12b5d78b27 100644 --- a/packages/client/src/pages/scratchpad.vue +++ b/packages/client/src/pages/scratchpad.vue @@ -28,8 +28,8 @@ import 'prismjs/themes/prism-okaidia.css'; import { PrismEditor } from 'vue-prism-editor'; import 'vue-prism-editor/dist/prismeditor.min.css'; import { AiScript, parse, utils } from '@syuilo/aiscript'; -import MkContainer from '@/components/ui/container.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import MkButton from '@/components/MkButton.vue'; import { createAiScriptEnv } from '@/scripts/aiscript/api'; import * as os from '@/os'; import { $i } from '@/account'; diff --git a/packages/client/src/pages/search.vue b/packages/client/src/pages/search.vue index 25fef7af50..fdcbb57e44 100644 --- a/packages/client/src/pages/search.vue +++ b/packages/client/src/pages/search.vue @@ -9,7 +9,7 @@ <script lang="ts" setup> import { computed } from 'vue'; -import XNotes from '@/components/notes.vue'; +import XNotes from '@/components/MkNotes.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -34,6 +34,5 @@ const headerTabs = $computed(() => []); definePageMetadata(computed(() => ({ title: i18n.t('searchWith', { q: props.query }), icon: 'fas fa-search', - bg: 'var(--bg)', }))); </script> diff --git a/packages/client/src/pages/settings/2fa.vue b/packages/client/src/pages/settings/2fa.vue index fb3a7a17f3..89d8178dc6 100644 --- a/packages/client/src/pages/settings/2fa.vue +++ b/packages/client/src/pages/settings/2fa.vue @@ -55,7 +55,7 @@ <li>{{ i18n.ts._2fa.step2 }}<br><img :src="twoFactorData.qr"><p>{{ $ts._2fa.step2Url }}<br>{{ twoFactorData.url }}</p></li> <li> {{ i18n.ts._2fa.step3 }}<br> - <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput> <MkButton primary @click="submit">{{ i18n.ts.done }}</MkButton> </li> </ol> @@ -68,8 +68,8 @@ import { ref } from 'vue'; import { hostname } from '@/config'; import { byteify, hexify, stringify } from '@/scripts/2fa'; -import MkButton from '@/components/ui/button.vue'; -import MkInfo from '@/components/ui/info.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; import MkInput from '@/components/form/input.vue'; import MkSwitch from '@/components/form/switch.vue'; import * as os from '@/os'; diff --git a/packages/client/src/pages/settings/account-info.vue b/packages/client/src/pages/settings/account-info.vue index 65b6233693..93e65d55b1 100644 --- a/packages/client/src/pages/settings/account-info.vue +++ b/packages/client/src/pages/settings/account-info.vue @@ -129,7 +129,7 @@ <script lang="ts" setup> import { onMounted, ref } from 'vue'; import FormSection from '@/components/form/section.vue'; -import MkKeyValue from '@/components/key-value.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; import * as os from '@/os'; import number from '@/filters/number'; import bytes from '@/filters/bytes'; diff --git a/packages/client/src/pages/settings/accounts.vue b/packages/client/src/pages/settings/accounts.vue index 47b816243f..e16931a9ca 100644 --- a/packages/client/src/pages/settings/accounts.vue +++ b/packages/client/src/pages/settings/accounts.vue @@ -23,7 +23,7 @@ <script lang="ts" setup> import { defineAsyncComponent, ref } from 'vue'; import FormSuspense from '@/components/form/suspense.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account'; import { i18n } from '@/i18n'; @@ -75,7 +75,7 @@ function removeAccount(account) { } function addExistingAccount() { - os.popup(defineAsyncComponent(() => import('@/components/signin-dialog.vue')), {}, { + os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { done: res => { addAccounts(res.id, res.i); os.success(); @@ -84,7 +84,7 @@ function addExistingAccount() { } function createAccount() { - os.popup(defineAsyncComponent(() => import('@/components/signup-dialog.vue')), {}, { + os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { done: res => { addAccounts(res.id, res.i); switchAccountWithToken(res.i); @@ -109,7 +109,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.accounts, icon: 'fas fa-users', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/api.vue b/packages/client/src/pages/settings/api.vue index d94862712e..7165089e39 100644 --- a/packages/client/src/pages/settings/api.vue +++ b/packages/client/src/pages/settings/api.vue @@ -9,7 +9,7 @@ <script lang="ts" setup> import { defineAsyncComponent, ref } from 'vue'; import FormLink from '@/components/form/link.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -17,7 +17,7 @@ import { definePageMetadata } from '@/scripts/page-metadata'; const isDesktop = ref(window.innerWidth >= 1100); function generateToken() { - os.popup(defineAsyncComponent(() => import('@/components/token-generate-window.vue')), {}, { + os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, { done: async result => { const { name, permissions } = result; const { token } = await os.api('miauth/gen-token', { @@ -42,6 +42,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: 'API', icon: 'fas fa-key', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/apps.vue b/packages/client/src/pages/settings/apps.vue index 673e91fe6b..8b345c8e9f 100644 --- a/packages/client/src/pages/settings/apps.vue +++ b/packages/client/src/pages/settings/apps.vue @@ -39,7 +39,7 @@ <script lang="ts" setup> import { ref } from 'vue'; -import FormPagination from '@/components/ui/pagination.vue'; +import FormPagination from '@/components/MkPagination.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -67,7 +67,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.installedApps, icon: 'fas fa-plug', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/custom-css.vue b/packages/client/src/pages/settings/custom-css.vue index 3e032be257..2992906e6d 100644 --- a/packages/client/src/pages/settings/custom-css.vue +++ b/packages/client/src/pages/settings/custom-css.vue @@ -11,7 +11,7 @@ <script lang="ts" setup> import { ref, watch } from 'vue'; import FormTextarea from '@/components/form/textarea.vue'; -import FormInfo from '@/components/ui/info.vue'; +import FormInfo from '@/components/MkInfo.vue'; import * as os from '@/os'; import { unisonReload } from '@/scripts/unison-reload'; import { i18n } from '@/i18n'; @@ -42,6 +42,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.customCss, icon: 'fas fa-code', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/deck.vue b/packages/client/src/pages/settings/deck.vue index edada683ae..1285a6641c 100644 --- a/packages/client/src/pages/settings/deck.vue +++ b/packages/client/src/pages/settings/deck.vue @@ -1,9 +1,6 @@ <template> <div class="_formRoot"> - <FormGroup> - <template #label>{{ i18n.ts.defaultNavigationBehaviour }}</template> - <FormSwitch v-model="navWindow">{{ i18n.ts.openInWindow }}</FormSwitch> - </FormGroup> + <FormSwitch v-model="navWindow">{{ i18n.ts.defaultNavigationBehaviour }}: {{ i18n.ts.openInWindow }}</FormSwitch> <FormSwitch v-model="alwaysShowMainColumn" class="_formBlock">{{ i18n.ts._deck.alwaysShowMainColumn }}</FormSwitch> @@ -12,20 +9,6 @@ <option value="left">{{ i18n.ts.left }}</option> <option value="center">{{ i18n.ts.center }}</option> </FormRadios> - - <FormRadios v-model="columnHeaderHeight" class="_formBlock"> - <template #label>{{ i18n.ts._deck.columnHeaderHeight }}</template> - <option :value="42">{{ i18n.ts.narrow }}</option> - <option :value="45">{{ i18n.ts.medium }}</option> - <option :value="48">{{ i18n.ts.wide }}</option> - </FormRadios> - - <FormInput v-model="columnMargin" type="number" class="_formBlock"> - <template #label>{{ i18n.ts._deck.columnMargin }}</template> - <template #suffix>px</template> - </FormInput> - - <FormLink class="_formBlock" @click="setProfile">{{ i18n.ts._deck.profile }}<template #suffix>{{ profile }}</template></FormLink> </div> </template> @@ -35,7 +18,6 @@ import FormSwitch from '@/components/form/switch.vue'; import FormLink from '@/components/form/link.vue'; import FormRadios from '@/components/form/radios.vue'; import FormInput from '@/components/form/input.vue'; -import FormGroup from '@/components/form/group.vue'; import { deckStore } from '@/ui/deck/deck-store'; import * as os from '@/os'; import { unisonReload } from '@/scripts/unison-reload'; @@ -45,30 +27,6 @@ import { definePageMetadata } from '@/scripts/page-metadata'; const navWindow = computed(deckStore.makeGetterSetter('navWindow')); const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn')); const columnAlign = computed(deckStore.makeGetterSetter('columnAlign')); -const columnMargin = computed(deckStore.makeGetterSetter('columnMargin')); -const columnHeaderHeight = computed(deckStore.makeGetterSetter('columnHeaderHeight')); -const profile = computed(deckStore.makeGetterSetter('profile')); - -watch(navWindow, async () => { - const { canceled } = await os.confirm({ - type: 'info', - text: i18n.ts.reloadToApplySetting, - }); - if (canceled) return; - - unisonReload(); -}); - -async function setProfile() { - const { canceled, result: name } = await os.inputText({ - title: i18n.ts._deck.profile, - allowEmpty: false, - }); - if (canceled) return; - - profile.value = name; - unisonReload(); -} const headerActions = $computed(() => []); @@ -77,6 +35,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.deck, icon: 'fas fa-columns', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/delete-account.vue b/packages/client/src/pages/settings/delete-account.vue index a587c32998..851a857fed 100644 --- a/packages/client/src/pages/settings/delete-account.vue +++ b/packages/client/src/pages/settings/delete-account.vue @@ -8,8 +8,8 @@ </template> <script lang="ts" setup> -import FormInfo from '@/components/ui/info.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { signout } from '@/account'; import { i18n } from '@/i18n'; @@ -48,6 +48,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts._accountDelete.accountDelete, icon: 'fas fa-exclamation-triangle', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/drive.vue b/packages/client/src/pages/settings/drive.vue index 73c0384f1f..a10e2d9f7d 100644 --- a/packages/client/src/pages/settings/drive.vue +++ b/packages/client/src/pages/settings/drive.vue @@ -28,7 +28,17 @@ <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> <template #suffixIcon><i class="fas fa-folder-open"></i></template> </FormLink> - <FormSwitch v-model="keepOriginalUploading" class="_formBlock">{{ i18n.ts.keepOriginalUploading }}<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template></FormSwitch> + <FormSwitch v-model="keepOriginalUploading" class="_formBlock"> + <template #label>{{ i18n.ts.keepOriginalUploading }}</template> + <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template> + </FormSwitch> + <FormSwitch v-model="alwaysMarkNsfw" class="_formBlock" @update:modelValue="saveProfile()"> + <template #label>{{ i18n.ts.alwaysMarkSensitive }}</template> + </FormSwitch> + <FormSwitch v-model="autoSensitive" class="_formBlock" @update:modelValue="saveProfile()"> + <template #label>{{ i18n.ts.enableAutoSensitive }}<span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption>{{ i18n.ts.enableAutoSensitiveDescription }}</template> + </FormSwitch> </FormSection> </div> </template> @@ -39,19 +49,22 @@ import tinycolor from 'tinycolor2'; import FormLink from '@/components/form/link.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormSection from '@/components/form/section.vue'; -import MkKeyValue from '@/components/key-value.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os'; import bytes from '@/filters/bytes'; import { defaultStore } from '@/store'; -import MkChart from '@/components/chart.vue'; +import MkChart from '@/components/MkChart.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; const fetching = ref(true); const usage = ref<any>(null); const capacity = ref<any>(null); const uploadFolder = ref<any>(null); +let alwaysMarkNsfw = $ref($i.alwaysMarkNsfw); +let autoSensitive = $ref($i.autoSensitive); const meterStyle = computed(() => { return { @@ -94,6 +107,13 @@ function chooseUploadFolder() { }); } +function saveProfile() { + os.api('i/update', { + alwaysMarkNsfw: !!alwaysMarkNsfw, + autoSensitive: !!autoSensitive, + }); +} + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); @@ -101,7 +121,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.drive, icon: 'fas fa-cloud', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/email.vue b/packages/client/src/pages/settings/email.vue index 8b67ff34dd..1dae233a07 100644 --- a/packages/client/src/pages/settings/email.vue +++ b/packages/client/src/pages/settings/email.vue @@ -1,39 +1,39 @@ <template> <div class="_formRoot"> <FormSection> - <template #label>{{ $ts.emailAddress }}</template> + <template #label>{{ i18n.ts.emailAddress }}</template> <FormInput v-model="emailAddress" type="email" manual-save> <template #prefix><i class="fas fa-envelope"></i></template> - <template v-if="$i.email && !$i.emailVerified" #caption>{{ $ts.verificationEmailSent }}</template> - <template v-else-if="emailAddress === $i.email && $i.emailVerified" #caption><i class="fas fa-check" style="color: var(--success);"></i> {{ $ts.emailVerified }}</template> + <template v-if="$i.email && !$i.emailVerified" #caption>{{ i18n.ts.verificationEmailSent }}</template> + <template v-else-if="emailAddress === $i.email && $i.emailVerified" #caption><i class="fas fa-check" style="color: var(--success);"></i> {{ i18n.ts.emailVerified }}</template> </FormInput> </FormSection> <FormSection> - <FormSwitch :value="$i.receiveAnnouncementEmail" @update:modelValue="onChangeReceiveAnnouncementEmail"> - {{ $ts.receiveAnnouncementFromInstance }} + <FormSwitch :model-value="$i.receiveAnnouncementEmail" @update:modelValue="onChangeReceiveAnnouncementEmail"> + {{ i18n.ts.receiveAnnouncementFromInstance }} </FormSwitch> </FormSection> <FormSection> - <template #label>{{ $ts.emailNotification }}</template> + <template #label>{{ i18n.ts.emailNotification }}</template> <FormSwitch v-model="emailNotification_mention" class="_formBlock"> - {{ $ts._notification._types.mention }} + {{ i18n.ts._notification._types.mention }} </FormSwitch> <FormSwitch v-model="emailNotification_reply" class="_formBlock"> - {{ $ts._notification._types.reply }} + {{ i18n.ts._notification._types.reply }} </FormSwitch> <FormSwitch v-model="emailNotification_quote" class="_formBlock"> - {{ $ts._notification._types.quote }} + {{ i18n.ts._notification._types.quote }} </FormSwitch> <FormSwitch v-model="emailNotification_follow" class="_formBlock"> - {{ $ts._notification._types.follow }} + {{ i18n.ts._notification._types.follow }} </FormSwitch> <FormSwitch v-model="emailNotification_receiveFollowRequest" class="_formBlock"> - {{ $ts._notification._types.receiveFollowRequest }} + {{ i18n.ts._notification._types.receiveFollowRequest }} </FormSwitch> <FormSwitch v-model="emailNotification_groupInvited" class="_formBlock"> - {{ $ts._notification._types.groupInvited }} + {{ i18n.ts._notification._types.groupInvited }} </FormSwitch> </FormSection> </div> @@ -107,6 +107,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.email, icon: 'fas fa-envelope', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue index ac2e3a4968..9072bcefc9 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> @@ -81,10 +81,10 @@ <option value="force">{{ i18n.ts._nsfw.force }}</option> </FormSelect> - <FormGroup> - <template #label>{{ i18n.ts.defaultNavigationBehaviour }}</template> - <FormSwitch v-model="defaultSideView">{{ i18n.ts.openInSideView }}</FormSwitch> - </FormGroup> + <FormRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing class="_formBlock"> + <template #label>{{ i18n.ts.numberOfPageCache }}</template> + <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template> + </FormRange> <FormLink to="/settings/deck" class="_formBlock">{{ i18n.ts.deck }}</FormLink> @@ -97,10 +97,10 @@ import { computed, ref, watch } from 'vue'; import FormSwitch from '@/components/form/switch.vue'; import FormSelect from '@/components/form/select.vue'; import FormRadios from '@/components/form/radios.vue'; -import FormGroup from '@/components/form/group.vue'; +import FormRange from '@/components/form/range.vue'; import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; -import MkLink from '@/components/link.vue'; +import MkLink from '@/components/MkLink.vue'; import { langs } from '@/config'; import { defaultStore } from '@/store'; import * as os from '@/os'; @@ -137,7 +137,7 @@ const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); const nsfw = computed(defaultStore.makeGetterSetter('nsfw')); const disablePagesScript = computed(defaultStore.makeGetterSetter('disablePagesScript')); const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm')); -const defaultSideView = computed(defaultStore.makeGetterSetter('defaultSideView')); +const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache')); const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker')); const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll')); const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu')); @@ -186,6 +186,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.general, icon: 'fas fa-cogs', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/import-export.vue b/packages/client/src/pages/settings/import-export.vue index 438ecbd330..d3d155894e 100644 --- a/packages/client/src/pages/settings/import-export.vue +++ b/packages/client/src/pages/settings/import-export.vue @@ -1,47 +1,79 @@ <template> <div class="_formRoot"> <FormSection> - <template #label>{{ $ts._exportOrImport.allNotes }}</template> - <MkButton :class="$style.button" inline @click="exportNotes()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <template #label>{{ i18n.ts._exportOrImport.allNotes }}</template> + <FormFolder> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="fas fa-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="fas fa-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> </FormSection> <FormSection> - <template #label>{{ $ts._exportOrImport.followingList }}</template> - <FormGroup> + <template #label>{{ i18n.ts._exportOrImport.followingList }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="fas fa-download"></i></template> <FormSwitch v-model="excludeMutingUsers" class="_formBlock"> - {{ $ts._exportOrImport.excludeMutingUsers }} + {{ i18n.ts._exportOrImport.excludeMutingUsers }} </FormSwitch> <FormSwitch v-model="excludeInactiveUsers" class="_formBlock"> - {{ $ts._exportOrImport.excludeInactiveUsers }} + {{ i18n.ts._exportOrImport.excludeInactiveUsers }} </FormSwitch> - <MkButton :class="$style.button" inline @click="exportFollowing()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> - </FormGroup> - <FormGroup> - <MkButton :class="$style.button" inline @click="importFollowing($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> - </FormGroup> + <MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="fas fa-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="fas fa-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="fas fa-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> </FormSection> <FormSection> - <template #label>{{ $ts._exportOrImport.userLists }}</template> - <MkButton :class="$style.button" inline @click="exportUserLists()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> - <MkButton :class="$style.button" inline @click="importUserLists($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + <template #label>{{ i18n.ts._exportOrImport.userLists }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="fas fa-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="fas fa-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="fas fa-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="fas fa-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> </FormSection> <FormSection> - <template #label>{{ $ts._exportOrImport.muteList }}</template> - <MkButton :class="$style.button" inline @click="exportMuting()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> - <MkButton :class="$style.button" inline @click="importMuting($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + <template #label>{{ i18n.ts._exportOrImport.muteList }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="fas fa-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="fas fa-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="fas fa-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="fas fa-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> </FormSection> <FormSection> - <template #label>{{ $ts._exportOrImport.blockingList }}</template> - <MkButton :class="$style.button" inline @click="exportBlocking()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> - <MkButton :class="$style.button" inline @click="importBlocking($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + <template #label>{{ i18n.ts._exportOrImport.blockingList }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="fas fa-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="fas fa-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="fas fa-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="fas fa-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> </FormSection> </div> </template> <script lang="ts" setup> import { ref } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import FormSection from '@/components/form/section.vue'; -import FormGroup from '@/components/form/group.vue'; +import FormFolder from '@/components/form/folder.vue'; import FormSwitch from '@/components/form/switch.vue'; import * as os from '@/os'; import { selectFile } from '@/scripts/select-file'; @@ -123,7 +155,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.importAndExport, icon: 'fas fa-boxes', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue index 12fbbdaa18..73407ff5fb 100644 --- a/packages/client/src/pages/settings/index.vue +++ b/packages/client/src/pages/settings/index.vue @@ -4,15 +4,15 @@ <MkSpacer :content-max="900" :margin-min="20" :margin-max="32"> <div ref="el" class="vvcocwet" :class="{ wide: !narrow }"> <div class="body"> - <div v-if="!narrow || initialPage == null" class="nav"> + <div v-if="!narrow || currentPage?.route.name == null" class="nav"> <div class="baaadecd"> - <MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo> - <MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu> + <MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu> </div> </div> - <div v-if="!(narrow && initialPage == null)" class="main"> + <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> <div class="bkzroven"> - <component :is="component" :key="initialPage" v-bind="pageProps"/> + <RouterView/> </div> </div> </div> @@ -22,26 +22,21 @@ </template> <script setup lang="ts"> -import { computed, defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'; +import { computed, defineAsyncComponent, inject, nextTick, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue'; import { i18n } from '@/i18n'; -import MkInfo from '@/components/ui/info.vue'; -import MkSuperMenu from '@/components/ui/super-menu.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkSuperMenu from '@/components/MkSuperMenu.vue'; import { scroll } from '@/scripts/scroll'; import { signout , $i } from '@/account'; import { unisonReload } from '@/scripts/unison-reload'; import { instance } from '@/instance'; import { useRouter } from '@/router'; import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; - -const props = withDefaults(defineProps<{ - initialPage?: string; -}>(), { -}); +import * as os from '@/os'; const indexInfo = { title: i18n.ts.settings, icon: 'fas fa-cog', - bg: 'var(--bg)', hideHeader: true, }; const INFO = ref(indexInfo); @@ -50,12 +45,14 @@ const childInfo = ref(null); const router = useRouter(); -const narrow = ref(false); +let narrow = $ref(false); const NARROW_THRESHOLD = 600; +let currentPage = $computed(() => router.currentRef.value.child); + const ro = new ResizeObserver((entries, observer) => { if (entries.length === 0) return; - narrow.value = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD; + narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD; }); const menuDef = computed(() => [{ @@ -64,42 +61,42 @@ const menuDef = computed(() => [{ icon: 'fas fa-user', text: i18n.ts.profile, to: '/settings/profile', - active: props.initialPage === 'profile', + active: currentPage?.route.name === 'profile', }, { icon: 'fas fa-lock-open', text: i18n.ts.privacy, to: '/settings/privacy', - active: props.initialPage === 'privacy', + active: currentPage?.route.name === 'privacy', }, { icon: 'fas fa-laugh', text: i18n.ts.reaction, to: '/settings/reaction', - active: props.initialPage === 'reaction', + active: currentPage?.route.name === 'reaction', }, { icon: 'fas fa-cloud', text: i18n.ts.drive, to: '/settings/drive', - active: props.initialPage === 'drive', + active: currentPage?.route.name === 'drive', }, { icon: 'fas fa-bell', text: i18n.ts.notifications, to: '/settings/notifications', - active: props.initialPage === 'notifications', + active: currentPage?.route.name === 'notifications', }, { icon: 'fas fa-envelope', text: i18n.ts.email, to: '/settings/email', - active: props.initialPage === 'email', + active: currentPage?.route.name === 'email', }, { icon: 'fas fa-share-alt', text: i18n.ts.integration, to: '/settings/integration', - active: props.initialPage === 'integration', + active: currentPage?.route.name === 'integration', }, { icon: 'fas fa-lock', text: i18n.ts.security, to: '/settings/security', - active: props.initialPage === 'security', + active: currentPage?.route.name === 'security', }], }, { title: i18n.ts.clientSettings, @@ -107,32 +104,32 @@ const menuDef = computed(() => [{ icon: 'fas fa-cogs', text: i18n.ts.general, to: '/settings/general', - active: props.initialPage === 'general', + active: currentPage?.route.name === 'general', }, { icon: 'fas fa-palette', text: i18n.ts.theme, to: '/settings/theme', - active: props.initialPage === 'theme', + active: currentPage?.route.name === 'theme', }, { - icon: 'fas fa-list-ul', - text: i18n.ts.menu, - to: '/settings/menu', - active: props.initialPage === 'menu', + icon: 'fas fa-bars', + text: i18n.ts.navbar, + to: '/settings/navbar', + active: currentPage?.route.name === 'navbar', + }, { + icon: 'fas fa-bars-progress', + text: i18n.ts.statusbar, + to: '/settings/statusbar', + active: currentPage?.route.name === 'statusbar', }, { icon: 'fas fa-music', text: i18n.ts.sounds, to: '/settings/sounds', - active: props.initialPage === 'sounds', + active: currentPage?.route.name === 'sounds', }, { icon: 'fas fa-plug', text: i18n.ts.plugins, to: '/settings/plugin', - active: props.initialPage === 'plugin', - }, { - icon: 'fas fa-floppy-disk', - text: i18n.ts.preferencesRegistryShort, - to: '/settings/preferences-registry', - active: props.initialPage === 'preferences-registry', + active: currentPage?.route.name === 'plugin', }], }, { title: i18n.ts.otherSettings, @@ -140,40 +137,45 @@ const menuDef = computed(() => [{ icon: 'fas fa-boxes', text: i18n.ts.importAndExport, to: '/settings/import-export', - active: props.initialPage === 'import-export', + active: currentPage?.route.name === 'import-export', }, { icon: 'fas fa-volume-mute', text: i18n.ts.instanceMute, to: '/settings/instance-mute', - active: props.initialPage === 'instance-mute', + active: currentPage?.route.name === 'instance-mute', }, { icon: 'fas fa-ban', text: i18n.ts.muteAndBlock, to: '/settings/mute-block', - active: props.initialPage === 'mute-block', + active: currentPage?.route.name === 'mute-block', }, { icon: 'fas fa-comment-slash', text: i18n.ts.wordMute, to: '/settings/word-mute', - active: props.initialPage === 'word-mute', + active: currentPage?.route.name === 'word-mute', }, { icon: 'fas fa-key', text: 'API', to: '/settings/api', - active: props.initialPage === 'api', + active: currentPage?.route.name === 'api', }, { icon: 'fas fa-bolt', text: 'Webhook', to: '/settings/webhook', - active: props.initialPage === 'webhook', + active: currentPage?.route.name === 'webhook', }, { icon: 'fas fa-ellipsis-h', text: i18n.ts.other, to: '/settings/other', - active: props.initialPage === 'other', + active: currentPage?.route.name === 'other', }], }, { items: [{ + icon: 'fas fa-floppy-disk', + text: i18n.ts.preferencesBackups, + to: '/settings/preferences-backups', + active: currentPage?.route.name === 'preferences-backups', + }, { type: 'button', icon: 'fas fa-trash', text: i18n.ts.clearCache, @@ -186,84 +188,36 @@ const menuDef = computed(() => [{ type: 'button', icon: 'fas fa-sign-in-alt fa-flip-horizontal', text: i18n.ts.logout, - action: () => { + action: async () => { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.logoutConfirm, + }); + if (canceled) return; signout(); }, danger: true, }], }]); -const pageProps = ref({}); -const component = computed(() => { - if (props.initialPage == null) return null; - switch (props.initialPage) { - case 'accounts': return defineAsyncComponent(() => import('./accounts.vue')); - case 'profile': return defineAsyncComponent(() => import('./profile.vue')); - case 'privacy': return defineAsyncComponent(() => import('./privacy.vue')); - case 'reaction': return defineAsyncComponent(() => import('./reaction.vue')); - case 'drive': return defineAsyncComponent(() => import('./drive.vue')); - case 'notifications': return defineAsyncComponent(() => import('./notifications.vue')); - case 'mute-block': return defineAsyncComponent(() => import('./mute-block.vue')); - case 'word-mute': return defineAsyncComponent(() => import('./word-mute.vue')); - case 'instance-mute': return defineAsyncComponent(() => import('./instance-mute.vue')); - case 'integration': return defineAsyncComponent(() => import('./integration.vue')); - case 'security': return defineAsyncComponent(() => import('./security.vue')); - case '2fa': return defineAsyncComponent(() => import('./2fa.vue')); - case 'api': return defineAsyncComponent(() => import('./api.vue')); - case 'webhook': return defineAsyncComponent(() => import('./webhook.vue')); - case 'webhook/new': return defineAsyncComponent(() => import('./webhook.new.vue')); - case 'webhook/edit': return defineAsyncComponent(() => import('./webhook.edit.vue')); - case 'apps': return defineAsyncComponent(() => import('./apps.vue')); - case 'other': return defineAsyncComponent(() => import('./other.vue')); - case 'general': return defineAsyncComponent(() => import('./general.vue')); - case 'email': return defineAsyncComponent(() => import('./email.vue')); - 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 'sounds': return defineAsyncComponent(() => import('./sounds.vue')); - case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue')); - case 'deck': return defineAsyncComponent(() => import('./deck.vue')); - case 'plugin': return defineAsyncComponent(() => import('./plugin.vue')); - case 'plugin/install': return defineAsyncComponent(() => import('./plugin.install.vue')); - case 'preferences-registry': return defineAsyncComponent(() => import('./preferences-registry.vue')); - case 'import-export': return defineAsyncComponent(() => import('./import-export.vue')); - case 'account-info': return defineAsyncComponent(() => import('./account-info.vue')); - case 'delete-account': return defineAsyncComponent(() => import('./delete-account.vue')); - } - return null; +watch($$(narrow), () => { }); -watch(component, () => { - pageProps.value = {}; +onMounted(() => { + ro.observe(el.value); - nextTick(() => { - scroll(el.value, { top: 0 }); - }); -}, { immediate: true }); + narrow = el.value.offsetWidth < NARROW_THRESHOLD; -watch(() => props.initialPage, () => { - if (props.initialPage == null && !narrow.value) { - router.push('/settings/profile'); - } else { - if (props.initialPage == null) { - INFO.value = indexInfo; - } + if (!narrow && currentPage?.route.name == null) { + router.replace('/settings/profile'); } }); -watch(narrow, () => { - if (props.initialPage == null && !narrow.value) { - router.push('/settings/profile'); - } -}); - -onMounted(() => { - ro.observe(el.value); +onActivated(() => { + narrow = el.value.offsetWidth < NARROW_THRESHOLD; - narrow.value = el.value.offsetWidth < NARROW_THRESHOLD; - if (props.initialPage == null && !narrow.value) { - router.push('/settings/profile'); + if (!narrow && currentPage?.route.name == null) { + router.replace('/settings/profile'); } }); @@ -286,6 +240,8 @@ const headerActions = $computed(() => []); const headerTabs = $computed(() => []); definePageMetadata(INFO); +// w 890 +// h 700 </script> <style lang="scss" scoped> @@ -323,13 +279,11 @@ definePageMetadata(INFO); width: 34%; padding-right: 32px; box-sizing: border-box; - overflow: auto; } > .main { flex: 1; min-width: 0; - overflow: auto; } } } diff --git a/packages/client/src/pages/settings/instance-mute.vue b/packages/client/src/pages/settings/instance-mute.vue index d0ca85adca..5a0d48b82e 100644 --- a/packages/client/src/pages/settings/instance-mute.vue +++ b/packages/client/src/pages/settings/instance-mute.vue @@ -12,8 +12,8 @@ <script lang="ts" setup> import { ref, watch } from 'vue'; import FormTextarea from '@/components/form/textarea.vue'; -import MkInfo from '@/components/ui/info.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { $i } from '@/account'; import { i18n } from '@/i18n'; diff --git a/packages/client/src/pages/settings/integration.vue b/packages/client/src/pages/settings/integration.vue index 7de151040e..c8219519f8 100644 --- a/packages/client/src/pages/settings/integration.vue +++ b/packages/client/src/pages/settings/integration.vue @@ -27,7 +27,7 @@ import { computed, onMounted, ref, watch } from 'vue'; import { apiUrl } from '@/config'; import FormSection from '@/components/form/section.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import { $i } from '@/account'; import { instance } from '@/instance'; import { i18n } from '@/i18n'; @@ -95,6 +95,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.integration, icon: 'fas fa-share-alt', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/mute-block.vue b/packages/client/src/pages/settings/mute-block.vue index d8cb286626..3832933cf9 100644 --- a/packages/client/src/pages/settings/mute-block.vue +++ b/packages/client/src/pages/settings/mute-block.vue @@ -1,12 +1,12 @@ <template> <div class="_formRoot"> <MkTab v-model="tab" style="margin-bottom: var(--margin);"> - <option value="mute">{{ $ts.mutedUsers }}</option> - <option value="block">{{ $ts.blockedUsers }}</option> + <option value="mute">{{ i18n.ts.mutedUsers }}</option> + <option value="block">{{ i18n.ts.blockedUsers }}</option> </MkTab> <div v-if="tab === 'mute'"> <MkPagination :pagination="mutingPagination" class="muting"> - <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template> + <template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template> <template #default="{items}"> <FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)"> <MkAcct :user="mute.mutee"/> @@ -16,7 +16,7 @@ </div> <div v-if="tab === 'block'"> <MkPagination :pagination="blockingPagination" class="blocking"> - <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template> + <template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template> <template #default="{items}"> <FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)"> <MkAcct :user="block.blockee"/> @@ -29,9 +29,9 @@ <script lang="ts" setup> import { } from 'vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkTab from '@/components/tab.vue'; -import FormInfo from '@/components/ui/info.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkTab from '@/components/MkTab.vue'; +import FormInfo from '@/components/MkInfo.vue'; import FormLink from '@/components/form/link.vue'; import { userPage } from '@/filters/user'; import * as os from '@/os'; @@ -57,6 +57,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.muteAndBlock, icon: 'fas fa-ban', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/menu.vue b/packages/client/src/pages/settings/navbar.vue index 1b4d8799c8..6c501e9f2f 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> @@ -21,9 +21,9 @@ import { computed, ref, watch } from 'vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormRadios from '@/components/form/radios.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.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,8 +81,7 @@ const headerActions = $computed(() => []); const headerTabs = $computed(() => []); definePageMetadata({ - title: i18n.ts.menu, + title: i18n.ts.navbar, icon: 'fas fa-list-ul', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/notifications.vue b/packages/client/src/pages/settings/notifications.vue index 494a3eebe0..5703e0c6b6 100644 --- a/packages/client/src/pages/settings/notifications.vue +++ b/packages/client/src/pages/settings/notifications.vue @@ -12,7 +12,7 @@ <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; import { notificationTypes } from 'misskey-js'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import * as os from '@/os'; @@ -34,7 +34,7 @@ async function readAllNotifications() { function configure() { const includingTypes = notificationTypes.filter(x => !$i!.mutingNotificationTypes.includes(x)); - os.popup(defineAsyncComponent(() => import('@/components/notification-setting-window.vue')), { + os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { includingTypes, showGlobalToggle: false, }, { @@ -56,6 +56,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.notifications, icon: 'fas fa-bell', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/other.vue b/packages/client/src/pages/settings/other.vue index 283d87a066..51dab04cfa 100644 --- a/packages/client/src/pages/settings/other.vue +++ b/packages/client/src/pages/settings/other.vue @@ -10,6 +10,8 @@ <FormLink to="/settings/account-info" class="_formBlock">{{ i18n.ts.accountInfo }}</FormLink> + <FormLink to="/registry" class="_formBlock"><template #icon><i class="fas fa-cogs"></i></template>{{ i18n.ts.registry }}</FormLink> + <FormLink to="/settings/delete-account" class="_formBlock"><template #icon><i class="fas fa-exclamation-triangle"></i></template>{{ i18n.ts.closeAccount }}</FormLink> </div> </template> @@ -41,6 +43,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.other, icon: 'fas fa-ellipsis-h', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/plugin.install.vue b/packages/client/src/pages/settings/plugin.install.vue index 7ff55e9d83..e259bbeb3a 100644 --- a/packages/client/src/pages/settings/plugin.install.vue +++ b/packages/client/src/pages/settings/plugin.install.vue @@ -18,8 +18,8 @@ import { AiScript, parse } from '@syuilo/aiscript'; import { serialize } from '@syuilo/aiscript/built/serializer'; import { v4 as uuid } from 'uuid'; import FormTextarea from '@/components/form/textarea.vue'; -import FormButton from '@/components/ui/button.vue'; -import FormInfo from '@/components/ui/info.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; import * as os from '@/os'; import { ColdDeviceStorage } from '@/store'; import { unisonReload } from '@/scripts/unison-reload'; @@ -79,7 +79,7 @@ async function install() { } const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => { - os.popup(defineAsyncComponent(() => import('@/components/token-generate-window.vue')), { + os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), { title: i18n.ts.tokenRequested, information: i18n.ts.pluginTokenRequestedDescription, initialName: name, @@ -120,6 +120,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts._plugin.install, icon: 'fas fa-download', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/plugin.vue b/packages/client/src/pages/settings/plugin.vue index 75cf42bb89..8ce6fe4445 100644 --- a/packages/client/src/pages/settings/plugin.vue +++ b/packages/client/src/pages/settings/plugin.vue @@ -36,8 +36,8 @@ import { nextTick, ref } from 'vue'; import FormLink from '@/components/form/link.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormSection from '@/components/form/section.vue'; -import MkButton from '@/components/ui/button.vue'; -import MkKeyValue from '@/components/key-value.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; import * as os from '@/os'; import { ColdDeviceStorage } from '@/store'; import { unisonReload } from '@/scripts/unison-reload'; @@ -90,7 +90,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.plugins, icon: 'fas fa-plug', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/preferences-backups.vue b/packages/client/src/pages/settings/preferences-backups.vue new file mode 100644 index 0000000000..fac67185bc --- /dev/null +++ b/packages/client/src/pages/settings/preferences-backups.vue @@ -0,0 +1,444 @@ +<template> +<div class="_formRoot"> + <div :class="$style.buttons"> + <MkButton inline primary @click="saveNew">{{ ts._preferencesBackups.saveNew }}</MkButton> + <MkButton inline @click="loadFile">{{ ts._preferencesBackups.loadFile }}</MkButton> + </div> + + <FormSection> + <template #label>{{ ts._preferencesBackups.list }}</template> + <template v-if="profiles && Object.keys(profiles).length > 0"> + <div + v-for="(profile, id) in profiles" + :key="id" + class="_formBlock _panel" + :class="$style.profile" + @click="$event => menu($event, id)" + @contextmenu.prevent.stop="$event => menu($event, id)" + > + <div :class="$style.profileName">{{ profile.name }}</div> + <div :class="$style.profileTime">{{ t('_preferencesBackups.createdAt', { date: (new Date(profile.createdAt)).toLocaleDateString(), time: (new Date(profile.createdAt)).toLocaleTimeString() }) }}</div> + <div v-if="profile.updatedAt" :class="$style.profileTime">{{ t('_preferencesBackups.updatedAt', { date: (new Date(profile.updatedAt)).toLocaleDateString(), time: (new Date(profile.updatedAt)).toLocaleTimeString() }) }}</div> + </div> + </template> + <div v-else-if="profiles"> + <MkInfo>{{ ts._preferencesBackups.noBackups }}</MkInfo> + </div> + <MkLoading v-else/> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, onUnmounted, useCssModule } from 'vue'; +import { v4 as uuid } from 'uuid'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage, defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { stream } from '@/stream'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { version, host } from '@/config'; +import { definePageMetadata } from '@/scripts/page-metadata'; +const { t, ts } = i18n; + +useCssModule(); + +const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ + 'menu', + 'visibility', + 'localOnly', + 'statusbars', + 'widgets', + 'tl', + 'overridedDeviceKind', + 'serverDisconnectedBehavior', + 'nsfw', + 'animation', + 'animatedMfm', + 'loadRawImages', + 'imageNewTab', + 'disableShowingAnimatedImages', + 'disablePagesScript', + 'useOsNativeEmojis', + 'disableDrawer', + 'useBlurEffectForModal', + 'useBlurEffect', + 'showFixedPostForm', + 'enableInfiniteScroll', + 'useReactionPickerForContextMenu', + 'showGapBetweenNotesInTimeline', + 'instanceTicker', + 'reactionPickerSize', + 'reactionPickerWidth', + 'reactionPickerHeight', + 'reactionPickerUseDrawerForMobile', + 'defaultSideView', + 'menuDisplay', + 'reportError', + 'squareAvatars', + 'numberOfPageCache', + 'aiChanMode', +]; +const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ + 'lightTheme', + 'darkTheme', + 'syncDeviceDarkMode', + 'plugins', + 'mediaVolume', + 'sound_masterVolume', + 'sound_note', + 'sound_noteMy', + 'sound_notification', + 'sound_chat', + 'sound_chatBg', + 'sound_antenna', + 'sound_channel', +]; + +const scope = ['clientPreferencesProfiles']; + +const profileProps = ['name', 'createdAt', 'updatedAt', 'misskeyVersion', 'settings']; + +type Profile = { + name: string; + createdAt: string; + updatedAt: string | null; + misskeyVersion: string; + host: string; + settings: { + hot: Record<keyof typeof defaultStoreSaveKeys, unknown>; + cold: Record<keyof typeof coldDeviceStorageSaveKeys, unknown>; + fontSize: string | null; + useSystemFont: 't' | null; + wallpaper: string | null; + }; +}; + +const connection = $i && stream.useChannel('main'); + +let profiles = $ref<Record<string, Profile> | null>(null); + +os.api('i/registry/get-all', { scope }) + .then(res => { + profiles = res || {}; + }); + +function isObject(value: unknown): value is Record<string, unknown> { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function validate(profile: unknown): void { + if (!isObject(profile)) throw new Error('not an object'); + + // Check if unnecessary properties exist + if (Object.keys(profile).some(key => !profileProps.includes(key))) throw new Error('Unnecessary properties exist'); + + if (!profile.name) throw new Error('Missing required prop: name'); + if (!profile.misskeyVersion) throw new Error('Missing required prop: misskeyVersion'); + + // Check if createdAt and updatedAt is Date + // https://zenn.dev/lollipop_onl/articles/eoz-judge-js-invalid-date + if (!profile.createdAt || Number.isNaN(new Date(profile.createdAt).getTime())) throw new Error('createdAt is falsy or not Date'); + if (profile.updatedAt) { + if (Number.isNaN(new Date(profile.updatedAt).getTime())) { + throw new Error('updatedAt is not Date'); + } + } else if (profile.updatedAt !== null) { + throw new Error('updatedAt is not null'); + } + + if (!profile.settings) throw new Error('Missing required prop: settings'); + if (!isObject(profile.settings)) throw new Error('Invalid prop: settings'); +} + +function getSettings(): Profile['settings'] { + const hot = {} as Record<keyof typeof defaultStoreSaveKeys, unknown>; + for (const key of defaultStoreSaveKeys) { + hot[key] = defaultStore.state[key]; + } + + const cold = {} as Record<keyof typeof coldDeviceStorageSaveKeys, unknown>; + for (const key of coldDeviceStorageSaveKeys) { + cold[key] = ColdDeviceStorage.get(key); + } + + return { + hot, + cold, + fontSize: localStorage.getItem('fontSize'), + useSystemFont: localStorage.getItem('useSystemFont') as 't' | null, + wallpaper: localStorage.getItem('wallpaper'), + }; +} + +async function saveNew(): Promise<void> { + if (!profiles) return; + + const { canceled, result: name } = await os.inputText({ + title: ts._preferencesBackups.inputName, + }); + if (canceled) return; + + if (Object.values(profiles).some(x => x.name === name)) { + return os.alert({ + title: ts._preferencesBackups.cannotSave, + text: t('_preferencesBackups.nameAlreadyExists', { name }), + }); + } + + const id = uuid(); + const profile: Profile = { + name, + createdAt: (new Date()).toISOString(), + updatedAt: null, + misskeyVersion: version, + host, + settings: getSettings(), + }; + await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); +} + +function loadFile(): void { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = false; + input.onchange = async () => { + if (!profiles) return; + if (!input.files || input.files.length === 0) return; + + const file = input.files[0]; + + if (file.type !== 'application/json') { + return os.alert({ + type: 'error', + title: ts._preferencesBackups.cannotLoad, + text: ts._preferencesBackups.invalidFile, + }); + } + + let profile: Profile; + try { + profile = JSON.parse(await file.text()) as unknown as Profile; + validate(profile); + } catch (err) { + return os.alert({ + type: 'error', + title: ts._preferencesBackups.cannotLoad, + text: err?.message, + }); + } + + const id = uuid(); + await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); + + // 一応廃棄 + (window as any).__misskey_input_ref__ = null; + }; + + // https://qiita.com/fukasawah/items/b9dc732d95d99551013d + // iOS Safari で正常に動かす為のおまじない + (window as any).__misskey_input_ref__ = input; + + input.click(); +} + +async function applyProfile(id: string): Promise<void> { + if (!profiles) return; + + const profile = profiles[id]; + + const { canceled: cancel1 } = await os.confirm({ + type: 'warning', + title: ts._preferencesBackups.apply, + text: t('_preferencesBackups.applyConfirm', { name: profile.name }), + }); + if (cancel1) return; + + // TODO: バージョン or ホストが違ったらさらに警告を表示 + + const settings = profile.settings; + + // defaultStore + for (const key of defaultStoreSaveKeys) { + if (settings.hot[key] !== undefined) { + defaultStore.set(key, settings.hot[key]); + } + } + + // coldDeviceStorage + for (const key of coldDeviceStorageSaveKeys) { + if (settings.cold[key] !== undefined) { + ColdDeviceStorage.set(key, settings.cold[key]); + } + } + + // fontSize + if (settings.fontSize) { + localStorage.setItem('fontSize', settings.fontSize); + } else { + localStorage.removeItem('fontSize'); + } + + // useSystemFont + if (settings.useSystemFont) { + localStorage.setItem('useSystemFont', settings.useSystemFont); + } else { + localStorage.removeItem('useSystemFont'); + } + + // wallpaper + if (settings.wallpaper != null) { + localStorage.setItem('wallpaper', settings.wallpaper); + } else { + localStorage.removeItem('wallpaper'); + } + + const { canceled: cancel2 } = await os.confirm({ + type: 'info', + text: ts.reloadToApplySetting, + }); + if (cancel2) return; + + unisonReload(); +} + +async function deleteProfile(id: string): Promise<void> { + if (!profiles) return; + + const { canceled } = await os.confirm({ + type: 'info', + title: ts.delete, + text: t('deleteAreYouSure', { x: profiles[id].name }), + }); + if (canceled) return; + + await os.apiWithDialog('i/registry/remove', { scope, key: id }); + delete profiles[id]; +} + +async function save(id: string): Promise<void> { + if (!profiles) return; + + const { name, createdAt } = profiles[id]; + + const { canceled } = await os.confirm({ + type: 'info', + title: ts._preferencesBackups.save, + text: t('_preferencesBackups.saveConfirm', { name }), + }); + if (canceled) return; + + const profile: Profile = { + name, + createdAt, + updatedAt: (new Date()).toISOString(), + misskeyVersion: version, + host, + settings: getSettings(), + }; + await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); +} + +async function rename(id: string): Promise<void> { + if (!profiles) return; + + const { canceled: cancel1, result: name } = await os.inputText({ + title: ts._preferencesBackups.inputName, + }); + if (cancel1 || profiles[id].name === name) return; + + if (Object.values(profiles).some(x => x.name === name)) { + return os.alert({ + title: ts._preferencesBackups.cannotSave, + text: t('_preferencesBackups.nameAlreadyExists', { name }), + }); + } + + const registry = Object.assign({}, { ...profiles[id] }); + + const { canceled: cancel2 } = await os.confirm({ + type: 'info', + title: ts._preferencesBackups.rename, + text: t('_preferencesBackups.renameConfirm', { old: registry.name, new: name }), + }); + if (cancel2) return; + + registry.name = name; + await os.apiWithDialog('i/registry/set', { scope, key: id, value: registry }); +} + +function menu(ev: MouseEvent, profileId: string) { + if (!profiles) return; + + return os.popupMenu([{ + text: ts._preferencesBackups.apply, + icon: 'fas fa-circle-down', + action: () => applyProfile(profileId), + }, { + type: 'a', + text: ts.download, + icon: 'fas fa-download', + href: URL.createObjectURL(new Blob([JSON.stringify(profiles[profileId], null, 2)], { type: 'application/json' })), + download: `${profiles[profileId].name}.json`, + }, null, { + text: ts.rename, + icon: 'fas fa-i-cursor', + action: () => rename(profileId), + }, { + text: ts._preferencesBackups.save, + icon: 'fas fa-floppy-disk', + action: () => save(profileId), + }, null, { + text: ts._preferencesBackups.delete, + icon: 'fas fa-trash-can', + action: () => deleteProfile(profileId), + danger: true, + }], ev.currentTarget ?? ev.target); +} + +onMounted(() => { + // streamingのuser storage updateイベントを監視して更新 + connection?.on('registryUpdated', ({ scope: recievedScope, key, value }) => { + if (!recievedScope || recievedScope.length !== scope.length || recievedScope[0] !== scope[0]) return; + if (!profiles) return; + + profiles[key] = value; + }); +}); + +onUnmounted(() => { + connection?.off('registryUpdated'); +}); + +definePageMetadata(computed(() => ({ + title: ts.preferencesBackups, + icon: 'fas fa-floppy-disk', + bg: 'var(--bg)', +}))); +</script> + +<style lang="scss" module> +.buttons { + display: flex; + gap: var(--margin); + flex-wrap: wrap; +} + +.profile { + padding: 20px; + cursor: pointer; + + &Name { + font-weight: 700; + } + + &Time { + font-size: .85em; + opacity: .7; + } +} +</style> diff --git a/packages/client/src/pages/settings/privacy.vue b/packages/client/src/pages/settings/privacy.vue index a209c3f469..45a0358a92 100644 --- a/packages/client/src/pages/settings/privacy.vue +++ b/packages/client/src/pages/settings/privacy.vue @@ -1,49 +1,54 @@ <template> <div class="_formRoot"> - <FormSwitch v-model="isLocked" class="_formBlock" @update:modelValue="save()">{{ $ts.makeFollowManuallyApprove }}<template #caption>{{ $ts.lockedAccountInfo }}</template></FormSwitch> - <FormSwitch v-if="isLocked" v-model="autoAcceptFollowed" class="_formBlock" @update:modelValue="save()">{{ $ts.autoAcceptFollowed }}</FormSwitch> + <FormSwitch v-model="isLocked" class="_formBlock" @update:modelValue="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></FormSwitch> + <FormSwitch v-if="isLocked" v-model="autoAcceptFollowed" class="_formBlock" @update:modelValue="save()">{{ i18n.ts.autoAcceptFollowed }}</FormSwitch> <FormSwitch v-model="publicReactions" class="_formBlock" @update:modelValue="save()"> - {{ $ts.makeReactionsPublic }} - <template #caption>{{ $ts.makeReactionsPublicDescription }}</template> + {{ i18n.ts.makeReactionsPublic }} + <template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template> </FormSwitch> <FormSelect v-model="ffVisibility" class="_formBlock" @update:modelValue="save()"> - <template #label>{{ $ts.ffVisibility }}</template> - <option value="public">{{ $ts._ffVisibility.public }}</option> - <option value="followers">{{ $ts._ffVisibility.followers }}</option> - <option value="private">{{ $ts._ffVisibility.private }}</option> - <template #caption>{{ $ts.ffVisibilityDescription }}</template> + <template #label>{{ i18n.ts.ffVisibility }}</template> + <option value="public">{{ i18n.ts._ffVisibility.public }}</option> + <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option> + <option value="private">{{ i18n.ts._ffVisibility.private }}</option> + <template #caption>{{ i18n.ts.ffVisibilityDescription }}</template> </FormSelect> <FormSwitch v-model="hideOnlineStatus" class="_formBlock" @update:modelValue="save()"> - {{ $ts.hideOnlineStatus }} - <template #caption>{{ $ts.hideOnlineStatusDescription }}</template> + {{ i18n.ts.hideOnlineStatus }} + <template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template> </FormSwitch> <FormSwitch v-model="noCrawle" class="_formBlock" @update:modelValue="save()"> - {{ $ts.noCrawle }} - <template #caption>{{ $ts.noCrawleDescription }}</template> + {{ i18n.ts.noCrawle }} + <template #caption>{{ i18n.ts.noCrawleDescription }}</template> </FormSwitch> <FormSwitch v-model="isExplorable" class="_formBlock" @update:modelValue="save()"> - {{ $ts.makeExplorable }} - <template #caption>{{ $ts.makeExplorableDescription }}</template> + {{ i18n.ts.makeExplorable }} + <template #caption>{{ i18n.ts.makeExplorableDescription }}</template> </FormSwitch> <FormSection> - <FormSwitch v-model="rememberNoteVisibility" class="_formBlock" @update:modelValue="save()">{{ $ts.rememberNoteVisibility }}</FormSwitch> - <FormGroup v-if="!rememberNoteVisibility" class="_formBlock"> - <template #label>{{ $ts.defaultNoteVisibility }}</template> + <FormSwitch v-model="rememberNoteVisibility" class="_formBlock" @update:modelValue="save()">{{ i18n.ts.rememberNoteVisibility }}</FormSwitch> + <FormFolder v-if="!rememberNoteVisibility" class="_formBlock"> + <template #label>{{ i18n.ts.defaultNoteVisibility }}</template> + <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template> + <template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template> + <template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template> + <template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template> + <FormSelect v-model="defaultNoteVisibility" class="_formBlock"> - <option value="public">{{ $ts._visibility.public }}</option> - <option value="home">{{ $ts._visibility.home }}</option> - <option value="followers">{{ $ts._visibility.followers }}</option> - <option value="specified">{{ $ts._visibility.specified }}</option> + <option value="public">{{ i18n.ts._visibility.public }}</option> + <option value="home">{{ i18n.ts._visibility.home }}</option> + <option value="followers">{{ i18n.ts._visibility.followers }}</option> + <option value="specified">{{ i18n.ts._visibility.specified }}</option> </FormSelect> - <FormSwitch v-model="defaultNoteLocalOnly" class="_formBlock">{{ $ts._visibility.localOnly }}</FormSwitch> - </FormGroup> + <FormSwitch v-model="defaultNoteLocalOnly" class="_formBlock">{{ i18n.ts._visibility.localOnly }}</FormSwitch> + </FormFolder> </FormSection> - <FormSwitch v-model="keepCw" class="_formBlock" @update:modelValue="save()">{{ $ts.keepCw }}</FormSwitch> + <FormSwitch v-model="keepCw" class="_formBlock" @update:modelValue="save()">{{ i18n.ts.keepCw }}</FormSwitch> </div> </template> @@ -52,7 +57,7 @@ import { } from 'vue'; import FormSwitch from '@/components/form/switch.vue'; import FormSelect from '@/components/form/select.vue'; import FormSection from '@/components/form/section.vue'; -import FormGroup from '@/components/form/group.vue'; +import FormFolder from '@/components/form/folder.vue'; import * as os from '@/os'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; @@ -91,6 +96,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.privacy, icon: 'fas fa-lock-open', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue index b662de9e3d..aaf60c8d55 100644 --- a/packages/client/src/pages/settings/profile.vue +++ b/packages/client/src/pages/settings/profile.vue @@ -1,11 +1,11 @@ <template> <div class="_formRoot"> <div class="llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> - <div class="avatar _acrylic"> + <div class="avatar"> <MkAvatar class="avatar" :user="$i" :disable-link="true" @click="changeAvatar"/> - <MkButton primary class="avatarEdit" @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton> + <MkButton primary rounded class="avatarEdit" @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton> </div> - <MkButton primary class="bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton> + <MkButton primary rounded class="bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton> </div> <FormInput v-model="profile.name" :max="30" manual-save class="_formBlock"> @@ -39,10 +39,10 @@ <div class="_formRoot"> <FormSplit v-for="(record, i) in fields" :min-width="250" class="_formBlock"> - <FormInput v-model="record.name"> + <FormInput v-model="record.name" small> <template #label>{{ i18n.ts._profile.metadataLabel }} #{{ i + 1 }}</template> </FormInput> - <FormInput v-model="record.value"> + <FormInput v-model="record.value" small> <template #label>{{ i18n.ts._profile.metadataContent }} #{{ i + 1 }}</template> </FormInput> </FormSplit> @@ -56,14 +56,12 @@ <FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></FormSwitch> <FormSwitch v-model="profile.showTimelineReplies" class="_formBlock">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></FormSwitch> <FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></FormSwitch> - - <FormSwitch v-model="profile.alwaysMarkNsfw" class="_formBlock">{{ i18n.ts.alwaysMarkSensitive }}</FormSwitch> </div> </template> <script lang="ts" setup> import { reactive, watch } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import FormInput from '@/components/form/input.vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormSwitch from '@/components/form/switch.vue'; @@ -88,7 +86,6 @@ const profile = reactive({ isBot: $i.isBot, isCat: $i.isCat, showTimelineReplies: $i.showTimelineReplies, - alwaysMarkNsfw: $i.alwaysMarkNsfw, }); watch(() => profile, () => { @@ -126,7 +123,6 @@ function save() { isBot: !!profile.isBot, isCat: !!profile.isCat, showTimelineReplies: !!profile.showTimelineReplies, - alwaysMarkNsfw: !!profile.alwaysMarkNsfw, }); } @@ -183,7 +179,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.profile, icon: 'fas fa-user', - bg: 'var(--bg)', }); </script> @@ -192,6 +187,7 @@ definePageMetadata({ position: relative; background-size: cover; background-position: center; + border: solid 1px var(--divider); border-radius: 10px; overflow: clip; diff --git a/packages/client/src/pages/settings/reaction.vue b/packages/client/src/pages/settings/reaction.vue index d0fdf835cf..f8d57cbcd5 100644 --- a/packages/client/src/pages/settings/reaction.vue +++ b/packages/client/src/pages/settings/reaction.vue @@ -1,7 +1,7 @@ <template> <div class="_formRoot"> <FromSlot class="_formBlock"> - <template #label>{{ $ts.reactionSettingDescription }}</template> + <template #label>{{ i18n.ts.reactionSettingDescription }}</template> <div v-panel style="border-radius: 6px;"> <XDraggable v-model="reactions" class="zoaiodol" :item-key="item => item" animation="150" delay="100" delay-on-touch-only="true"> <template #item="{element}"> @@ -14,17 +14,17 @@ </template> </XDraggable> </div> - <template #caption>{{ $ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ $ts.preview }}</button></template> + <template #caption>{{ i18n.ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ i18n.ts.preview }}</button></template> </FromSlot> <FormRadios v-model="reactionPickerSize" class="_formBlock"> - <template #label>{{ $ts.size }}</template> - <option :value="1">{{ $ts.small }}</option> - <option :value="2">{{ $ts.medium }}</option> - <option :value="3">{{ $ts.large }}</option> + <template #label>{{ i18n.ts.size }}</template> + <option :value="1">{{ i18n.ts.small }}</option> + <option :value="2">{{ i18n.ts.medium }}</option> + <option :value="3">{{ i18n.ts.large }}</option> </FormRadios> <FormRadios v-model="reactionPickerWidth" class="_formBlock"> - <template #label>{{ $ts.numberOfColumn }}</template> + <template #label>{{ i18n.ts.numberOfColumn }}</template> <option :value="1">5</option> <option :value="2">6</option> <option :value="3">7</option> @@ -32,22 +32,22 @@ <option :value="5">9</option> </FormRadios> <FormRadios v-model="reactionPickerHeight" class="_formBlock"> - <template #label>{{ $ts.height }}</template> - <option :value="1">{{ $ts.small }}</option> - <option :value="2">{{ $ts.medium }}</option> - <option :value="3">{{ $ts.large }}</option> - <option :value="4">{{ $ts.large }}+</option> + <template #label>{{ i18n.ts.height }}</template> + <option :value="1">{{ i18n.ts.small }}</option> + <option :value="2">{{ i18n.ts.medium }}</option> + <option :value="3">{{ i18n.ts.large }}</option> + <option :value="4">{{ i18n.ts.large }}+</option> </FormRadios> <FormSwitch v-model="reactionPickerUseDrawerForMobile" class="_formBlock"> - {{ $ts.useDrawerReactionPickerForMobile }} - <template #caption>{{ $ts.needReloadToApply }}</template> + {{ i18n.ts.useDrawerReactionPickerForMobile }} + <template #caption>{{ i18n.ts.needReloadToApply }}</template> </FormSwitch> <FormSection> <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> - <FormButton inline @click="preview"><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton> - <FormButton inline danger @click="setDefault"><i class="fas fa-undo"></i> {{ $ts.default }}</FormButton> + <FormButton inline @click="preview"><i class="fas fa-eye"></i> {{ i18n.ts.preview }}</FormButton> + <FormButton inline danger @click="setDefault"><i class="fas fa-undo"></i> {{ i18n.ts.default }}</FormButton> </div> </FormSection> </div> @@ -59,15 +59,16 @@ import XDraggable from 'vuedraggable'; import FormInput from '@/components/form/input.vue'; import FormRadios from '@/components/form/radios.vue'; import FromSlot from '@/components/form/slot.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import FormSection from '@/components/form/section.vue'; import FormSwitch from '@/components/form/switch.vue'; import * as os from '@/os'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { deepClone } from '@/scripts/clone'; -let reactions = $ref(JSON.parse(JSON.stringify(defaultStore.state.reactions))); +let reactions = $ref(deepClone(defaultStore.state.reactions)); const reactionPickerSize = $computed(defaultStore.makeGetterSetter('reactionPickerSize')); const reactionPickerWidth = $computed(defaultStore.makeGetterSetter('reactionPickerWidth')); @@ -88,7 +89,7 @@ function remove(reaction, ev: MouseEvent) { } function preview(ev: MouseEvent) { - os.popup(defineAsyncComponent(() => import('@/components/emoji-picker-dialog.vue')), { + os.popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { asReactionPicker: true, src: ev.currentTarget ?? ev.target, }, {}, 'closed'); @@ -101,7 +102,7 @@ async function setDefault() { }); if (canceled) return; - reactions = JSON.parse(JSON.stringify(defaultStore.def.reactions.default)); + reactions = deepClone(defaultStore.def.reactions.default); } function chooseEmoji(ev: MouseEvent) { @@ -131,7 +132,6 @@ definePageMetadata({ icon: 'fas fa-eye', handler: preview, }, - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/security.vue b/packages/client/src/pages/settings/security.vue index 57880ef3dd..d109a4ba7c 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"> @@ -41,8 +41,8 @@ import X2fa from './2fa.vue'; import FormSection from '@/components/form/section.vue'; import FormSlot from '@/components/form/slot.vue'; -import FormButton from '@/components/ui/button.vue'; -import MkPagination from '@/components/ui/pagination.vue'; +import FormButton from '@/components/MkButton.vue'; +import MkPagination from '@/components/MkPagination.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -104,7 +104,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.security, icon: 'fas fa-lock', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/sounds.vue b/packages/client/src/pages/settings/sounds.vue index bb23257d7a..2729609522 100644 --- a/packages/client/src/pages/settings/sounds.vue +++ b/packages/client/src/pages/settings/sounds.vue @@ -20,7 +20,7 @@ <script lang="ts" setup> import { computed, ref } from 'vue'; import FormRange from '@/components/form/range.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import * as os from '@/os'; @@ -90,7 +90,7 @@ async function edit(type) { }, volume: { type: 'range', - mim: 0, + min: 0, max: 1, step: 0.05, textConverter: (v) => `${Math.floor(v * 100)}%`, @@ -131,6 +131,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.sounds, icon: 'fas fa-music', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/statusbar.statusbar.vue b/packages/client/src/pages/settings/statusbar.statusbar.vue new file mode 100644 index 0000000000..608222386e --- /dev/null +++ b/packages/client/src/pages/settings/statusbar.statusbar.vue @@ -0,0 +1,140 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="statusbar.type" placeholder="Please select" class="_formBlock"> + <template #label>{{ i18n.ts.type }}</template> + <option value="rss">RSS</option> + <option value="federation">Federation</option> + <option value="userList">User list timeline</option> + </FormSelect> + + <MkInput v-model="statusbar.name" manual-save class="_formBlock"> + <template #label>{{ i18n.ts.label }}</template> + </MkInput> + + <MkSwitch v-model="statusbar.black" class="_formBlock"> + <template #label>Black</template> + </MkSwitch> + + <FormRadios v-model="statusbar.size" class="_formBlock"> + <template #label>{{ i18n.ts.size }}</template> + <option value="verySmall">{{ i18n.ts.small }}+</option> + <option value="small">{{ i18n.ts.small }}</option> + <option value="medium">{{ i18n.ts.medium }}</option> + <option value="large">{{ i18n.ts.large }}</option> + <option value="veryLarge">{{ i18n.ts.large }}+</option> + </FormRadios> + + <template v-if="statusbar.type === 'rss'"> + <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> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + </template> + <template v-else-if="statusbar.type === 'federation'"> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + <MkSwitch v-model="statusbar.props.colored" class="_formBlock"> + <template #label>{{ i18n.ts.colored }}</template> + </MkSwitch> + </template> + <template v-else-if="statusbar.type === 'userList' && userLists != null"> + <FormSelect v-model="statusbar.props.userListId" class="_formBlock"> + <template #label>{{ i18n.ts.userList }}</template> + <option v-for="list in userLists" :value="list.id">{{ list.name }}</option> + </FormSelect> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + </template> + + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton danger @click="del">{{ i18n.ts.remove }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, reactive, ref, watch } from 'vue'; +import FormSelect from '@/components/form/select.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormRange from '@/components/form/range.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { deepClone } from '@/scripts/clone'; + +const props = defineProps<{ + _id: string; + userLists: any[] | null; +}>(); + +const statusbar = reactive(deepClone(defaultStore.state.statusbars.find(x => x.id === props._id))); + +watch(() => statusbar.type, () => { + if (statusbar.type === 'rss') { + statusbar.name = 'NEWS'; + statusbar.props.url = 'http://feeds.afpbb.com/rss/afpbb/afpbbnews'; + statusbar.props.shuffle = true; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + } else if (statusbar.type === 'federation') { + statusbar.name = 'FEDERATION'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + statusbar.props.colored = false; + } else if (statusbar.type === 'userList') { + statusbar.name = 'LIST TL'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + } +}); + +watch(statusbar, save); + +async function save() { + const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id); + const statusbars = deepClone(defaultStore.state.statusbars); + statusbars[i] = deepClone(statusbar); + defaultStore.set('statusbars', statusbars); +} + +function del() { + defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id)); +} +</script> diff --git a/packages/client/src/pages/settings/statusbar.vue b/packages/client/src/pages/settings/statusbar.vue new file mode 100644 index 0000000000..9dbf182142 --- /dev/null +++ b/packages/client/src/pages/settings/statusbar.vue @@ -0,0 +1,54 @@ +<template> +<div class="_formRoot"> + <FormFolder v-for="x in statusbars" :key="x.id" class="_formBlock"> + <template #label>{{ x.type ?? i18n.ts.notSet }}</template> + <template #suffix>{{ x.name }}</template> + <XStatusbar :_id="x.id" :user-lists="userLists"/> + </FormFolder> + <FormButton primary @click="add">{{ i18n.ts.add }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XStatusbar from './statusbar.statusbar.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const statusbars = defaultStore.reactiveState.statusbars; + +let userLists = $ref(); + +onMounted(() => { + os.api('users/lists/list').then(res => { + userLists = res; + }); +}); + +async function add() { + defaultStore.push('statusbars', { + id: uuid(), + type: null, + black: false, + size: 'medium', + props: {}, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.statusbar, + icon: 'fas fa-list-ul', + bg: 'var(--bg)', +}); +</script> diff --git a/packages/client/src/pages/settings/theme.install.vue b/packages/client/src/pages/settings/theme.install.vue index 6a863ed9e6..34f8384d87 100644 --- a/packages/client/src/pages/settings/theme.install.vue +++ b/packages/client/src/pages/settings/theme.install.vue @@ -15,7 +15,7 @@ import { } from 'vue'; import JSON5 from 'json5'; import FormTextarea from '@/components/form/textarea.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import { applyTheme, validateTheme } from '@/scripts/theme'; import * as os from '@/os'; import { addTheme, getThemes } from '@/theme-store'; @@ -76,6 +76,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts._theme.install, icon: 'fas fa-download', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/theme.manage.vue b/packages/client/src/pages/settings/theme.manage.vue index 68cbf3c353..792bb15e5d 100644 --- a/packages/client/src/pages/settings/theme.manage.vue +++ b/packages/client/src/pages/settings/theme.manage.vue @@ -31,7 +31,7 @@ import JSON5 from 'json5'; import FormTextarea from '@/components/form/textarea.vue'; import FormSelect from '@/components/form/select.vue'; import FormInput from '@/components/form/input.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import { Theme, getBuiltinThemesRef } from '@/scripts/theme'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import * as os from '@/os'; @@ -74,6 +74,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts._theme.manage, icon: 'fas fa-folder-open', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue index db4262ba7e..6571a881a9 100644 --- a/packages/client/src/pages/settings/theme.vue +++ b/packages/client/src/pages/settings/theme.vue @@ -1,12 +1,12 @@ <template> -<div class="_formRoot"> +<div class="_formRoot rsljpzjq"> <div v-adaptive-border class="rfqxtzch _panel _formBlock"> <div class="toggle"> <div class="toggleWrapper"> <input id="dn" v-model="darkMode" type="checkbox" class="dn"/> <label for="dn" class="toggle"> - <span class="before">{{ $ts.light }}</span> - <span class="after">{{ $ts.dark }}</span> + <span class="before">{{ i18n.ts.light }}</span> + <span class="after">{{ i18n.ts.dark }}</span> <span class="toggle__handler"> <span class="crater crater--1"></span> <span class="crater crater--2"></span> @@ -22,66 +22,46 @@ </div> </div> <div class="sync"> - <FormSwitch v-model="syncDeviceDarkMode">{{ $ts.syncDeviceDarkMode }}</FormSwitch> + <FormSwitch v-model="syncDeviceDarkMode">{{ i18n.ts.syncDeviceDarkMode }}</FormSwitch> </div> </div> - <template v-if="darkMode"> - <FormSelect v-model="darkThemeId" class="_formBlock"> - <template #label>{{ $ts.themeForDarkMode }}</template> - <template #prefix><i class="fas fa-moon"></i></template> - <optgroup :label="$ts.darkThemes"> - <option v-for="x in darkThemes" :key="x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$ts.lightThemes"> - <option v-for="x in lightThemes" :key="x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - </FormSelect> - <FormSelect v-model="lightThemeId" class="_formBlock"> - <template #label>{{ $ts.themeForLightMode }}</template> - <template #prefix><i class="fas fa-sun"></i></template> - <optgroup :label="$ts.lightThemes"> - <option v-for="x in lightThemes" :key="x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$ts.darkThemes"> - <option v-for="x in darkThemes" :key="x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - </FormSelect> - </template> - <template v-else> - <FormSelect v-model="lightThemeId" class="_formBlock"> - <template #label>{{ $ts.themeForLightMode }}</template> + <div class="selects _formBlock"> + <FormSelect v-model="lightThemeId" large class="select"> + <template #label>{{ i18n.ts.themeForLightMode }}</template> <template #prefix><i class="fas fa-sun"></i></template> - <optgroup :label="$ts.lightThemes"> - <option v-for="x in lightThemes" :key="x.id" :value="x.id">{{ x.name }}</option> + <option v-if="instanceLightTheme" :key="'instance:' + instanceLightTheme.id" :value="instanceLightTheme.id">{{ instanceLightTheme.name }}</option> + <optgroup v-if="installedLightThemes.length > 0" :label="i18n.ts._theme.installedThemes"> + <option v-for="x in installedLightThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option> </optgroup> - <optgroup :label="$ts.darkThemes"> - <option v-for="x in darkThemes" :key="x.id" :value="x.id">{{ x.name }}</option> + <optgroup :label="i18n.ts._theme.builtinThemes"> + <option v-for="x in builtinLightThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option> </optgroup> </FormSelect> - <FormSelect v-model="darkThemeId" class="_formBlock"> - <template #label>{{ $ts.themeForDarkMode }}</template> + <FormSelect v-model="darkThemeId" large class="select"> + <template #label>{{ i18n.ts.themeForDarkMode }}</template> <template #prefix><i class="fas fa-moon"></i></template> - <optgroup :label="$ts.darkThemes"> - <option v-for="x in darkThemes" :key="x.id" :value="x.id">{{ x.name }}</option> + <option v-if="instanceDarkTheme" :key="'instance:' + instanceDarkTheme.id" :value="instanceDarkTheme.id">{{ instanceDarkTheme.name }}</option> + <optgroup v-if="installedDarkThemes.length > 0" :label="i18n.ts._theme.installedThemes"> + <option v-for="x in installedDarkThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option> </optgroup> - <optgroup :label="$ts.lightThemes"> - <option v-for="x in lightThemes" :key="x.id" :value="x.id">{{ x.name }}</option> + <optgroup :label="i18n.ts._theme.builtinThemes"> + <option v-for="x in builtinDarkThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option> </optgroup> </FormSelect> - </template> + </div> <FormSection> <div class="_formLinksGrid"> - <FormLink to="/settings/theme/manage"><template #icon><i class="fas fa-folder-open"></i></template>{{ $ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink> - <FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="fas fa-globe"></i></template>{{ $ts._theme.explore }}</FormLink> - <FormLink to="/settings/theme/install"><template #icon><i class="fas fa-download"></i></template>{{ $ts._theme.install }}</FormLink> - <FormLink to="/theme-editor"><template #icon><i class="fas fa-paint-roller"></i></template>{{ $ts._theme.make }}</FormLink> + <FormLink to="/settings/theme/manage"><template #icon><i class="fas fa-folder-open"></i></template>{{ i18n.ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink> + <FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="fas fa-globe"></i></template>{{ i18n.ts._theme.explore }}</FormLink> + <FormLink to="/settings/theme/install"><template #icon><i class="fas fa-download"></i></template>{{ i18n.ts._theme.install }}</FormLink> + <FormLink to="/theme-editor"><template #icon><i class="fas fa-paint-roller"></i></template>{{ i18n.ts._theme.make }}</FormLink> </div> </FormSection> - <FormButton v-if="wallpaper == null" class="_formBlock" @click="setWallpaper">{{ $ts.setWallpaper }}</FormButton> - <FormButton v-else class="_formBlock" @click="wallpaper = null">{{ $ts.removeWallpaper }}</FormButton> + <FormButton v-if="wallpaper == null" class="_formBlock" @click="setWallpaper">{{ i18n.ts.setWallpaper }}</FormButton> + <FormButton v-else class="_formBlock" @click="wallpaper = null">{{ i18n.ts.removeWallpaper }}</FormButton> </div> </template> @@ -92,7 +72,7 @@ import FormSwitch from '@/components/form/switch.vue'; import FormSelect from '@/components/form/select.vue'; import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import { getBuiltinThemesRef } from '@/scripts/theme'; import { selectFile } from '@/scripts/select-file'; import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; @@ -105,21 +85,25 @@ import { definePageMetadata } from '@/scripts/page-metadata'; const installedThemes = ref(getThemes()); const builtinThemes = getBuiltinThemesRef(); -const instanceThemes = []; -if (instance.defaultLightTheme != null) instanceThemes.push(JSON5.parse(instance.defaultLightTheme)); -if (instance.defaultDarkTheme != null) instanceThemes.push(JSON5.parse(instance.defaultDarkTheme)); +const instanceDarkTheme = computed(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null); +const installedDarkThemes = computed(() => installedThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); +const builtinDarkThemes = computed(() => builtinThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); +const instanceLightTheme = computed(() => instance.defaultLightTheme ? JSON5.parse(instance.defaultLightTheme) : null); +const installedLightThemes = computed(() => installedThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); +const builtinLightThemes = computed(() => builtinThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); +const themes = computed(() => uniqueBy([ instanceDarkTheme.value, instanceLightTheme.value, ...builtinThemes.value, ...installedThemes.value ].filter(x => x != null), theme => theme.id)); -const themes = computed(() => uniqueBy([ ...instanceThemes, ...builtinThemes.value, ...installedThemes.value ], theme => theme.id)); -const darkThemes = computed(() => themes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); -const lightThemes = computed(() => themes.value.filter(t => t.base === 'light' || t.kind === 'light')); const darkTheme = ColdDeviceStorage.ref('darkTheme'); const darkThemeId = computed({ get() { return darkTheme.value.id; }, set(id) { - ColdDeviceStorage.set('darkTheme', themes.value.find(x => x.id === id)); + const t = themes.value.find(x => x.id === id); + if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる + ColdDeviceStorage.set('darkTheme', t); + } }, }); const lightTheme = ColdDeviceStorage.ref('lightTheme'); @@ -128,7 +112,10 @@ const lightThemeId = computed({ return lightTheme.value.id; }, set(id) { - ColdDeviceStorage.set('lightTheme', themes.value.find(x => x.id === id)); + const t = themes.value.find(x => x.id === id); + if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる + ColdDeviceStorage.set('lightTheme', t); + } }, }); const darkMode = computed(defaultStore.makeGetterSetter('darkMode')); @@ -174,7 +161,6 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.theme, icon: 'fas fa-palette', - bg: 'var(--bg)', }); </script> @@ -200,6 +186,7 @@ definePageMetadata({ text-align: left; overflow: clip; padding: 0 100px; + vertical-align: bottom; input { position: absolute; @@ -406,4 +393,17 @@ definePageMetadata({ border-top: solid 0.5px var(--divider); } } + +.rsljpzjq { + > .selects { + display: flex; + gap: 1.5em var(--margin); + flex-wrap: wrap; + + > .select { + flex: 1; + min-width: 280px; + } + } +} </style> diff --git a/packages/client/src/pages/settings/webhook.edit.vue b/packages/client/src/pages/settings/webhook.edit.vue index d3cf5d7b79..5d41f3d087 100644 --- a/packages/client/src/pages/settings/webhook.edit.vue +++ b/packages/client/src/pages/settings/webhook.edit.vue @@ -38,13 +38,17 @@ import { } from 'vue'; import FormInput from '@/components/form/input.vue'; import FormSection from '@/components/form/section.vue'; import FormSwitch from '@/components/form/switch.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +const props = defineProps<{ + webhookId: string; +}>(); + const webhook = await os.api('i/webhooks/show', { - webhookId: new URLSearchParams(window.location.search).get('id'), + webhookId: props.webhookId, }); let name = $ref(webhook.name); @@ -74,6 +78,7 @@ async function save(): Promise<void> { name, url, secret, + webhookId: props.webhookId, on: events, active, }); @@ -86,6 +91,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: 'Edit webhook', icon: 'fas fa-bolt', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/webhook.new.vue b/packages/client/src/pages/settings/webhook.new.vue index 508c0d78be..fcf1329ff6 100644 --- a/packages/client/src/pages/settings/webhook.new.vue +++ b/packages/client/src/pages/settings/webhook.new.vue @@ -36,7 +36,7 @@ import { } from 'vue'; import FormInput from '@/components/form/input.vue'; import FormSection from '@/components/form/section.vue'; import FormSwitch from '@/components/form/switch.vue'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -78,6 +78,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: 'Create new webhook', icon: 'fas fa-bolt', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/webhook.vue b/packages/client/src/pages/settings/webhook.vue index 50739e2fd1..1a7e73940c 100644 --- a/packages/client/src/pages/settings/webhook.vue +++ b/packages/client/src/pages/settings/webhook.vue @@ -9,7 +9,7 @@ <FormSection> <MkPagination :pagination="pagination"> <template #default="{items}"> - <FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit?id=${webhook.id}`" class="_formBlock"> + <FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`" class="_formBlock"> <template #icon> <i v-if="webhook.active === false" class="fas fa-circle-pause"></i> <i v-else-if="webhook.latestStatus === null" class="far fa-circle"></i> @@ -29,7 +29,7 @@ <script lang="ts" setup> import { } from 'vue'; -import MkPagination from '@/components/ui/pagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; import { userPage } from '@/filters/user'; @@ -49,6 +49,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: 'Webhook', icon: 'fas fa-bolt', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/settings/word-mute.vue b/packages/client/src/pages/settings/word-mute.vue index c6af0e7661..e297379568 100644 --- a/packages/client/src/pages/settings/word-mute.vue +++ b/packages/client/src/pages/settings/word-mute.vue @@ -31,10 +31,10 @@ <script lang="ts" setup> import { ref, watch } from 'vue'; import FormTextarea from '@/components/form/textarea.vue'; -import MkKeyValue from '@/components/key-value.vue'; -import MkButton from '@/components/ui/button.vue'; -import MkInfo from '@/components/ui/info.vue'; -import MkTab from '@/components/tab.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkTab from '@/components/MkTab.vue'; import * as os from '@/os'; import number from '@/filters/number'; import { defaultStore } from '@/store'; @@ -124,6 +124,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.wordMute, icon: 'fas fa-comment-slash', - bg: 'var(--bg)', }); </script> diff --git a/packages/client/src/pages/share.vue b/packages/client/src/pages/share.vue index 8984823b60..69d22ed632 100644 --- a/packages/client/src/pages/share.vue +++ b/packages/client/src/pages/share.vue @@ -1,36 +1,35 @@ <template> -<div class=""> - <section class="_section"> - <div class="_content"> - <XPostForm - v-if="state === 'writing'" - fixed - :instant="true" - :initial-text="initialText" - :initial-visibility="visibility" - :initial-files="files" - :initial-local-only="localOnly" - :reply="reply" - :renote="renote" - :initial-visible-users="visibleUsers" - class="_panel" - @posted="state = 'posted'" - /> - <MkButton v-else-if="state === 'posted'" primary class="close" @click="close()">{{ $ts.close }}</MkButton> - </div> - </section> -</div> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <XPostForm + v-if="state === 'writing'" + fixed + :instant="true" + :initial-text="initialText" + :initial-visibility="visibility" + :initial-files="files" + :initial-local-only="localOnly" + :reply="reply" + :renote="renote" + :initial-visible-users="visibleUsers" + class="_panel" + @posted="state = 'posted'" + /> + <MkButton v-else-if="state === 'posted'" primary class="close" @click="close()">{{ i18n.ts.close }}</MkButton> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> // SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html -import { defineComponent } from 'vue'; +import { } from 'vue'; import { noteVisibilities } from 'misskey-js'; import * as Acct from 'misskey-js/built/acct'; import * as Misskey from 'misskey-js'; -import MkButton from '@/components/ui/button.vue'; -import XPostForm from '@/components/post-form.vue'; +import MkButton from '@/components/MkButton.vue'; +import XPostForm from '@/components/MkPostForm.vue'; import * as os from '@/os'; import { mainRouter } from '@/router'; import { definePageMetadata } from '@/scripts/page-metadata'; diff --git a/packages/client/src/pages/tag.vue b/packages/client/src/pages/tag.vue index d63864ed5c..5498c2999d 100644 --- a/packages/client/src/pages/tag.vue +++ b/packages/client/src/pages/tag.vue @@ -1,12 +1,15 @@ <template> -<div class="_section"> - <XNotes class="_content" :pagination="pagination"/> -</div> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <XNotes class="_content" :pagination="pagination"/> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { computed } from 'vue'; -import XNotes from '@/components/notes.vue'; +import XNotes from '@/components/MkNotes.vue'; import { definePageMetadata } from '@/scripts/page-metadata'; const props = defineProps<{ @@ -28,6 +31,5 @@ const headerTabs = $computed(() => []); definePageMetadata(computed(() => ({ title: props.tag, icon: 'fas fa-hashtag', - bg: 'var(--bg)', }))); </script> diff --git a/packages/client/src/pages/theme-editor.vue b/packages/client/src/pages/theme-editor.vue index 38f3b90a6e..7dfeee16ed 100644 --- a/packages/client/src/pages/theme-editor.vue +++ b/packages/client/src/pages/theme-editor.vue @@ -1,68 +1,70 @@ -<template><MkStickyContainer> +<template> +<MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> - <div class="cwepdizn _formRoot"> - <FormFolder :default-open="true" class="_formBlock"> - <template #label>{{ i18n.ts.backgroundColor }}</template> - <div class="cwepdizn-colors"> - <div class="row"> - <button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> - <div class="preview" :style="{ background: color.forPreview }"></div> - </button> + <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <div class="cwepdizn _formRoot"> + <FormFolder :default-open="true" class="_formBlock"> + <template #label>{{ i18n.ts.backgroundColor }}</template> + <div class="cwepdizn-colors"> + <div class="row"> + <button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> + <div class="preview" :style="{ background: color.forPreview }"></div> + </button> + </div> + <div class="row"> + <button v-for="color in bgColors.filter(x => x.kind === 'dark')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> + <div class="preview" :style="{ background: color.forPreview }"></div> + </button> + </div> </div> - <div class="row"> - <button v-for="color in bgColors.filter(x => x.kind === 'dark')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> - <div class="preview" :style="{ background: color.forPreview }"></div> - </button> - </div> - </div> - </FormFolder> + </FormFolder> - <FormFolder :default-open="true" class="_formBlock"> - <template #label>{{ i18n.ts.accentColor }}</template> - <div class="cwepdizn-colors"> - <div class="row"> - <button v-for="color in accentColors" :key="color" class="color rounded _button" :class="{ active: theme.props.accent === color }" @click="setAccentColor(color)"> - <div class="preview" :style="{ background: color }"></div> - </button> + <FormFolder :default-open="true" class="_formBlock"> + <template #label>{{ i18n.ts.accentColor }}</template> + <div class="cwepdizn-colors"> + <div class="row"> + <button v-for="color in accentColors" :key="color" class="color rounded _button" :class="{ active: theme.props.accent === color }" @click="setAccentColor(color)"> + <div class="preview" :style="{ background: color }"></div> + </button> + </div> </div> - </div> - </FormFolder> + </FormFolder> - <FormFolder :default-open="true" class="_formBlock"> - <template #label>{{ i18n.ts.textColor }}</template> - <div class="cwepdizn-colors"> - <div class="row"> - <button v-for="color in fgColors" :key="color" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }" @click="setFgColor(color)"> - <div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div> - </button> + <FormFolder :default-open="true" class="_formBlock"> + <template #label>{{ i18n.ts.textColor }}</template> + <div class="cwepdizn-colors"> + <div class="row"> + <button v-for="color in fgColors" :key="color" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }" @click="setFgColor(color)"> + <div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div> + </button> + </div> </div> - </div> - </FormFolder> + </FormFolder> - <FormFolder :default-open="false" class="_formBlock"> - <template #icon><i class="fas fa-code"></i></template> - <template #label>{{ i18n.ts.editCode }}</template> + <FormFolder :default-open="false" class="_formBlock"> + <template #icon><i class="fas fa-code"></i></template> + <template #label>{{ i18n.ts.editCode }}</template> - <div class="_formRoot"> - <FormTextarea v-model="themeCode" tall class="_formBlock"> - <template #label>{{ i18n.ts._theme.code }}</template> - </FormTextarea> - <FormButton primary class="_formBlock" @click="applyThemeCode">{{ i18n.ts.apply }}</FormButton> - </div> - </FormFolder> + <div class="_formRoot"> + <FormTextarea v-model="themeCode" tall class="_formBlock"> + <template #label>{{ i18n.ts._theme.code }}</template> + </FormTextarea> + <FormButton primary class="_formBlock" @click="applyThemeCode">{{ i18n.ts.apply }}</FormButton> + </div> + </FormFolder> - <FormFolder :default-open="false" class="_formBlock"> - <template #label>{{ i18n.ts.addDescription }}</template> + <FormFolder :default-open="false" class="_formBlock"> + <template #label>{{ i18n.ts.addDescription }}</template> - <div class="_formRoot"> - <FormTextarea v-model="description"> - <template #label>{{ i18n.ts._theme.description }}</template> - </FormTextarea> - </div> - </FormFolder> - </div> -</MkSpacer></MkStickyContainer> + <div class="_formRoot"> + <FormTextarea v-model="description"> + <template #label>{{ i18n.ts._theme.description }}</template> + </FormTextarea> + </div> + </FormFolder> + </div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> @@ -72,10 +74,11 @@ import tinycolor from 'tinycolor2'; import { v4 as uuid } from 'uuid'; import JSON5 from 'json5'; -import FormButton from '@/components/ui/button.vue'; +import FormButton from '@/components/MkButton.vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormFolder from '@/components/form/folder.vue'; +import { $i } from '@/account'; import { Theme, applyTheme } from '@/scripts/theme'; import lightTheme from '@/themes/_light.json5'; import darkTheme from '@/themes/_dark.json5'; @@ -85,6 +88,7 @@ import { ColdDeviceStorage, defaultStore } from '@/store'; import { addTheme } from '@/theme-store'; import { i18n } from '@/i18n'; import { useLeaveGuard } from '@/scripts/use-leave-guard'; +import { definePageMetadata } from '@/scripts/page-metadata'; const bgColors = [ { color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' }, @@ -115,7 +119,7 @@ const fgColors = [ { color: 'pink', forLight: '#84667d', forDark: '#e4d1e0', forPreview: '#b12390' }, ]; -const theme = $ref<Partial<Theme>>({ +let theme = $ref<Partial<Theme>>({ base: 'light', props: lightTheme.props, }); @@ -188,7 +192,7 @@ async function saveAs() { theme.name = name; theme.author = `@${$i.username}@${toUnicode(host)}`; if (description) theme.desc = description; - addTheme(theme); + await addTheme(theme); applyTheme(theme); if (defaultStore.state.darkMode) { ColdDeviceStorage.set('darkTheme', theme); @@ -204,25 +208,23 @@ async function saveAs() { watch($$(theme), apply, { deep: true }); -const headerActions = $computed(() => []); +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'fas fa-eye', + text: i18n.ts.preview, + handler: showPreview, +}, { + asFullButton: true, + icon: 'fas fa-check', + text: i18n.ts.saveAs, + handler: saveAs, +}]); const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.themeEditor, icon: 'fas fa-palette', - bg: 'var(--bg)', - actions: [{ - asFullButton: true, - icon: 'fas fa-eye', - text: i18n.ts.preview, - handler: showPreview, - }, { - asFullButton: true, - icon: 'fas fa-check', - text: i18n.ts.saveAs, - handler: saveAs, - }], }); </script> diff --git a/packages/client/src/pages/timeline.tutorial.vue b/packages/client/src/pages/timeline.tutorial.vue index 432d28c60b..7f08ccc2a1 100644 --- a/packages/client/src/pages/timeline.tutorial.vue +++ b/packages/client/src/pages/timeline.tutorial.vue @@ -1,52 +1,52 @@ <template> <div class="_card tbkwesmv"> - <div class="_title"><i class="fas fa-info-circle"></i> {{ $ts._tutorial.title }}</div> + <div class="_title"><i class="fas fa-info-circle"></i> {{ i18n.ts._tutorial.title }}</div> <div v-if="tutorial === 0" class="_content"> - <div>{{ $ts._tutorial.step1_1 }}</div> - <div>{{ $ts._tutorial.step1_2 }}</div> - <div>{{ $ts._tutorial.step1_3 }}</div> + <div>{{ i18n.ts._tutorial.step1_1 }}</div> + <div>{{ i18n.ts._tutorial.step1_2 }}</div> + <div>{{ i18n.ts._tutorial.step1_3 }}</div> </div> <div v-else-if="tutorial === 1" class="_content"> - <div>{{ $ts._tutorial.step2_1 }}</div> - <div>{{ $ts._tutorial.step2_2 }}</div> - <MkA class="_link" to="/settings/profile">{{ $ts.editProfile }}</MkA> + <div>{{ i18n.ts._tutorial.step2_1 }}</div> + <div>{{ i18n.ts._tutorial.step2_2 }}</div> + <MkA class="_link" to="/settings/profile">{{ i18n.ts.editProfile }}</MkA> </div> <div v-else-if="tutorial === 2" class="_content"> - <div>{{ $ts._tutorial.step3_1 }}</div> - <div>{{ $ts._tutorial.step3_2 }}</div> - <div>{{ $ts._tutorial.step3_3 }}</div> - <small>{{ $ts._tutorial.step3_4 }}</small> + <div>{{ i18n.ts._tutorial.step3_1 }}</div> + <div>{{ i18n.ts._tutorial.step3_2 }}</div> + <div>{{ i18n.ts._tutorial.step3_3 }}</div> + <small>{{ i18n.ts._tutorial.step3_4 }}</small> </div> <div v-else-if="tutorial === 3" class="_content"> - <div>{{ $ts._tutorial.step4_1 }}</div> - <div>{{ $ts._tutorial.step4_2 }}</div> + <div>{{ i18n.ts._tutorial.step4_1 }}</div> + <div>{{ i18n.ts._tutorial.step4_2 }}</div> </div> <div v-else-if="tutorial === 4" class="_content"> - <div>{{ $ts._tutorial.step5_1 }}</div> - <I18n :src="$ts._tutorial.step5_2" tag="div"> + <div>{{ i18n.ts._tutorial.step5_1 }}</div> + <I18n :src="i18n.ts._tutorial.step5_2" tag="div"> <template #featured> - <MkA class="_link" to="/featured">{{ $ts.featured }}</MkA> + <MkA class="_link" to="/featured">{{ i18n.ts.featured }}</MkA> </template> <template #explore> - <MkA class="_link" to="/explore">{{ $ts.explore }}</MkA> + <MkA class="_link" to="/explore">{{ i18n.ts.explore }}</MkA> </template> </I18n> - <div>{{ $ts._tutorial.step5_3 }}</div> - <small>{{ $ts._tutorial.step5_4 }}</small> + <div>{{ i18n.ts._tutorial.step5_3 }}</div> + <small>{{ i18n.ts._tutorial.step5_4 }}</small> </div> <div v-else-if="tutorial === 5" class="_content"> - <div>{{ $ts._tutorial.step6_1 }}</div> - <div>{{ $ts._tutorial.step6_2 }}</div> - <div>{{ $ts._tutorial.step6_3 }}</div> + <div>{{ i18n.ts._tutorial.step6_1 }}</div> + <div>{{ i18n.ts._tutorial.step6_2 }}</div> + <div>{{ i18n.ts._tutorial.step6_3 }}</div> </div> <div v-else-if="tutorial === 6" class="_content"> - <div>{{ $ts._tutorial.step7_1 }}</div> - <I18n :src="$ts._tutorial.step7_2" tag="div"> + <div>{{ i18n.ts._tutorial.step7_1 }}</div> + <I18n :src="i18n.ts._tutorial.step7_2" tag="div"> <template #help> - <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ $ts.help }}</a> + <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a> </template> </I18n> - <div>{{ $ts._tutorial.step7_3 }}</div> + <div>{{ i18n.ts._tutorial.step7_3 }}</div> </div> <div class="_footer navigation"> @@ -59,20 +59,21 @@ <i class="fas fa-chevron-right"></i> </button> </div> - <MkButton v-if="tutorial === 6" class="ok" primary @click="tutorial = -1"><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton> - <MkButton v-else class="ok" primary @click="tutorial++"><i class="fas fa-check"></i> {{ $ts.next }}</MkButton> + <MkButton v-if="tutorial === 6" class="ok" primary @click="tutorial = -1"><i class="fas fa-check"></i> {{ i18n.ts.gotIt }}</MkButton> + <MkButton v-else class="ok" primary @click="tutorial++"><i class="fas fa-check"></i> {{ i18n.ts.next }}</MkButton> </div> </div> </template> <script lang="ts" setup> import { computed } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; const tutorial = computed({ get() { return defaultStore.reactiveState.tutorial.value || 0; }, - set(value) { defaultStore.set('tutorial', value); } + set(value) { defaultStore.set('tutorial', value); }, }); </script> diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue index 111451632c..9d42997025 100644 --- a/packages/client/src/pages/timeline.vue +++ b/packages/client/src/pages/timeline.vue @@ -1,12 +1,12 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="headerTabs" :display-my-avatar="true"/></template> <MkSpacer :content-max="800"> <div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf"> <XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/> <XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/> - <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> <div class="tl _block"> <XTimeline ref="tl" :key="src" @@ -23,8 +23,8 @@ <script lang="ts" setup> import { defineAsyncComponent, computed, watch } from 'vue'; -import XTimeline from '@/components/timeline.vue'; -import XPostForm from '@/components/post-form.vue'; +import XTimeline from '@/components/MkTimeline.vue'; +import XPostForm from '@/components/MkPostForm.vue'; import { scroll } from '@/scripts/scroll'; import * as os from '@/os'; import { defaultStore } from '@/store'; @@ -45,7 +45,7 @@ const tlComponent = $ref<InstanceType<typeof XTimeline>>(); const rootEl = $ref<HTMLElement>(); let queue = $ref(0); -const src = $computed(() => defaultStore.reactiveState.tl.value.src); +const src = $computed({ get: () => defaultStore.reactiveState.tl.value.src, set: (x) => saveSrc(x) }); watch ($$(src), () => queue = 0); @@ -112,29 +112,25 @@ function focus(): void { const headerActions = $computed(() => []); const headerTabs = $computed(() => [{ - active: src === 'home', + key: 'home', title: i18n.ts._timelines.home, icon: 'fas fa-home', iconOnly: true, - onClick: () => { saveSrc('home'); }, }, ...(isLocalTimelineAvailable ? [{ - active: src === 'local', + key: 'local', title: i18n.ts._timelines.local, icon: 'fas fa-comments', iconOnly: true, - onClick: () => { saveSrc('local'); }, }, { - active: src === 'social', + key: 'social', title: i18n.ts._timelines.social, icon: 'fas fa-share-alt', iconOnly: true, - onClick: () => { saveSrc('social'); }, }] : []), ...(isGlobalTimelineAvailable ? [{ - active: src === 'global', + key: 'global', title: i18n.ts._timelines.global, icon: 'fas fa-globe', iconOnly: true, - onClick: () => { saveSrc('global'); }, }] : []), { icon: 'fas fa-list-ul', title: i18n.ts.lists, @@ -155,7 +151,6 @@ const headerTabs = $computed(() => [{ definePageMetadata(computed(() => ({ title: i18n.ts.timeline, icon: src === 'local' ? 'fas fa-comments' : src === 'social' ? 'fas fa-share-alt' : src === 'global' ? 'fas fa-globe' : 'fas fa-home', - bg: 'var(--bg)', }))); </script> diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue index 996c018dd9..d376f11c58 100644 --- a/packages/client/src/pages/user-info.vue +++ b/packages/client/src/pages/user-info.vue @@ -1,52 +1,71 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="500" :margin-min="16" :margin-max="32"> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600" :margin-min="16" :margin-max="32"> <FormSuspense :p="init"> - <div class="_formRoot"> + <div v-if="tab === 'overview'" class="_formRoot"> <div class="_formBlock aeakzknw"> <MkAvatar class="avatar" :user="user" :show-indicator="true"/> + <div class="body"> + <span class="name"><MkUserName class="name" :user="user"/></span> + <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> + <span class="state"> + <span v-if="suspended" class="suspended">Suspended</span> + <span v-if="silenced" class="silenced">Silenced</span> + <span v-if="moderator" class="moderator">Moderator</span> + </span> + </div> </div> - <FormLink class="_formBlock" :to="userPage(user)">Profile</FormLink> + <MkInfo v-if="user.username.includes('.')" class="_formBlock">{{ i18n.ts.isSystemAccount }}</MkInfo> - <FormLink v-if="user.url" class="_formBlock" :to="user.url" :external="true">Profile (remote)</FormLink> + <div v-if="user.url" class="_formLinksGrid _formBlock"> + <FormLink :to="userPage(user)">Profile</FormLink> + <FormLink :to="user.url" :external="true">Profile (remote)</FormLink> + </div> + <FormLink v-else class="_formBlock" :to="userPage(user)">Profile</FormLink> - <div class="_formBlock"> - <MkKeyValue :copy="acct(user)" oneline style="margin: 1em 0;"> - <template #key>Acct</template> - <template #value><span class="_monospace">{{ acct(user) }}</span></template> - </MkKeyValue> + <FormLink v-if="user.host" class="_formBlock" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink> + <div class="_formBlock"> <MkKeyValue :copy="user.id" oneline style="margin: 1em 0;"> <template #key>ID</template> <template #value><span class="_monospace">{{ user.id }}</span></template> </MkKeyValue> + <!-- 要る? + <MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline style="margin: 1em 0;"> + <template #key>IP (recent)</template> + <template #value><span class="_monospace">{{ ips[0].ip }}</span></template> + </MkKeyValue> + --> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.createdAt }}</template> + <template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.lastActiveDate }}</template> + <template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.email }}</template> + <template #value><span class="_monospace">{{ info.email }}</span></template> + </MkKeyValue> </div> - <FormSection v-if="iAmModerator"> - <template #label>Moderation</template> - <FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:modelValue="toggleModerator">{{ $ts.moderator }}</FormSwitch> - <FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ $ts.silence }}</FormSwitch> - <FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.suspend }}</FormSwitch> - {{ $ts.reflectMayTakeTime }} - <FormButton v-if="user.host == null && iAmModerator" class="_formBlock" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton> - </FormSection> - <FormSection> <template #label>ActivityPub</template> <div class="_formBlock"> <MkKeyValue v-if="user.host" oneline style="margin: 1em 0;"> - <template #key>{{ $ts.instanceInfo }}</template> + <template #key>{{ i18n.ts.instanceInfo }}</template> <template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="fas fa-angle-right"></i></MkA></template> </MkKeyValue> <MkKeyValue v-else oneline style="margin: 1em 0;"> - <template #key>{{ $ts.instanceInfo }}</template> + <template #key>{{ i18n.ts.instanceInfo }}</template> <template #value>(Local user)</template> </MkKeyValue> <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.updatedAt }}</template> + <template #key>{{ i18n.ts.updatedAt }}</template> <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template> </MkKeyValue> <MkKeyValue v-if="ap" oneline style="margin: 1em 0;"> @@ -55,9 +74,72 @@ </MkKeyValue> </div> - <FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton> + <FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ i18n.ts.updateRemoteUser }}</FormButton> + + <FormFolder class="_formBlock"> + <template #label>Raw</template> + + <MkObjectView v-if="ap" tall :value="ap"> + </MkObjectView> + </FormFolder> </FormSection> + </div> + <div v-else-if="tab === 'moderation'" class="_formRoot"> + <FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:modelValue="toggleModerator">{{ i18n.ts.moderator }}</FormSwitch> + <FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</FormSwitch> + <FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</FormSwitch> + {{ i18n.ts.reflectMayTakeTime }} + <div class="_formBlock"> + <FormButton v-if="user.host == null && iAmModerator" inline style="margin-right: 8px;" @click="resetPassword"><i class="fas fa-key"></i> {{ i18n.ts.resetPassword }}</FormButton> + <FormButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</FormButton> + </div> + <FormTextarea v-model="moderationNote" manual-save class="_formBlock"> + <template #label>Moderation note</template> + </FormTextarea> + <FormFolder class="_formBlock"> + <template #label>IP</template> + <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo> + <MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo> + <template v-if="iAmAdmin && ips"> + <div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;"> + <span class="date">{{ record.createdAt }}</span> + <span class="ip">{{ record.ip }}</span> + </div> + </template> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.files }}</template> + + <MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/> + </FormFolder> + <FormSection> + <template #label>Drive Capacity Override</template> + <FormInput v-if="user.host == null" v-model="driveCapacityOverrideMb" inline :manual-save="true" type="number" :placeholder="i18n.t('defaultValueIs', { value: instance.driveCapacityPerLocalUserMb })" @update:model-value="applyDriveCapacityOverride"> + <template #label>{{ i18n.ts.driveCapOverrideLabel }}</template> + <template #suffix>MB</template> + <template #caption> + {{ i18n.ts.driveCapOverrideCaption }} + </template> + </FormInput> + </FormSection> + </div> + <div v-else-if="tab === 'chart'" class="_formRoot"> + <div class="cmhjzshm"> + <div class="selects"> + <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> + <option value="per-user-notes">{{ i18n.ts.notes }}</option> + </MkSelect> + </div> + <div class="charts"> + <div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> + <div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> + </div> + </div> + </div> + <div v-else-if="tab === 'raw'" class="_formRoot"> <MkObjectView v-if="info && $i.isAdmin" tall :value="info"> </MkObjectView> @@ -70,16 +152,23 @@ </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, defineComponent, watch } from 'vue'; +import { computed, watch } from 'vue'; import * as misskey from 'misskey-js'; -import MkObjectView from '@/components/object-view.vue'; +import MkChart from '@/components/MkChart.vue'; +import MkObjectView from '@/components/MkObjectView.vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; -import FormButton from '@/components/ui/button.vue'; -import MkKeyValue from '@/components/key-value.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInput from '@/components/form/input.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormFolder from '@/components/form/folder.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkSelect from '@/components/form/select.vue'; import FormSuspense from '@/components/form/suspense.vue'; +import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; +import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os'; import number from '@/filters/number'; import bytes from '@/filters/bytes'; @@ -87,19 +176,32 @@ import { url } from '@/config'; import { userPage, acct } from '@/filters/user'; import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; -import { iAmModerator } from '@/account'; +import { iAmAdmin, iAmModerator } from '@/account'; +import { instance } from '@/instance'; const props = defineProps<{ userId: string; }>(); +let tab = $ref('overview'); +let chartSrc = $ref('per-user-notes'); let user = $ref<null | misskey.entities.UserDetailed>(); -let init = $ref(); +let init = $ref<ReturnType<typeof createFetcher>>(); let info = $ref(); +let ips = $ref(null); let ap = $ref(null); let moderator = $ref(false); let silenced = $ref(false); let suspended = $ref(false); +let driveCapacityOverrideMb: number | null = $ref(0); +let moderationNote = $ref(''); +const filesPagination = { + endpoint: 'admin/drive/files' as const, + limit: 10, + params: computed(() => ({ + userId: props.userId, + })), +}; function createFetcher() { if (iAmModerator) { @@ -107,12 +209,22 @@ function createFetcher() { userId: props.userId, }), os.api('admin/show-user', { userId: props.userId, - })]).then(([_user, _info]) => { + }), iAmAdmin ? os.api('admin/get-user-ips', { + userId: props.userId, + }) : Promise.resolve(null)]).then(([_user, _info, _ips]) => { user = _user; info = _info; + ips = _ips; moderator = info.isModerator; silenced = info.isSilenced; suspended = info.isSuspended; + driveCapacityOverrideMb = user.driveCapacityOverrideMb; + moderationNote = info.moderationNote; + + watch($$(moderationNote), async () => { + await os.api('admin/update-user-note', { userId: user.id, text: moderationNote }); + await refreshUser(); + }); }); } else { return () => os.api('users/show', { @@ -193,15 +305,55 @@ async function deleteAllFiles() { await refreshUser(); } +async function applyDriveCapacityOverride() { + let driveCapOrMb = driveCapacityOverrideMb; + if (driveCapacityOverrideMb && driveCapacityOverrideMb < 0) { + driveCapOrMb = null; + } + try { + await os.apiWithDialog('admin/drive-capacity-override', { userId: user.id, overrideMb: driveCapOrMb }); + await refreshUser(); + } catch (err) { + os.alert({ + type: 'error', + text: err.toString(), + }); + } +} + +async function deleteAccount() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteAccountConfirm, + }); + if (confirm.canceled) return; + + const typed = await os.inputText({ + text: i18n.t('typeToConfirm', { x: user?.username }), + }); + if (typed.canceled) return; + + if (typed.result === user?.username) { + await os.apiWithDialog('admin/delete-account', { + userId: user.id, + }); + } else { + os.alert({ + type: 'error', + text: 'input not match', + }); + } +} + watch(() => props.userId, () => { init = createFetcher(); }, { immediate: true, }); -watch(() => user, () => { +watch($$(user), () => { os.api('ap/get', { - uri: user.uri || `${url}/users/${user.id}`, + uri: user.uri ?? `${url}/users/${user.id}`, }).then(res => { ap = res; }); @@ -209,21 +361,125 @@ watch(() => user, () => { const headerActions = $computed(() => []); -const headerTabs = $computed(() => []); +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, + icon: 'fas fa-info-circle', +}, iAmModerator ? { + key: 'moderation', + title: i18n.ts.moderation, + icon: 'fas fa-shield-halved', +} : null, { + key: 'chart', + title: i18n.ts.charts, + icon: 'fas fa-chart-simple', +}, { + key: 'raw', + title: 'Raw', + icon: 'fas fa-code', +}].filter(x => x != null)); definePageMetadata(computed(() => ({ title: user ? acct(user) : i18n.ts.userInfo, icon: 'fas fa-info-circle', - bg: 'var(--bg)', }))); </script> <style lang="scss" scoped> .aeakzknw { + display: flex; + align-items: center; + > .avatar { display: block; width: 64px; height: 64px; + margin-right: 16px; + } + + > .body { + flex: 1; + overflow: hidden; + + > .name { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .sub { + display: block; + width: 100%; + font-size: 85%; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .state { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; + + &:empty { + display: none; + } + + > .suspended, > .silenced, > .moderator { + display: inline-block; + border: solid 1px; + border-radius: 6px; + padding: 2px 6px; + font-size: 85%; + } + + > .suspended { + color: var(--error); + border-color: var(--error); + } + + > .silenced { + color: var(--warn); + border-color: var(--warn); + } + + > .moderator { + color: var(--success); + border-color: var(--success); + } + } + } +} + +.cmhjzshm { + > .selects { + display: flex; + margin: 0 0 16px 0; + } + + > .charts { + > .label { + margin-bottom: 12px; + font-weight: bold; + } + } +} +</style> + +<style lang="scss" module> +.ip { + display: flex; + + > :global(.date) { + opacity: 0.7; + } + + > :global(.ip) { + margin-left: auto; } } </style> diff --git a/packages/client/src/pages/user-list-timeline.vue b/packages/client/src/pages/user-list-timeline.vue index 07d5a166e9..4a534e47ba 100644 --- a/packages/client/src/pages/user-list-timeline.vue +++ b/packages/client/src/pages/user-list-timeline.vue @@ -2,7 +2,7 @@ <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <div ref="rootEl" v-size="{ min: [800] }" class="eqqrhokj"> - <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> <div class="tl _block"> <XTimeline ref="tlEl" :key="listId" @@ -19,7 +19,7 @@ <script lang="ts" setup> import { computed, watch, inject } from 'vue'; -import XTimeline from '@/components/timeline.vue'; +import XTimeline from '@/components/MkTimeline.vue'; import { scroll } from '@/scripts/scroll'; import * as os from '@/os'; import { useRouter } from '@/router'; @@ -79,7 +79,6 @@ const headerTabs = $computed(() => []); definePageMetadata(computed(() => list ? { title: list.name, icon: 'fas fa-list-ul', - bg: 'var(--bg)', } : null)); </script> diff --git a/packages/client/src/pages/user/clips.vue b/packages/client/src/pages/user/clips.vue index 870e6f7174..50a5d4b818 100644 --- a/packages/client/src/pages/user/clips.vue +++ b/packages/client/src/pages/user/clips.vue @@ -9,40 +9,22 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkPagination from '@/components/ui/pagination.vue'; +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkPagination from '@/components/MkPagination.vue'; -export default defineComponent({ - components: { - MkPagination, - }, +const props = defineProps<{ + user: misskey.entities.User; +}>(); - props: { - user: { - type: Object, - required: true - }, - }, - - data() { - return { - pagination: { - endpoint: 'users/clips' as const, - limit: 20, - params: { - userId: this.user.id, - } - }, - }; - }, - - watch: { - user() { - this.$refs.list.reload(); - } - }, -}); +const pagination = { + endpoint: 'users/clips' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/user/follow-list.vue b/packages/client/src/pages/user/follow-list.vue index 98a1fc0f86..d42acd838f 100644 --- a/packages/client/src/pages/user/follow-list.vue +++ b/packages/client/src/pages/user/follow-list.vue @@ -1,7 +1,7 @@ <template> <div> <MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers"> - <div class="users _isolated"> + <div class="users"> <MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" class="user" :user="user"/> </div> </MkPagination> @@ -11,8 +11,8 @@ <script lang="ts" setup> import { computed } from 'vue'; import * as misskey from 'misskey-js'; -import MkUserInfo from '@/components/user-info.vue'; -import MkPagination from '@/components/ui/pagination.vue'; +import MkUserInfo from '@/components/MkUserInfo.vue'; +import MkPagination from '@/components/MkPagination.vue'; const props = defineProps<{ user: misskey.entities.User; diff --git a/packages/client/src/pages/user/followers.vue b/packages/client/src/pages/user/followers.vue new file mode 100644 index 0000000000..b61b48329a --- /dev/null +++ b/packages/client/src/pages/user/followers.vue @@ -0,0 +1,61 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000"> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XFollowList :user="user" type="followers"/> + </div> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> + </transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import XFollowList from './follow-list.vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + acct: string; +}>(), { +}); + +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); + +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} + +watch(() => props.acct, fetchUser, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => user ? { + icon: 'fas fa-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: i18n.ts.followers, + userName: user, + avatar: user, +} : null)); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/client/src/pages/user/following.vue b/packages/client/src/pages/user/following.vue new file mode 100644 index 0000000000..a23977b425 --- /dev/null +++ b/packages/client/src/pages/user/following.vue @@ -0,0 +1,61 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000"> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XFollowList :user="user" type="following"/> + </div> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> + </transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import XFollowList from './follow-list.vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + acct: string; +}>(), { +}); + +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); + +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} + +watch(() => props.acct, fetchUser, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => user ? { + icon: 'fas fa-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: i18n.ts.following, + userName: user, + avatar: user, +} : null)); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/client/src/pages/user/gallery.vue b/packages/client/src/pages/user/gallery.vue index 07dda4a292..3b6768e64a 100644 --- a/packages/client/src/pages/user/gallery.vue +++ b/packages/client/src/pages/user/gallery.vue @@ -8,36 +8,24 @@ </div> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; -import MkGalleryPostPreview from '@/components/gallery-post-preview.vue'; -import MkPagination from '@/components/ui/pagination.vue'; +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; -export default defineComponent({ - components: { - MkPagination, - MkGalleryPostPreview, - }, - - props: { - user: { - type: Object, - required: true - }, - }, - - data() { - return { - pagination: { - endpoint: 'users/gallery/posts' as const, - limit: 6, - params: computed(() => ({ - userId: this.user.id - })), - }, - }; - }, +const props = withDefaults(defineProps<{ + user: misskey.entities.User; +}>(), { }); + +const pagination = { + endpoint: 'users/gallery/posts' as const, + limit: 6, + params: computed(() => ({ + userId: props.user.id, + })), +}; </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/user/home.vue b/packages/client/src/pages/user/home.vue new file mode 100644 index 0000000000..352db4616e --- /dev/null +++ b/packages/client/src/pages/user/home.vue @@ -0,0 +1,478 @@ +<template> +<MkSpacer :content-max="narrow ? 800 : 1100"> + <div ref="rootEl" v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }"> + <div class="main"> + <!-- TODO --> + <!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> --> + <!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> --> + + <div class="profile"> + <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/> + + <div :key="user.id" class="_block main"> + <div class="banner-container" :style="style"> + <div ref="bannerEl" class="banner" :style="style"></div> + <div class="fade"></div> + <div class="title"> + <MkUserName class="name" :user="user" :nowrap="true"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true"/></span> + <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span> + <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="fas fa-lock"></i></span> + <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="fas fa-robot"></i></span> + </div> + </div> + <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> + <div v-if="$i" class="actions"> + <button class="menu _button" @click="menu"><i class="fas fa-ellipsis-h"></i></button> + <MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + </div> + </div> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> + <div class="title"> + <MkUserName :user="user" :nowrap="false" class="name"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true"/></span> + <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span> + <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="fas fa-lock"></i></span> + <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="fas fa-robot"></i></span> + </div> + </div> + <div class="description"> + <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/> + <p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p> + </div> + <div class="fields system"> + <dl v-if="user.location" class="field"> + <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ i18n.ts.location }}</dt> + <dd class="value">{{ user.location }}</dd> + </dl> + <dl v-if="user.birthday" class="field"> + <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ i18n.ts.birthday }}</dt> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> + </dl> + <dl class="field"> + <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ i18n.ts.registeredDate }}</dt> + <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd> + </dl> + </div> + <div v-if="user.fields.length > 0" class="fields"> + <dl v-for="(field, i) in user.fields" :key="i" class="field"> + <dt class="name"> + <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> + </dt> + <dd class="value"> + <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/> + </dd> + </dl> + </div> + <div class="status"> + <MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }"> + <b>{{ number(user.notesCount) }}</b> + <span>{{ i18n.ts.notes }}</span> + </MkA> + <MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }"> + <b>{{ number(user.followingCount) }}</b> + <span>{{ i18n.ts.following }}</span> + </MkA> + <MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }"> + <b>{{ number(user.followersCount) }}</b> + <span>{{ i18n.ts.followers }}</span> + </MkA> + </div> + </div> + </div> + + <div class="contents"> + <div v-if="user.pinnedNotes.length > 0" class="_gap"> + <XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/> + </div> + <MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> + <template v-if="narrow"> + <XPhotos :key="user.id" :user="user"/> + <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> + </template> + </div> + <div> + <XUserTimeline :user="user"/> + </div> + </div> + <div v-if="!narrow" class="sub"> + <XPhotos :key="user.id" :user="user"/> + <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> + </div> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import calcAge from 's-age'; +import * as misskey from 'misskey-js'; +import XUserTimeline from './index.timeline.vue'; +import XNote from '@/components/MkNote.vue'; +import MkFollowButton from '@/components/MkFollowButton.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import { getScrollPosition } from '@/scripts/scroll'; +import { getUserMenu } from '@/scripts/get-user-menu'; +import number from '@/filters/number'; +import { userPage, acct as getAcct } from '@/filters/user'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; + +const XPhotos = defineAsyncComponent(() => import('./index.photos.vue')); +const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); + +const props = withDefaults(defineProps<{ + user: misskey.entities.UserDetailed; +}>(), { +}); + +const router = useRouter(); + +let parallaxAnimationId = $ref<null | number>(null); +let narrow = $ref<null | boolean>(null); +let rootEl = $ref<null | HTMLElement>(null); +let bannerEl = $ref<null | HTMLElement>(null); + +const style = $computed(() => { + if (props.user.bannerUrl == null) return {}; + return { + backgroundImage: `url(${ props.user.bannerUrl })`, + }; +}); + +const age = $computed(() => { + return calcAge(props.user.birthday); +}); + +function menu(ev) { + os.popupMenu(getUserMenu(props.user, router), ev.currentTarget ?? ev.target); +} + +function parallaxLoop() { + parallaxAnimationId = window.requestAnimationFrame(parallaxLoop); + parallax(); +} + +function parallax() { + const banner = bannerEl as any; + if (banner == null) return; + + const top = getScrollPosition(rootEl); + + if (top < 0) return; + + const z = 1.75; // 奥行き(小さいほど奥) + const pos = -(top / z); + banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; +} + +onMounted(() => { + window.requestAnimationFrame(parallaxLoop); + narrow = rootEl!.clientWidth < 1000; +}); + +onUnmounted(() => { + if (parallaxAnimationId) { + window.cancelAnimationFrame(parallaxAnimationId); + } +}); +</script> + +<style lang="scss" scoped> +.ftskorzw { + + > .main { + + > .punished { + font-size: 0.8em; + padding: 16px; + } + + > .profile { + + > .main { + position: relative; + overflow: hidden; + + > .banner-container { + position: relative; + height: 250px; + overflow: hidden; + background-size: cover; + background-position: center; + + > .banner { + height: 100%; + background-color: #4c5e6d; + background-size: cover; + background-position: center; + box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; + will-change: background-position; + } + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 78px; + background: linear-gradient(transparent, rgba(#000, 0.7)); + } + + > .followed { + position: absolute; + top: 12px; + left: 12px; + padding: 4px 8px; + color: #fff; + background: rgba(0, 0, 0, 0.7); + font-size: 0.7em; + border-radius: 6px; + } + + > .actions { + position: absolute; + top: 12px; + right: 12px; + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + background: rgba(0, 0, 0, 0.2); + padding: 8px; + border-radius: 24px; + + > .menu { + vertical-align: bottom; + height: 31px; + width: 31px; + color: #fff; + text-shadow: 0 0 8px #000; + font-size: 16px; + } + + > .koudoku { + margin-left: 4px; + vertical-align: bottom; + } + } + + > .title { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 0 0 8px 154px; + box-sizing: border-box; + color: #fff; + + > .name { + display: block; + margin: 0; + line-height: 32px; + font-weight: bold; + font-size: 1.8em; + text-shadow: 0 0 8px #000; + } + + > .bottom { + > * { + display: inline-block; + margin-right: 16px; + line-height: 20px; + opacity: 0.8; + + &.username { + font-weight: bold; + } + } + } + } + } + + > .title { + display: none; + text-align: center; + padding: 50px 8px 16px 8px; + font-weight: bold; + border-bottom: solid 0.5px var(--divider); + + > .bottom { + > * { + display: inline-block; + margin-right: 8px; + opacity: 0.8; + } + } + } + + > .avatar { + display: block; + position: absolute; + top: 170px; + left: 16px; + z-index: 2; + width: 120px; + height: 120px; + box-shadow: 1px 1px 3px rgba(#000, 0.2); + } + + > .description { + padding: 24px 24px 24px 154px; + font-size: 0.95em; + + > .empty { + margin: 0; + opacity: 0.5; + } + } + + > .fields { + padding: 24px; + font-size: 0.9em; + border-top: solid 0.5px var(--divider); + + > .field { + display: flex; + padding: 0; + margin: 0; + align-items: center; + + &:not(:last-child) { + margin-bottom: 8px; + } + + > .name { + width: 30%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: bold; + text-align: center; + } + + > .value { + width: 70%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0; + } + } + + &.system > .field > .name { + } + } + + > .status { + display: flex; + padding: 24px; + border-top: solid 0.5px var(--divider); + + > a { + flex: 1; + text-align: center; + + &.active { + color: var(--accent); + } + + &:hover { + text-decoration: none; + } + + > b { + display: block; + line-height: 16px; + } + + > span { + font-size: 70%; + } + } + } + } + } + + > .contents { + > .content { + margin-bottom: var(--margin); + } + } + } + + &.max-width_500px { + > .main { + > .profile > .main { + > .banner-container { + height: 140px; + + > .fade { + display: none; + } + + > .title { + display: none; + } + } + + > .title { + display: block; + } + + > .avatar { + top: 90px; + left: 0; + right: 0; + width: 92px; + height: 92px; + margin: auto; + } + + > .description { + padding: 16px; + text-align: center; + } + + > .fields { + padding: 16px; + } + + > .status { + padding: 16px; + } + } + + > .contents { + > .nav { + font-size: 80%; + } + } + } + } + + &.wide { + display: flex; + width: 100%; + + > .main { + width: 100%; + min-width: 0; + } + + > .sub { + max-width: 350px; + min-width: 350px; + margin-left: var(--margin); + } + } +} +</style> diff --git a/packages/client/src/pages/user/index.activity.vue b/packages/client/src/pages/user/index.activity.vue index aecd25d6b0..43c2ed8b04 100644 --- a/packages/client/src/pages/user/index.activity.vue +++ b/packages/client/src/pages/user/index.activity.vue @@ -1,6 +1,6 @@ <template> <MkContainer> - <template #header><i class="fas fa-chart-bar" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template> + <template #header><i class="fas fa-chart-simple" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template> <template #func> <button class="_button" @click="showMenu"> <i class="fas fa-ellipsis-h"></i> @@ -16,8 +16,8 @@ <script lang="ts" setup> import { } from 'vue'; import * as misskey from 'misskey-js'; -import MkContainer from '@/components/ui/container.vue'; -import MkChart from '@/components/chart.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import MkChart from '@/components/MkChart.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -36,8 +36,8 @@ function showMenu(ev: MouseEvent) { active: true, action: () => { chartSrc = 'per-user-notes'; - } - }/*, { + }, + },/*, { text: i18n.ts.following, action: () => { chartSrc = 'per-user-following'; diff --git a/packages/client/src/pages/user/index.photos.vue b/packages/client/src/pages/user/index.photos.vue index 79dd1726e1..5c9a73dcb7 100644 --- a/packages/client/src/pages/user/index.photos.vue +++ b/packages/client/src/pages/user/index.photos.vue @@ -4,12 +4,13 @@ <div class="ujigsodd"> <MkLoading v-if="fetching"/> <div v-if="!fetching && images.length > 0" class="stream"> - <MkA v-for="image in images" - :key="image.id" + <MkA + v-for="image in images" + :key="image.note.id + image.file.id" class="img" :to="notePage(image.note)" > - <ImgWithBlurhash :hash="image.blurhash" :src="thumbnail(image.file)" :alt="image.name" :title="image.name"/> + <ImgWithBlurhash :hash="image.file.blurhash" :src="thumbnail(image.file)" :title="image.file.name"/> </MkA> </div> <p v-if="!fetching && images.length == 0" class="empty">{{ $ts.nothing }}</p> @@ -17,64 +18,56 @@ </MkContainer> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { onMounted } from 'vue'; +import * as misskey from 'misskey-js'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import { notePage } from '@/filters/note'; import * as os from '@/os'; -import MkContainer from '@/components/ui/container.vue'; -import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import { defaultStore } from '@/store'; -export default defineComponent({ - components: { - MkContainer, - ImgWithBlurhash, - }, - props: { - user: { - type: Object, - required: true - }, - }, - data() { - return { - fetching: true, - images: [], - }; - }, - mounted() { - const image = [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/apng', - 'image/vnd.mozilla.apng', - ]; - os.api('users/notes', { - userId: this.user.id, - fileType: image, - excludeNsfw: this.$store.state.nsfw !== 'ignore', - limit: 10, - }).then(notes => { - for (const note of notes) { - for (const file of note.files) { - this.images.push({ - note, - file - }); - } +const props = defineProps<{ + user: misskey.entities.UserDetailed; +}>(); + +let fetching = $ref(true); +let images = $ref<{ + note: misskey.entities.Note; + file: misskey.entities.DriveFile; +}[]>([]); + +function thumbnail(image: misskey.entities.DriveFile): string { + return defaultStore.state.disableShowingAnimatedImages + ? getStaticImageUrl(image.thumbnailUrl) + : image.thumbnailUrl; +} + +onMounted(() => { + const image = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/apng', + 'image/vnd.mozilla.apng', + ]; + os.api('users/notes', { + userId: props.user.id, + fileType: image, + excludeNsfw: defaultStore.state.nsfw !== 'ignore', + limit: 10, + }).then(notes => { + for (const note of notes) { + for (const file of note.files) { + images.push({ + note, + file, + }); } - this.fetching = false; - }); - }, - methods: { - thumbnail(image: any): string { - return this.$store.state.disableShowingAnimatedImages - ? getStaticImageUrl(image.thumbnailUrl) - : image.thumbnailUrl; - }, - notePage - }, + } + fetching = false; + }); }); </script> diff --git a/packages/client/src/pages/user/index.timeline.vue b/packages/client/src/pages/user/index.timeline.vue index a1329a7411..41983a5ae8 100644 --- a/packages/client/src/pages/user/index.timeline.vue +++ b/packages/client/src/pages/user/index.timeline.vue @@ -1,20 +1,23 @@ <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">{{ i18n.ts.notes }}</option> + <option value="replies">{{ i18n.ts.notesAndReplies }}</option> + <option value="files">{{ i18n.ts.withFiles }}</option> + </MkTab> + </template> <XNotes :no-gap="true" :pagination="pagination"/> -</div> +</MkStickyContainer> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; import * as misskey from 'misskey-js'; -import XNotes from '@/components/notes.vue'; -import MkTab from '@/components/tab.vue'; +import XNotes from '@/components/MkNotes.vue'; +import MkTab from '@/components/MkTab.vue'; import * as os from '@/os'; +import { i18n } from '@/i18n'; const props = defineProps<{ user: misskey.entities.UserDetailed; @@ -33,12 +36,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/pages/user/index.vue b/packages/client/src/pages/user/index.vue index 7b2e2cde1a..7e635f8b2e 100644 --- a/packages/client/src/pages/user/index.vue +++ b/packages/client/src/pages/user/index.vue @@ -1,125 +1,16 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <div ref="rootEl"> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <div> <transition name="fade" mode="out-in"> - <MkSpacer v-if="user" :content-max="narrow ? 800 : 1100"> - <div v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }"> - <div class="main"> - <!-- TODO --> - <!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> --> - <!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSilenced }}</div> --> - - <div class="profile"> - <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/> - - <div :key="user.id" class="_block main"> - <div class="banner-container" :style="style"> - <div ref="bannerEl" class="banner" :style="style"></div> - <div class="fade"></div> - <div class="title"> - <MkUserName class="name" :user="user" :nowrap="true"/> - <div class="bottom"> - <span class="username"><MkAcct :user="user" :detail="true"/></span> - <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span> - <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span> - <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span> - <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span> - </div> - </div> - <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span> - <div v-if="$i" class="actions"> - <button class="menu _button" @click="menu"><i class="fas fa-ellipsis-h"></i></button> - <MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> - </div> - </div> - <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> - <div class="title"> - <MkUserName :user="user" :nowrap="false" class="name"/> - <div class="bottom"> - <span class="username"><MkAcct :user="user" :detail="true"/></span> - <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span> - <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span> - <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span> - <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span> - </div> - </div> - <div class="description"> - <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/> - <p v-else class="empty">{{ $ts.noAccountDescription }}</p> - </div> - <div class="fields system"> - <dl v-if="user.location" class="field"> - <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt> - <dd class="value">{{ user.location }}</dd> - </dl> - <dl v-if="user.birthday" class="field"> - <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt> - <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> - </dl> - <dl class="field"> - <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt> - <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd> - </dl> - </div> - <div v-if="user.fields.length > 0" class="fields"> - <dl v-for="(field, i) in user.fields" :key="i" class="field"> - <dt class="name"> - <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> - </dt> - <dd class="value"> - <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/> - </dd> - </dl> - </div> - <div class="status"> - <MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }"> - <b>{{ number(user.notesCount) }}</b> - <span>{{ $ts.notes }}</span> - </MkA> - <MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }"> - <b>{{ number(user.followingCount) }}</b> - <span>{{ $ts.following }}</span> - </MkA> - <MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }"> - <b>{{ number(user.followersCount) }}</b> - <span>{{ $ts.followers }}</span> - </MkA> - </div> - </div> - </div> - - <div class="contents"> - <template v-if="page === 'index'"> - <div> - <div v-if="user.pinnedNotes.length > 0" class="_gap"> - <XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/> - </div> - <MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo> - <template v-if="narrow"> - <XPhotos :key="user.id" :user="user"/> - <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> - </template> - </div> - <div> - <XUserTimeline :user="user"/> - </div> - </template> - <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/> - <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/> - <XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/> - <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/> - <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/> - <XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/> - </div> - </div> - <div v-if="!narrow" class="sub"> - <XPhotos :key="user.id" :user="user"/> - <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> - </div> - </div> - </MkSpacer> - <MkError v-else-if="error" @retry="fetch()"/> + <div v-if="user"> + <XHome v-if="tab === 'home'" :user="user"/> + <XReactions v-else-if="tab === 'reactions'" :user="user"/> + <XClips v-else-if="tab === 'clips'" :user="user"/> + <XPages v-else-if="tab === 'pages'" :user="user"/> + <XGallery v-else-if="tab === 'gallery'" :user="user"/> + </div> + <MkError v-else-if="error" @retry="fetchUser()"/> <MkLoading v-else/> </transition> </div> @@ -131,16 +22,7 @@ import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } import calcAge from 's-age'; import * as Acct from 'misskey-js/built/acct'; import * as misskey from 'misskey-js'; -import XUserTimeline from './index.timeline.vue'; -import XNote from '@/components/note.vue'; -import MkFollowButton from '@/components/follow-button.vue'; -import MkContainer from '@/components/ui/container.vue'; -import MkFolder from '@/components/ui/folder.vue'; -import MkRemoteCaution from '@/components/remote-caution.vue'; -import MkTab from '@/components/tab.vue'; -import MkInfo from '@/components/ui/info.vue'; import { getScrollPosition } from '@/scripts/scroll'; -import { getUserMenu } from '@/scripts/get-user-menu'; import number from '@/filters/number'; import { userPage, acct as getAcct } from '@/filters/user'; import * as os from '@/os'; @@ -149,41 +31,24 @@ import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; import { $i } from '@/account'; -const XFollowList = defineAsyncComponent(() => import('./follow-list.vue')); +const XHome = defineAsyncComponent(() => import('./home.vue')); const XReactions = defineAsyncComponent(() => import('./reactions.vue')); const XClips = defineAsyncComponent(() => import('./clips.vue')); const XPages = defineAsyncComponent(() => import('./pages.vue')); const XGallery = defineAsyncComponent(() => import('./gallery.vue')); -const XPhotos = defineAsyncComponent(() => import('./index.photos.vue')); -const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); const props = withDefaults(defineProps<{ acct: string; page?: string; }>(), { - page: 'index', + page: 'home', }); const router = useRouter(); +let tab = $ref(props.page); let user = $ref<null | misskey.entities.UserDetailed>(null); let error = $ref(null); -let parallaxAnimationId = $ref<null | number>(null); -let narrow = $ref<null | boolean>(null); -let rootEl = $ref<null | HTMLElement>(null); -let bannerEl = $ref<null | HTMLElement>(null); - -const style = $computed(() => { - if (user?.bannerUrl == null) return {}; - return { - backgroundImage: `url(${ user.bannerUrl })`, - }; -}); - -const age = $computed(() => { - if (user == null) return null; - return calcAge(user.birthday); -}); function fetchUser(): void { if (props.acct == null) return; @@ -199,66 +64,28 @@ watch(() => props.acct, fetchUser, { immediate: true, }); -function menu(ev) { - os.popupMenu(getUserMenu(user), ev.currentTarget ?? ev.target); -} - -function parallaxLoop() { - parallaxAnimationId = window.requestAnimationFrame(parallaxLoop); - parallax(); -} - -function parallax() { - const banner = bannerEl as any; - if (banner == null) return; - - const top = getScrollPosition(rootEl); - - if (top < 0) return; - - const z = 1.75; // 奥行き(小さいほど奥) - const pos = -(top / z); - banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; -} - -onMounted(() => { - window.requestAnimationFrame(parallaxLoop); - narrow = rootEl!.clientWidth < 1000; -}); - -onUnmounted(() => { - if (parallaxAnimationId) { - window.cancelAnimationFrame(parallaxAnimationId); - } -}); - const headerActions = $computed(() => []); const headerTabs = $computed(() => user ? [{ - active: props.page === 'index', + key: 'home', title: i18n.ts.overview, icon: 'fas fa-home', - onClick: () => { router.push('/@' + getAcct(user)); }, }, ...($i && ($i.id === user.id)) || user.publicReactions ? [{ - active: props.page === 'reactions', + key: 'reactions', title: i18n.ts.reaction, icon: 'fas fa-laugh', - onClick: () => { router.push('/@' + getAcct(user) + '/reactions'); }, }] : [], { - active: props.page === 'clips', + key: 'clips', title: i18n.ts.clips, icon: 'fas fa-paperclip', - onClick: () => { router.push('/@' + getAcct(user) + '/clips'); }, }, { - active: props.page === 'pages', + key: 'pages', title: i18n.ts.pages, icon: 'fas fa-file-alt', - onClick: () => { router.push('/@' + getAcct(user) + '/pages'); }, }, { - active: props.page === 'gallery', + key: 'gallery', title: i18n.ts.gallery, icon: 'fas fa-icons', - onClick: () => { router.push('/@' + getAcct(user) + '/gallery'); }, }] : null); definePageMetadata(computed(() => user ? { @@ -271,7 +98,6 @@ definePageMetadata(computed(() => user ? { share: { title: user.name, }, - bg: 'var(--bg)', } : null)); </script> @@ -284,291 +110,4 @@ definePageMetadata(computed(() => user ? { .fade-leave-to { opacity: 0; } - -.ftskorzw { - - > .main { - - > .punished { - font-size: 0.8em; - padding: 16px; - } - - > .profile { - - > .main { - position: relative; - overflow: hidden; - - > .banner-container { - position: relative; - height: 250px; - overflow: hidden; - background-size: cover; - background-position: center; - - > .banner { - height: 100%; - background-color: #4c5e6d; - background-size: cover; - background-position: center; - box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; - will-change: background-position; - } - - > .fade { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 78px; - background: linear-gradient(transparent, rgba(#000, 0.7)); - } - - > .followed { - position: absolute; - top: 12px; - left: 12px; - padding: 4px 8px; - color: #fff; - background: rgba(0, 0, 0, 0.7); - font-size: 0.7em; - border-radius: 6px; - } - - > .actions { - position: absolute; - top: 12px; - right: 12px; - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); - background: rgba(0, 0, 0, 0.2); - padding: 8px; - border-radius: 24px; - - > .menu { - vertical-align: bottom; - height: 31px; - width: 31px; - color: #fff; - text-shadow: 0 0 8px #000; - font-size: 16px; - } - - > .koudoku { - margin-left: 4px; - vertical-align: bottom; - } - } - - > .title { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - padding: 0 0 8px 154px; - box-sizing: border-box; - color: #fff; - - > .name { - display: block; - margin: 0; - line-height: 32px; - font-weight: bold; - font-size: 1.8em; - text-shadow: 0 0 8px #000; - } - - > .bottom { - > * { - display: inline-block; - margin-right: 16px; - line-height: 20px; - opacity: 0.8; - - &.username { - font-weight: bold; - } - } - } - } - } - - > .title { - display: none; - text-align: center; - padding: 50px 8px 16px 8px; - font-weight: bold; - border-bottom: solid 0.5px var(--divider); - - > .bottom { - > * { - display: inline-block; - margin-right: 8px; - opacity: 0.8; - } - } - } - - > .avatar { - display: block; - position: absolute; - top: 170px; - left: 16px; - z-index: 2; - width: 120px; - height: 120px; - box-shadow: 1px 1px 3px rgba(#000, 0.2); - } - - > .description { - padding: 24px 24px 24px 154px; - font-size: 0.95em; - - > .empty { - margin: 0; - opacity: 0.5; - } - } - - > .fields { - padding: 24px; - font-size: 0.9em; - border-top: solid 0.5px var(--divider); - - > .field { - display: flex; - padding: 0; - margin: 0; - align-items: center; - - &:not(:last-child) { - margin-bottom: 8px; - } - - > .name { - width: 30%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - font-weight: bold; - text-align: center; - } - - > .value { - width: 70%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - margin: 0; - } - } - - &.system > .field > .name { - } - } - - > .status { - display: flex; - padding: 24px; - border-top: solid 0.5px var(--divider); - - > a { - flex: 1; - text-align: center; - - &.active { - color: var(--accent); - } - - &:hover { - text-decoration: none; - } - - > b { - display: block; - line-height: 16px; - } - - > span { - font-size: 70%; - } - } - } - } - } - - > .contents { - > .content { - margin-bottom: var(--margin); - } - } - } - - &.max-width_500px { - > .main { - > .profile > .main { - > .banner-container { - height: 140px; - - > .fade { - display: none; - } - - > .title { - display: none; - } - } - - > .title { - display: block; - } - - > .avatar { - top: 90px; - left: 0; - right: 0; - width: 92px; - height: 92px; - margin: auto; - } - - > .description { - padding: 16px; - text-align: center; - } - - > .fields { - padding: 16px; - } - - > .status { - padding: 16px; - } - } - - > .contents { - > .nav { - font-size: 80%; - } - } - } - } - - &.wide { - display: flex; - width: 100%; - - > .main { - width: 100%; - min-width: 0; - } - - > .sub { - max-width: 350px; - min-width: 350px; - margin-left: var(--margin); - } - } -} </style> diff --git a/packages/client/src/pages/user/pages.vue b/packages/client/src/pages/user/pages.vue index ad101158e0..bd16c46681 100644 --- a/packages/client/src/pages/user/pages.vue +++ b/packages/client/src/pages/user/pages.vue @@ -9,8 +9,8 @@ <script lang="ts" setup> import { computed } from 'vue'; import * as misskey from 'misskey-js'; -import MkPagePreview from '@/components/page-preview.vue'; -import MkPagination from '@/components/ui/pagination.vue'; +import MkPagePreview from '@/components/MkPagePreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; const props = defineProps<{ user: misskey.entities.User; diff --git a/packages/client/src/pages/user/reactions.vue b/packages/client/src/pages/user/reactions.vue index d2c1f92ebb..7e84e100a4 100644 --- a/packages/client/src/pages/user/reactions.vue +++ b/packages/client/src/pages/user/reactions.vue @@ -16,9 +16,9 @@ <script lang="ts" setup> import { computed } from 'vue'; import * as misskey from 'misskey-js'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkNote from '@/components/note.vue'; -import MkReactionIcon from '@/components/reaction-icon.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkNote from '@/components/MkNote.vue'; +import MkReactionIcon from '@/components/MkReactionIcon.vue'; const props = defineProps<{ user: misskey.entities.User; diff --git a/packages/client/src/pages/welcome.entrance.a.vue b/packages/client/src/pages/welcome.entrance.a.vue index 47e1f12342..827162a0c0 100644 --- a/packages/client/src/pages/welcome.entrance.a.vue +++ b/packages/client/src/pages/welcome.entrance.a.vue @@ -13,10 +13,9 @@ <MkEmoji :normal="true" :no-style="true" emoji="🎉"/> <MkEmoji :normal="true" :no-style="true" emoji="🍮"/> </div> - <div class="main _panel"> - <div class="bg"> - <div class="fade"></div> - </div> + <div class="main"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + <button class="_button _acrylic menu" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button> <div class="fg"> <h1> <!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に --> @@ -24,123 +23,108 @@ <span class="text">{{ instanceName }}</span> </h1> <div class="about"> - <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + <!-- eslint-disable-next-line vue/no-v-html --> + <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div> </div> <div class="action"> - <MkButton inline gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ $ts.signup }}</MkButton> - <MkButton inline data-cy-signin @click="signin()">{{ $ts.login }}</MkButton> - </div> - <div v-if="onlineUsersCount && stats" class="status"> - <div> - <I18n :src="$ts.nUsers" text-tag="span" class="users"> - <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> - </I18n> - <I18n :src="$ts.nNotes" text-tag="span" class="notes"> - <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> - </I18n> - </div> - <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online"> - <template #n><b>{{ onlineUsersCount }}</b></template> - </I18n> + <MkButton inline rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.signup }}</MkButton> + <MkButton inline rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton> </div> - <button class="_button _acrylic menu" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button> </div> </div> + <div v-if="instances" class="federation"> + <MarqueeText :duration="40"> + <MkA v-for="instance in instances" :key="instance.id" :class="$style.federationInstance" :to="`/instance-info/${instance.host}`" behavior="window"> + <!--<MkInstanceCardMini :instance="instance"/>--> + <img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/> + <span class="name _monospace">{{ instance.host }}</span> + </MkA> + </MarqueeText> + </div> </div> </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import { toUnicode } from 'punycode/'; -import XSigninDialog from '@/components/signin-dialog.vue'; -import XSignupDialog from '@/components/signup-dialog.vue'; -import MkButton from '@/components/ui/button.vue'; -import XNote from '@/components/note.vue'; -import MkFeaturedPhotos from '@/components/featured-photos.vue'; import XTimeline from './welcome.timeline.vue'; +import MarqueeText from '@/components/MkMarquee.vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; +import XNote from '@/components/MkNote.vue'; +import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; import { host, instanceName } from '@/config'; import * as os from '@/os'; import number from '@/filters/number'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkButton, - XNote, - MkFeaturedPhotos, - XTimeline, - }, +let meta = $ref(); +let stats = $ref(); +let tags = $ref(); +let onlineUsersCount = $ref(); +let instances = $ref(); - data() { - return { - host: toUnicode(host), - instanceName, - meta: null, - stats: null, - tags: [], - onlineUsersCount: null, - }; - }, +os.api('meta', { detail: true }).then(_meta => { + meta = _meta; +}); - created() { - os.api('meta', { detail: true }).then(meta => { - this.meta = meta; - }); +os.api('stats').then(_stats => { + stats = _stats; +}); - os.api('stats').then(stats => { - this.stats = stats; - }); +os.api('get-online-users-count').then(res => { + onlineUsersCount = res.count; +}); - os.api('get-online-users-count').then(res => { - this.onlineUsersCount = res.count; - }); +os.api('hashtags/list', { + sort: '+mentionedLocalUsers', + limit: 8, +}).then(_tags => { + tags = _tags; +}); - os.api('hashtags/list', { - sort: '+mentionedLocalUsers', - limit: 8 - }).then(tags => { - this.tags = tags; - }); - }, +os.api('federation/instances', { + sort: '+pubSub', + limit: 20, +}).then(_instances => { + instances = _instances; +}); - methods: { - signin() { - os.popup(XSigninDialog, { - autoSet: true - }, {}, 'closed'); - }, +function signin() { + os.popup(XSigninDialog, { + autoSet: true, + }, {}, 'closed'); +} - signup() { - os.popup(XSignupDialog, { - autoSet: true - }, {}, 'closed'); - }, +function signup() { + os.popup(XSignupDialog, { + autoSet: true, + }, {}, 'closed'); +} - showMenu(ev) { - os.popupMenu([{ - text: this.$t('aboutX', { x: instanceName }), - icon: 'fas fa-info-circle', - action: () => { - os.pageWindow('/about'); - } - }, { - text: this.$ts.aboutMisskey, - icon: 'fas fa-info-circle', - action: () => { - os.pageWindow('/about-misskey'); - } - }, null, { - text: this.$ts.help, - icon: 'fas fa-question-circle', - action: () => { - window.open(`https://misskey-hub.net/help.md`, '_blank'); - } - }], ev.currentTarget ?? ev.target); +function showMenu(ev) { + os.popupMenu([{ + text: i18n.ts.instanceInfo, + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about'); }, - - number - } -}); + }, { + text: i18n.ts.aboutMisskey, + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + }, + }, null, { + text: i18n.ts.help, + icon: 'fas fa-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.md', '_blank'); + }, + }], ev.currentTarget ?? ev.target); +} </script> <style lang="scss" scoped> @@ -201,7 +185,7 @@ export default defineComponent({ position: absolute; top: 42px; left: 42px; - width: 160px; + width: 140px; @media (max-width: 450px) { width: 130px; @@ -226,30 +210,29 @@ export default defineComponent({ position: relative; width: min(480px, 100%); margin: auto auto auto 128px; + background: var(--panel); + border-radius: var(--radius); box-shadow: 0 12px 32px rgb(0 0 0 / 25%); @media (max-width: 1200px) { margin: auto; } - > .bg { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 128px; - background-position: center; - background-size: cover; - opacity: 0.75; + > .icon { + width: 85px; + margin-top: -47px; + border-radius: 100%; + vertical-align: bottom; + } - > .fade { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 128px; - background: linear-gradient(0deg, var(--panel), var(--X15)); - } + > .menu { + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + border-radius: 8px; + font-size: 18px; } > .fg { @@ -259,8 +242,8 @@ export default defineComponent({ > h1 { display: block; margin: 0; - padding: 32px 32px 24px 32px; - font-size: 1.5em; + padding: 16px 32px 24px 32px; + font-size: 1.4em; > .logo { vertical-align: bottom; @@ -280,41 +263,47 @@ export default defineComponent({ line-height: 28px; } } + } + } - > .status { - border-top: solid 0.5px var(--divider); - padding: 32px; - font-size: 90%; - - > div { - > span:not(:last-child) { - padding-right: 1em; - margin-right: 1em; - border-right: solid 0.5px var(--divider); - } - } - - > .online { - ::v-deep(b) { - color: #41b781; - } - - ::v-deep(span) { - opacity: 0.7; - } - } - } + > .federation { + position: absolute; + bottom: 16px; + left: 0; + right: 0; + margin: auto; + background: var(--acrylicPanel); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + border-radius: 999px; + overflow: clip; + width: 800px; + padding: 8px 0; - > .menu { - position: absolute; - top: 16px; - right: 16px; - width: 32px; - height: 32px; - border-radius: 8px; - } + @media (max-width: 900px) { + display: none; } } } } </style> + +<style lang="scss" module> +.federationInstance { + display: inline-flex; + align-items: center; + vertical-align: bottom; + padding: 6px 12px 6px 6px; + margin: 0 10px 0 0; + background: var(--panel); + border-radius: 999px; + + > :global(.icon) { + display: inline-block; + width: 20px; + height: 20px; + margin-right: 5px; + border-radius: 999px; + } +} +</style> diff --git a/packages/client/src/pages/welcome.entrance.b.vue b/packages/client/src/pages/welcome.entrance.b.vue index 053087fda0..4bf117590a 100644 --- a/packages/client/src/pages/welcome.entrance.b.vue +++ b/packages/client/src/pages/welcome.entrance.b.vue @@ -9,6 +9,7 @@ <img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> </h1> <div class="about"> + <!-- eslint-disable-next-line vue/no-v-html --> <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> </div> <div class="action"> @@ -37,11 +38,11 @@ <script lang="ts"> import { defineComponent } from 'vue'; import { toUnicode } from 'punycode/'; -import XSigninDialog from '@/components/signin-dialog.vue'; -import XSignupDialog from '@/components/signup-dialog.vue'; -import MkButton from '@/components/ui/button.vue'; -import XNote from '@/components/note.vue'; -import MkFeaturedPhotos from '@/components/featured-photos.vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; +import XNote from '@/components/MkNote.vue'; +import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; import XTimeline from './welcome.timeline.vue'; import { host, instanceName } from '@/config'; import * as os from '@/os'; diff --git a/packages/client/src/pages/welcome.entrance.c.vue b/packages/client/src/pages/welcome.entrance.c.vue index 6bf487e16e..a590834a4c 100644 --- a/packages/client/src/pages/welcome.entrance.c.vue +++ b/packages/client/src/pages/welcome.entrance.c.vue @@ -21,6 +21,7 @@ <img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> </h1> <div class="about"> + <!-- eslint-disable-next-line vue/no-v-html --> <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> </div> <div class="action"> @@ -57,11 +58,11 @@ <script lang="ts"> import { defineComponent } from 'vue'; import { toUnicode } from 'punycode/'; -import XSigninDialog from '@/components/signin-dialog.vue'; -import XSignupDialog from '@/components/signup-dialog.vue'; -import MkButton from '@/components/ui/button.vue'; -import XNote from '@/components/note.vue'; -import MkFeaturedPhotos from '@/components/featured-photos.vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; +import XNote from '@/components/MkNote.vue'; +import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; import XTimeline from './welcome.timeline.vue'; import { host, instanceName } from '@/config'; import * as os from '@/os'; diff --git a/packages/client/src/pages/welcome.setup.vue b/packages/client/src/pages/welcome.setup.vue index 1a2f460283..d25651e2a3 100644 --- a/packages/client/src/pages/welcome.setup.vue +++ b/packages/client/src/pages/welcome.setup.vue @@ -3,7 +3,7 @@ <h1>Welcome to Misskey!</h1> <div class="_formRoot"> <p>{{ $ts.intro }}</p> - <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" spellcheck="false" required data-cy-admin-username class="_formBlock"> + <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username class="_formBlock"> <template #label>{{ $ts.username }}</template> <template #prefix>@</template> <template #suffix>@{{ host }}</template> @@ -21,50 +21,37 @@ </form> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +<script lang="ts" setup> +import { } from 'vue'; +import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/form/input.vue'; import { host } from '@/config'; import * as os from '@/os'; import { login } from '@/account'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkButton, - MkInput, - }, +let username = $ref(''); +let password = $ref(''); +let submitting = $ref(false); - data() { - return { - username: '', - password: '', - submitting: false, - host, - }; - }, +function submit() { + if (submitting) return; + submitting = true; - methods: { - submit() { - if (this.submitting) return; - this.submitting = true; + os.api('admin/accounts/create', { + username: username, + password: password, + }).then(res => { + return login(res.token); + }).catch(() => { + submitting = false; - os.api('admin/accounts/create', { - username: this.username, - password: this.password, - }).then(res => { - return login(res.token); - }).catch(() => { - this.submitting = false; - - os.alert({ - type: 'error', - text: this.$ts.somethingHappened - }); - }); - } - } -}); + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + }); +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/welcome.timeline.vue b/packages/client/src/pages/welcome.timeline.vue index bec9481ffd..e19ebac3ed 100644 --- a/packages/client/src/pages/welcome.timeline.vue +++ b/packages/client/src/pages/welcome.timeline.vue @@ -23,9 +23,9 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import XReactionsViewer from '@/components/reactions-viewer.vue'; -import XMediaList from '@/components/media-list.vue'; -import XPoll from '@/components/poll.vue'; +import XReactionsViewer from '@/components/MkReactionsViewer.vue'; +import XMediaList from '@/components/MkMediaList.vue'; +import XPoll from '@/components/MkPoll.vue'; import * as os from '@/os'; export default defineComponent({ diff --git a/packages/client/src/plugin.ts b/packages/client/src/plugin.ts index ca7b4b73d3..de1c955675 100644 --- a/packages/client/src/plugin.ts +++ b/packages/client/src/plugin.ts @@ -38,7 +38,7 @@ export function install(plugin) { function createPluginEnv(opts) { const config = new Map(); for (const [k, v] of Object.entries(opts.plugin.config || {})) { - config.set(k, jsToVal(opts.plugin.configData.hasOwnProperty(k) ? opts.plugin.configData[k] : v.default)); + config.set(k, jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default)); } return { diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts index 769d9cb2ac..111b15e0a6 100644 --- a/packages/client/src/router.ts +++ b/packages/client/src/router.ts @@ -12,16 +12,22 @@ const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({ }); export const routes = [{ - name: 'user', - path: '/@:acct/:page?', - component: page(() => import('./pages/user/index.vue')), -}, { path: '/@:initUser/pages/:initPageName/view-source', component: page(() => import('./pages/page-editor/page-editor.vue')), }, { path: '/@:username/pages/:pageName', component: page(() => import('./pages/page.vue')), }, { + path: '/@:acct/following', + component: page(() => import('./pages/user/following.vue')), +}, { + path: '/@:acct/followers', + component: page(() => import('./pages/user/followers.vue')), +}, { + name: 'user', + path: '/@:acct/:page?', + component: page(() => import('./pages/user/index.vue')), +}, { name: 'note', path: '/notes/:noteId', component: page(() => import('./pages/note.vue')), @@ -36,8 +42,145 @@ export const routes = [{ component: page(() => import('./pages/instance-info.vue')), }, { name: 'settings', - path: '/settings/:initialPage(*)?', + path: '/settings', component: page(() => import('./pages/settings/index.vue')), + loginRequired: true, + children: [{ + path: '/profile', + name: 'profile', + component: page(() => import('./pages/settings/profile.vue')), + }, { + path: '/privacy', + name: 'privacy', + component: page(() => import('./pages/settings/privacy.vue')), + }, { + path: '/reaction', + name: 'reaction', + component: page(() => import('./pages/settings/reaction.vue')), + }, { + path: '/drive', + name: 'drive', + component: page(() => import('./pages/settings/drive.vue')), + }, { + path: '/notifications', + name: 'notifications', + component: page(() => import('./pages/settings/notifications.vue')), + }, { + path: '/email', + name: 'email', + component: page(() => import('./pages/settings/email.vue')), + }, { + path: '/integration', + name: 'integration', + component: page(() => import('./pages/settings/integration.vue')), + }, { + path: '/security', + name: 'security', + component: page(() => import('./pages/settings/security.vue')), + }, { + path: '/general', + name: 'general', + component: page(() => import('./pages/settings/general.vue')), + }, { + path: '/theme/install', + name: 'theme', + component: page(() => import('./pages/settings/theme.install.vue')), + }, { + path: '/theme/manage', + name: 'theme', + component: page(() => import('./pages/settings/theme.manage.vue')), + }, { + path: '/theme', + name: 'theme', + component: page(() => import('./pages/settings/theme.vue')), + }, { + path: '/navbar', + name: 'navbar', + component: page(() => import('./pages/settings/navbar.vue')), + }, { + path: '/statusbar', + name: 'statusbar', + component: page(() => import('./pages/settings/statusbar.vue')), + }, { + path: '/sounds', + name: 'sounds', + component: page(() => import('./pages/settings/sounds.vue')), + }, { + path: '/plugin/install', + name: 'plugin', + component: page(() => import('./pages/settings/plugin.install.vue')), + }, { + path: '/plugin', + name: 'plugin', + component: page(() => import('./pages/settings/plugin.vue')), + }, { + path: '/import-export', + name: 'import-export', + component: page(() => import('./pages/settings/import-export.vue')), + }, { + path: '/instance-mute', + name: 'instance-mute', + component: page(() => import('./pages/settings/instance-mute.vue')), + }, { + path: '/mute-block', + name: 'mute-block', + component: page(() => import('./pages/settings/mute-block.vue')), + }, { + path: '/word-mute', + name: 'word-mute', + component: page(() => import('./pages/settings/word-mute.vue')), + }, { + path: '/api', + name: 'api', + component: page(() => import('./pages/settings/api.vue')), + }, { + path: '/apps', + name: 'api', + component: page(() => import('./pages/settings/apps.vue')), + }, { + path: '/webhook/edit/:webhookId', + name: 'webhook', + component: page(() => import('./pages/settings/webhook.edit.vue')), + }, { + path: '/webhook/new', + name: 'webhook', + component: page(() => import('./pages/settings/webhook.new.vue')), + }, { + path: '/webhook', + name: 'webhook', + component: page(() => import('./pages/settings/webhook.vue')), + }, { + path: '/deck', + name: 'deck', + component: page(() => import('./pages/settings/deck.vue')), + }, { + path: '/preferences-backups', + name: 'preferences-backups', + component: page(() => import('./pages/settings/preferences-backups.vue')), + }, { + path: '/custom-css', + name: 'general', + component: page(() => import('./pages/settings/custom-css.vue')), + }, { + path: '/accounts', + name: 'profile', + component: page(() => import('./pages/settings/accounts.vue')), + }, { + path: '/account-info', + name: 'other', + component: page(() => import('./pages/settings/account-info.vue')), + }, { + path: '/delete-account', + name: 'other', + component: page(() => import('./pages/settings/delete-account.vue')), + }, { + path: '/other', + name: 'other', + component: page(() => import('./pages/settings/other.vue')), + }, { + path: '/', + component: page(() => import('./pages/_empty_.vue')), + }], }, { path: '/reset-password/:token?', component: page(() => import('./pages/reset-password.vue')), @@ -50,15 +193,14 @@ export const routes = [{ }, { path: '/about', component: page(() => import('./pages/about.vue')), + hash: 'initialTab', }, { path: '/about-misskey', component: page(() => import('./pages/about-misskey.vue')), }, { - path: '/featured', - component: page(() => import('./pages/featured.vue')), -}, { path: '/theme-editor', component: page(() => import('./pages/theme-editor.vue')), + loginRequired: true, }, { path: '/explore/tags/:tag', component: page(() => import('./pages/explore.vue')), @@ -66,12 +208,6 @@ export const routes = [{ path: '/explore', component: page(() => import('./pages/explore.vue')), }, { - path: '/federation', - component: page(() => import('./pages/federation.vue')), -}, { - path: '/emojis', - component: page(() => import('./pages/emojis.vue')), -}, { path: '/search', component: page(() => import('./pages/search.vue')), query: { @@ -81,12 +217,15 @@ export const routes = [{ }, { path: '/authorize-follow', component: page(() => import('./pages/follow.vue')), + loginRequired: true, }, { path: '/share', component: page(() => import('./pages/share.vue')), + loginRequired: true, }, { path: '/api-console', component: page(() => import('./pages/api-console.vue')), + loginRequired: true, }, { path: '/mfm-cheat-sheet', component: page(() => import('./pages/mfm-cheat-sheet.vue')), @@ -114,18 +253,22 @@ export const routes = [{ }, { path: '/pages/new', component: page(() => import('./pages/page-editor/page-editor.vue')), + loginRequired: true, }, { path: '/pages/edit/:initPageId', component: page(() => import('./pages/page-editor/page-editor.vue')), + loginRequired: true, }, { path: '/pages', component: page(() => import('./pages/pages.vue')), }, { path: '/gallery/:postId/edit', component: page(() => import('./pages/gallery/edit.vue')), + loginRequired: true, }, { path: '/gallery/new', component: page(() => import('./pages/gallery/edit.vue')), + loginRequired: true, }, { path: '/gallery/:postId', component: page(() => import('./pages/gallery/post.vue')), @@ -135,9 +278,11 @@ export const routes = [{ }, { path: '/channels/:channelId/edit', component: page(() => import('./pages/channel-editor.vue')), + loginRequired: true, }, { path: '/channels/new', component: page(() => import('./pages/channel-editor.vue')), + loginRequired: true, }, { path: '/channels/:channelId', component: page(() => import('./pages/channel.vue')), @@ -145,77 +290,172 @@ export const routes = [{ path: '/channels', component: page(() => import('./pages/channels.vue')), }, { + path: '/registry/keys/system/:path(*)?', + component: page(() => import('./pages/registry.keys.vue')), +}, { + path: '/registry/value/system/:path(*)?', + component: page(() => import('./pages/registry.value.vue')), +}, { + path: '/registry', + component: page(() => import('./pages/registry.vue')), +}, { path: '/admin/file/:fileId', component: iAmModerator ? page(() => import('./pages/admin-file.vue')) : page(() => import('./pages/not-found.vue')), }, { - path: '/admin/:initialPage(*)?', + path: '/admin', component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page(() => import('./pages/not-found.vue')), + children: [{ + path: '/overview', + name: 'overview', + component: page(() => import('./pages/admin/overview.vue')), + }, { + path: '/users', + name: 'users', + component: page(() => import('./pages/admin/users.vue')), + }, { + path: '/emojis', + name: 'emojis', + component: page(() => import('./pages/admin/emojis.vue')), + }, { + path: '/queue', + name: 'queue', + component: page(() => import('./pages/admin/queue.vue')), + }, { + path: '/files', + name: 'files', + component: page(() => import('./pages/admin/files.vue')), + }, { + path: '/announcements', + name: 'announcements', + component: page(() => import('./pages/admin/announcements.vue')), + }, { + path: '/ads', + name: 'ads', + component: page(() => import('./pages/admin/ads.vue')), + }, { + path: '/database', + name: 'database', + component: page(() => import('./pages/admin/database.vue')), + }, { + path: '/abuses', + name: 'abuses', + component: page(() => import('./pages/admin/abuses.vue')), + }, { + path: '/settings', + name: 'settings', + component: page(() => import('./pages/admin/settings.vue')), + }, { + path: '/email-settings', + name: 'email-settings', + component: page(() => import('./pages/admin/email-settings.vue')), + }, { + path: '/object-storage', + name: 'object-storage', + component: page(() => import('./pages/admin/object-storage.vue')), + }, { + path: '/security', + name: 'security', + component: page(() => import('./pages/admin/security.vue')), + }, { + path: '/relays', + name: 'relays', + component: page(() => import('./pages/admin/relays.vue')), + }, { + path: '/integrations', + name: 'integrations', + component: page(() => import('./pages/admin/integrations.vue')), + }, { + path: '/instance-block', + name: 'instance-block', + component: page(() => import('./pages/admin/instance-block.vue')), + }, { + path: '/proxy-account', + name: 'proxy-account', + component: page(() => import('./pages/admin/proxy-account.vue')), + }, { + path: '/other-settings', + name: 'other-settings', + component: page(() => import('./pages/admin/other-settings.vue')), + }, { + path: '/', + component: page(() => import('./pages/_empty_.vue')), + }], }, { path: '/my/notifications', component: page(() => import('./pages/notifications.vue')), + loginRequired: true, }, { path: '/my/favorites', component: page(() => import('./pages/favorites.vue')), -}, { - path: '/my/messages', - component: page(() => import('./pages/messages.vue')), -}, { - path: '/my/mentions', - component: page(() => import('./pages/mentions.vue')), + loginRequired: true, }, { name: 'messaging', path: '/my/messaging', component: page(() => import('./pages/messaging/index.vue')), + loginRequired: true, }, { path: '/my/messaging/:userAcct', component: page(() => import('./pages/messaging/messaging-room.vue')), + loginRequired: true, }, { path: '/my/messaging/group/:groupId', component: page(() => import('./pages/messaging/messaging-room.vue')), + loginRequired: true, }, { path: '/my/drive/folder/:folder', component: page(() => import('./pages/drive.vue')), + loginRequired: true, }, { path: '/my/drive', component: page(() => import('./pages/drive.vue')), + loginRequired: true, }, { path: '/my/follow-requests', component: page(() => import('./pages/follow-requests.vue')), + loginRequired: true, }, { path: '/my/lists/:listId', component: page(() => import('./pages/my-lists/list.vue')), + loginRequired: true, }, { path: '/my/lists', component: page(() => import('./pages/my-lists/index.vue')), + loginRequired: true, }, { path: '/my/clips', component: page(() => import('./pages/my-clips/index.vue')), + loginRequired: true, }, { path: '/my/antennas/create', component: page(() => import('./pages/my-antennas/create.vue')), + loginRequired: true, }, { path: '/my/antennas/:antennaId', component: page(() => import('./pages/my-antennas/edit.vue')), + loginRequired: true, }, { path: '/my/antennas', component: page(() => import('./pages/my-antennas/index.vue')), + loginRequired: true, }, { path: '/timeline/list/:listId', component: page(() => import('./pages/user-list-timeline.vue')), + loginRequired: true, }, { path: '/timeline/antenna/:antennaId', component: page(() => import('./pages/antenna-timeline.vue')), + loginRequired: true, }, { name: 'index', path: '/', component: $i ? page(() => import('./pages/timeline.vue')) : page(() => import('./pages/welcome.vue')), globalCacheKey: 'index', }, { - path: '/(*)', + path: '/:(*)', component: page(() => import('./pages/not-found.vue')), }]; -export const mainRouter = new Router(routes, location.pathname + location.search); +export const mainRouter = new Router(routes, location.pathname + location.search + location.hash); window.history.replaceState({ key: mainRouter.getCurrentKey() }, '', location.href); @@ -235,17 +475,25 @@ mainRouter.addListener('push', ctx => { if (scrollPos !== 0) { window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール window.scroll({ top: scrollPos, behavior: 'instant' }); - }, 1000); + }, 100); } }); +mainRouter.addListener('replace', ctx => { + window.history.replaceState({ key: ctx.key }, '', ctx.path); +}); + +mainRouter.addListener('same', () => { + window.scroll({ top: 0, behavior: 'smooth' }); +}); + window.addEventListener('popstate', (event) => { - mainRouter.change(location.pathname + location.search, event.state?.key); + mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key, false); const scrollPos = scrollPosStore.get(event.state?.key) ?? 0; window.scroll({ top: scrollPos, behavior: 'instant' }); window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール window.scroll({ top: scrollPos, behavior: 'instant' }); - }, 1000); + }, 100); }); export function useRouter(): Router { diff --git a/packages/client/src/scripts/aiscript/api.ts b/packages/client/src/scripts/aiscript/api.ts index 01b8fd05fe..6debcb8a13 100644 --- a/packages/client/src/scripts/aiscript/api.ts +++ b/packages/client/src/scripts/aiscript/api.ts @@ -27,7 +27,7 @@ export function createAiScriptEnv(opts) { if (token) utils.assertString(token); apiRequests++; if (apiRequests > 16) return values.NULL; - const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token || null)); + const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token ?? null)); return utils.jsToVal(res); }), 'Mk:save': values.FN_NATIVE(([key, value]) => { diff --git a/packages/client/src/scripts/array.ts b/packages/client/src/scripts/array.ts index 29d027de14..26c6195d66 100644 --- a/packages/client/src/scripts/array.ts +++ b/packages/client/src/scripts/array.ts @@ -98,7 +98,7 @@ export function groupOn<T, S>(f: (x: T) => S, xs: T[]): T[][] { export function groupByX<T>(collections: T[], keySelector: (x: T) => string) { return collections.reduce((obj: Record<string, T[]>, item: T) => { const key = keySelector(item); - if (!obj.hasOwnProperty(key)) { + if (typeof obj[key] === 'undefined') { obj[key] = []; } diff --git a/packages/client/src/scripts/autocomplete.ts b/packages/client/src/scripts/autocomplete.ts index 8d9bdee8f5..206724de9e 100644 --- a/packages/client/src/scripts/autocomplete.ts +++ b/packages/client/src/scripts/autocomplete.ts @@ -8,7 +8,7 @@ export class Autocomplete { x: Ref<number>; y: Ref<number>; q: Ref<string | null>; - close: Function; + close: () => void; } | null; private textarea: HTMLInputElement | HTMLTextAreaElement; private currentType: string; @@ -157,7 +157,7 @@ export class Autocomplete { const _y = ref(y); const _q = ref(q); - const { dispose } = await popup(defineAsyncComponent(() => import('@/components/autocomplete.vue')), { + const { dispose } = await popup(defineAsyncComponent(() => import('@/components/MkAutocomplete.vue')), { textarea: this.textarea, close: this.close, type: type, diff --git a/packages/client/src/scripts/check-word-mute.ts b/packages/client/src/scripts/check-word-mute.ts index fa74c09939..35d40a6e08 100644 --- a/packages/client/src/scripts/check-word-mute.ts +++ b/packages/client/src/scripts/check-word-mute.ts @@ -3,7 +3,9 @@ export function checkWordMute(note: Record<string, any>, me: Record<string, any> if (me && (note.userId === me.id)) return false; if (mutedWords.length > 0) { - if (note.text == null) return false; + const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim(); + + if (text === '') return false; const matched = mutedWords.some(filter => { if (Array.isArray(filter)) { @@ -11,7 +13,7 @@ export function checkWordMute(note: Record<string, any>, me: Record<string, any> const filteredFilter = filter.filter(keyword => keyword !== ''); if (filteredFilter.length === 0) return false; - return filteredFilter.every(keyword => note.text!.includes(keyword)); + return filteredFilter.every(keyword => text.includes(keyword)); } else { // represents RegExp const regexp = filter.match(/^\/(.+)\/(.*)$/); @@ -20,7 +22,7 @@ export function checkWordMute(note: Record<string, any>, me: Record<string, any> if (!regexp) return false; try { - return new RegExp(regexp[1], regexp[2]).test(note.text!); + return new RegExp(regexp[1], regexp[2]).test(text); } catch (err) { // This should never happen due to input sanitisation. return false; diff --git a/packages/client/src/scripts/clone.ts b/packages/client/src/scripts/clone.ts new file mode 100644 index 0000000000..16fad24129 --- /dev/null +++ b/packages/client/src/scripts/clone.ts @@ -0,0 +1,18 @@ +// structredCloneが遅いため +// SEE: http://var.blog.jp/archives/86038606.html + +type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[]; + +export function deepClone<T extends Cloneable>(x: T): T { + if (typeof x === 'object') { + if (x === null) return x; + if (Array.isArray(x)) return x.map(deepClone) as T; + const obj = {} as Record<string, Cloneable>; + for (const [k, v] of Object.entries(x)) { + obj[k] = deepClone(v); + } + return obj as T; + } else { + return x; + } +} diff --git a/packages/client/src/scripts/emojilist.ts b/packages/client/src/scripts/emojilist.ts index 4196170d24..4ce63dc7e7 100644 --- a/packages/client/src/scripts/emojilist.ts +++ b/packages/client/src/scripts/emojilist.ts @@ -8,4 +8,6 @@ export type UnicodeEmojiDef = { } // initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb -export const emojilist = (await import('../emojilist.json')).default as UnicodeEmojiDef[]; +import _emojilist from '../emojilist.json'; + +export const emojilist = _emojilist as UnicodeEmojiDef[]; diff --git a/packages/client/src/scripts/gen-search-query.ts b/packages/client/src/scripts/gen-search-query.ts index 57a06c280c..b413cbbab1 100644 --- a/packages/client/src/scripts/gen-search-query.ts +++ b/packages/client/src/scripts/gen-search-query.ts @@ -21,7 +21,6 @@ export async function genSearchQuery(v: any, q: string) { } } } - } return { query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '), diff --git a/packages/client/src/scripts/get-note-menu.ts b/packages/client/src/scripts/get-note-menu.ts index 283c90362c..4826cd70fd 100644 --- a/packages/client/src/scripts/get-note-menu.ts +++ b/packages/client/src/scripts/get-note-menu.ts @@ -1,5 +1,6 @@ import { defineAsyncComponent, Ref, inject } from 'vue'; import * as misskey from 'misskey-js'; +import { pleaseLogin } from './please-login'; import { $i } from '@/account'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; @@ -7,7 +8,7 @@ import * as os from '@/os'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import { url } from '@/config'; import { noteActions } from '@/store'; -import { pleaseLogin } from './please-login'; +import { notePage } from '@/filters/note'; export function getNoteMenu(props: { note: misskey.entities.Note; @@ -34,7 +35,7 @@ export function getNoteMenu(props: { if (canceled) return; os.api('notes/delete', { - noteId: appearNote.id + noteId: appearNote.id, }); }); } @@ -47,7 +48,7 @@ export function getNoteMenu(props: { if (canceled) return; os.api('notes/delete', { - noteId: appearNote.id + noteId: appearNote.id, }); os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); @@ -56,19 +57,13 @@ export function getNoteMenu(props: { function toggleFavorite(favorite: boolean): void { os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { - noteId: appearNote.id - }); - } - - function toggleWatch(watch: boolean): void { - os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { - noteId: appearNote.id + noteId: appearNote.id, }); } function toggleThreadMute(mute: boolean): void { os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { - noteId: appearNote.id + noteId: appearNote.id, }); } @@ -84,12 +79,12 @@ export function getNoteMenu(props: { function togglePin(pin: boolean): void { os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { - noteId: appearNote.id + noteId: appearNote.id, }, undefined, null, res => { if (res.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { os.alert({ type: 'error', - text: i18n.ts.pinLimitExceeded + text: i18n.ts.pinLimitExceeded, }); } }); @@ -104,26 +99,26 @@ export function getNoteMenu(props: { const { canceled, result } = await os.form(i18n.ts.createNewClip, { name: { type: 'string', - label: i18n.ts.name + label: i18n.ts.name, }, description: { type: 'string', required: false, multiline: true, - label: i18n.ts.description + label: i18n.ts.description, }, isPublic: { type: 'boolean', label: i18n.ts.public, - default: false - } + default: false, + }, }); if (canceled) return; const clip = await os.apiWithDialog('clips/create', result); os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); - } + }, }, null, ...clips.map(clip => ({ text: clip.name, action: () => { @@ -146,9 +141,9 @@ export function getNoteMenu(props: { text: err.message + '\n' + err.id, }); } - } + }, ); - } + }, }))], props.menuButton.value, { }).then(focus); } @@ -178,7 +173,9 @@ export function getNoteMenu(props: { url: `${url}/notes/${appearNote.id}`, }); } - + function notedetails(): void { + os.pageWindow(`/notes/${appearNote.id}`); + } async function translate(): Promise<void> { if (props.translation.value != null) return; props.translating.value = true; @@ -193,86 +190,80 @@ export function getNoteMenu(props: { let menu; if ($i) { const statePromise = os.api('notes/state', { - noteId: appearNote.id + noteId: appearNote.id, }); menu = [ - ...( - props.currentClipPage?.value.userId === $i.id ? [{ - icon: 'fas fa-circle-minus', - text: i18n.ts.unclip, - danger: true, - action: unclip, - }, null] : [] - ), - { - icon: 'fas fa-copy', - text: i18n.ts.copyContent, - action: copyContent - }, { - icon: 'fas fa-link', - text: i18n.ts.copyLink, - action: copyLink - }, (appearNote.url || appearNote.uri) ? { - icon: 'fas fa-external-link-square-alt', - text: i18n.ts.showOnRemote, - action: () => { - window.open(appearNote.url || appearNote.uri, '_blank'); - } - } : undefined, - { - icon: 'fas fa-share-alt', - text: i18n.ts.share, - action: share - }, - instance.translatorAvailable ? { - icon: 'fas fa-language', - text: i18n.ts.translate, - action: translate - } : undefined, - null, - statePromise.then(state => state.isFavorited ? { - icon: 'fas fa-star', - text: i18n.ts.unfavorite, - action: () => toggleFavorite(false) - } : { - icon: 'fas fa-star', - text: i18n.ts.favorite, - action: () => toggleFavorite(true) - }), - { - icon: 'fas fa-paperclip', - text: i18n.ts.clip, - action: () => clip() - }, - (appearNote.userId !== $i.id) ? statePromise.then(state => state.isWatching ? { - icon: 'fas fa-eye-slash', - text: i18n.ts.unwatch, - action: () => toggleWatch(false) - } : { - icon: 'fas fa-eye', - text: i18n.ts.watch, - action: () => toggleWatch(true) - }) : undefined, - statePromise.then(state => state.isMutedThread ? { - icon: 'fas fa-comment-slash', - text: i18n.ts.unmuteThread, - action: () => toggleThreadMute(false) - } : { - icon: 'fas fa-comment-slash', - text: i18n.ts.muteThread, - action: () => toggleThreadMute(true) - }), - appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { - icon: 'fas fa-thumbtack', - text: i18n.ts.unpin, - action: () => togglePin(false) - } : { - icon: 'fas fa-thumbtack', - text: i18n.ts.pin, - action: () => togglePin(true) - } : undefined, - /* + ...( + props.currentClipPage?.value.userId === $i.id ? [{ + icon: 'fas fa-circle-minus', + text: i18n.ts.unclip, + danger: true, + action: unclip, + }, null] : [] + ), { + icon: 'fas fa-external-link-alt', + text: i18n.ts.details, + action: notedetails, + }, { + icon: 'fas fa-copy', + text: i18n.ts.copyContent, + action: copyContent, + }, { + icon: 'fas fa-link', + text: i18n.ts.copyLink, + action: copyLink, + }, (appearNote.url || appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: i18n.ts.showOnRemote, + action: () => { + window.open(appearNote.url || appearNote.uri, '_blank'); + }, + } : undefined, + { + icon: 'fas fa-share-alt', + text: i18n.ts.share, + action: share, + }, + instance.translatorAvailable ? { + icon: 'fas fa-language', + text: i18n.ts.translate, + action: translate, + } : undefined, + null, + statePromise.then(state => state.isFavorited ? { + icon: 'fas fa-star', + text: i18n.ts.unfavorite, + action: () => toggleFavorite(false), + } : { + icon: 'fas fa-star', + text: i18n.ts.favorite, + action: () => toggleFavorite(true), + }), + { + icon: 'fas fa-paperclip', + text: i18n.ts.clip, + action: () => clip(), + }, + statePromise.then(state => state.isMutedThread ? { + icon: 'fas fa-comment-slash', + text: i18n.ts.unmuteThread, + action: () => toggleThreadMute(false), + } : { + icon: 'fas fa-comment-slash', + text: i18n.ts.muteThread, + action: () => toggleThreadMute(true), + }), + appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { + icon: 'fas fa-thumbtack', + text: i18n.ts.unpin, + action: () => togglePin(false), + } : { + icon: 'fas fa-thumbtack', + text: i18n.ts.pin, + action: () => togglePin(true), + } : undefined, + /* ...($i.isModerator || $i.isAdmin ? [ null, { @@ -282,54 +273,58 @@ export function getNoteMenu(props: { }] : [] ),*/ - ...(appearNote.userId !== $i.id ? [ - null, - { - icon: 'fas fa-exclamation-circle', - text: i18n.ts.reportAbuse, - action: () => { - const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`; - os.popup(defineAsyncComponent(() => import('@/components/abuse-report-window.vue')), { - user: appearNote.user, - initialComment: `Note: ${u}\n-----\n` - }, {}, 'closed'); - } - }] + ...(appearNote.userId !== $i.id ? [ + null, + { + icon: 'fas fa-exclamation-circle', + text: i18n.ts.reportAbuse, + action: () => { + const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`; + os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: appearNote.user, + initialComment: `Note: ${u}\n-----\n`, + }, {}, 'closed'); + }, + }] : [] - ), - ...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [ - null, - appearNote.userId === $i.id ? { - icon: 'fas fa-edit', - text: i18n.ts.deleteAndEdit, - action: delEdit - } : undefined, - { - icon: 'fas fa-trash-alt', - text: i18n.ts.delete, - danger: true, - action: del - }] + ), + ...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [ + null, + appearNote.userId === $i.id ? { + icon: 'fas fa-edit', + text: i18n.ts.deleteAndEdit, + action: delEdit, + } : undefined, + { + icon: 'fas fa-trash-alt', + text: i18n.ts.delete, + danger: true, + action: del, + }] : [] - )] - .filter(x => x !== undefined); + )] + .filter(x => x !== undefined); } else { menu = [{ + icon: 'fas fa-external-link-alt', + text: i18n.ts.detailed, + action: openDetail, + }, { icon: 'fas fa-copy', text: i18n.ts.copyContent, - action: copyContent + action: copyContent, }, { icon: 'fas fa-link', text: i18n.ts.copyLink, - action: copyLink + action: copyLink, }, (appearNote.url || appearNote.uri) ? { icon: 'fas fa-external-link-square-alt', text: i18n.ts.showOnRemote, action: () => { window.open(appearNote.url || appearNote.uri, '_blank'); - } + }, } : undefined] - .filter(x => x !== undefined); + .filter(x => x !== undefined); } if (noteActions.length > 0) { @@ -338,7 +333,7 @@ export function getNoteMenu(props: { text: action.title, action: () => { action.handler(appearNote); - } + }, }))]); } diff --git a/packages/client/src/scripts/get-user-menu.ts b/packages/client/src/scripts/get-user-menu.ts index 25bcd90e9f..4a5a2d42f0 100644 --- a/packages/client/src/scripts/get-user-menu.ts +++ b/packages/client/src/scripts/get-user-menu.ts @@ -7,8 +7,9 @@ import * as os from '@/os'; import { userActions } from '@/store'; import { $i, iAmModerator } from '@/account'; import { mainRouter } from '@/router'; +import { Router } from '@/nirax'; -export function getUserMenu(user) { +export function getUserMenu(user, router: Router = mainRouter) { const meId = $i ? $i.id : null; async function pushList() { @@ -128,7 +129,7 @@ export function getUserMenu(user) { } function reportAbuse() { - os.popup(defineAsyncComponent(() => import('@/components/abuse-report-window.vue')), { + os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { user: user, }, {}, 'closed'); } @@ -161,7 +162,7 @@ export function getUserMenu(user) { icon: 'fas fa-info-circle', text: i18n.ts.info, action: () => { - os.pageWindow(`/user-info/${user.id}`); + router.push(`/user-info/${user.id}`); }, }, { icon: 'fas fa-envelope', @@ -227,7 +228,7 @@ export function getUserMenu(user) { icon: 'fas fa-pencil-alt', text: i18n.ts.editProfile, action: () => { - mainRouter.push('/settings/profile'); + router.push('/settings/profile'); }, }]); } diff --git a/packages/client/src/scripts/hotkey.ts b/packages/client/src/scripts/hotkey.ts index fd9c74f6c8..bd8c3b6cab 100644 --- a/packages/client/src/scripts/hotkey.ts +++ b/packages/client/src/scripts/hotkey.ts @@ -1,6 +1,8 @@ import keyCode from './keycode'; -type Keymap = Record<string, Function>; +type Callback = (ev: KeyboardEvent) => void; + +type Keymap = Record<string, Callback>; type Pattern = { which: string[]; @@ -11,14 +13,14 @@ type Pattern = { type Action = { patterns: Pattern[]; - callback: Function; + callback: Callback; allowRepeat: boolean; }; const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => { const result = { patterns: [], - callback: callback, + callback, allowRepeat: true } as Action; diff --git a/packages/client/src/scripts/hpml/evaluator.ts b/packages/client/src/scripts/hpml/evaluator.ts index 8106687b61..10023edffb 100644 --- a/packages/client/src/scripts/hpml/evaluator.ts +++ b/packages/client/src/scripts/hpml/evaluator.ts @@ -159,7 +159,6 @@ export class Hpml { @autobind private evaluate(expr: Expr, scope: HpmlScope): any { - if (isLiteralValue(expr)) { if (expr.type === null) { return null; diff --git a/packages/client/src/scripts/hpml/expr.ts b/packages/client/src/scripts/hpml/expr.ts index 00e3ed118b..18c7c2a14b 100644 --- a/packages/client/src/scripts/hpml/expr.ts +++ b/packages/client/src/scripts/hpml/expr.ts @@ -16,7 +16,7 @@ export type TextValue = ExprBase & { value: string; }; -export type MultiLineTextValue = ExprBase & { +export type MultiLineTextValue = ExprBase & { type: 'multiLineText'; value: string; }; diff --git a/packages/client/src/scripts/hpml/index.ts b/packages/client/src/scripts/hpml/index.ts index ac81eac2d9..7cf88d5961 100644 --- a/packages/client/src/scripts/hpml/index.ts +++ b/packages/client/src/scripts/hpml/index.ts @@ -14,13 +14,13 @@ export type Fn = { export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null; export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = { - text: { out: 'string', category: 'value', icon: 'fas fa-quote-right', }, - multiLineText: { out: 'string', category: 'value', icon: 'fas fa-align-left', }, - textList: { out: 'stringArray', category: 'value', icon: 'fas fa-list', }, - number: { out: 'number', category: 'value', icon: 'fas fa-sort-numeric-up', }, - ref: { out: null, category: 'value', icon: 'fas fa-magic', }, - aiScriptVar: { out: null, category: 'value', icon: 'fas fa-magic', }, - fn: { out: 'function', category: 'value', icon: 'fas fa-square-root-alt', }, + text: { out: 'string', category: 'value', icon: 'fas fa-quote-right', }, + multiLineText: { out: 'string', category: 'value', icon: 'fas fa-align-left', }, + textList: { out: 'stringArray', category: 'value', icon: 'fas fa-list', }, + number: { out: 'number', category: 'value', icon: 'fas fa-sort-numeric-up', }, + ref: { out: null, category: 'value', icon: 'fas fa-magic', }, + aiScriptVar: { out: null, category: 'value', icon: 'fas fa-magic', }, + fn: { out: 'function', category: 'value', icon: 'fas fa-square-root-alt', }, }; export const blockDefs = [ diff --git a/packages/client/src/scripts/hpml/lib.ts b/packages/client/src/scripts/hpml/lib.ts index 01a44ffcdf..cab467a920 100644 --- a/packages/client/src/scripts/hpml/lib.ts +++ b/packages/client/src/scripts/hpml/lib.ts @@ -125,55 +125,56 @@ export function initAiLib(hpml: Hpml) { } }); */ - }) + }), }; } export const funcDefs: Record<string, { in: any[]; out: any; category: string; icon: any; }> = { - if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'fas fa-share-alt', }, - for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'fas fa-recycle', }, - not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag', }, - or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag', }, - and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag', }, - add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-plus', }, - subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-minus', }, - multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-times', }, - divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide', }, - mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide', }, - round: { in: ['number'], out: 'number', category: 'operation', icon: 'fas fa-calculator', }, - eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-equals', }, - notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-not-equal', }, - gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than', }, - lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than', }, - gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than-equal', }, - ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than-equal', }, - strLen: { in: ['string'], out: 'number', category: 'text', icon: 'fas fa-quote-right', }, - strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'fas fa-quote-right', }, - strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', }, - strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', }, - join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', }, - stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'fas fa-exchange-alt', }, - numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'fas fa-exchange-alt', }, - splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'fas fa-exchange-alt', }, - pick: { in: [null, 'number'], out: null, category: 'list', icon: 'fas fa-indent', }, - listLen: { in: [null], out: 'number', category: 'list', icon: 'fas fa-indent', }, - rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice', }, - dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice', }, - seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice', }, - random: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice', }, - dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice', }, - seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'fas fa-dice', }, - randomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice', }, - dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice', }, - seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'fas fa-dice', }, - DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'fas fa-dice', }, // dailyRandomPickWithProbabilityMapping + if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'fas fa-share-alt' }, + for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'fas fa-recycle' }, + not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-plus' }, + subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-minus' }, + multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-times' }, + divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' }, + mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' }, + round: { in: ['number'], out: 'number', category: 'operation', icon: 'fas fa-calculator' }, + eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-equals' }, + notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-not-equal' }, + gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than' }, + lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than' }, + gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than-equal' }, + ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than-equal' }, + strLen: { in: ['string'], out: 'number', category: 'text', icon: 'fas fa-quote-right' }, + strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'fas fa-quote-right' }, + strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right' }, + strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'fas fa-quote-right' }, + join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right' }, + stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'fas fa-exchange-alt' }, + numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'fas fa-exchange-alt' }, + splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'fas fa-exchange-alt' }, + pick: { in: [null, 'number'], out: null, category: 'list', icon: 'fas fa-indent' }, + listLen: { in: [null], out: 'number', category: 'list', icon: 'fas fa-indent' }, + rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + random: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + randomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' }, + dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' }, + seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'fas fa-dice' }, + DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'fas fa-dice' }, // dailyRandomPickWithProbabilityMapping }; export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, visitor?: any) { - const date = new Date(); const day = `${visitor ? visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; + // SHOULD be fine to ignore since it's intended + function shape isn't defined + // eslint-disable-next-line @typescript-eslint/ban-types const funcs: Record<string, Function> = { not: (a: boolean) => !a, or: (a: boolean, b: boolean) => a || b, @@ -189,7 +190,7 @@ export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, vi const result: any[] = []; for (let i = 0; i < times; i++) { result.push(fn.exec({ - [fn.slots[0]]: i + 1 + [fn.slots[0]]: i + 1, })); } return result; diff --git a/packages/client/src/scripts/hpml/type-checker.ts b/packages/client/src/scripts/hpml/type-checker.ts index 9633b3cd01..24c9ed8bcb 100644 --- a/packages/client/src/scripts/hpml/type-checker.ts +++ b/packages/client/src/scripts/hpml/type-checker.ts @@ -1,7 +1,9 @@ import autobind from 'autobind-decorator'; -import { Type, envVarsDef, PageVar } from '.'; -import { Expr, isLiteralValue, Variable } from './expr'; +import { isLiteralValue } from './expr'; import { funcDefs } from './lib'; +import { envVarsDef } from '.'; +import type { Type, PageVar } from '.'; +import type { Expr, Variable } from './expr'; type TypeError = { arg: number; @@ -44,14 +46,14 @@ export class HpmlTypeChecker { return { arg: i, expect: generic[arg], - actual: type + actual: type, }; } } else if (type !== arg) { return { arg: i, expect: arg, - actual: type + actual: type, }; } } @@ -81,7 +83,7 @@ export class HpmlTypeChecker { } if (typeof def.in[slot] === 'number') { - return generic[def.in[slot]] || null; + return generic[def.in[slot]] ?? null; } else { return def.in[slot]; } diff --git a/packages/client/src/scripts/idb-proxy.ts b/packages/client/src/scripts/idb-proxy.ts index d462a0d7ce..77bb84463c 100644 --- a/packages/client/src/scripts/idb-proxy.ts +++ b/packages/client/src/scripts/idb-proxy.ts @@ -11,16 +11,15 @@ const fallbackName = (key: string) => `idbfallback::${key}`; let idbAvailable = typeof window !== 'undefined' ? !!window.indexedDB : true; if (idbAvailable) { - try { - await iset('idb-test', 'test'); - } catch (err) { + iset('idb-test', 'test').catch(err => { console.error('idb error', err); + console.error('indexedDB is unavailable. It will use localStorage.'); idbAvailable = false; - } + }); +} else { + console.error('indexedDB is unavailable. It will use localStorage.'); } -if (!idbAvailable) console.error('indexedDB is unavailable. It will use localStorage.'); - export async function get(key: string) { if (idbAvailable) return iget(key); return JSON.parse(localStorage.getItem(fallbackName(key))); diff --git a/packages/client/src/scripts/media-proxy.ts b/packages/client/src/scripts/media-proxy.ts new file mode 100644 index 0000000000..76e20786f4 --- /dev/null +++ b/packages/client/src/scripts/media-proxy.ts @@ -0,0 +1,13 @@ +import { query } from '@/scripts/url'; +import { url } from '@/config'; + +export function getProxiedImageUrl(imageUrl: string): string { + return `${url}/proxy/image.webp?${query({ + url: imageUrl, + })}`; +} + +export function getProxiedImageUrlNullable(imageUrl: string | null | undefined): string | null { + if (imageUrl == null) return null; + return getProxiedImageUrl(imageUrl); +} diff --git a/packages/client/src/scripts/please-login.ts b/packages/client/src/scripts/please-login.ts index e21a6d2ed3..3323968f71 100644 --- a/packages/client/src/scripts/please-login.ts +++ b/packages/client/src/scripts/please-login.ts @@ -6,7 +6,7 @@ import { popup } from '@/os'; export function pleaseLogin(path?: string) { if ($i) return; - popup(defineAsyncComponent(() => import('@/components/signin-dialog.vue')), { + popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), { autoSet: true, message: i18n.ts.signinRequired }, { @@ -17,5 +17,5 @@ export function pleaseLogin(path?: string) { }, }, 'closed'); - throw new Error('signin required'); + if (!path) throw new Error('signin required'); } diff --git a/packages/client/src/components/ui/tooltip.vue b/packages/client/src/scripts/popup-position.ts index 152c939a1a..e84eebf103 100644 --- a/packages/client/src/components/ui/tooltip.vue +++ b/packages/client/src/scripts/popup-position.ts @@ -1,66 +1,36 @@ -<template> -<transition :name="$store.state.animation ? 'tooltip' : ''" appear @after-leave="emit('closed')"> - <div v-show="showing" ref="el" class="buebdbiu _acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }"> - <slot> - <Mfm v-if="asMfm" :text="text"/> - <span v-else>{{ text }}</span> - </slot> - </div> -</transition> -</template> +import { Ref } from 'vue'; -<script lang="ts" setup> -import { nextTick, onMounted, onUnmounted, ref } from 'vue'; -import * as os from '@/os'; - -const props = withDefaults(defineProps<{ - showing: boolean; - targetElement?: HTMLElement; +export function calcPopupPosition(el: HTMLElement, props: { + anchorElement: HTMLElement | null; + innerMargin: number; + direction: 'top' | 'bottom' | 'left' | 'right'; + align: 'top' | 'bottom' | 'left' | 'right' | 'center'; + alignOffset?: number; x?: number; y?: number; - text?: string; - asMfm?: boolean; - maxWidth?: number; - direction?: 'top' | 'bottom' | 'right' | 'left'; - innerMargin?: number; -}>(), { - maxWidth: 250, - direction: 'top', - innerMargin: 0, -}); - -const emit = defineEmits<{ - (ev: 'closed'): void; -}>(); - -const el = ref<HTMLElement>(); -const zIndex = os.claimZIndex('high'); - -const setPosition = () => { - if (el.value == null) return; - - const contentWidth = el.value.offsetWidth; - const contentHeight = el.value.offsetHeight; +}): { top: number; left: number; transformOrigin: string; } { + const contentWidth = el.offsetWidth; + const contentHeight = el.offsetHeight; let rect: DOMRect; - if (props.targetElement) { - rect = props.targetElement.getBoundingClientRect(); + if (props.anchorElement) { + rect = props.anchorElement.getBoundingClientRect(); } const calcPosWhenTop = () => { let left: number; let top: number; - if (props.targetElement) { - left = rect.left + window.pageXOffset + (props.targetElement.offsetWidth / 2); + if (props.anchorElement) { + left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin; } else { left = props.x; top = (props.y - contentHeight) - props.innerMargin; } - left -= (el.value.offsetWidth / 2); + left -= (el.offsetWidth / 2); if (left + contentWidth - window.pageXOffset > window.innerWidth) { left = window.innerWidth - contentWidth + window.pageXOffset - 1; @@ -73,15 +43,15 @@ const setPosition = () => { let left: number; let top: number; - if (props.targetElement) { - left = rect.left + window.pageXOffset + (props.targetElement.offsetWidth / 2); - top = (rect.top + window.pageYOffset + props.targetElement.offsetHeight) + props.innerMargin; + if (props.anchorElement) { + left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); + top = (rect.top + window.pageYOffset + props.anchorElement.offsetHeight) + props.innerMargin; } else { left = props.x; top = (props.y) + props.innerMargin; } - left -= (el.value.offsetWidth / 2); + left -= (el.offsetWidth / 2); if (left + contentWidth - window.pageXOffset > window.innerWidth) { left = window.innerWidth - contentWidth + window.pageXOffset - 1; @@ -94,15 +64,15 @@ const setPosition = () => { let left: number; let top: number; - if (props.targetElement) { + if (props.anchorElement) { left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin; - top = rect.top + window.pageYOffset + (props.targetElement.offsetHeight / 2); + top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); } else { left = (props.x - contentWidth) - props.innerMargin; top = props.y; } - top -= (el.value.offsetHeight / 2); + top -= (el.offsetHeight / 2); if (top + contentHeight - window.pageYOffset > window.innerHeight) { top = window.innerHeight - contentHeight + window.pageYOffset - 1; @@ -115,16 +85,24 @@ const setPosition = () => { let left: number; let top: number; - if (props.targetElement) { - left = (rect.left + window.pageXOffset) + props.innerMargin; - top = rect.top + window.pageYOffset + (props.targetElement.offsetHeight / 2); + if (props.anchorElement) { + left = (rect.left + props.anchorElement.offsetWidth + window.pageXOffset) + props.innerMargin; + + if (props.align === 'top') { + top = rect.top + window.pageYOffset; + if (props.alignOffset != null) top += props.alignOffset; + } else if (props.align === 'bottom') { + // TODO + } else { // center + top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); + top -= (el.offsetHeight / 2); + } } else { left = props.x + props.innerMargin; top = props.y; + top -= (el.offsetHeight / 2); } - top -= (el.value.offsetHeight / 2); - if (top + contentHeight - window.pageYOffset > window.innerHeight) { top = window.innerHeight - contentHeight + window.pageYOffset - 1; } @@ -176,56 +154,5 @@ const setPosition = () => { } }; - const { left, top, transformOrigin } = calc(); - el.value.style.transformOrigin = transformOrigin; - el.value.style.left = left + 'px'; - el.value.style.top = top + 'px'; -}; - -let loopHandler; - -onMounted(() => { - nextTick(() => { - setPosition(); - - const loop = () => { - loopHandler = window.requestAnimationFrame(() => { - setPosition(); - loop(); - }); - }; - - loop(); - }); -}); - -onUnmounted(() => { - window.cancelAnimationFrame(loopHandler); -}); -</script> - -<style lang="scss" scoped> -.tooltip-enter-active, -.tooltip-leave-active { - opacity: 1; - transform: scale(1); - transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), opacity 200ms cubic-bezier(0.23, 1, 0.32, 1); -} -.tooltip-enter-from, -.tooltip-leave-active { - opacity: 0; - transform: scale(0.75); -} - -.buebdbiu { - position: absolute; - font-size: 0.8em; - padding: 8px 12px; - box-sizing: border-box; - text-align: center; - border-radius: 4px; - border: solid 0.5px var(--divider); - pointer-events: none; - transform-origin: center center; + return calc(); } -</style> diff --git a/packages/client/src/scripts/reaction-picker.ts b/packages/client/src/scripts/reaction-picker.ts index b7699cae4a..a6d0940a40 100644 --- a/packages/client/src/scripts/reaction-picker.ts +++ b/packages/client/src/scripts/reaction-picker.ts @@ -12,7 +12,7 @@ class ReactionPicker { } public async init() { - await popup(defineAsyncComponent(() => import('@/components/emoji-picker-dialog.vue')), { + await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { src: this.src, asReactionPicker: true, manualShowing: this.manualShowing diff --git a/packages/client/src/scripts/safe-uri-decode.ts b/packages/client/src/scripts/safe-uri-decode.ts new file mode 100644 index 0000000000..301b56d7fd --- /dev/null +++ b/packages/client/src/scripts/safe-uri-decode.ts @@ -0,0 +1,7 @@ +export function safeURIDecode(str: string): string { + try { + return decodeURIComponent(str); + } catch { + return str; + } +} diff --git a/packages/client/src/scripts/scroll.ts b/packages/client/src/scripts/scroll.ts index 0643bad2fb..f5bc6bf9ce 100644 --- a/packages/client/src/scripts/scroll.ts +++ b/packages/client/src/scripts/scroll.ts @@ -2,12 +2,8 @@ type ScrollBehavior = 'auto' | 'smooth' | 'instant'; export function getScrollContainer(el: HTMLElement | null): HTMLElement | null { if (el == null || el.tagName === 'HTML') return null; - const overflow = window.getComputedStyle(el).getPropertyValue('overflow'); - if ( - // xとyを個別に指定している場合、`hidden scroll`みたいな値になる - overflow.endsWith('scroll') || - overflow.endsWith('auto') - ) { + const overflow = window.getComputedStyle(el).getPropertyValue('overflow-y'); + if (overflow === 'scroll' || overflow === 'auto') { return el; } else { return getScrollContainer(el.parentElement); diff --git a/packages/client/src/scripts/select-file.ts b/packages/client/src/scripts/select-file.ts index 461d613b42..17e31d96f1 100644 --- a/packages/client/src/scripts/select-file.ts +++ b/packages/client/src/scripts/select-file.ts @@ -1,9 +1,9 @@ import { ref } from 'vue'; +import { DriveFile } from 'misskey-js/built/entities'; import * as os from '@/os'; import { stream } from '@/stream'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; -import { DriveFile } from 'misskey-js/built/entities'; import { uploadFile } from '@/scripts/upload'; function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> { @@ -20,10 +20,7 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv Promise.all(promises).then(driveFiles => { res(multiple ? driveFiles : driveFiles[0]); }).catch(err => { - os.alert({ - type: 'error', - text: err - }); + // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない }); // 一応廃棄 @@ -47,7 +44,7 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv os.inputText({ title: i18n.ts.uploadFromUrl, type: 'url', - placeholder: i18n.ts.uploadFromUrlDescription + placeholder: i18n.ts.uploadFromUrlDescription, }).then(({ canceled, result: url }) => { if (canceled) return; @@ -64,35 +61,35 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv os.api('drive/files/upload-from-url', { url: url, folderId: defaultStore.state.uploadFolder, - marker + marker, }); os.alert({ title: i18n.ts.uploadFromUrlRequested, - text: i18n.ts.uploadFromUrlMayTakeTime + text: i18n.ts.uploadFromUrlMayTakeTime, }); }); }; os.popupMenu([label ? { text: label, - type: 'label' + type: 'label', } : undefined, { type: 'switch', text: i18n.ts.keepOriginalUploading, - ref: keepOriginal + ref: keepOriginal, }, { text: i18n.ts.upload, icon: 'fas fa-upload', - action: chooseFileFromPc + action: chooseFileFromPc, }, { text: i18n.ts.fromDrive, icon: 'fas fa-cloud', - action: chooseFileFromDrive + action: chooseFileFromDrive, }, { text: i18n.ts.fromUrl, icon: 'fas fa-link', - action: chooseFileFromUrl + action: chooseFileFromUrl, }], src); }); } 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/scripts/theme.ts b/packages/client/src/scripts/theme.ts index dec9fb355c..62a2b9459a 100644 --- a/packages/client/src/scripts/theme.ts +++ b/packages/client/src/scripts/theme.ts @@ -1,6 +1,6 @@ import { ref } from 'vue'; -import { globalEvents } from '@/events'; import tinycolor from 'tinycolor2'; +import { globalEvents } from '@/events'; export type Theme = { id: string; @@ -13,6 +13,7 @@ export type Theme = { import lightTheme from '@/themes/_light.json5'; import darkTheme from '@/themes/_dark.json5'; +import { deepClone } from './clone'; export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X')); @@ -25,17 +26,19 @@ export const getBuiltinThemes = () => Promise.all( 'l-vivid', 'l-cherry', 'l-sushi', + 'l-u0', 'd-dark', 'd-persimmon', 'd-astro', 'd-future', 'd-botanical', + 'd-green-lime', + 'd-green-orange', 'd-cherry', 'd-ice', - 'd-pumpkin', - 'd-black', - ].map(name => import(`../themes/${name}.json5`).then(({ default: _default }): Theme => _default)) + 'd-u0', + ].map(name => import(`../themes/${name}.json5`).then(({ default: _default }): Theme => _default)), ); export const getBuiltinThemesRef = () => { @@ -55,8 +58,10 @@ export function applyTheme(theme: Theme, persist = true) { document.documentElement.classList.remove('_themeChanging_'); }, 1000); + const colorSchema = theme.base === 'dark' ? 'dark' : 'light'; + // Deep copy - const _theme = JSON.parse(JSON.stringify(theme)); + const _theme = deepClone(theme); if (_theme.base) { const base = [lightTheme, darkTheme].find(x => x.id === _theme.base); @@ -76,8 +81,11 @@ export function applyTheme(theme: Theme, persist = true) { document.documentElement.style.setProperty(`--${k}`, v.toString()); } + document.documentElement.style.setProperty('color-schema', colorSchema); + if (persist) { localStorage.setItem('theme', JSON.stringify(props)); + localStorage.setItem('colorSchema', colorSchema); } // 色計算など再度行えるようにクライアント全体に通知 diff --git a/packages/client/src/scripts/timezones.ts b/packages/client/src/scripts/timezones.ts new file mode 100644 index 0000000000..8ce07323f6 --- /dev/null +++ b/packages/client/src/scripts/timezones.ts @@ -0,0 +1,49 @@ +export const timezones = [{ + name: 'UTC', + abbrev: 'UTC', + offset: 0, +}, { + name: 'Europe/Berlin', + abbrev: 'CET', + offset: 60, +}, { + name: 'Asia/Tokyo', + abbrev: 'JST', + offset: 540, +}, { + name: 'Asia/Seoul', + abbrev: 'KST', + offset: 540, +}, { + name: 'Asia/Shanghai', + abbrev: 'CST', + offset: 480, +}, { + name: 'Australia/Sydney', + abbrev: 'AEST', + offset: 600, +}, { + name: 'Australia/Darwin', + abbrev: 'ACST', + offset: 570, +}, { + name: 'Australia/Perth', + abbrev: 'AWST', + offset: 480, +}, { + name: 'America/New_York', + abbrev: 'EST', + offset: -300, +}, { + name: 'America/Mexico_City', + abbrev: 'CST', + offset: -360, +}, { + name: 'America/Phoenix', + abbrev: 'MST', + offset: -420, +}, { + name: 'America/Los_Angeles', + abbrev: 'PST', + offset: -480, +}]; diff --git a/packages/client/src/scripts/upload.ts b/packages/client/src/scripts/upload.ts index 2f7b30b58d..51f1c1b86f 100644 --- a/packages/client/src/scripts/upload.ts +++ b/packages/client/src/scripts/upload.ts @@ -1,10 +1,11 @@ import { reactive, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { readAndCompressImage } from 'browser-image-resizer'; import { defaultStore } from '@/store'; import { apiUrl } from '@/config'; -import * as Misskey from 'misskey-js'; import { $i } from '@/account'; -import { readAndCompressImage } from 'browser-image-resizer'; import { alert } from '@/os'; +import { i18n } from '@/i18n'; type Uploading = { id: string; @@ -31,7 +32,7 @@ export function uploadFile( file: File, folder?: any, name?: string, - keepOriginal: boolean = defaultStore.state.keepOriginalUploading + keepOriginal: boolean = defaultStore.state.keepOriginalUploading, ): Promise<Misskey.entities.DriveFile> { if (folder && typeof folder === 'object') folder = folder.id; @@ -45,7 +46,7 @@ export function uploadFile( name: name || file.name || 'untitled', progressMax: undefined, progressValue: undefined, - img: window.URL.createObjectURL(file) + img: window.URL.createObjectURL(file), }); uploads.value.push(ctx); @@ -80,14 +81,37 @@ export function uploadFile( xhr.open('POST', apiUrl + '/drive/files/create', true); xhr.onload = (ev) => { if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { - // TODO: 消すのではなくて再送できるようにしたい + // TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい uploads.value = uploads.value.filter(x => x.id !== id); - alert({ - type: 'error', - title: 'Failed to upload', - text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}` - }); + if (ev.target?.response) { + const res = JSON.parse(ev.target.response); + if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseInappropriate, + }); + } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseNoFreeSpace, + }); + } else { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, + }); + } + } else { + alert({ + type: 'error', + title: 'Failed to upload', + text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, + }); + } reject(); return; diff --git a/packages/client/src/scripts/url.ts b/packages/client/src/scripts/url.ts index 542b00e0f0..86735de9f0 100644 --- a/packages/client/src/scripts/url.ts +++ b/packages/client/src/scripts/url.ts @@ -1,4 +1,4 @@ -export function query(obj: {}): string { +export function query(obj: Record<string, any>): string { const params = Object.entries(obj) .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) .reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>); diff --git a/packages/client/src/scripts/use-chart-tooltip.ts b/packages/client/src/scripts/use-chart-tooltip.ts new file mode 100644 index 0000000000..91c27585f3 --- /dev/null +++ b/packages/client/src/scripts/use-chart-tooltip.ts @@ -0,0 +1,50 @@ +import { onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import MkChartTooltip from '@/components/MkChartTooltip.vue'; + +export function useChartTooltip() { + const tooltipShowing = ref(false); + const tooltipX = ref(0); + const tooltipY = ref(0); + const tooltipTitle = ref(null); + const tooltipSeries = ref(null); + let disposeTooltipComponent; + + os.popup(MkChartTooltip, { + showing: tooltipShowing, + x: tooltipX, + y: tooltipY, + title: tooltipTitle, + series: tooltipSeries, + }, {}).then(({ dispose }) => { + disposeTooltipComponent = dispose; + }); + + onUnmounted(() => { + if (disposeTooltipComponent) disposeTooltipComponent(); + }); + + function handler(context) { + if (context.tooltip.opacity === 0) { + tooltipShowing.value = false; + return; + } + + tooltipTitle.value = context.tooltip.title[0]; + tooltipSeries.value = context.tooltip.body.map((b, i) => ({ + backgroundColor: context.tooltip.labelColors[i].backgroundColor, + borderColor: context.tooltip.labelColors[i].borderColor, + text: b.lines[0], + })); + + const rect = context.chart.canvas.getBoundingClientRect(); + + tooltipShowing.value = true; + tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX; + tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY; + } + + return { + handler, + }; +} diff --git a/packages/client/src/scripts/use-interval.ts b/packages/client/src/scripts/use-interval.ts new file mode 100644 index 0000000000..201ba417ef --- /dev/null +++ b/packages/client/src/scripts/use-interval.ts @@ -0,0 +1,24 @@ +import { onMounted, onUnmounted } from 'vue'; + +export function useInterval(fn: () => void, interval: number, options: { + immediate: boolean; + afterMounted: boolean; +}): void { + if (Number.isNaN(interval)) return; + + let intervalId: number | null = null; + + if (options.afterMounted) { + onMounted(() => { + if (options.immediate) fn(); + intervalId = window.setInterval(fn, interval); + }); + } else { + if (options.immediate) fn(); + intervalId = window.setInterval(fn, interval); + } + + onUnmounted(() => { + if (intervalId) window.clearInterval(intervalId); + }); +} diff --git a/packages/client/src/scripts/use-leave-guard.ts b/packages/client/src/scripts/use-leave-guard.ts index 379a7e577c..a93b84d1fe 100644 --- a/packages/client/src/scripts/use-leave-guard.ts +++ b/packages/client/src/scripts/use-leave-guard.ts @@ -3,6 +3,7 @@ import { i18n } from '@/i18n'; import * as os from '@/os'; export function useLeaveGuard(enabled: Ref<boolean>) { + /* TODO const setLeaveGuard = inject('setLeaveGuard'); if (setLeaveGuard) { @@ -28,6 +29,7 @@ export function useLeaveGuard(enabled: Ref<boolean>) { return !canceled; }); } + */ /* function onBeforeLeave(ev: BeforeUnloadEvent) { diff --git a/packages/client/src/scripts/use-tooltip.ts b/packages/client/src/scripts/use-tooltip.ts index bc8f27a038..1f6e0fb6ce 100644 --- a/packages/client/src/scripts/use-tooltip.ts +++ b/packages/client/src/scripts/use-tooltip.ts @@ -3,6 +3,7 @@ import { Ref, ref, watch, onUnmounted } from 'vue'; export function useTooltip( elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>, onShow: (showing: Ref<boolean>) => void, + delay = 300, ): void { let isHovering = false; @@ -40,7 +41,7 @@ export function useTooltip( if (isHovering) return; if (shouldIgnoreMouseover) return; isHovering = true; - timeoutId = window.setTimeout(open, 300); + timeoutId = window.setTimeout(open, delay); }; const onMouseleave = () => { @@ -54,7 +55,7 @@ export function useTooltip( shouldIgnoreMouseover = true; if (isHovering) return; isHovering = true; - timeoutId = window.setTimeout(open, 300); + timeoutId = window.setTimeout(open, delay); }; const onTouchend = () => { diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts index 74d72ddb18..b83904b6a9 100644 --- a/packages/client/src/store.ts +++ b/packages/client/src/store.ts @@ -13,55 +13,55 @@ export const notePostInterruptors = []; export const defaultStore = markRaw(new Storage('base', { tutorial: { where: 'account', - default: 0 + default: 0, }, keepCw: { where: 'account', - default: true + default: true, }, showFullAcct: { where: 'account', - default: false + default: false, }, rememberNoteVisibility: { where: 'account', - default: false + default: false, }, defaultNoteVisibility: { where: 'account', - default: 'public' + default: 'public', }, defaultNoteLocalOnly: { where: 'account', - default: false + default: false, }, uploadFolder: { where: 'account', - default: null as string | null + default: null as string | null, }, pastedFileName: { where: 'account', - default: 'yyyy-MM-dd HH-mm-ss [{{number}}]' + default: 'yyyy-MM-dd HH-mm-ss [{{number}}]', }, keepOriginalUploading: { where: 'account', - default: false + default: false, }, memo: { where: 'account', - default: null + default: null, }, reactions: { where: 'account', - default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'] + default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], }, mutedWords: { where: 'account', - default: [] + default: [], }, mutedAds: { where: 'account', - default: [] as string[] + default: [] as string[], }, menu: { @@ -72,21 +72,31 @@ export const defaultStore = markRaw(new Storage('base', { 'drive', 'followRequests', '-', - 'featured', 'explore', 'announcements', 'search', '-', 'ui', - ] + ], }, visibility: { where: 'deviceAccount', - default: 'public' as 'public' | 'home' | 'followers' | 'specified' + default: 'public' as 'public' | 'home' | 'followers' | 'specified', }, localOnly: { where: 'deviceAccount', - default: false + default: false, + }, + statusbars: { + where: 'deviceAccount', + default: [] as { + name: string; + id: string; + type: string; + size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge'; + black: boolean; + props: Record<string, any>; + }[], }, widgets: { where: 'deviceAccount', @@ -95,14 +105,14 @@ export const defaultStore = markRaw(new Storage('base', { id: string; place: string | null; data: Record<string, any>; - }[] + }[], }, tl: { where: 'deviceAccount', default: { src: 'home' as 'home' | 'local' | 'social' | 'global', - arg: null - } + arg: null, + }, }, overridedDeviceKind: { @@ -111,87 +121,87 @@ export const defaultStore = markRaw(new Storage('base', { }, serverDisconnectedBehavior: { where: 'device', - default: 'quiet' as 'quiet' | 'reload' | 'dialog' + default: 'quiet' as 'quiet' | 'reload' | 'dialog', }, nsfw: { where: 'device', - default: 'respect' as 'respect' | 'force' | 'ignore' + default: 'respect' as 'respect' | 'force' | 'ignore', }, animation: { where: 'device', - default: true + default: true, }, animatedMfm: { where: 'device', - default: true + default: true, }, loadRawImages: { where: 'device', - default: false + default: false, }, imageNewTab: { where: 'device', - default: false + default: false, }, disableShowingAnimatedImages: { where: 'device', - default: false + default: false, }, disablePagesScript: { where: 'device', - default: false + default: false, }, useOsNativeEmojis: { where: 'device', - default: false + default: false, }, disableDrawer: { where: 'device', - default: false + default: false, }, useBlurEffectForModal: { where: 'device', - default: true + default: true, }, useBlurEffect: { where: 'device', - default: true + default: true, }, showFixedPostForm: { where: 'device', - default: false + default: false, }, enableInfiniteScroll: { where: 'device', - default: true + default: true, }, useReactionPickerForContextMenu: { where: 'device', - default: false + default: false, }, showGapBetweenNotesInTimeline: { where: 'device', - default: false + default: false, }, darkMode: { where: 'device', - default: false + default: false, }, instanceTicker: { where: 'device', - default: 'remote' as 'none' | 'remote' | 'always' + default: 'remote' as 'none' | 'remote' | 'always', }, reactionPickerSize: { where: 'device', - default: 1 + default: 1, }, reactionPickerWidth: { where: 'device', - default: 1 + default: 1, }, reactionPickerHeight: { where: 'device', - default: 2 + default: 2, }, reactionPickerUseDrawerForMobile: { where: 'device', @@ -199,43 +209,47 @@ export const defaultStore = markRaw(new Storage('base', { }, recentlyUsedEmojis: { where: 'device', - default: [] as string[] + default: [] as string[], }, recentlyUsedUsers: { where: 'device', - default: [] as string[] + default: [] as string[], }, defaultSideView: { where: 'device', - default: false + default: false, }, menuDisplay: { where: 'device', - default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top' + default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top', }, reportError: { where: 'device', - default: false + default: false, }, squareAvatars: { where: 'device', - default: false + default: false, }, postFormWithHashtags: { where: 'device', - default: false + default: false, }, postFormHashtags: { where: 'device', - default: '' + default: '', }, themeInitial: { where: 'device', default: true, }, + numberOfPageCache: { + where: 'device', + default: 5, + }, aiChanMode: { where: 'device', - default: false + default: false, }, })); @@ -256,7 +270,7 @@ type Plugin = { * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ) */ import lightTheme from '@/themes/l-light.json5'; -import darkTheme from '@/themes/d-dark.json5'; +import darkTheme from '@/themes/d-green-lime.json5'; export class ColdDeviceStorage { public static default = { @@ -300,6 +314,14 @@ export class ColdDeviceStorage { } public static set<T extends keyof typeof ColdDeviceStorage.default>(key: T, value: typeof ColdDeviceStorage.default[T]): void { + // 呼び出し側のバグ等で undefined が来ることがある + // undefined を文字列として localStorage に入れると参照する際の JSON.parse でコケて不具合の元になるため無視 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (value === undefined) { + console.error(`attempt to store undefined value for key '${key}'`); + return; + } + localStorage.setItem(PREFIX + key, JSON.stringify(value)); for (const watcher of this.watchers) { @@ -336,7 +358,7 @@ export class ColdDeviceStorage { set: (value: unknown) => { const val = value; ColdDeviceStorage.set(key, val); - } + }, }; } } diff --git a/packages/client/src/style.scss b/packages/client/src/style.scss index c1d47ffd08..9b562d3228 100644 --- a/packages/client/src/style.scss +++ b/packages/client/src/style.scss @@ -29,8 +29,8 @@ html { accent-color: var(--accent); overflow: auto; overflow-wrap: break-word; - font-family: "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; - font-size: 15px; + font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; + font-size: 14px; line-height: 1.35; text-size-adjust: 100%; tab-size: 2; @@ -39,14 +39,6 @@ html { scrollbar-color: var(--scrollbarHandle) inherit; scrollbar-width: thin; - &:hover { - scrollbar-color: var(--scrollbarHandleHover) inherit; - } - - &:active { - scrollbar-color: var(--accent) inherit; - } - &::-webkit-scrollbar { width: 6px; height: 6px; @@ -69,20 +61,20 @@ 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 { - font-family: sans-serif; + font-family: 'Hiragino Maru Gothic Pro', sans-serif; } } @@ -338,12 +330,6 @@ hr { } } -._window { - background: var(--panel); - border-radius: var(--radius); - contain: content; -} - ._popup { background: var(--popup); border-radius: var(--radius); @@ -413,6 +399,16 @@ hr { } } +._beta { + margin-left: 0.7em; + font-size: 65%; + padding: 2px 3px; + color: var(--accent); + border: solid 1px var(--accent); + border-radius: 4px; + vertical-align: top; +} + ._table { > ._row { display: flex; diff --git a/packages/client/src/themes/_dark.json5 b/packages/client/src/themes/_dark.json5 index 1d87788794..88ec8a5459 100644 --- a/packages/client/src/themes/_dark.json5 +++ b/packages/client/src/themes/_dark.json5 @@ -30,6 +30,7 @@ panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: '" solid 1px var(--divider)', acrylicPanel: ':alpha<0.5<@panel', + windowHeader: ':alpha<0.85<@panel', popup: ':lighten<3<@panel', shadow: 'rgba(0, 0, 0, 0.3)', header: ':alpha<0.7<@panel', @@ -59,6 +60,10 @@ buttonHoverBg: 'rgba(255, 255, 255, 0.1)', buttonGradateA: '@accent', buttonGradateB: ':hue<20<@accent', + swutchOffBg: 'rgba(255, 255, 255, 0.1)', + swutchOffFg: '@fg', + swutchOnBg: '@accentedBg', + swutchOnFg: '@accent', inputBorder: 'rgba(255, 255, 255, 0.1)', inputBorderHover: 'rgba(255, 255, 255, 0.2)', listItemHoverBg: 'rgba(255, 255, 255, 0.03)', @@ -72,6 +77,7 @@ codeString: '#ffb675', codeNumber: '#cfff9e', codeBoolean: '#c59eff', + deckDivider: '#000', htmlThemeColor: '@bg', X2: ':darken<2<@panel', X3: 'rgba(255, 255, 255, 0.05)', diff --git a/packages/client/src/themes/_light.json5 b/packages/client/src/themes/_light.json5 index 359b560688..bad1291c83 100644 --- a/packages/client/src/themes/_light.json5 +++ b/packages/client/src/themes/_light.json5 @@ -30,6 +30,7 @@ panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: '" solid 1px var(--divider)', acrylicPanel: ':alpha<0.5<@panel', + windowHeader: ':alpha<0.85<@panel', popup: ':lighten<3<@panel', shadow: 'rgba(0, 0, 0, 0.1)', header: ':alpha<0.7<@panel', @@ -59,6 +60,10 @@ buttonHoverBg: 'rgba(0, 0, 0, 0.1)', buttonGradateA: '@accent', buttonGradateB: ':hue<20<@accent', + swutchOffBg: 'rgba(0, 0, 0, 0.1)', + swutchOffFg: '@panel', + swutchOnBg: '@accent', + swutchOnFg: '@fgOnAccent', inputBorder: 'rgba(0, 0, 0, 0.1)', inputBorderHover: 'rgba(0, 0, 0, 0.2)', listItemHoverBg: 'rgba(0, 0, 0, 0.03)', @@ -72,6 +77,7 @@ codeString: '#b98710', codeNumber: '#0fbbbb', codeBoolean: '#62b70c', + deckDivider: ':darken<3<@bg', htmlThemeColor: '@bg', X2: ':darken<2<@panel', X3: 'rgba(0, 0, 0, 0.05)', diff --git a/packages/client/src/themes/d-black.json5 b/packages/client/src/themes/d-black.json5 deleted file mode 100644 index 3c18ebdaf1..0000000000 --- a/packages/client/src/themes/d-black.json5 +++ /dev/null @@ -1,17 +0,0 @@ -{ - id: '8c539dc1-0fab-4d47-9194-39c508e9bfe1', - - name: 'Mi Black', - author: 'syuilo', - - base: 'dark', - - props: { - divider: '#2d2d2d', - panel: '#131313', - panelHeaderBg: '@panel', - panelHeaderDivider: '@divider', - shadow: 'rgba(255, 255, 255, 0.05)', - modalBg: 'rgba(255, 255, 255, 0.1)', - }, -} diff --git a/packages/client/src/themes/d-green-lime.json5 b/packages/client/src/themes/d-green-lime.json5 new file mode 100644 index 0000000000..a6983b9ac2 --- /dev/null +++ b/packages/client/src/themes/d-green-lime.json5 @@ -0,0 +1,24 @@ +{ + id: '02816013-8107-440f-877e-865083ffe194', + + name: 'Mi Green+Lime Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: '#b4e900', + bg: '#0C1210', + fg: '#dee7e4', + fgHighlighted: '#fff', + fgOnAccent: '#192320', + divider: '#e7fffb24', + panel: '#192320', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + popup: '#293330', + renote: '@accent', + mentionMe: '#ffaa00', + link: '#24d7ce', + }, +} diff --git a/packages/client/src/themes/d-green-orange.json5 b/packages/client/src/themes/d-green-orange.json5 new file mode 100644 index 0000000000..62adc39e29 --- /dev/null +++ b/packages/client/src/themes/d-green-orange.json5 @@ -0,0 +1,24 @@ +{ + id: 'dc489603-27b5-424a-9b25-1ff6aec9824a', + + name: 'Mi Green+Orange Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: '#e97f00', + bg: '#0C1210', + fg: '#dee7e4', + fgHighlighted: '#fff', + fgOnAccent: '#192320', + divider: '#e7fffb24', + panel: '#192320', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + popup: '#293330', + renote: '@accent', + mentionMe: '#b4e900', + link: '#24d7ce', + }, +} diff --git a/packages/client/src/themes/d-pumpkin.json5 b/packages/client/src/themes/d-u0.json5 index 064ca4577b..b270f809ac 100644 --- a/packages/client/src/themes/d-pumpkin.json5 +++ b/packages/client/src/themes/d-u0.json5 @@ -1,11 +1,7 @@ { - id: '0b64fef3-02c7-20b5-dd87-b3f77e2b4301', - - name: 'Mi Pumpkin Dark', - author: 'syuilo', - + id: '7a5bc13b-df8f-4d44-8e94-4452f0c634bb', base: 'dark', - + name: 'Mi U0 Dark', props: { X2: ':darken<2<@panel', X3: 'rgba(255, 255, 255, 0.05)', @@ -15,8 +11,8 @@ X7: 'rgba(255, 255, 255, 0.05)', X8: ':lighten<5<@accent', X9: ':darken<5<@accent', - bg: 'rgb(37, 32, 47)', - fg: '#e0d5c0', + bg: '#172426', + fg: '#dadada', X10: ':alpha<0.4<@accent', X11: 'rgba(0, 0, 0, 0.3)', X12: 'rgba(255, 255, 255, 0.1)', @@ -27,7 +23,7 @@ X17: ':alpha<0.8<@bg', cwBg: '#687390', cwFg: '#393f4f', - link: 'rgb(172, 193, 68)', + link: '@accent', warn: '#ecb637', badge: '#31b1ce', error: '#ec4137', @@ -36,15 +32,15 @@ navFg: '@fg', panel: ':lighten<3<@bg', popup: ':lighten<3<@panel', - accent: 'rgb(242, 133, 36)', + accent: '#00a497', header: ':alpha<0.7<@panel', infoBg: '#253142', infoFg: '#fff', - renote: 'rgb(110, 179, 72)', + renote: '@accent', shadow: 'rgba(0, 0, 0, 0.3)', divider: 'rgba(255, 255, 255, 0.1)', - hashtag: 'rgb(188, 90, 255)', - mention: 'rgb(72, 179, 139)', + hashtag: '#e6b422', + mention: '@accent', modalBg: 'rgba(0, 0, 0, 0.5)', success: '#86b300', buttonBg: 'rgba(255, 255, 255, 0.05)', @@ -52,14 +48,17 @@ acrylicBg: ':alpha<0.5<@bg', cwHoverBg: '#707b97', indicator: '@accent', - mentionMe: '@accent', + mentionMe: '@mention', messageBg: '@bg', navActive: '@accent', accentedBg: ':alpha<0.15<@accent', - fgOnAccent: '#000', + codeNumber: '#cfff9e', + codeString: '#ffb675', + fgOnAccent: '#fff', infoWarnBg: '#42321c', infoWarnFg: '#ffbd3e', navHoverFg: ':lighten<17<@fg', + codeBoolean: '#c59eff', dateLabelFg: '@fg', inputBorder: 'rgba(255, 255, 255, 0.1)', panelBorder: '" solid 1px var(--divider)', @@ -84,5 +83,6 @@ fgTransparentWeak: ':alpha<0.75<@fg', panelHeaderDivider: 'rgba(0, 0, 0, 0)', scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + deckDivider: '#142022', }, } diff --git a/packages/client/src/themes/l-u0.json5 b/packages/client/src/themes/l-u0.json5 new file mode 100644 index 0000000000..03b114ba39 --- /dev/null +++ b/packages/client/src/themes/l-u0.json5 @@ -0,0 +1,87 @@ +{ + id: 'e2c940b5-6e9a-4c03-b738-261c720c426d', + base: 'light', + name: 'Mi U0 Light', + props: { + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: 'rgba(255, 255, 255, 0.05)', + X6: 'rgba(255, 255, 255, 0.15)', + X7: 'rgba(255, 255, 255, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + bg: '#e7e7eb', + fg: '#5f5f5f', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: 'rgba(255, 255, 255, 0.1)', + X13: 'rgba(255, 255, 255, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + cwBg: '#687390', + cwFg: '#393f4f', + link: '@accent', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: ':lighten<3<@bg', + popup: ':lighten<3<@panel', + accent: '#478384', + header: ':alpha<0.7<@panel', + infoBg: '#253142', + infoFg: '#fff', + renote: '@accent', + shadow: 'rgba(0, 0, 0, 0.3)', + divider: '#4646461a', + hashtag: '#1f3134', + mention: '@accent', + modalBg: 'rgba(0, 0, 0, 0.5)', + success: '#86b300', + buttonBg: '#0000000d', + switchBg: 'rgba(255, 255, 255, 0.15)', + acrylicBg: ':alpha<0.5<@bg', + cwHoverBg: '#707b97', + indicator: '@accent', + mentionMe: '@mention', + messageBg: '@bg', + navActive: '@accent', + accentedBg: ':alpha<0.15<@accent', + codeNumber: '#cfff9e', + codeString: '#ffb675', + fgOnAccent: '#fff', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + navHoverFg: ':lighten<17<@fg', + codeBoolean: '#c59eff', + dateLabelFg: '@fg', + inputBorder: 'rgba(255, 255, 255, 0.1)', + panelBorder: '" solid 1px var(--divider)', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@indicator', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: '#0000001a', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':lighten<3<@fg', + fgTransparent: ':alpha<0.5<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + buttonGradateA: '@accent', + buttonGradateB: ':hue<20<@accent', + htmlThemeColor: '@bg', + panelHighlight: ':lighten<3<@panel', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + scrollbarHandle: '#74747433', + inputBorderHover: 'rgba(255, 255, 255, 0.2)', + wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', + fgTransparentWeak: ':alpha<0.75<@fg', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + }, +} diff --git a/packages/client/src/types/menu.ts b/packages/client/src/types/menu.ts index ed67e6ab88..972f6db214 100644 --- a/packages/client/src/types/menu.ts +++ b/packages/client/src/types/menu.ts @@ -11,10 +11,11 @@ export type MenuA = { type: 'a', href: string, target?: string, download?: strin export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction }; export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean }; export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean, avatar?: Misskey.entities.User; action: MenuAction }; +export type MenuParent = { type: 'parent', text: string, icon?: string, children: OuterMenuItem[] }; export type MenuPending = { type: 'pending' }; -type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton; -type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton>; +type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent; +type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent>; export type MenuItem = OuterMenuItem | OuterPromiseMenuItem; -export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton; +export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent; diff --git a/packages/client/src/ui/_common_/common.vue b/packages/client/src/ui/_common_/common.vue index 9f7388db53..1ea59dd260 100644 --- a/packages/client/src/ui/_common_/common.vue +++ b/packages/client/src/ui/_common_/common.vue @@ -1,5 +1,6 @@ <template> -<component :is="popup.component" +<component + :is="popup.component" v-for="popup in popups" :key="popup.id" v-bind="popup.props" @@ -15,56 +16,45 @@ <div v-if="dev" id="devTicker"><span>DEV BUILD</span></div> </template> -<script lang="ts"> -import { defineAsyncComponent, defineComponent } from 'vue'; +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import { swInject } from './sw-inject'; import { popup, popups, pendingApiRequestsCount } from '@/os'; import { uploads } from '@/scripts/upload'; import * as sound from '@/scripts/sound'; import { $i } from '@/account'; -import { swInject } from './sw-inject'; import { stream } from '@/stream'; -export default defineComponent({ - components: { - XStreamIndicator: defineAsyncComponent(() => import('./stream-indicator.vue')), - XUpload: defineAsyncComponent(() => import('./upload.vue')), - }, +const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); +const XUpload = defineAsyncComponent(() => import('./upload.vue')); - setup() { - const onNotification = notification => { - if ($i.mutingNotificationTypes.includes(notification.type)) return; +const dev = _DEV_; - if (document.visibilityState === 'visible') { - stream.send('readNotification', { - id: notification.id - }); +const onNotification = notification => { + if ($i.mutingNotificationTypes.includes(notification.type)) return; - popup(defineAsyncComponent(() => import('@/components/notification-toast.vue')), { - notification - }, {}, 'closed'); - } + if (document.visibilityState === 'visible') { + stream.send('readNotification', { + id: notification.id, + }); - sound.play('notification'); - }; + popup(defineAsyncComponent(() => import('@/components/MkNotificationToast.vue')), { + notification, + }, {}, 'closed'); + } - if ($i) { - const connection = stream.useChannel('main', null, 'UI'); - connection.on('notification', onNotification); + sound.play('notification'); +}; - //#region Listen message from SW - if ('serviceWorker' in navigator) { - swInject(); - } - } +if ($i) { + const connection = stream.useChannel('main', null, 'UI'); + connection.on('notification', onNotification); - return { - uploads, - popups, - pendingApiRequestsCount, - dev: _DEV_, - }; - }, -}); + //#region Listen message from SW + if ('serviceWorker' in navigator) { + swInject(); + } +} </script> <style lang="scss"> 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..de000447ad --- /dev/null +++ b/packages/client/src/ui/_common_/navbar-for-mobile.vue @@ -0,0 +1,314 @@ +<template> +<div class="kmwsukvl"> + <div class="body"> + <div class="top"> + <div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div> + <button v-click-anime 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">{{ 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 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 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 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 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 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', + }, null, { + type: 'parent', + text: i18n.ts.help, + icon: 'fas fa-question-circle', + children: [{ + type: 'link', + to: '/mfm-cheat-sheet', + text: i18n.ts._mfm.cheatSheet, + icon: 'fas fa-code', + }, { + type: 'link', + to: '/scratchpad', + text: i18n.ts.scratchpad, + icon: 'fas fa-terminal', + }, { + type: 'link', + to: '/api-console', + text: 'API Console', + icon: 'fas fa-terminal', + }, null, { + text: i18n.ts.document, + icon: 'fas fa-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.html', '_blank'); + }, + }], + }, { + type: 'link', + text: i18n.ts.aboutMisskey, + to: '/about-misskey', + }], ev.currentTarget ?? ev.target, { + align: 'left', + }); +} + +function more() { + os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.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..20622b083a --- /dev/null +++ b/packages/client/src/ui/_common_/navbar.vue @@ -0,0 +1,521 @@ +<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.noDelay.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.noDelay.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.noDelay.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.noDelay.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.noDelay.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.noDelay.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.noDelay.right="`${i18n.ts.account}: @${$i.username}`" 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', + }, null, { + type: 'parent', + text: i18n.ts.help, + icon: 'fas fa-question-circle', + children: [{ + type: 'link', + to: '/mfm-cheat-sheet', + text: i18n.ts._mfm.cheatSheet, + icon: 'fas fa-code', + }, { + type: 'link', + to: '/scratchpad', + text: i18n.ts.scratchpad, + icon: 'fas fa-terminal', + }, { + type: 'link', + to: '/api-console', + text: 'API Console', + icon: 'fas fa-terminal', + }, null, { + text: i18n.ts.document, + icon: 'fas fa-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.html', '_blank'); + }, + }], + }, { + type: 'link', + text: i18n.ts.aboutMisskey, + to: '/about-misskey', + }], ev.currentTarget ?? ev.target, { + align: 'left', + }); +} + +function more(ev: MouseEvent) { + os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { + src: ev.currentTarget ?? ev.target, + }, { + }, 'closed'); +} +</script> + +<style lang="scss" scoped> +.mvcprjjd { + $nav-width: 250px; + $nav-icon-only-width: 80px; + + 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; + height: 100dvh; + 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: 30px; + 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 41d0837233..0000000000 --- a/packages/client/src/ui/_common_/sidebar-for-mobile.vue +++ /dev/null @@ -1,209 +0,0 @@ -<template> -<div class="kmwsukvl"> - <div> - <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="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="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> - </template> - <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin"> - <i class="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="fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> - <span v-if="otherMenuItemIndicated" class="indicator"><i class="fas fa-circle"></i></span> - </button> - <MkA v-click-anime class="item" active-class="active" to="/settings"> - <i class="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="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; - - > div { - - > .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); - - > i { - position: relative; - width: 32px; - } - - > i, - > .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 d65e776d86..0000000000 --- a/packages/client/src/ui/_common_/sidebar.vue +++ /dev/null @@ -1,302 +0,0 @@ -<template> -<div class="mvcprjjd" :class="{ iconOnly }"> - <div> - <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="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="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> - </template> - <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin"> - <i class="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="fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> - <span v-if="otherMenuItemIndicated" class="indicator"><i class="fas fa-circle"></i></span> - </button> - <MkA v-click-anime class="item" active-class="active" to="/settings"> - <i class="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="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; - - > div { - position: fixed; - top: 0; - left: 0; - z-index: 1001; - width: $nav-width; - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - height: calc(var(--vh, 1vh) * 100); - box-sizing: border-box; - overflow: auto; - overflow-x: clip; - background: var(--navBg); - - > .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); - - > i { - position: relative; - width: 32px; - } - - > i, - > .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; - - > div { - 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; - - > i, - > .avatar { - display: block; - margin: 0 auto; - } - - > i { - opacity: 0.7; - } - - > .text { - display: none; - } - - &:hover, &.active { - > i, > .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; - - > i { - 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-federation.vue b/packages/client/src/ui/_common_/statusbar-federation.vue new file mode 100644 index 0000000000..7d4f0d6166 --- /dev/null +++ b/packages/client/src/ui/_common_/statusbar-federation.vue @@ -0,0 +1,103 @@ +<template> +<span v-if="!fetching" class="nmidsaqw"> + <template v-if="display === 'marquee'"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <span v-for="instance in instances" :key="instance.id" class="item" :class="{ colored }" :style="{ background: colored ? instance.themeColor : null }"> + <img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/> + <MkA :to="`/instance-info/${instance.host}`" class="host _monospace"> + {{ instance.host }} + </MkA> + <span class="divider"></span> + </span> + </MarqueeText> + </transition> + </template> + <template v-else-if="display === 'oneByOne'"> + <!-- TODO --> + </template> +</span> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MarqueeText from '@/components/MkMarquee.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { getNoteSummary } from '@/scripts/get-note-summary'; +import { notePage } from '@/filters/note'; + +const props = defineProps<{ + display?: 'marquee' | 'oneByOne'; + colored?: boolean; + marqueeDuration?: number; + marqueeReverse?: boolean; + oneByOneInterval?: number; + refreshIntervalSec?: number; +}>(); + +const instances = ref<misskey.entities.Instance[]>([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 30, + }).then(res => { + instances.value = res; + fetching.value = false; + key++; + }); +}; + +useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.nmidsaqw { + display: inline-block; + position: relative; + + ::v-deep(.item) { + display: inline-block; + vertical-align: bottom; + margin-right: 5em; + + > .icon { + display: inline-block; + height: var(--height); + aspect-ratio: 1; + vertical-align: bottom; + margin-right: 1em; + } + + > .host { + vertical-align: bottom; + } + + &.colored { + padding-right: 1em; + color: #fff; + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/statusbar-rss.vue b/packages/client/src/ui/_common_/statusbar-rss.vue new file mode 100644 index 0000000000..e75e13bb48 --- /dev/null +++ b/packages/client/src/ui/_common_/statusbar-rss.vue @@ -0,0 +1,93 @@ +<template> +<span v-if="!fetching" class="xbhtxfms"> + <template v-if="display === 'marquee'"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <span v-for="item in items" class="item"> + <a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span> + </span> + </MarqueeText> + </transition> + </template> + <template v-else-if="display === 'oneByOne'"> + <!-- TODO --> + </template> +</span> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import MarqueeText from '@/components/MkMarquee.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; + oneByOneInterval?: number; + refreshIntervalSec?: number; +}>(); + +const items = ref([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => { + res.json().then(feed => { + if (props.shuffle) { + shuffle(feed.items); + } + items.value = feed.items; + fetching.value = false; + key++; + }); + }); +}; + +useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.xbhtxfms { + display: inline-block; + position: relative; + + ::v-deep(.item) { + display: inline-flex; + align-items: center; + vertical-align: bottom; + margin: 0; + + > .divider { + display: inline-block; + width: 0.5px; + height: var(--height); + margin: 0 3em; + background: currentColor; + opacity: 0.3; + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/statusbar-user-list.vue b/packages/client/src/ui/_common_/statusbar-user-list.vue new file mode 100644 index 0000000000..f4d989c387 --- /dev/null +++ b/packages/client/src/ui/_common_/statusbar-user-list.vue @@ -0,0 +1,113 @@ +<template> +<span v-if="!fetching" class="osdsvwzy"> + <template v-if="display === 'marquee'"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <span v-for="note in notes" :key="note.id" class="item"> + <img class="avatar" :src="note.user.avatarUrl" decoding="async"/> + <MkA class="text" :to="notePage(note)"> + <Mfm class="text" :text="getNoteSummary(note)" :plain="true" :nowrap="true" :custom-emojis="note.emojis"/> + </MkA> + <span class="divider"></span> + </span> + </MarqueeText> + </transition> + </template> + <template v-else-if="display === 'oneByOne'"> + <!-- TODO --> + </template> +</span> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MarqueeText from '@/components/MkMarquee.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { getNoteSummary } from '@/scripts/get-note-summary'; +import { notePage } from '@/filters/note'; + +const props = defineProps<{ + userListId?: string; + display?: 'marquee' | 'oneByOne'; + marqueeDuration?: number; + marqueeReverse?: boolean; + oneByOneInterval?: number; + refreshIntervalSec?: number; +}>(); + +const notes = ref<misskey.entities.Note[]>([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + if (props.userListId == null) return; + os.api('notes/user-list-timeline', { + listId: props.userListId, + }).then(res => { + notes.value = res; + fetching.value = false; + key++; + }); +}; + +watch(() => props.userListId, tick); + +useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.osdsvwzy { + display: inline-block; + position: relative; + + ::v-deep(.item) { + display: inline-flex; + align-items: center; + vertical-align: bottom; + margin: 0; + + > .avatar { + display: inline-block; + height: var(--height); + aspect-ratio: 1; + vertical-align: bottom; + margin-right: 8px; + } + + > .text { + > .text { + display: inline-block; + vertical-align: bottom; + } + } + + > .divider { + display: inline-block; + width: 0.5px; + height: 16px; + margin: 0 3em; + background: currentColor; + opacity: 0; + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/statusbars.vue b/packages/client/src/ui/_common_/statusbars.vue new file mode 100644 index 0000000000..114ca5be8c --- /dev/null +++ b/packages/client/src/ui/_common_/statusbars.vue @@ -0,0 +1,92 @@ +<template> +<div class="dlrsnxqu"> + <div + v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" class="item" :class="[{ black: x.black }, { + verySmall: x.size === 'verySmall', + small: x.size === 'small', + medium: x.size === 'medium', + large: x.size === 'large', + veryLarge: x.size === 'veryLarge', + }]" + > + <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" :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> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +const XRss = defineAsyncComponent(() => import('./statusbar-rss.vue')); +const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vue')); +const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue')); +</script> + +<style lang="scss" scoped> +.dlrsnxqu { + font-size: 15px; + background: var(--panel); + + > .item { + --height: 24px; + --nameMargin: 10px; + font-size: 0.85em; + + &.verySmall { + --nameMargin: 7px; + --height: 16px; + font-size: 0.75em; + } + + &.small { + --nameMargin: 8px; + --height: 20px; + font-size: 0.8em; + } + + &.large { + --nameMargin: 12px; + --height: 26px; + font-size: 0.875em; + } + + &.veryLarge { + --nameMargin: 14px; + --height: 30px; + font-size: 0.9em; + } + + display: flex; + vertical-align: bottom; + width: 100%; + line-height: var(--height); + height: var(--height); + overflow: clip; + contain: strict; + + > .name { + padding: 0 var(--nameMargin); + font-weight: bold; + color: var(--accent); + + &:empty { + display: none; + } + } + + > .body { + min-width: 0; + flex: 1; + } + + &.black { + background: #000; + color: #fff; + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/stream-indicator.vue b/packages/client/src/ui/_common_/stream-indicator.vue index 5e811e1b88..a855de8ab9 100644 --- a/packages/client/src/ui/_common_/stream-indicator.vue +++ b/packages/client/src/ui/_common_/stream-indicator.vue @@ -1,9 +1,9 @@ <template> <div v-if="hasDisconnected && $store.state.serverDisconnectedBehavior === 'quiet'" class="nsbbhtug" @click="resetDisconnected"> - <div>{{ $ts.disconnectedFromServer }}</div> + <div>{{ i18n.ts.disconnectedFromServer }}</div> <div class="command"> - <button class="_textButton" @click="reload">{{ $ts.reload }}</button> - <button class="_textButton">{{ $ts.doNothing }}</button> + <button class="_textButton" @click="reload">{{ i18n.ts.reload }}</button> + <button class="_textButton">{{ i18n.ts.doNothing }}</button> </div> </div> </template> @@ -11,6 +11,7 @@ <script lang="ts" setup> import { onUnmounted } from 'vue'; import { stream } from '@/stream'; +import { i18n } from '@/i18n'; let hasDisconnected = $ref(false); diff --git a/packages/client/src/ui/_common_/upload.vue b/packages/client/src/ui/_common_/upload.vue index f3703d0e8f..8324e9e75e 100644 --- a/packages/client/src/ui/_common_/upload.vue +++ b/packages/client/src/ui/_common_/upload.vue @@ -6,7 +6,7 @@ <div class="top"> <p class="name"><i class="fas fa-spinner fa-pulse"></i>{{ ctx.name }}</p> <p class="status"> - <span v-if="ctx.progressValue === undefined" class="initing">{{ $ts.waiting }}<MkEllipsis/></span> + <span v-if="ctx.progressValue === undefined" class="initing">{{ i18n.ts.waiting }}<MkEllipsis/></span> <span v-if="ctx.progressValue !== undefined" class="kb">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> <span v-if="ctx.progressValue !== undefined" class="percentage">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span> </p> @@ -21,6 +21,7 @@ import { } from 'vue'; import * as os from '@/os'; import { uploads } from '@/scripts/upload'; +import { i18n } from '@/i18n'; const zIndex = os.claimZIndex('high'); </script> diff --git a/packages/client/src/ui/classic.header.vue b/packages/client/src/ui/classic.header.vue index 57008aeaed..306d32c597 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,9 +43,9 @@ 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 MkButton from '@/components/MkButton.vue'; export default defineComponent({ components: { @@ -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; }, @@ -101,7 +101,7 @@ export default defineComponent({ }, more(ev) { - os.popup(defineAsyncComponent(() => import('@/components/launch-pad.vue')), { + os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { src: ev.currentTarget ?? ev.target, anchor: { x: 'center', y: 'bottom' }, }, { @@ -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..7479c1c9c6 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,9 +45,9 @@ 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 MkButton from '@/components/MkButton.vue'; import { StickySidebar } from '@/scripts/sticky-sidebar'; //import MisskeyLogo from '@assets/client/misskey.svg'; @@ -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; }, @@ -121,7 +121,7 @@ export default defineComponent({ }, more(ev) { - os.popup(defineAsyncComponent(() => import('@/components/launch-pad.vue')), { + os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { src: ev.currentTarget ?? ev.target, }, {}, 'closed'); }, @@ -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 f5fa8f336a..cf82142fe0 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'; @@ -57,11 +56,11 @@ const XWidgets = defineAsyncComponent(() => import('./classic.widgets.vue')); const DESKTOP_THRESHOLD = 1100; -const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD); +let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD); let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); -const widgetsShowing = $ref(false); -const fullView = $ref(false); +let widgetsShowing = $ref(false); +let fullView = $ref(false); let globalHeaderHeight = $ref(0); const wallpaper = localStorage.getItem('wallpaper') != null; const showMenuOnTop = $computed(() => defaultStore.state.menuDisplay === 'top'); @@ -199,8 +198,7 @@ onMounted(() => { $ui-font-size: 1em; $widgets-hide-threshold: 1200px; - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - min-height: calc(var(--vh, 1vh) * 100); + min-height: 100dvh; box-sizing: border-box; &.wallpaper { @@ -302,8 +300,7 @@ onMounted(() => { top: 0; right: 0; z-index: 1001; - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - height: calc(var(--vh, 1vh) * 100); + height: 100dvh; padding: var(--margin); box-sizing: border-box; overflow: auto; diff --git a/packages/client/src/ui/classic.widgets.vue b/packages/client/src/ui/classic.widgets.vue index 6f9d18bde5..ca8e3f4dbc 100644 --- a/packages/client/src/ui/classic.widgets.vue +++ b/packages/client/src/ui/classic.widgets.vue @@ -10,7 +10,7 @@ <script lang="ts"> import { defineComponent, defineAsyncComponent } from 'vue'; -import XWidgets from '@/components/widgets.vue'; +import XWidgets from '@/components/MkWidgets.vue'; export default defineComponent({ components: { diff --git a/packages/client/src/ui/deck.vue b/packages/client/src/ui/deck.vue index 7433264794..224ad7ee1a 100644 --- a/packages/client/src/ui/deck.vue +++ b/packages/client/src/ui/deck.vue @@ -1,30 +1,51 @@ <template> <div - class="mk-deck" :class="[{ isMobile }, `${deckStore.reactiveState.columnAlign.value}`]" :style="{ '--deckMargin': deckStore.reactiveState.columnMargin.value + 'px' }" - @contextmenu.self.prevent="onContextmenu" + class="mk-deck" :class="[{ isMobile }]" > <XSidebar v-if="!isMobile"/> - <template v-for="ids in layout"> - <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> - <section - v-if="ids.length > 1" - class="folder column" - :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" - > - <DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/> - </section> - <DeckColumnCore - v-else - :ref="ids[0]" - :key="ids[0]" - class="column" - :column="columns.find(c => c.id === ids[0])" - :is-stacked="false" - :style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }" - @parent-focus="moveFocus(ids[0], $event)" - /> - </template> + <div class="main"> + <XStatusBars class="statusbars"/> + <div ref="columnsEl" class="columns" :class="deckStore.reactiveState.columnAlign.value" @contextmenu.self.prevent="onContextmenu"> + <template v-for="ids in layout"> + <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> + <section + v-if="ids.length > 1" + class="folder column" + :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" + > + <DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/> + </section> + <DeckColumnCore + v-else + :ref="ids[0]" + :key="ids[0]" + class="column" + :column="columns.find(c => c.id === ids[0])" + :is-stacked="false" + :style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }" + @parent-focus="moveFocus(ids[0], $event)" + /> + </template> + <div v-if="layout.length === 0" class="intro _panel"> + <div>{{ i18n.ts._deck.introduction }}</div> + <MkButton primary class="add" @click="addColumn">{{ i18n.ts._deck.addColumn }}</MkButton> + <div>{{ i18n.ts._deck.introduction2 }}</div> + </div> + <div class="sideMenu"> + <div class="top"> + <button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${deckStore.state.profile}`" class="_button button" @click="changeProfile"><i class="fas fa-caret-down"></i></button> + <button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" class="_button button" @click="deleteProfile"><i class="fas fa-trash-can"></i></button> + </div> + <div class="middle"> + <button v-tooltip.noDelay.left="i18n.ts._deck.addColumn" class="_button button" @click="addColumn"><i class="fas fa-plus"></i></button> + </div> + <div class="bottom"> + <button v-tooltip.noDelay.left="i18n.ts.settings" class="_button button settings" @click="showSettings"><i class="fas fa-cog"></i></button> + </div> + </div> + </div> + </div> <div v-if="isMobile" class="buttons"> <button class="button nav _button" @click="drawerMenuShowing = true"><i class="fas fa-bars"></i><span v-if="menuIndicated" class="indicator"><i class="fas fa-circle"></i></span></button> @@ -51,19 +72,32 @@ </template> <script lang="ts" setup> -import { computed, provide, ref, watch } from 'vue'; +import { computed, defineAsyncComponent, onMounted, provide, ref, watch } from 'vue'; import { v4 as uuid } from 'uuid'; import XCommon from './_common_/common.vue'; -import { deckStore, addColumn as addColumnToStore, loadDeck } from './deck/deck-store'; +import { deckStore, addColumn as addColumnToStore, loadDeck, getProfiles, deleteProfile as deleteProfile_ } 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/MkButton.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'; +import { unisonReload } from '@/scripts/unison-reload'; +const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); + +mainRouter.navHook = (path, flag): boolean => { + if (flag === 'forcePage') return false; + const noMainColumn = !deckStore.state.columns.some(x => x.type === 'main'); + if (deckStore.state.navWindow || noMainColumn) { + os.pageWindow(path); + return true; + } + return false; +}; const isMobile = ref(window.innerWidth <= 500); window.addEventListener('resize', () => { @@ -81,12 +115,18 @@ 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; }); +function showSettings() { + os.pageWindow('/settings/deck'); +} + +let columnsEl = $ref<HTMLElement>(); + const addColumn = async (ev) => { const columns = [ 'main', @@ -122,13 +162,13 @@ const onContextmenu = (ev) => { }], ev); }; -provide('shouldSpacerMin', true); - document.documentElement.style.overflowY = 'hidden'; document.documentElement.style.scrollBehavior = 'auto'; window.addEventListener('wheel', (ev) => { - if (getScrollContainer(ev.target as HTMLElement) == null && ev.deltaX === 0) { - document.documentElement.scrollLeft += ev.deltaY; + if (ev.target === columnsEl && ev.deltaX === 0) { + columnsEl.scrollLeft += ev.deltaY; + } else if (getScrollContainer(ev.target as HTMLElement) == null && ev.deltaX === 0) { + columnsEl.scrollLeft += ev.deltaY; } }); loadDeck(); @@ -136,6 +176,51 @@ loadDeck(); function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') { // TODO?? } + +function changeProfile(ev: MouseEvent) { + const items = ref([{ + text: deckStore.state.profile, + active: true.valueOf, + }]); + getProfiles().then(profiles => { + items.value = [{ + text: deckStore.state.profile, + active: true.valueOf, + }, ...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({ + text: k, + action: () => { + deckStore.set('profile', k); + unisonReload(); + }, + }))), null, { + text: i18n.ts._deck.newProfile, + icon: 'fas fa-plus', + action: async () => { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts._deck.profile, + allowEmpty: false, + }); + if (canceled) return; + + deckStore.set('profile', name); + unisonReload(); + }, + }]; + }); + os.popupMenu(items, ev.currentTarget ?? ev.target); +} + +async function deleteProfile() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('deleteAreYouSure', { x: deckStore.state.profile }), + }); + if (canceled) return; + + deleteProfile_(deckStore.state.profile); + deckStore.set('profile', 'default'); + unisonReload(); +} </script> <style lang="scss" scoped> @@ -167,37 +252,97 @@ function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') { // TODO: ここではなくて、各カラムで自身の幅に応じて上書きするようにしたい --margin: var(--marginHalf); + --deckDividerThickness: 5px; + display: flex; - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - height: calc(var(--vh, 1vh) * 100); + height: 100dvh; box-sizing: border-box; flex: 1; - padding: var(--deckMargin); - - &.center { - > .column:first-of-type { - margin-left: auto; - } - - > .column:last-of-type { - margin-right: auto; - } - } &.isMobile { padding-bottom: 100px; } - > .column { - flex-shrink: 0; - margin-right: var(--deckMargin); + > .main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; - &.folder { + > .columns { + flex: 1; display: flex; - flex-direction: column; + overflow-x: auto; + overflow-y: clip; - > *:not(:last-child) { - margin-bottom: var(--deckMargin); + &.center { + > .column:first-of-type { + margin-left: auto; + } + + > .column:last-of-type { + margin-right: auto; + } + } + + > .column { + flex-shrink: 0; + border-right: solid var(--deckDividerThickness) var(--deckDivider); + + &:first-of-type { + border-left: solid var(--deckDividerThickness) var(--deckDivider); + } + + &.folder { + display: flex; + flex-direction: column; + + > *:not(:last-of-type) { + border-bottom: solid var(--deckDividerThickness) var(--deckDivider); + } + } + } + + > .intro { + padding: 32px; + height: min-content; + text-align: center; + margin: auto; + + > .add { + margin: 1em auto; + } + } + + > .sideMenu { + flex-shrink: 0; + margin-right: 0; + margin-left: auto; + display: flex; + flex-direction: column; + justify-content: center; + width: 32px; + + > .top, > .middle, > .bottom { + > .button { + display: block; + width: 100%; + aspect-ratio: 1; + } + } + + > .top { + margin-bottom: auto; + } + + > .middle { + margin-top: auto; + margin-bottom: auto; + } + + > .bottom { + margin-top: auto; + } } } } @@ -278,13 +423,13 @@ function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') { top: 0; left: 0; z-index: 1001; - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - height: calc(var(--vh, 1vh) * 100); + height: 100dvh; 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/antenna-column.vue b/packages/client/src/ui/deck/antenna-column.vue index f12f5c6b25..df9539617c 100644 --- a/packages/client/src/ui/deck/antenna-column.vue +++ b/packages/client/src/ui/deck/antenna-column.vue @@ -1,5 +1,5 @@ <template> -<XColumn :func="{ handler: setAntenna, title: $ts.selectAntenna }" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> <template #header> <i class="fas fa-satellite"></i><span style="margin-left: 8px;">{{ column.name }}</span> </template> @@ -11,9 +11,9 @@ <script lang="ts" setup> import { onMounted } from 'vue'; import XColumn from './column.vue'; -import XTimeline from '@/components/timeline.vue'; -import * as os from '@/os'; import { updateColumn, Column } from './deck-store'; +import XTimeline from '@/components/MkTimeline.vue'; +import * as os from '@/os'; import { i18n } from '@/i18n'; const props = defineProps<{ @@ -39,15 +39,22 @@ async function setAntenna() { const { canceled, result: antenna } = await os.select({ title: i18n.ts.selectAntenna, items: antennas.map(x => ({ - value: x, text: x.name + value: x, text: x.name, })), - default: props.column.antennaId + default: props.column.antennaId, }); if (canceled) return; updateColumn(props.column.id, { - antennaId: antenna.id + antennaId: antenna.id, }); } + +const menu = [{ + icon: 'fas fa-pencil-alt', + text: i18n.ts.selectAntenna, + action: setAntenna, +}]; + /* function focus() { timeline.focus(); diff --git a/packages/client/src/ui/deck/column-core.vue b/packages/client/src/ui/deck/column-core.vue index 2667b6d745..30c0dc5e1c 100644 --- a/packages/client/src/ui/deck/column-core.vue +++ b/packages/client/src/ui/deck/column-core.vue @@ -31,14 +31,4 @@ defineProps<{ const emit = defineEmits<{ (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; }>(); - -/* -export default defineComponent({ - methods: { - focus() { - this.$children[0].focus(); - } - } -}); -*/ </script> diff --git a/packages/client/src/ui/deck/column.vue b/packages/client/src/ui/deck/column.vue index 6db3549fbb..7b30ffad45 100644 --- a/packages/client/src/ui/deck/column.vue +++ b/packages/client/src/ui/deck/column.vue @@ -1,13 +1,14 @@ <template> <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> -<section v-hotkey="keymap" class="dnpfarvg _panel _narrow_" +<section + v-hotkey="keymap" class="dnpfarvg _narrow_" :class="{ paged: isMainColumn, naked, active, isStacked, draghover, dragging, dropready }" - :style="{ '--deckColumnHeaderHeight': deckStore.reactiveState.columnHeaderHeight.value + 'px' }" @dragover.prevent.stop="onDragover" @dragleave="onDragleave" @drop.prevent.stop="onDrop" > - <header :class="{ indicated }" + <header + :class="{ indicated }" draggable="true" @click="goTop" @dragstart="onDragstart" @@ -22,7 +23,7 @@ <slot name="action"></slot> </div> <span class="header"><slot name="header"></slot></span> - <button v-if="func" v-tooltip="func.title" class="menu _button" @click.stop="func.handler"><i :class="func.icon || '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> @@ -30,32 +31,25 @@ </section> </template> -<script lang="ts"> -export type DeckFunc = { - title: string; - handler: (payload: MouseEvent) => void; - icon?: string; -}; -</script> <script lang="ts" setup> -import { onBeforeUnmount, onMounted, provide, watch } from 'vue'; +import { onBeforeUnmount, onMounted, provide, Ref, watch } from 'vue'; +import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn, Column , deckStore } from './deck-store'; import * as os from '@/os'; -import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn, Column } from './deck-store'; -import { deckStore } from './deck-store'; import { i18n } from '@/i18n'; +import { MenuItem } from '@/types/menu'; provide('shouldHeaderThin', true); provide('shouldOmitHeaderTitle', true); +provide('shouldSpacerMin', true); const props = withDefaults(defineProps<{ column: Column; isStacked?: boolean; - func?: DeckFunc | null; naked?: boolean; indicated?: boolean; + menu?: MenuItem[]; }>(), { isStacked: false, - func: null, naked: false, indicated: false, }); @@ -105,82 +99,97 @@ function onOtherDragEnd() { function toggleActive() { if (!props.isStacked) return; updateColumn(props.column.id, { - active: !props.column.active + active: !props.column.active, }); } function getMenu() { - const items = [{ - icon: 'fas fa-pencil-alt', - text: i18n.ts.edit, + let items = [{ + icon: 'fas fa-cog', + text: i18n.ts._deck.configureColumn, action: async () => { const { canceled, result } = await os.form(props.column.name, { name: { type: 'string', label: i18n.ts.name, - default: props.column.name + default: props.column.name, }, width: { type: 'number', label: i18n.ts.width, - default: props.column.width + default: props.column.width, }, flexible: { type: 'boolean', label: i18n.ts.flexible, - default: props.column.flexible - } + default: props.column.flexible, + }, }); if (canceled) return; updateColumn(props.column.id, result); - } - }, null, { - icon: 'fas fa-arrow-left', - text: i18n.ts._deck.swapLeft, - action: () => { - swapLeftColumn(props.column.id); - } + }, + }, { + type: 'parent', + text: i18n.ts.move + '...', + icon: 'fas fa-arrows-up-down-left-right', + children: [{ + icon: 'fas fa-arrow-left', + text: i18n.ts._deck.swapLeft, + action: () => { + swapLeftColumn(props.column.id); + }, + }, { + icon: 'fas fa-arrow-right', + text: i18n.ts._deck.swapRight, + action: () => { + swapRightColumn(props.column.id); + }, + }, props.isStacked ? { + icon: 'fas fa-arrow-up', + text: i18n.ts._deck.swapUp, + action: () => { + swapUpColumn(props.column.id); + }, + } : undefined, props.isStacked ? { + icon: 'fas fa-arrow-down', + text: i18n.ts._deck.swapDown, + action: () => { + swapDownColumn(props.column.id); + }, + } : undefined], }, { - icon: 'fas fa-arrow-right', - text: i18n.ts._deck.swapRight, - action: () => { - swapRightColumn(props.column.id); - } - }, props.isStacked ? { - icon: 'fas fa-arrow-up', - text: i18n.ts._deck.swapUp, - action: () => { - swapUpColumn(props.column.id); - } - } : undefined, props.isStacked ? { - icon: 'fas fa-arrow-down', - text: i18n.ts._deck.swapDown, - action: () => { - swapDownColumn(props.column.id); - } - } : undefined, null, { icon: 'fas fa-window-restore', text: i18n.ts._deck.stackLeft, action: () => { stackLeftColumn(props.column.id); - } + }, }, props.isStacked ? { icon: 'fas fa-window-maximize', text: i18n.ts._deck.popRight, action: () => { popRightColumn(props.column.id); - } + }, } : undefined, null, { icon: 'fas fa-trash-alt', text: i18n.ts.remove, danger: true, action: () => { removeColumn(props.column.id); - } + }, }]; + + if (props.menu) { + items.unshift(null); + items = props.menu.concat(items); + } + return items; } +function showSettingsMenu(ev: MouseEvent) { + os.popupMenu(getMenu(), ev.currentTarget ?? ev.target); +} + function onContextmenu(ev: MouseEvent) { os.contextMenu(getMenu(), ev); } @@ -188,7 +197,7 @@ function onContextmenu(ev: MouseEvent) { function goTop() { body.scrollTo({ top: 0, - behavior: 'smooth' + behavior: 'smooth', }); } @@ -239,15 +248,13 @@ function onDrop(ev) { <style lang="scss" scoped> .dnpfarvg { --root-margin: 10px; + --deckColumnHeaderHeight: 42px; height: 100%; overflow: hidden; - contain: content; - box-shadow: 0 0 8px 0 var(--shadow); + contain: strict; &.draghover { - box-shadow: 0 0 0 2px var(--focus); - &:after { content: ""; display: block; @@ -262,7 +269,18 @@ function onDrop(ev) { } &.dragging { - box-shadow: 0 0 0 2px var(--focus); + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1000; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--focus); + opacity: 0.5; + } } &.dropready { @@ -338,7 +356,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/deck/deck-store.ts b/packages/client/src/ui/deck/deck-store.ts index 03d57c3467..56db7398e5 100644 --- a/packages/client/src/ui/deck/deck-store.ts +++ b/packages/client/src/ui/deck/deck-store.ts @@ -4,6 +4,7 @@ import { notificationTypes } from 'misskey-js'; import { Storage } from '../../pizzax'; import { i18n } from '@/i18n'; import { api } from '@/os'; +import { deepClone } from '@/scripts/clone'; type ColumnWidget = { name: string; @@ -13,7 +14,7 @@ type ColumnWidget = { export type Column = { id: string; - type: string; + type: 'main' | 'widgets' | 'notifications' | 'tl' | 'antenna' | 'list' | 'mentions' | 'direct'; name: string | null; width: number; widgets?: ColumnWidget[]; @@ -25,10 +26,6 @@ export type Column = { tl?: 'home' | 'local' | 'social' | 'global'; }; -function copy<T>(x: T): T { - return JSON.parse(JSON.stringify(x)); -} - export const deckStore = markRaw(new Storage('deck', { profile: { where: 'deviceAccount', @@ -54,14 +51,6 @@ export const deckStore = markRaw(new Storage('deck', { where: 'deviceAccount', default: true, }, - columnMargin: { - where: 'deviceAccount', - default: 16, - }, - columnHeaderHeight: { - where: 'deviceAccount', - default: 42, - }, })); export const loadDeck = async () => { @@ -80,18 +69,8 @@ export const loadDeck = async () => { return; } - deckStore.set('columns', [{ - id: 'a', - type: 'main', - name: i18n.ts._deck._columns.main, - width: 350, - }, { - id: 'b', - type: 'notifications', - name: i18n.ts._deck._columns.notifications, - width: 330, - }]); - deckStore.set('layout', [['a'], ['b']]); + deckStore.set('columns', []); + deckStore.set('layout', []); return; } throw err; @@ -113,6 +92,19 @@ export const saveDeck = throttle(1000, () => { }); }); +export async function getProfiles(): Promise<string[]> { + return await api('i/registry/keys', { + scope: ['client', 'deck', 'profiles'], + }); +} + +export async function deleteProfile(key: string): Promise<void> { + return await api('i/registry/remove', { + scope: ['client', 'deck', 'profiles'], + key: key, + }); +} + export function addColumn(column: Column) { if (column.name === undefined) column.name = null; deckStore.push('columns', column); @@ -133,7 +125,7 @@ export function swapColumn(a: Column['id'], b: Column['id']) { const aY = deckStore.state.layout[aX].findIndex(id => id === a); const bX = deckStore.state.layout.findIndex(ids => ids.indexOf(b) !== -1); const bY = deckStore.state.layout[bX].findIndex(id => id === b); - const layout = copy(deckStore.state.layout); + const layout = deepClone(deckStore.state.layout); layout[aX][aY] = b; layout[bX][bY] = a; deckStore.set('layout', layout); @@ -141,7 +133,7 @@ export function swapColumn(a: Column['id'], b: Column['id']) { } export function swapLeftColumn(id: Column['id']) { - const layout = copy(deckStore.state.layout); + const layout = deepClone(deckStore.state.layout); deckStore.state.layout.some((ids, i) => { if (ids.includes(id)) { const left = deckStore.state.layout[i - 1]; @@ -157,7 +149,7 @@ export function swapLeftColumn(id: Column['id']) { } export function swapRightColumn(id: Column['id']) { - const layout = copy(deckStore.state.layout); + const layout = deepClone(deckStore.state.layout); deckStore.state.layout.some((ids, i) => { if (ids.includes(id)) { const right = deckStore.state.layout[i + 1]; @@ -173,9 +165,9 @@ export function swapRightColumn(id: Column['id']) { } export function swapUpColumn(id: Column['id']) { - const layout = copy(deckStore.state.layout); + const layout = deepClone(deckStore.state.layout); const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); - const ids = copy(deckStore.state.layout[idsIndex]); + const ids = deepClone(deckStore.state.layout[idsIndex]); ids.some((x, i) => { if (x === id) { const up = ids[i - 1]; @@ -193,9 +185,9 @@ export function swapUpColumn(id: Column['id']) { } export function swapDownColumn(id: Column['id']) { - const layout = copy(deckStore.state.layout); + const layout = deepClone(deckStore.state.layout); const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); - const ids = copy(deckStore.state.layout[idsIndex]); + const ids = deepClone(deckStore.state.layout[idsIndex]); ids.some((x, i) => { if (x === id) { const down = ids[i + 1]; @@ -213,7 +205,7 @@ export function swapDownColumn(id: Column['id']) { } export function stackLeftColumn(id: Column['id']) { - let layout = copy(deckStore.state.layout); + let layout = deepClone(deckStore.state.layout); const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); layout = layout.map(ids => ids.filter(_id => _id !== id)); layout[i - 1].push(id); @@ -223,7 +215,7 @@ export function stackLeftColumn(id: Column['id']) { } export function popRightColumn(id: Column['id']) { - let layout = copy(deckStore.state.layout); + let layout = deepClone(deckStore.state.layout); const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); const affected = layout[i]; layout = layout.map(ids => ids.filter(_id => _id !== id)); @@ -231,7 +223,7 @@ export function popRightColumn(id: Column['id']) { layout = layout.filter(ids => ids.length > 0); deckStore.set('layout', layout); - const columns = copy(deckStore.state.columns); + const columns = deepClone(deckStore.state.columns); for (const column of columns) { if (affected.includes(column.id)) { column.active = true; @@ -243,9 +235,9 @@ export function popRightColumn(id: Column['id']) { } export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { - const columns = copy(deckStore.state.columns); + const columns = deepClone(deckStore.state.columns); const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = copy(deckStore.state.columns[columnIndex]); + const column = deepClone(deckStore.state.columns[columnIndex]); if (column == null) return; if (column.widgets == null) column.widgets = []; column.widgets.unshift(widget); @@ -255,9 +247,9 @@ export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { } export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { - const columns = copy(deckStore.state.columns); + const columns = deepClone(deckStore.state.columns); const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = copy(deckStore.state.columns[columnIndex]); + const column = deepClone(deckStore.state.columns[columnIndex]); if (column == null) return; column.widgets = column.widgets.filter(w => w.id !== widget.id); columns[columnIndex] = column; @@ -266,9 +258,9 @@ export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { } export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { - const columns = copy(deckStore.state.columns); + const columns = deepClone(deckStore.state.columns); const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = copy(deckStore.state.columns[columnIndex]); + const column = deepClone(deckStore.state.columns[columnIndex]); if (column == null) return; column.widgets = widgets; columns[columnIndex] = column; @@ -277,9 +269,9 @@ export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { } export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) { - const columns = copy(deckStore.state.columns); + const columns = deepClone(deckStore.state.columns); const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = copy(deckStore.state.columns[columnIndex]); + const column = deepClone(deckStore.state.columns[columnIndex]); if (column == null) return; column.widgets = column.widgets.map(w => w.id === widgetId ? { ...w, @@ -291,9 +283,9 @@ export function updateColumnWidget(id: Column['id'], widgetId: string, widgetDat } export function updateColumn(id: Column['id'], column: Partial<Column>) { - const columns = copy(deckStore.state.columns); + const columns = deepClone(deckStore.state.columns); const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const currentColumn = copy(deckStore.state.columns[columnIndex]); + const currentColumn = deepClone(deckStore.state.columns[columnIndex]); if (currentColumn == null) return; for (const [k, v] of Object.entries(column)) { currentColumn[k] = v; diff --git a/packages/client/src/ui/deck/direct-column.vue b/packages/client/src/ui/deck/direct-column.vue index 4837c0ce38..104f781b35 100644 --- a/packages/client/src/ui/deck/direct-column.vue +++ b/packages/client/src/ui/deck/direct-column.vue @@ -9,7 +9,7 @@ <script lang="ts" setup> import { } from 'vue'; import XColumn from './column.vue'; -import XNotes from '@/components/notes.vue'; +import XNotes from '@/components/MkNotes.vue'; import { Column } from './deck-store'; defineProps<{ diff --git a/packages/client/src/ui/deck/list-column.vue b/packages/client/src/ui/deck/list-column.vue index 843a3bd1cb..8fdf19cabb 100644 --- a/packages/client/src/ui/deck/list-column.vue +++ b/packages/client/src/ui/deck/list-column.vue @@ -1,5 +1,5 @@ <template> -<XColumn :func="{ handler: setList, title: $ts.selectList }" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> <template #header> <i class="fas fa-list-ul"></i><span style="margin-left: 8px;">{{ column.name }}</span> </template> @@ -9,11 +9,11 @@ </template> <script lang="ts" setup> -import { } from 'vue'; +import { } from 'vue'; import XColumn from './column.vue'; -import XTimeline from '@/components/timeline.vue'; -import * as os from '@/os'; import { updateColumn, Column } from './deck-store'; +import XTimeline from '@/components/MkTimeline.vue'; +import * as os from '@/os'; import { i18n } from '@/i18n'; const props = defineProps<{ @@ -37,29 +37,21 @@ async function setList() { const { canceled, result: list } = await os.select({ title: i18n.ts.selectList, items: lists.map(x => ({ - value: x, text: x.name + value: x, text: x.name, })), - default: props.column.listId + default: props.column.listId, }); if (canceled) return; updateColumn(props.column.id, { - listId: list.id + listId: list.id, }); } -/* -function focus() { - timeline.focus(); -} - -export default defineComponent({ - watch: { - mediaOnly() { - (this.$refs.timeline as any).reload(); - } - } -}); -*/ +const menu = [{ + icon: 'fas fa-pencil-alt', + text: i18n.ts.selectList, + action: setList, +}]; </script> <style lang="scss" scoped> diff --git a/packages/client/src/ui/deck/main-column.vue b/packages/client/src/ui/deck/main-column.vue index 670b4a212b..9a5fd43af7 100644 --- a/packages/client/src/ui/deck/main-column.vue +++ b/packages/client/src/ui/deck/main-column.vue @@ -53,7 +53,7 @@ function onContextmenu(ev: MouseEvent) { if (isLink(ev.target as HTMLElement)) return; if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes((ev.target as HTMLElement).tagName) || (ev.target as HTMLElement).attributes['contenteditable']) return; if (window.getSelection()?.toString() !== '') return; - const path = router.currentRoute.value.path; + const path = mainRouter.currentRoute.value.path; os.contextMenu([{ type: 'label', text: path, diff --git a/packages/client/src/ui/deck/mentions-column.vue b/packages/client/src/ui/deck/mentions-column.vue index 0b6ca3a239..18055215d2 100644 --- a/packages/client/src/ui/deck/mentions-column.vue +++ b/packages/client/src/ui/deck/mentions-column.vue @@ -9,7 +9,7 @@ <script lang="ts" setup> import { } from 'vue'; import XColumn from './column.vue'; -import XNotes from '@/components/notes.vue'; +import XNotes from '@/components/MkNotes.vue'; import { Column } from './deck-store'; defineProps<{ diff --git a/packages/client/src/ui/deck/notifications-column.vue b/packages/client/src/ui/deck/notifications-column.vue index 6dd040cb8d..b02118ee56 100644 --- a/packages/client/src/ui/deck/notifications-column.vue +++ b/packages/client/src/ui/deck/notifications-column.vue @@ -1,5 +1,5 @@ <template> -<XColumn :column="column" :is-stacked="isStacked" :func="{ handler: func, title: $ts.notificationSetting }" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :column="column" :is-stacked="isStacked" :menu="menu" @parent-focus="$event => emit('parent-focus', $event)"> <template #header><i class="fas fa-bell" style="margin-right: 8px;"></i>{{ column.name }}</template> <XNotifications :include-types="column.includingTypes"/> @@ -9,10 +9,10 @@ <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; import XColumn from './column.vue'; -import XNotifications from '@/components/notifications.vue'; +import { updateColumn , Column } from './deck-store'; +import XNotifications from '@/components/MkNotifications.vue'; import * as os from '@/os'; -import { updateColumn } from './deck-store'; -import { Column } from './deck-store'; +import { i18n } from '@/i18n'; const props = defineProps<{ column: Column; @@ -24,15 +24,21 @@ const emit = defineEmits<{ }>(); function func() { - os.popup(defineAsyncComponent(() => import('@/components/notification-setting-window.vue')), { + os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { includingTypes: props.column.includingTypes, }, { done: async (res) => { const { includingTypes } = res; updateColumn(props.column.id, { - includingTypes: includingTypes + includingTypes: includingTypes, }); }, }, 'closed'); } + +const menu = [{ + icon: 'fas fa-pencil-alt', + text: i18n.ts.notificationSetting, + action: func, +}]; </script> diff --git a/packages/client/src/ui/deck/tl-column.vue b/packages/client/src/ui/deck/tl-column.vue index f3ecda5aa4..e64ed852b2 100644 --- a/packages/client/src/ui/deck/tl-column.vue +++ b/packages/client/src/ui/deck/tl-column.vue @@ -1,5 +1,5 @@ <template> -<XColumn :func="{ handler: setType, title: $ts.timeline }" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState" @parent-focus="$event => emit('parent-focus', $event)"> <template #header> <i v-if="column.tl === 'home'" class="fas fa-home"></i> <i v-else-if="column.tl === 'local'" class="fas fa-comments"></i> @@ -22,9 +22,9 @@ <script lang="ts" setup> import { onMounted } from 'vue'; import XColumn from './column.vue'; -import XTimeline from '@/components/timeline.vue'; -import * as os from '@/os'; import { removeColumn, updateColumn, Column } from './deck-store'; +import XTimeline from '@/components/MkTimeline.vue'; +import * as os from '@/os'; import { $i } from '@/account'; import { instance } from '@/instance'; import { i18n } from '@/i18n'; @@ -57,13 +57,13 @@ async function setType() { const { canceled, result: src } = await os.select({ title: i18n.ts.timeline, items: [{ - value: 'home' as const, text: i18n.ts._timelines.home + value: 'home' as const, text: i18n.ts._timelines.home, }, { - value: 'local' as const, text: i18n.ts._timelines.local + value: 'local' as const, text: i18n.ts._timelines.local, }, { - value: 'social' as const, text: i18n.ts._timelines.social + value: 'social' as const, text: i18n.ts._timelines.social, }, { - value: 'global' as const, text: i18n.ts._timelines.global + value: 'global' as const, text: i18n.ts._timelines.global, }], }); if (canceled) { @@ -73,7 +73,7 @@ async function setType() { return; } updateColumn(props.column.id, { - tl: src + tl: src, }); } @@ -97,21 +97,11 @@ function onChangeActiveState(state) { } } -/* -export default defineComponent({ - watch: { - mediaOnly() { - (this.$refs.timeline as any).reload(); - } - }, - - methods: { - focus() { - (this.$refs.timeline as any).focus(); - } - } -}); -*/ +const menu = [{ + icon: 'fas fa-pencil-alt', + text: i18n.ts.timeline, + action: setType, +}]; </script> <style lang="scss" scoped> diff --git a/packages/client/src/ui/deck/widgets-column.vue b/packages/client/src/ui/deck/widgets-column.vue index 9b10f602fb..2c97009b3b 100644 --- a/packages/client/src/ui/deck/widgets-column.vue +++ b/packages/client/src/ui/deck/widgets-column.vue @@ -1,8 +1,9 @@ <template> -<XColumn :func="{ handler: func, title: $ts.editWidgets }" :naked="true" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn :menu="menu" :naked="true" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> <template #header><i class="fas fa-window-maximize" style="margin-right: 8px;"></i>{{ column.name }}</template> <div class="wtdtxvec"> + <div v-if="!(column.widgets && column.widgets.length > 0) && !edit" class="intro">{{ i18n.ts._deck.widgetsIntroduction }}</div> <XWidgets :edit="edit" :widgets="column.widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> </div> </XColumn> @@ -12,7 +13,8 @@ import { } from 'vue'; import XColumn from './column.vue'; import { addColumnWidget, Column, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store'; -import XWidgets from '@/components/widgets.vue'; +import XWidgets from '@/components/MkWidgets.vue'; +import { i18n } from '@/i18n'; const props = defineProps<{ column: Column; @@ -44,6 +46,12 @@ function updateWidgets(widgets) { function func() { edit = !edit; } + +const menu = [{ + icon: 'fas fa-pencil-alt', + text: i18n.ts.editWidgets, + action: func, +}]; </script> <style lang="scss" scoped> @@ -52,5 +60,10 @@ function func() { --panelBorder: none; padding: 0 var(--margin); + + > .intro { + padding: 16px; + text-align: center; + } } </style> diff --git a/packages/client/src/ui/universal.vue b/packages/client/src/ui/universal.vue index 41d59342bd..7029f798f1 100644 --- a/packages/client/src/ui/universal.vue +++ b/packages/client/src/ui/universal.vue @@ -2,20 +2,21 @@ <div class="dkgtipfy" :class="{ wallpaper }"> <XSidebar v-if="!isMobile" class="sidebar"/> - <div class="contents" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> - <main> - <div class="content"> + <MkStickyContainer class="contents"> + <template #header><XStatusBars :class="$style.statusbars"/></template> + <main style="min-width: 0;" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> + <div :class="$style.content"> <RouterView/> </div> - <div class="spacer"></div> + <div :class="$style.spacer"></div> </main> - </div> + </MkStickyContainer> <div v-if="isDesktop" ref="widgetsEl" class="widgets"> <XWidgets @mounted="attachSticky"/> </div> - <button class="widgetButton _button" :class="{ show: true }" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button> + <button v-if="!isDesktop && !isMobile" class="widgetButton _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button> <div v-if="isMobile" class="buttons"> <button class="button nav _button" @click="drawerMenuShowing = true"><i class="fas fa-bars"></i><span v-if="menuIndicated" class="indicator"><i class="fas fa-circle"></i></span></button> @@ -60,25 +61,28 @@ 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'; +import { deviceKind } from '@/scripts/device-kind'; 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; const MOBILE_THRESHOLD = 500; +// デスクトップでウィンドウを狭くしたときモバイルUIが表示されて欲しいことはあるので deviceKind === 'desktop' の判定は行わない const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD); -const isMobile = ref(window.innerWidth <= MOBILE_THRESHOLD); +const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD); window.addEventListener('resize', () => { - isMobile.value = window.innerWidth <= MOBILE_THRESHOLD; + isMobile.value = deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD; }); let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); @@ -87,7 +91,6 @@ const widgetsShowing = $ref(false); provide('router', mainRouter); provideMetadataReceiver((info) => { - console.log(info); pageMetadata = info; if (pageMetadata.value) { document.title = `${pageMetadata.value.title} | ${instanceName}`; @@ -95,9 +98,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; }); @@ -217,8 +220,7 @@ const wallpaper = localStorage.getItem('wallpaper') != null; $ui-font-size: 1em; // TODO: どこかに集約したい $widgets-hide-threshold: 1090px; - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - min-height: calc(var(--vh, 1vh) * 100); + min-height: 100dvh; box-sizing: border-box; display: flex; @@ -234,19 +236,7 @@ const wallpaper = localStorage.getItem('wallpaper') != null; > .contents { width: 100%; min-width: 0; - background: var(--panel); - - > main { - min-width: 0; - - > .spacer { - height: calc(env(safe-area-inset-bottom, 0px) + 96px); - - @media (min-width: ($widgets-hide-threshold + 1px)) { - display: none; - } - } - } + background: var(--bg); } > .widgets { @@ -259,7 +249,6 @@ const wallpaper = localStorage.getItem('wallpaper') != null; } } -/* > .widgetButton { display: block; position: fixed; @@ -272,18 +261,6 @@ const wallpaper = localStorage.getItem('wallpaper') != null; box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); font-size: 22px; background: var(--panel); - - &.navHidden { - display: none; - } - - @media (min-width: ($widgets-hide-threshold + 1px)) { - display: none; - } - }*/ - - > .widgetButton { - display: none; } > .widgetsDrawer-back { @@ -295,8 +272,7 @@ const wallpaper = localStorage.getItem('wallpaper') != null; top: 0; right: 0; z-index: 1001; - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - height: calc(var(--vh, 1vh) * 100); + height: 100dvh; padding: var(--margin); box-sizing: border-box; overflow: auto; @@ -384,17 +360,31 @@ const wallpaper = localStorage.getItem('wallpaper') != null; top: 0; left: 0; z-index: 1001; - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - height: calc(var(--vh, 1vh) * 100); + height: 100dvh; width: 240px; box-sizing: border-box; + contain: strict; overflow: auto; overscroll-behavior: contain; - background: var(--bg); + background: var(--navBg); } - } </style> -<style lang="scss"> +<style lang="scss" module> +.statusbars { + position: sticky; + top: 0; + left: 0; +} + +.spacer { + $widgets-hide-threshold: 1090px; + + height: calc(env(safe-area-inset-bottom, 0px) + 96px); + + @media (min-width: ($widgets-hide-threshold + 1px)) { + display: none; + } +} </style> diff --git a/packages/client/src/ui/universal.widgets.vue b/packages/client/src/ui/universal.widgets.vue index 7aed083886..179f8a6baa 100644 --- a/packages/client/src/ui/universal.widgets.vue +++ b/packages/client/src/ui/universal.widgets.vue @@ -9,7 +9,7 @@ <script lang="ts" setup> import { onMounted } from 'vue'; -import XWidgets from '@/components/widgets.vue'; +import XWidgets from '@/components/MkWidgets.vue'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; diff --git a/packages/client/src/ui/visitor/a.vue b/packages/client/src/ui/visitor/a.vue index e98247cbb1..f8db7a9d09 100644 --- a/packages/client/src/ui/visitor/a.vue +++ b/packages/client/src/ui/visitor/a.vue @@ -4,6 +4,7 @@ <div> <h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> <div v-if="meta" class="about"> + <!-- eslint-disable-next-line vue/no-v-html --> <div class="desc" v-html="meta.description || $ts.introMisskey"></div> </div> <div class="action"> @@ -41,8 +42,8 @@ import XHeader from './header.vue'; import { host, instanceName } from '@/config'; import { search } from '@/scripts/search'; import * as os from '@/os'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; import { ColdDeviceStorage } from '@/store'; import { mainRouter } from '@/router'; @@ -101,13 +102,18 @@ export default defineComponent({ }, methods: { - setParallax(el) { - //new simpleParallax(el); - }, + // @ThatOneCalculator: Are these methods even used? + // I can't find references to them anywhere else in the code... + + // setParallax(el) { + // new simpleParallax(el); + // }, changePage(page) { if (page == null) return; + // eslint-disable-next-line no-undef if (page[symbols.PAGE_INFO]) { + // eslint-disable-next-line no-undef this.pageInfo = page[symbols.PAGE_INFO]; } }, diff --git a/packages/client/src/ui/visitor/b.vue b/packages/client/src/ui/visitor/b.vue index 28933f272a..3c308cfe5b 100644 --- a/packages/client/src/ui/visitor/b.vue +++ b/packages/client/src/ui/visitor/b.vue @@ -52,10 +52,10 @@ import XKanban from './kanban.vue'; import { host, instanceName } from '@/config'; import { search } from '@/scripts/search'; import * as os from '@/os'; -import MkPagination from '@/components/ui/pagination.vue'; -import XSigninDialog from '@/components/signin-dialog.vue'; -import XSignupDialog from '@/components/signup-dialog.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; import { ColdDeviceStorage, defaultStore } from '@/store'; import { mainRouter } from '@/router'; import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; @@ -116,6 +116,10 @@ onMounted(() => { }, { passive: true }); } }); + +defineExpose({ + showMenu: $$(showMenu), +}); </script> <style> diff --git a/packages/client/src/ui/visitor/header.vue b/packages/client/src/ui/visitor/header.vue index c39dc65777..e2b9034851 100644 --- a/packages/client/src/ui/visitor/header.vue +++ b/packages/client/src/ui/visitor/header.vue @@ -41,8 +41,8 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import XSigninDialog from '@/components/signin-dialog.vue'; -import XSignupDialog from '@/components/signup-dialog.vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; import * as os from '@/os'; import { search } from '@/scripts/search'; diff --git a/packages/client/src/ui/visitor/kanban.vue b/packages/client/src/ui/visitor/kanban.vue index ee0f11b838..51e47f277d 100644 --- a/packages/client/src/ui/visitor/kanban.vue +++ b/packages/client/src/ui/visitor/kanban.vue @@ -1,10 +1,11 @@ +<!-- eslint-disable vue/no-v-html --> <template> <div class="rwqkcmrc" :style="{ backgroundImage: transparent ? 'none' : `url(${ $instance.backgroundImageUrl })` }"> <div class="back" :class="{ transparent }"></div> <div class="contents"> <div class="wrapper"> <h1 v-if="meta" :class="{ full }"> - <MkA to="/" class="link"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></MkA> + <MkA to="/" class="link"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl" alt="logo"><span v-else class="text">{{ instanceName }}</span></MkA> </h1> <template v-if="full"> <div v-if="meta" class="about"> @@ -21,7 +22,7 @@ <div class="title">{{ announcement.title }}</div> <div class="content"> <Mfm :text="announcement.text"/> - <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl" alt="announcement image"/> </div> </section> </MkPagination> @@ -40,10 +41,10 @@ import { defineComponent, defineAsyncComponent } from 'vue'; import { host, instanceName } from '@/config'; import * as os from '@/os'; -import MkPagination from '@/components/ui/pagination.vue'; -import XSigninDialog from '@/components/signin-dialog.vue'; -import XSignupDialog from '@/components/signup-dialog.vue'; -import MkButton from '@/components/ui/button.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; export default defineComponent({ components: { @@ -92,16 +93,16 @@ export default defineComponent({ methods: { signin() { os.popup(XSigninDialog, { - autoSet: true + autoSet: true, }, {}, 'closed'); }, signup() { os.popup(XSignupDialog, { - autoSet: true + autoSet: true, }, {}, 'closed'); - } - } + }, + }, }); </script> diff --git a/packages/client/src/ui/zen.vue b/packages/client/src/ui/zen.vue index c915f82428..84c96a1dae 100644 --- a/packages/client/src/ui/zen.vue +++ b/packages/client/src/ui/zen.vue @@ -28,8 +28,7 @@ document.documentElement.style.overflowY = 'scroll'; <style lang="scss" scoped> .mk-app { - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - min-height: calc(var(--vh, 1vh) * 100); + min-height: 100dvh; box-sizing: border-box; } </style> diff --git a/packages/client/src/widgets/activity.chart.vue b/packages/client/src/widgets/activity.chart.vue index b7db2af580..b61e419f94 100644 --- a/packages/client/src/widgets/activity.chart.vue +++ b/packages/client/src/widgets/activity.chart.vue @@ -39,15 +39,15 @@ let pointsRenote: any = $ref(null); let pointsTotal: any = $ref(null); function dragListen(fn) { - window.addEventListener('mousemove', fn); + window.addEventListener('mousemove', fn); window.addEventListener('mouseleave', dragClear.bind(null, fn)); - window.addEventListener('mouseup', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); } function dragClear(fn) { - window.removeEventListener('mousemove', fn); + window.removeEventListener('mousemove', fn); window.removeEventListener('mouseleave', dragClear); - window.removeEventListener('mouseup', dragClear); + window.removeEventListener('mouseup', dragClear); } function onMousedown(ev) { diff --git a/packages/client/src/widgets/activity.vue b/packages/client/src/widgets/activity.vue index 7fb9f5894c..acca21bff6 100644 --- a/packages/client/src/widgets/activity.vue +++ b/packages/client/src/widgets/activity.vue @@ -1,6 +1,6 @@ <template> <MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" class="mkw-activity"> - <template #header><i class="fas fa-chart-bar"></i>{{ $ts._widgets.activity }}</template> + <template #header><i class="fas fa-chart-simple"></i>{{ i18n.ts._widgets.activity }}</template> <template #func><button class="_button" @click="toggleView()"><i class="fas fa-sort"></i></button></template> <div> @@ -15,13 +15,14 @@ <script lang="ts" setup> import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; -import * as os from '@/os'; -import MkContainer from '@/components/ui/container.vue'; import XCalendar from './activity.calendar.vue'; import XChart from './activity.chart.vue'; +import { GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; import { $i } from '@/account'; +import { i18n } from '@/i18n'; const name = 'activity'; @@ -67,7 +68,7 @@ const toggleView = () => { save(); }; -os.api('charts/user/notes', { +os.apiGet('charts/user/notes', { userId: $i.id, span: 'day', limit: 7 * 21, @@ -76,7 +77,7 @@ os.api('charts/user/notes', { total: res.diffs.normal[i] + res.diffs.reply[i] + res.diffs.renote[i], notes: res.diffs.normal[i], replies: res.diffs.reply[i], - renotes: res.diffs.renote[i] + renotes: res.diffs.renote[i], })); fetching.value = false; }); diff --git a/packages/client/src/widgets/aichan.vue b/packages/client/src/widgets/aichan.vue index cdd367cc84..828490fd9c 100644 --- a/packages/client/src/widgets/aichan.vue +++ b/packages/client/src/widgets/aichan.vue @@ -6,8 +6,8 @@ <script lang="ts" setup> import { onMounted, onUnmounted, reactive, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; const name = 'ai'; @@ -38,22 +38,23 @@ const touched = () => { //if (this.live2d) this.live2d.changeExpression('gurugurume'); }; -onMounted(() => { - const onMousemove = (ev: MouseEvent) => { - const iframeRect = live2d.value.getBoundingClientRect(); - live2d.value.contentWindow.postMessage({ - type: 'moveCursor', - body: { - x: ev.clientX - iframeRect.left, - y: ev.clientY - iframeRect.top, - } - }, '*'); - }; +const onMousemove = (ev: MouseEvent) => { + const iframeRect = live2d.value.getBoundingClientRect(); + live2d.value.contentWindow.postMessage({ + type: 'moveCursor', + body: { + x: ev.clientX - iframeRect.left, + y: ev.clientY - iframeRect.top, + }, + }, '*'); +}; +onMounted(() => { window.addEventListener('mousemove', onMousemove, { passive: true }); - onUnmounted(() => { - window.removeEventListener('mousemove', onMousemove); - }); +}); + +onUnmounted(() => { + window.removeEventListener('mousemove', onMousemove); }); defineExpose<WidgetComponentExpose>({ diff --git a/packages/client/src/widgets/aiscript.vue b/packages/client/src/widgets/aiscript.vue index 9fed292a69..cb6d29cd99 100644 --- a/packages/client/src/widgets/aiscript.vue +++ b/packages/client/src/widgets/aiscript.vue @@ -1,6 +1,6 @@ <template> <MkContainer :show-header="widgetProps.showHeader" class="mkw-aiscript"> - <template #header><i class="fas fa-terminal"></i>{{ $ts._widgets.aiscript }}</template> + <template #header><i class="fas fa-terminal"></i>{{ i18n.ts._widgets.aiscript }}</template> <div class="uylguesu _monospace"> <textarea v-model="widgetProps.script" placeholder="(1 + 1)"></textarea> @@ -14,13 +14,14 @@ <script lang="ts" setup> import { onMounted, onUnmounted, ref, watch } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; +import { AiScript, parse, utils } from '@syuilo/aiscript'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; -import MkContainer from '@/components/ui/container.vue'; -import { AiScript, parse, utils } from '@syuilo/aiscript'; +import MkContainer from '@/components/MkContainer.vue'; import { createAiScriptEnv } from '@/scripts/aiscript/api'; import { $i } from '@/account'; +import { i18n } from '@/i18n'; const name = 'aiscript'; @@ -88,7 +89,7 @@ const run = async () => { }); break; default: break; } - } + }, }); let ast; diff --git a/packages/client/src/widgets/button.vue b/packages/client/src/widgets/button.vue index ee4e9c6423..f0148d7f4e 100644 --- a/packages/client/src/widgets/button.vue +++ b/packages/client/src/widgets/button.vue @@ -8,13 +8,13 @@ <script lang="ts" setup> import { onMounted, onUnmounted, ref, watch } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; +import { AiScript, parse, utils } from '@syuilo/aiscript'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; -import { AiScript, parse, utils } from '@syuilo/aiscript'; import { createAiScriptEnv } from '@/scripts/aiscript/api'; import { $i } from '@/account'; -import MkButton from '@/components/ui/button.vue'; +import MkButton from '@/components/MkButton.vue'; const name = 'button'; @@ -67,7 +67,7 @@ const run = async () => { }, log: (type, params) => { // nop - } + }, }); let ast; diff --git a/packages/client/src/widgets/calendar.vue b/packages/client/src/widgets/calendar.vue index 2a2b035541..99bd36e2fc 100644 --- a/packages/client/src/widgets/calendar.vue +++ b/packages/client/src/widgets/calendar.vue @@ -11,19 +11,19 @@ </div> <div class="info"> <div> - <p>{{ $ts.today }}: <b>{{ dayP.toFixed(1) }}%</b></p> + <p>{{ i18n.ts.today }}: <b>{{ dayP.toFixed(1) }}%</b></p> <div class="meter"> <div class="val" :style="{ width: `${dayP}%` }"></div> </div> </div> <div> - <p>{{ $ts.thisMonth }}: <b>{{ monthP.toFixed(1) }}%</b></p> + <p>{{ i18n.ts.thisMonth }}: <b>{{ monthP.toFixed(1) }}%</b></p> <div class="meter"> <div class="val" :style="{ width: `${monthP}%` }"></div> </div> </div> <div> - <p>{{ $ts.thisYear }}: <b>{{ yearP.toFixed(1) }}%</b></p> + <p>{{ i18n.ts.thisYear }}: <b>{{ yearP.toFixed(1) }}%</b></p> <div class="meter"> <div class="val" :style="{ width: `${yearP}%` }"></div> </div> @@ -34,9 +34,10 @@ <script lang="ts" setup> import { onUnmounted, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import { i18n } from '@/i18n'; +import { useInterval } from '@/scripts/use-interval'; const name = 'calendar'; @@ -85,28 +86,26 @@ const tick = () => { i18n.ts._weekday.wednesday, i18n.ts._weekday.thursday, i18n.ts._weekday.friday, - i18n.ts._weekday.saturday + i18n.ts._weekday.saturday, ][now.getDay()]; - const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime(); - const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/; + const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime(); + const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/; const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime(); const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime(); - const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime(); - const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime(); + const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime(); + const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime(); - dayP.value = dayNumer / dayDenom * 100; + dayP.value = dayNumer / dayDenom * 100; monthP.value = monthNumer / monthDenom * 100; - yearP.value = yearNumer / yearDenom * 100; + yearP.value = yearNumer / yearDenom * 100; isHoliday.value = now.getDay() === 0 || now.getDay() === 6; }; -tick(); - -const intervalId = window.setInterval(tick, 1000); -onUnmounted(() => { - window.clearInterval(intervalId); +useInterval(tick, 1000, { + immediate: true, + afterMounted: false, }); defineExpose<WidgetComponentExpose>({ diff --git a/packages/client/src/widgets/clock.vue b/packages/client/src/widgets/clock.vue index fbd2f9e899..dc99b6631e 100644 --- a/packages/client/src/widgets/clock.vue +++ b/packages/client/src/widgets/clock.vue @@ -1,17 +1,31 @@ <template> <MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-clock"> - <div class="vubelbmv"> - <MkAnalogClock class="clock" :thickness="widgetProps.thickness"/> + <div class="vubelbmv" :class="widgetProps.size"> + <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label a abbrev">{{ tzAbbrev }}</div> + <MkAnalogClock + class="clock" + :thickness="widgetProps.thickness" + :offset="tzOffset" + :graduations="widgetProps.graduations" + :fade-graduations="widgetProps.fadeGraduations" + :twentyfour="widgetProps.twentyFour" + :s-animation="widgetProps.sAnimation" + /> + <MkDigitalClock v-if="widgetProps.label === 'time' || widgetProps.label === 'timeAndTz'" class="_monospace label c time" :show-s="false" :offset="tzOffset"/> + <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label d offset">{{ tzOffsetLabel }}</div> </div> </MkContainer> </template> <script lang="ts" setup> import { } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; -import MkContainer from '@/components/ui/container.vue'; -import MkAnalogClock from '@/components/analog-clock.vue'; +import { GetFormResultType } from '@/scripts/form'; +import MkContainer from '@/components/MkContainer.vue'; +import MkAnalogClock from '@/components/MkAnalogClock.vue'; +import MkDigitalClock from '@/components/MkDigitalClock.vue'; +import { timezones } from '@/scripts/timezones'; +import { i18n } from '@/i18n'; const name = 'clock'; @@ -20,15 +34,80 @@ const widgetPropsDef = { type: 'boolean' as const, default: false, }, + size: { + type: 'radio' as const, + default: 'medium', + options: [{ + value: 'small', label: i18n.ts.small, + }, { + value: 'medium', label: i18n.ts.medium, + }, { + value: 'large', label: i18n.ts.large, + }], + }, thickness: { type: 'radio' as const, - default: 0.1, + default: 0.2, + options: [{ + value: 0.1, label: 'thin', + }, { + value: 0.2, label: 'medium', + }, { + value: 0.3, label: 'thick', + }], + }, + graduations: { + type: 'radio' as const, + default: 'numbers', + options: [{ + value: 'none', label: 'None', + }, { + value: 'dots', label: 'Dots', + }, { + value: 'numbers', label: 'Numbers', + }], + }, + fadeGraduations: { + type: 'boolean' as const, + default: true, + }, + sAnimation: { + type: 'radio' as const, + default: 'elastic', + options: [{ + value: 'none', label: 'None', + }, { + value: 'elastic', label: 'Elastic', + }, { + value: 'easeOut', label: 'Ease out', + }], + }, + twentyFour: { + type: 'boolean' as const, + default: false, + }, + label: { + type: 'radio' as const, + default: 'none', options: [{ - value: 0.1, label: 'thin' + value: 'none', label: 'None', + }, { + value: 'time', label: 'Time', }, { - value: 0.2, label: 'medium' + value: 'tz', label: 'TZ', }, { - value: 0.3, label: 'thick' + value: 'timeAndTz', label: 'Time + TZ', + }], + }, + timezone: { + type: 'enum' as const, + default: null, + enum: [...timezones.map((tz) => ({ + label: tz.name, + value: tz.name.toLowerCase(), + })), { + label: '(auto)', + value: null, }], }, }; @@ -47,6 +126,16 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); +const tzAbbrev = $computed(() => (widgetProps.timezone === null + ? timezones.find((tz) => tz.name.toLowerCase() === Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase())?.abbrev + : timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.abbrev) ?? '?'); + +const tzOffset = $computed(() => widgetProps.timezone === null + ? 0 - new Date().getTimezoneOffset() + : timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.offset ?? 0); + +const tzOffsetLabel = $computed(() => (tzOffset >= 0 ? '+' : '-') + Math.floor(tzOffset / 60).toString().padStart(2, '0') + ':' + (tzOffset % 60).toString().padStart(2, '0')); + defineExpose<WidgetComponentExpose>({ name, configure, @@ -56,11 +145,59 @@ defineExpose<WidgetComponentExpose>({ <style lang="scss" scoped> .vubelbmv { - padding: 8px; + position: relative; + + > .label { + position: absolute; + opacity: 0.7; + + &.a { + top: 14px; + left: 14px; + } + + &.b { + top: 14px; + right: 14px; + } + + &.c { + bottom: 14px; + left: 14px; + } + + &.d { + bottom: 14px; + right: 14px; + } + } > .clock { - height: 150px; margin: auto; } + + &.small { + padding: 12px; + + > .clock { + height: 100px; + } + } + + &.medium { + padding: 14px; + + > .clock { + height: 150px; + } + } + + &.large { + padding: 16px; + + > .clock { + height: 200px; + } + } } </style> diff --git a/packages/client/src/widgets/digital-clock.vue b/packages/client/src/widgets/digital-clock.vue index a17ed040c9..d2bfd523f3 100644 --- a/packages/client/src/widgets/digital-clock.vue +++ b/packages/client/src/widgets/digital-clock.vue @@ -1,21 +1,19 @@ <template> <div class="mkw-digitalClock _monospace" :class="{ _panel: !widgetProps.transparent }" :style="{ fontSize: `${widgetProps.fontSize}em` }"> - <span> - <span v-text="hh"></span> - <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> - <span v-text="mm"></span> - <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> - <span v-text="ss"></span> - <span v-if="widgetProps.showMs" :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> - <span v-if="widgetProps.showMs" v-text="ms"></span> - </span> + <div v-if="widgetProps.showLabel" class="label">{{ tzAbbrev }}</div> + <div class="time"> + <MkDigitalClock :show-ms="widgetProps.showMs" :offset="tzOffset"/> + </div> + <div v-if="widgetProps.showLabel" class="label">{{ tzOffsetLabel }}</div> </div> </template> <script lang="ts" setup> import { onUnmounted, ref, watch } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import { timezones } from '@/scripts/timezones'; +import MkDigitalClock from '@/components/MkDigitalClock.vue'; const name = 'digitalClock'; @@ -33,6 +31,21 @@ const widgetPropsDef = { type: 'boolean' as const, default: true, }, + showLabel: { + type: 'boolean' as const, + default: true, + }, + timezone: { + type: 'enum' as const, + default: null, + enum: [...timezones.map((tz) => ({ + label: tz.name, + value: tz.name.toLowerCase(), + })), { + label: '(auto)', + value: null, + }], + }, }; type WidgetProps = GetFormResultType<typeof widgetPropsDef>; @@ -49,31 +62,15 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); -let intervalId; -const hh = ref(''); -const mm = ref(''); -const ss = ref(''); -const ms = ref(''); -const showColon = ref(true); -const tick = () => { - const now = new Date(); - hh.value = now.getHours().toString().padStart(2, '0'); - mm.value = now.getMinutes().toString().padStart(2, '0'); - ss.value = now.getSeconds().toString().padStart(2, '0'); - ms.value = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0'); - showColon.value = now.getSeconds() % 2 === 0; -}; +const tzAbbrev = $computed(() => (widgetProps.timezone === null + ? timezones.find((tz) => tz.name.toLowerCase() === Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase())?.abbrev + : timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.abbrev) ?? '?'); -tick(); +const tzOffset = $computed(() => widgetProps.timezone === null + ? 0 - new Date().getTimezoneOffset() + : timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.offset ?? 0); -watch(() => widgetProps.showMs, () => { - if (intervalId) window.clearInterval(intervalId); - intervalId = window.setInterval(tick, widgetProps.showMs ? 10 : 1000); -}, { immediate: true }); - -onUnmounted(() => { - window.clearInterval(intervalId); -}); +const tzOffsetLabel = $computed(() => (tzOffset >= 0 ? '+' : '-') + Math.floor(tzOffset / 60).toString().padStart(2, '0') + ':' + (tzOffset % 60).toString().padStart(2, '0')); defineExpose<WidgetComponentExpose>({ name, @@ -86,5 +83,10 @@ defineExpose<WidgetComponentExpose>({ .mkw-digitalClock { padding: 16px 0; text-align: center; + + > .label { + font-size: 65%; + opacity: 0.7; + } } </style> diff --git a/packages/client/src/widgets/federation.vue b/packages/client/src/widgets/federation.vue index a3862077bb..e07cab5bfa 100644 --- a/packages/client/src/widgets/federation.vue +++ b/packages/client/src/widgets/federation.vue @@ -1,6 +1,6 @@ <template> <MkContainer :show-header="widgetProps.showHeader" :foldable="foldable" :scrollable="scrollable" class="mkw-federation"> - <template #header><i class="fas fa-globe"></i>{{ $ts._widgets.federation }}</template> + <template #header><i class="fas fa-globe"></i>{{ i18n.ts._widgets.federation }}</template> <div class="wbrkwalb"> <MkLoading v-if="fetching"/> @@ -20,11 +20,13 @@ <script lang="ts" setup> import { onMounted, onUnmounted, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; -import MkContainer from '@/components/ui/container.vue'; -import MkMiniChart from '@/components/mini-chart.vue'; +import { GetFormResultType } from '@/scripts/form'; +import MkContainer from '@/components/MkContainer.vue'; +import MkMiniChart from '@/components/MkMiniChart.vue'; import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { i18n } from '@/i18n'; const name = 'federation'; @@ -56,20 +58,17 @@ const fetching = ref(true); const fetch = async () => { const fetchedInstances = await os.api('federation/instances', { sort: '+lastCommunicatedAt', - limit: 5 + limit: 5, }); - const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.api('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); + const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.apiGet('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); instances.value = fetchedInstances; charts.value = fetchedCharts; fetching.value = false; }; -onMounted(() => { - fetch(); - const intervalId = window.setInterval(fetch, 1000 * 60); - onUnmounted(() => { - window.clearInterval(intervalId); - }); +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, }); defineExpose<WidgetComponentExpose>({ diff --git a/packages/client/src/widgets/index.ts b/packages/client/src/widgets/index.ts index 51a82af080..66bec7c83f 100644 --- a/packages/client/src/widgets/index.ts +++ b/packages/client/src/widgets/index.ts @@ -6,17 +6,20 @@ export default function(app: App) { app.component('MkwTimeline', defineAsyncComponent(() => import('./timeline.vue'))); app.component('MkwCalendar', defineAsyncComponent(() => import('./calendar.vue'))); app.component('MkwRss', defineAsyncComponent(() => import('./rss.vue'))); + app.component('MkwRssTicker', defineAsyncComponent(() => import('./rss-ticker.vue'))); app.component('MkwTrends', defineAsyncComponent(() => import('./trends.vue'))); app.component('MkwClock', defineAsyncComponent(() => import('./clock.vue'))); app.component('MkwActivity', defineAsyncComponent(() => import('./activity.vue'))); app.component('MkwPhotos', defineAsyncComponent(() => import('./photos.vue'))); app.component('MkwDigitalClock', defineAsyncComponent(() => import('./digital-clock.vue'))); + app.component('MkwUnixClock', defineAsyncComponent(() => import('./unix-clock.vue'))); app.component('MkwFederation', defineAsyncComponent(() => import('./federation.vue'))); app.component('MkwPostForm', defineAsyncComponent(() => import('./post-form.vue'))); app.component('MkwSlideshow', defineAsyncComponent(() => import('./slideshow.vue'))); app.component('MkwServerMetric', defineAsyncComponent(() => import('./server-metric/index.vue'))); app.component('MkwOnlineUsers', defineAsyncComponent(() => import('./online-users.vue'))); app.component('MkwJobQueue', defineAsyncComponent(() => import('./job-queue.vue'))); + app.component('MkwInstanceCloud', defineAsyncComponent(() => import('./instance-cloud.vue'))); app.component('MkwButton', defineAsyncComponent(() => import('./button.vue'))); app.component('MkwAiscript', defineAsyncComponent(() => import('./aiscript.vue'))); app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue'))); @@ -28,12 +31,15 @@ export const widgets = [ 'timeline', 'calendar', 'rss', + 'rssTicker', 'trends', 'clock', 'activity', 'photos', 'digitalClock', + 'unixClock', 'federation', + 'instanceCloud', 'postForm', 'slideshow', 'serverMetric', diff --git a/packages/client/src/widgets/instance-cloud.vue b/packages/client/src/widgets/instance-cloud.vue new file mode 100644 index 0000000000..f8e463ee33 --- /dev/null +++ b/packages/client/src/widgets/instance-cloud.vue @@ -0,0 +1,76 @@ +<template> +<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-instance-cloud"> + <div class=""> + <MkTagCloud v-if="activeInstances"> + <li v-for="instance in activeInstances" :key="instance.id"> + <a @click.prevent="onInstanceClick(instance)"> + <img style="width: 32px;" :src="instance.iconUrl"> + </a> + </li> + </MkTagCloud> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import MkContainer from '@/components/MkContainer.vue'; +import MkTagCloud from '@/components/MkTagCloud.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; + +const name = 'instanceCloud'; + +const widgetPropsDef = { + transparent: { + type: 'boolean' as const, + default: false, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +let cloud = $ref<InstanceType<typeof MkTagCloud> | null>(); +let activeInstances = $shallowRef(null); + +function onInstanceClick(i) { + os.pageWindow(`/instance-info/${i.host}`); +} + +useInterval(() => { + os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 25, + }).then(res => { + activeInstances = res; + if (cloud) cloud.update(); + }); +}, 1000 * 60 * 3, { + immediate: true, + afterMounted: true, +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/widgets/job-queue.vue b/packages/client/src/widgets/job-queue.vue index 8897f240bd..363d1b3ea0 100644 --- a/packages/client/src/widgets/job-queue.vue +++ b/packages/client/src/widgets/job-queue.vue @@ -47,12 +47,13 @@ <script lang="ts" setup> import { onMounted, onUnmounted, reactive, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import { stream } from '@/stream'; import number from '@/filters/number'; import * as sound from '@/scripts/sound'; import * as os from '@/os'; +import { deepClone } from '@/scripts/clone'; const name = 'jobQueue'; @@ -100,12 +101,12 @@ const prev = reactive({} as typeof current); const jammedSound = sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1); for (const domain of ['inbox', 'deliver']) { - prev[domain] = JSON.parse(JSON.stringify(current[domain])); + prev[domain] = deepClone(current[domain]); } const onStats = (stats) => { for (const domain of ['inbox', 'deliver']) { - prev[domain] = JSON.parse(JSON.stringify(current[domain])); + prev[domain] = deepClone(current[domain]); current[domain].activeSincePrevTick = stats[domain].activeSincePrevTick; current[domain].active = stats[domain].active; current[domain].waiting = stats[domain].waiting; diff --git a/packages/client/src/widgets/memo.vue b/packages/client/src/widgets/memo.vue index 8670cb2bac..92c4168fff 100644 --- a/packages/client/src/widgets/memo.vue +++ b/packages/client/src/widgets/memo.vue @@ -1,21 +1,22 @@ <template> <MkContainer :show-header="widgetProps.showHeader" class="mkw-memo"> - <template #header><i class="fas fa-sticky-note"></i>{{ $ts._widgets.memo }}</template> + <template #header><i class="fas fa-sticky-note"></i>{{ i18n.ts._widgets.memo }}</template> <div class="otgbylcu"> - <textarea v-model="text" :placeholder="$ts.placeholder" @input="onChange"></textarea> - <button :disabled="!changed" class="_buttonPrimary" @click="saveMemo">{{ $ts.save }}</button> + <textarea v-model="text" :placeholder="i18n.ts.placeholder" @input="onChange"></textarea> + <button :disabled="!changed" class="_buttonPrimary" @click="saveMemo">{{ i18n.ts.save }}</button> </div> </MkContainer> </template> <script lang="ts" setup> import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; -import MkContainer from '@/components/ui/container.vue'; +import MkContainer from '@/components/MkContainer.vue'; import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; const name = 'memo'; diff --git a/packages/client/src/widgets/notifications.vue b/packages/client/src/widgets/notifications.vue index 18c546ee74..2729c310a0 100644 --- a/packages/client/src/widgets/notifications.vue +++ b/packages/client/src/widgets/notifications.vue @@ -1,6 +1,6 @@ <template> <MkContainer :style="`height: ${widgetProps.height}px;`" :show-header="widgetProps.showHeader" :scrollable="true" class="mkw-notifications"> - <template #header><i class="fas fa-bell"></i>{{ $ts.notifications }}</template> + <template #header><i class="fas fa-bell"></i>{{ i18n.ts.notifications }}</template> <template #func><button class="_button" @click="configureNotification()"><i class="fas fa-cog"></i></button></template> <div> @@ -10,12 +10,13 @@ </template> <script lang="ts" setup> -import { GetFormResultType } from '@/scripts/form'; +import { defineAsyncComponent } from 'vue'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; -import MkContainer from '@/components/ui/container.vue'; -import XNotifications from '@/components/notifications.vue'; +import { GetFormResultType } from '@/scripts/form'; +import MkContainer from '@/components/MkContainer.vue'; +import XNotifications from '@/components/MkNotifications.vue'; import * as os from '@/os'; -import { defineAsyncComponent } from 'vue'; +import { i18n } from '@/i18n'; const name = 'notifications'; @@ -50,14 +51,14 @@ const { widgetProps, configure, save } = useWidgetPropsManager(name, ); const configureNotification = () => { - os.popup(defineAsyncComponent(() => import('@/components/notification-setting-window.vue')), { + os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { includingTypes: widgetProps.includingTypes, }, { done: async (res) => { const { includingTypes } = res; widgetProps.includingTypes = includingTypes; save(); - } + }, }, 'closed'); }; diff --git a/packages/client/src/widgets/online-users.vue b/packages/client/src/widgets/online-users.vue index eb3184fe9d..e9ab79b111 100644 --- a/packages/client/src/widgets/online-users.vue +++ b/packages/client/src/widgets/online-users.vue @@ -1,6 +1,6 @@ <template> <div class="mkw-onlineUsers" :class="{ _panel: !widgetProps.transparent, pad: !widgetProps.transparent }"> - <I18n v-if="onlineUsersCount" :src="$ts.onlineUsersCount" text-tag="span" class="text"> + <I18n v-if="onlineUsersCount" :src="i18n.ts.onlineUsersCount" text-tag="span" class="text"> <template #n><b>{{ onlineUsersCount }}</b></template> </I18n> </div> @@ -8,9 +8,11 @@ <script lang="ts" setup> import { onMounted, onUnmounted, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { i18n } from '@/i18n'; const name = 'onlineUsers'; @@ -43,12 +45,9 @@ const tick = () => { }); }; -onMounted(() => { - tick(); - const intervalId = window.setInterval(tick, 1000 * 15); - onUnmounted(() => { - window.clearInterval(intervalId); - }); +useInterval(tick, 1000 * 15, { + immediate: true, + afterMounted: true, }); defineExpose<WidgetComponentExpose>({ diff --git a/packages/client/src/widgets/photos.vue b/packages/client/src/widgets/photos.vue index 5d9b9e2984..e891bd6a7d 100644 --- a/packages/client/src/widgets/photos.vue +++ b/packages/client/src/widgets/photos.vue @@ -1,11 +1,12 @@ <template> <MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" :class="$style.root" :data-transparent="widgetProps.transparent ? true : null" class="mkw-photos"> - <template #header><i class="fas fa-camera"></i>{{ $ts._widgets.photos }}</template> + <template #header><i class="fas fa-camera"></i>{{ i18n.ts._widgets.photos }}</template> <div class=""> <MkLoading v-if="fetching"/> <div v-else :class="$style.stream"> - <div v-for="(image, i) in images" :key="i" + <div + v-for="(image, i) in images" :key="i" :class="$style.img" :style="`background-image: url(${thumbnail(image)})`" ></div> @@ -16,13 +17,14 @@ <script lang="ts" setup> import { onMounted, onUnmounted, reactive, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import { stream } from '@/stream'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import * as os from '@/os'; -import MkContainer from '@/components/ui/container.vue'; +import MkContainer from '@/components/MkContainer.vue'; import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; const name = 'photos'; @@ -70,7 +72,7 @@ const thumbnail = (image: any): string => { os.api('drive/stream', { type: 'image/*', - limit: 9 + limit: 9, }).then(res => { images.value = res; fetching.value = false; diff --git a/packages/client/src/widgets/post-form.vue b/packages/client/src/widgets/post-form.vue index b542913357..f1708775ba 100644 --- a/packages/client/src/widgets/post-form.vue +++ b/packages/client/src/widgets/post-form.vue @@ -6,7 +6,7 @@ import { } from 'vue'; import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; -import XPostForm from '@/components/post-form.vue'; +import XPostForm from '@/components/MkPostForm.vue'; const name = 'postForm'; diff --git a/packages/client/src/widgets/rss-ticker.vue b/packages/client/src/widgets/rss-ticker.vue new file mode 100644 index 0000000000..58c16983c8 --- /dev/null +++ b/packages/client/src/widgets/rss-ticker.vue @@ -0,0 +1,152 @@ +<template> +<MkContainer :naked="widgetProps.transparent" :show-header="widgetProps.showHeader" class="mkw-rss-ticker"> + <template #header><i class="fas fa-rss-square"></i>RSS</template> + <template #func><button class="_button" @click="configure"><i class="fas fa-cog"></i></button></template> + + <div class="ekmkgxbk"> + <MkLoading v-if="fetching"/> + <div v-else class="feed"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="widgetProps.duration" :reverse="widgetProps.reverse"> + <span v-for="item in items" class="item"> + <a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span> + </span> + </MarqueeText> + </transition> + </div> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref, watch } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import MarqueeText from '@/components/MkMarquee.vue'; +import { GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; +import { useInterval } from '@/scripts/use-interval'; +import { shuffle } from '@/scripts/shuffle'; + +const name = 'rssTicker'; + +const widgetPropsDef = { + url: { + 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, + }, + duration: { + type: 'range' as const, + default: 70, + step: 1, + min: 5, + max: 200, + }, + reverse: { + type: 'boolean' as const, + default: false, + }, + showHeader: { + type: 'boolean' as const, + default: false, + }, + transparent: { + type: 'boolean' as const, + default: false, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const items = ref([]); +const fetching = ref(true); +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++; + }); + }); +}; + +watch(() => widgetProps.url, tick); + +useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.ekmkgxbk { + > .feed { + --height: 42px; + padding: 0; + font-size: 0.9em; + line-height: var(--height); + height: var(--height); + contain: strict; + + ::v-deep(.item) { + display: inline-flex; + align-items: center; + vertical-align: bottom; + color: var(--fg); + + > .divider { + display: inline-block; + width: 0.5px; + height: 16px; + margin: 0 1em; + background: var(--divider); + } + } + } +} +</style> diff --git a/packages/client/src/widgets/rss.vue b/packages/client/src/widgets/rss.vue index fc65f11813..3258b6c028 100644 --- a/packages/client/src/widgets/rss.vue +++ b/packages/client/src/widgets/rss.vue @@ -6,7 +6,7 @@ <div class="ekmkgxbj"> <MkLoading v-if="fetching"/> <div v-else class="feed"> - <a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> + <a v-for="item in items" class="item" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> </div> </div> </MkContainer> @@ -14,22 +14,23 @@ <script lang="ts" setup> import { onMounted, onUnmounted, ref, watch } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; -import MkContainer from '@/components/ui/container.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import { useInterval } from '@/scripts/use-interval'; const name = 'rss'; const widgetPropsDef = { - showHeader: { - type: 'boolean' as const, - default: true, - }, url: { type: 'string' as const, default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', }, + showHeader: { + type: 'boolean' as const, + default: true, + }, }; type WidgetProps = GetFormResultType<typeof widgetPropsDef>; @@ -50,7 +51,7 @@ const items = ref([]); const fetching = ref(true); const tick = () => { - fetch(`https://api.rss2json.com/v1/api.json?rss_url=${widgetProps.url}`, {}).then(res => { + fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => { res.json().then(feed => { items.value = feed.items; fetching.value = false; @@ -60,12 +61,9 @@ const tick = () => { watch(() => widgetProps.url, tick); -onMounted(() => { - tick(); - const intervalId = window.setInterval(tick, 60000); - onUnmounted(() => { - window.clearInterval(intervalId); - }); +useInterval(tick, 60000, { + immediate: true, + afterMounted: true, }); defineExpose<WidgetComponentExpose>({ @@ -81,7 +79,7 @@ defineExpose<WidgetComponentExpose>({ padding: 0; font-size: 0.9em; - > a { + > .item { display: block; padding: 8px 16px; color: var(--fg); diff --git a/packages/client/src/widgets/server-metric/index.vue b/packages/client/src/widgets/server-metric/index.vue index 9e86b811d1..cf4accfa2c 100644 --- a/packages/client/src/widgets/server-metric/index.vue +++ b/packages/client/src/widgets/server-metric/index.vue @@ -1,6 +1,6 @@ <template> <MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent"> - <template #header><i class="fas fa-server"></i>{{ $ts._widgets.serverMetric }}</template> + <template #header><i class="fas fa-server"></i>{{ i18n.ts._widgets.serverMetric }}</template> <template #func><button class="_button" @click="toggleView()"><i class="fas fa-sort"></i></button></template> <div v-if="meta" class="mkw-serverMetric"> @@ -15,16 +15,17 @@ <script lang="ts" setup> import { onMounted, onUnmounted, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from '../widget'; -import MkContainer from '@/components/ui/container.vue'; import XCpuMemory from './cpu-mem.vue'; import XNet from './net.vue'; import XCpu from './cpu.vue'; import XMemory from './mem.vue'; import XDisk from './disk.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; import { stream } from '@/stream'; +import { i18n } from '@/i18n'; const name = 'serverMetric'; diff --git a/packages/client/src/widgets/slideshow.vue b/packages/client/src/widgets/slideshow.vue index fd78edbe40..e317b8ab94 100644 --- a/packages/client/src/widgets/slideshow.vue +++ b/packages/client/src/widgets/slideshow.vue @@ -2,7 +2,7 @@ <div class="kvausudm _panel mkw-slideshow" :style="{ height: widgetProps.height + 'px' }"> <div @click="choose"> <p v-if="widgetProps.folderId == null"> - {{ $ts.folder }} + {{ i18n.ts.folder }} </p> <p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ $t('no-image') }}</p> <div ref="slideA" class="slide a"></div> @@ -13,9 +13,11 @@ <script lang="ts" setup> import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { i18n } from '@/i18n'; const name = 'slideshow'; @@ -75,7 +77,7 @@ const fetch = () => { os.api('drive/files', { folderId: widgetProps.folderId, type: 'image/*', - limit: 100 + limit: 100, }).then(res => { images.value = res; fetching.value = false; @@ -96,15 +98,15 @@ const choose = () => { }); }; +useInterval(change, 10000, { + immediate: false, + afterMounted: true, +}); + onMounted(() => { if (widgetProps.folderId != null) { fetch(); } - - const intervalId = window.setInterval(change, 10000); - onUnmounted(() => { - window.clearInterval(intervalId); - }); }); defineExpose<WidgetComponentExpose>({ diff --git a/packages/client/src/widgets/timeline.vue b/packages/client/src/widgets/timeline.vue index 3bcad1ae29..718162667d 100644 --- a/packages/client/src/widgets/timeline.vue +++ b/packages/client/src/widgets/timeline.vue @@ -24,8 +24,8 @@ import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'; import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; import * as os from '@/os'; -import MkContainer from '@/components/ui/container.vue'; -import XTimeline from '@/components/timeline.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import XTimeline from '@/components/MkTimeline.vue'; import { $i } from '@/account'; import { i18n } from '@/i18n'; diff --git a/packages/client/src/widgets/trends.vue b/packages/client/src/widgets/trends.vue index 9680f1c892..a783c04215 100644 --- a/packages/client/src/widgets/trends.vue +++ b/packages/client/src/widgets/trends.vue @@ -1,6 +1,6 @@ <template> <MkContainer :show-header="widgetProps.showHeader" class="mkw-trends"> - <template #header><i class="fas fa-hashtag"></i>{{ $ts._widgets.trends }}</template> + <template #header><i class="fas fa-hashtag"></i>{{ i18n.ts._widgets.trends }}</template> <div class="wbrkwala"> <MkLoading v-if="fetching"/> @@ -19,11 +19,13 @@ <script lang="ts" setup> import { onMounted, onUnmounted, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; -import MkContainer from '@/components/ui/container.vue'; -import MkMiniChart from '@/components/mini-chart.vue'; +import { GetFormResultType } from '@/scripts/form'; +import MkContainer from '@/components/MkContainer.vue'; +import MkMiniChart from '@/components/MkMiniChart.vue'; import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { i18n } from '@/i18n'; const name = 'hashtags'; @@ -58,12 +60,9 @@ const fetch = () => { }); }; -onMounted(() => { - fetch(); - const intervalId = window.setInterval(fetch, 1000 * 60); - onUnmounted(() => { - window.clearInterval(intervalId); - }); +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, }); defineExpose<WidgetComponentExpose>({ diff --git a/packages/client/src/widgets/unix-clock.vue b/packages/client/src/widgets/unix-clock.vue new file mode 100644 index 0000000000..cf85ac782c --- /dev/null +++ b/packages/client/src/widgets/unix-clock.vue @@ -0,0 +1,116 @@ +<template> +<div class="mkw-unixClock _monospace" :class="{ _panel: !widgetProps.transparent }" :style="{ fontSize: `${widgetProps.fontSize}em` }"> + <div v-if="widgetProps.showLabel" class="label">UNIX Epoch</div> + <div class="time"> + <span v-text="ss"></span> + <span v-if="widgetProps.showMs" class="colon" :class="{ showColon }">:</span> + <span v-if="widgetProps.showMs" v-text="ms"></span> + </div> + <div v-if="widgetProps.showLabel" class="label">UTC</div> +</div> +</template> + +<script lang="ts" setup> +import { onUnmounted, ref, watch } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; + +const name = 'unixClock'; + +const widgetPropsDef = { + transparent: { + type: 'boolean' as const, + default: false, + }, + fontSize: { + type: 'number' as const, + default: 1.5, + step: 0.1, + }, + showMs: { + type: 'boolean' as const, + default: true, + }, + showLabel: { + type: 'boolean' as const, + default: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +let intervalId; +const ss = ref(''); +const ms = ref(''); +const showColon = ref(false); +let prevSec: string | null = null; + +watch(showColon, (v) => { + if (v) { + window.setTimeout(() => { + showColon.value = false; + }, 30); + } +}); + +const tick = () => { + const now = new Date(); + ss.value = Math.floor(now.getTime() / 1000).toString(); + ms.value = Math.floor(now.getTime() % 1000 / 10).toString().padStart(2, '0'); + if (ss.value !== prevSec) showColon.value = true; + prevSec = ss.value; +}; + +tick(); + +watch(() => widgetProps.showMs, () => { + if (intervalId) window.clearInterval(intervalId); + intervalId = window.setInterval(tick, widgetProps.showMs ? 10 : 1000); +}, { immediate: true }); + +onUnmounted(() => { + window.clearInterval(intervalId); +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.mkw-unixClock { + padding: 16px 0; + text-align: center; + + > .label { + font-size: 65%; + opacity: 0.7; + } + + > .time { + > .colon { + opacity: 0; + transition: opacity 1s ease; + + &.showColon { + opacity: 1; + transition: opacity 0s; + } + } + } +} +</style> diff --git a/packages/client/src/widgets/widget.ts b/packages/client/src/widgets/widget.ts index 9626d01619..8bd56a5966 100644 --- a/packages/client/src/widgets/widget.ts +++ b/packages/client/src/widgets/widget.ts @@ -2,6 +2,7 @@ import { reactive, watch } from 'vue'; import { throttle } from 'throttle-debounce'; import { Form, GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; +import { deepClone } from '@/scripts/clone'; export type Widget<P extends Record<string, unknown>> = { id: string; @@ -32,24 +33,25 @@ export const useWidgetPropsManager = <F extends Form & Record<string, { default: save: () => void; configure: () => void; } => { - const widgetProps = reactive(props.widget ? JSON.parse(JSON.stringify(props.widget.data)) : {}); + const widgetProps = reactive(props.widget ? deepClone(props.widget.data) : {}); const mergeProps = () => { for (const prop of Object.keys(propsDef)) { - if (widgetProps.hasOwnProperty(prop)) continue; - widgetProps[prop] = propsDef[prop].default; + if (typeof widgetProps[prop] === 'undefined') { + widgetProps[prop] = propsDef[prop].default; + } } }; watch(widgetProps, () => { mergeProps(); - }, { deep: true, immediate: true, }); + }, { deep: true, immediate: true }); const save = throttle(3000, () => { emit('updateProps', widgetProps); }); const configure = async () => { - const form = JSON.parse(JSON.stringify(propsDef)); + const form = deepClone(propsDef); for (const item of Object.keys(form)) { form[item].default = widgetProps[item]; } |