diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
| commit | 9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch) | |
| tree | ce5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/components | |
| parent | wip: retention for dashboard (diff) | |
| download | sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2 sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip | |
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/components')
176 files changed, 24904 insertions, 0 deletions
diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue new file mode 100644 index 0000000000..9a3464b640 --- /dev/null +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -0,0 +1,109 @@ +<template> +<div class="bcekxzvu _gap _panel"> + <div class="target"> + <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"/> + <MkAcct class="acct" :user="report.targetUser" style="display: block;"/> + </div> + </MkA> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.registeredDate }}</template> + <template #value>{{ new Date(report.targetUser.createdAt).toLocaleString() }} (<MkTime :time="report.targetUser.createdAt"/>)</template> + </MkKeyValue> + </div> + <div class="detail"> + <div> + <Mfm :text="report.comment"/> + </div> + <hr/> + <div>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></div> + <div v-if="report.assignee"> + {{ 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"> + {{ i18n.ts.forwardReport }} + <template #caption>{{ i18n.ts.forwardReportIsAnonymous }}</template> + </MkSwitch> + <MkButton v-if="!report.resolved" primary @click="resolve">{{ i18n.ts.abuseMarkAsResolved }}</MkButton> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import MkButton from '@/components/MkButton.vue'; +import MkSwitch from '@/components/form/switch.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; +}>(); + +const emit = defineEmits<{ + (ev: 'resolved', reportId: string): void; +}>(); + +let forward = $ref(props.report.forwarded); + +function resolve() { + os.apiWithDialog('admin/resolve-abuse-user-report', { + forward: forward, + reportId: props.report.id, + }).then(() => { + emit('resolved', props.report.id); + }); +} +</script> + +<style lang="scss" scoped> +.bcekxzvu { + display: flex; + + > .target { + width: 35%; + box-sizing: border-box; + text-align: left; + padding: 24px; + border-right: solid 1px var(--divider); + + > .info { + display: flex; + box-sizing: border-box; + align-items: center; + padding: 14px; + border-radius: 8px; + --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; + + > .avatar { + width: 42px; + height: 42px; + } + + > .names { + margin-left: 0.3em; + padding: 0 8px; + flex: 1; + + > .name { + font-weight: bold; + } + } + } + } + + > .detail { + flex: 1; + padding: 24px; + } +} +</style> diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue new file mode 100644 index 0000000000..039f77c859 --- /dev/null +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -0,0 +1,65 @@ +<template> +<XWindow ref="uiWindow" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')"> + <template #header> + <i class="ti ti-exclamation-circle" style="margin-right: 0.5em;"></i> + <I18n :src="i18n.ts.reportAbuseOf" tag="span"> + <template #name> + <b><MkAcct :user="user"/></b> + </template> + </I18n> + </template> + <div class="dpvffvvy _monolithic_"> + <div class="_section"> + <MkTextarea v-model="comment"> + <template #label>{{ i18n.ts.details }}</template> + <template #caption>{{ i18n.ts.fillAbuseReportDescription }}</template> + </MkTextarea> + </div> + <div class="_section"> + <MkButton primary full :disabled="comment.length === 0" @click="send">{{ i18n.ts.send }}</MkButton> + </div> + </div> +</XWindow> +</template> + +<script setup lang="ts"> +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import XWindow from '@/components/MkWindow.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + user: Misskey.entities.User; + initialComment?: string; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +const uiWindow = ref<InstanceType<typeof XWindow>>(); +const comment = ref(props.initialComment || ''); + +function send() { + os.apiWithDialog('users/report-abuse', { + userId: props.user.id, + comment: comment.value, + }, undefined).then(res => { + os.alert({ + type: 'success', + text: i18n.ts.abuseReported, + }); + uiWindow.value?.close(); + emit('closed'); + }); +} +</script> + +<style lang="scss" scoped> +.dpvffvvy { + --root-margin: 16px; +} +</style> diff --git a/packages/frontend/src/components/MkActiveUsersHeatmap.vue b/packages/frontend/src/components/MkActiveUsersHeatmap.vue new file mode 100644 index 0000000000..02b2eeeb36 --- /dev/null +++ b/packages/frontend/src/components/MkActiveUsersHeatmap.vue @@ -0,0 +1,236 @@ +<template> +<div ref="rootEl"> + <MkLoading v-if="fetching"/> + <div v-else> + <canvas ref="chartEl"></canvas> + </div> +</div> +</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 * as os from '@/os'; +import 'chartjs-adapter-date-fns'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; +import { chartVLine } from '@/scripts/chart-vline'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + MatrixController, MatrixElement, +); + +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 rootEl = $ref<HTMLDivElement>(null); +const chartEl = $ref<HTMLCanvasElement>(null); +const now = new Date(); +let chartInstance: Chart = null; +let fetching = $ref(true); + +const { handler: externalTooltipHandler } = useChartTooltip({ + position: 'middle', +}); + +async function renderChart() { + if (chartInstance) { + chartInstance.destroy(); + } + + const wide = rootEl.offsetWidth > 700; + const narrow = rootEl.offsetWidth < 400; + + const weeks = wide ? 50 : narrow ? 10 : 25; + const chartLimit = 7 * weeks; + + 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) => { + const dt = getDate(i); + const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`; + return { + x: iso, + y: dt.getDay(), + d: iso, + v, + }; + }); + }; + + const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); + + fetching = false; + + await nextTick(); + + 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 color = '#3498db'; + + const max = Math.max(...raw.readWrite); + + const marginEachCell = 4; + + chartInstance = new Chart(chartEl, { + type: 'matrix', + data: { + datasets: [{ + label: 'Read & Write', + data: format(raw.readWrite), + pointRadius: 0, + borderWidth: 0, + borderJoinStyle: 'round', + borderRadius: 3, + backgroundColor(c) { + const value = c.dataset.data[c.dataIndex].v; + const a = value / max; + return alpha(color, a); + }, + fill: true, + width(c) { + const a = c.chart.chartArea ?? {}; + // 20週間 + return (a.right - a.left) / weeks - marginEachCell; + }, + height(c) { + const a = c.chart.chartArea ?? {}; + // 7日 + return (a.bottom - a.top) / 7 - marginEachCell; + }, + }], + }, + options: { + aspectRatio: wide ? 6 : narrow ? 1.8 : 3.2, + layout: { + padding: { + left: 8, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + offset: true, + position: 'bottom', + time: { + unit: 'week', + round: 'week', + isoWeekday: 0, + displayFormats: { + week: 'MMM dd', + }, + }, + grid: { + display: false, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: true, + maxRotation: 0, + autoSkipPadding: 8, + }, + }, + y: { + offset: true, + reverse: true, + position: 'right', + grid: { + display: false, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + maxRotation: 0, + autoSkip: true, + padding: 1, + font: { + size: 9, + }, + callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value], + }, + }, + }, + animation: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + callbacks: { + title(context) { + const v = context[0].dataset.data[context[0].dataIndex]; + return v.d; + }, + label(context) { + const v = context.dataset.data[context.dataIndex]; + return ['Active: ' + v.v]; + }, + }, + //mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + }); +} + +onMounted(async () => { + renderChart(); +}); +</script> diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue new file mode 100644 index 0000000000..40ef626aed --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue new file mode 100644 index 0000000000..72783921d5 --- /dev/null +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -0,0 +1,476 @@ +<template> +<div ref="rootEl" class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}"> + <ol v-if="type === 'user'" ref="suggests" class="users"> + <li v-for="user in users" tabindex="-1" class="user" @click="complete(type, user)" @keydown="onKeydown"> + <img class="avatar" :src="user.avatarUrl"/> + <span class="name"> + <MkUserName :key="user.id" :user="user"/> + </span> + <span class="username">@{{ acct(user) }}</span> + </li> + <li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li> + </ol> + <ol v-else-if="hashtags.length > 0" ref="suggests" class="hashtags"> + <li v-for="hashtag in hashtags" tabindex="-1" @click="complete(type, hashtag)" @keydown="onKeydown"> + <span class="name">{{ hashtag }}</span> + </li> + </ol> + <ol v-else-if="emojis.length > 0" ref="suggests" class="emojis"> + <li v-for="emoji in emojis" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown"> + <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.emojiStyle != 'native'" 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> + </ol> + <ol v-else-if="mfmTags.length > 0" ref="suggests" class="mfmTags"> + <li v-for="tag in mfmTags" tabindex="-1" @click="complete(type, tag)" @keydown="onKeydown"> + <span class="tag">{{ tag }}</span> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; +import contains from '@/scripts/contains'; +import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { acct } from '@/filters/user'; +import * as os from '@/os'; +import { MFM_TAGS } from '@/scripts/mfm-tags'; +import { defaultStore } from '@/store'; +import { emojilist } from '@/scripts/emojilist'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; + +type EmojiDef = { + emoji: string; + name: string; + aliasOf?: string; + url?: string; + isCustomEmoji?: boolean; +}; + +const lib = emojilist.filter(x => x.category !== 'flags'); + +const emjdb: EmojiDef[] = lib.map(x => ({ + emoji: x.char, + name: x.name, + url: char2path(x.char), +})); + +const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; + +for (const x of lib) { + if (x.keywords) { + for (const k of x.keywords) { + emjdb.push({ + emoji: x.char, + name: k, + aliasOf: x.name, + url: char2path(x.char), + }); + } + } +} + +emjdb.sort((a, b) => a.name.length - b.name.length); + +//#region Construct Emoji DB +const customEmojis = instance.emojis; +const emojiDefinitions: EmojiDef[] = []; + +for (const x of customEmojis) { + emojiDefinitions.push({ + name: x.name, + emoji: `:${x.name}:`, + url: x.url, + isCustomEmoji: true, + }); + + if (x.aliases) { + for (const alias of x.aliases) { + emojiDefinitions.push({ + name: alias, + aliasOf: x.name, + emoji: `:${x.name}:`, + url: x.url, + isCustomEmoji: true, + }); + } + } +} + +emojiDefinitions.sort((a, b) => a.name.length - b.name.length); + +const emojiDb = markRaw(emojiDefinitions.concat(emjdb)); +//#endregion + +export default { + emojiDb, + emojiDefinitions, + emojilist, + customEmojis, +}; +</script> + +<script lang="ts" setup> +const props = defineProps<{ + type: string; + q: string | null; + textarea: HTMLTextAreaElement; + close: () => void; + x: number; + y: number; +}>(); + +const emit = defineEmits<{ + (event: 'done', value: { type: string; value: any }): void; + (event: 'closed'): void; +}>(); + +const suggests = ref<Element>(); +const rootEl = ref<HTMLDivElement>(); + +const fetching = ref(true); +const users = ref<any[]>([]); +const hashtags = ref<any[]>([]); +const emojis = ref<(EmojiDef)[]>([]); +const items = ref<Element[] | HTMLCollection>([]); +const mfmTags = ref<string[]>([]); +const select = ref(-1); +const zIndex = os.claimZIndex('high'); + +function complete(type: string, value: any) { + emit('done', { type, value }); + emit('closed'); + if (type === 'emoji') { + let recents = defaultStore.state.recentlyUsedEmojis; + recents = recents.filter((emoji: any) => emoji !== value); + recents.unshift(value); + defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); + } +} + +function setPosition() { + if (!rootEl.value) return; + if (props.x + rootEl.value.offsetWidth > window.innerWidth) { + rootEl.value.style.left = (window.innerWidth - rootEl.value.offsetWidth) + 'px'; + } else { + rootEl.value.style.left = `${props.x}px`; + } + if (props.y + rootEl.value.offsetHeight > window.innerHeight) { + rootEl.value.style.top = (props.y - rootEl.value.offsetHeight) + 'px'; + rootEl.value.style.marginTop = '0'; + } else { + rootEl.value.style.top = props.y + 'px'; + rootEl.value.style.marginTop = 'calc(1em + 8px)'; + } +} + +function exec() { + select.value = -1; + if (suggests.value) { + for (const el of Array.from(items.value)) { + el.removeAttribute('data-selected'); + } + } + if (props.type === 'user') { + if (!props.q) { + users.value = []; + fetching.value = false; + return; + } + + const cacheKey = `autocomplete:user:${props.q}`; + const cache = sessionStorage.getItem(cacheKey); + + if (cache) { + users.value = JSON.parse(cache); + fetching.value = false; + } else { + os.api('users/search-by-username-and-host', { + username: props.q, + limit: 10, + detail: false, + }).then(searchedUsers => { + users.value = searchedUsers as any[]; + fetching.value = false; + // キャッシュ + sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers)); + }); + } + } else if (props.type === 'hashtag') { + if (!props.q || props.q === '') { + hashtags.value = JSON.parse(localStorage.getItem('hashtags') || '[]'); + fetching.value = false; + } else { + const cacheKey = `autocomplete:hashtag:${props.q}`; + const cache = sessionStorage.getItem(cacheKey); + if (cache) { + const hashtags = JSON.parse(cache); + hashtags.value = hashtags; + fetching.value = false; + } else { + os.api('hashtags/search', { + query: props.q, + limit: 30, + }).then(searchedHashtags => { + hashtags.value = searchedHashtags as any[]; + fetching.value = false; + // キャッシュ + sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags)); + }); + } + } + } else if (props.type === 'emoji') { + if (!props.q || props.q === '') { + // 最近使った絵文字をサジェスト + emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[]; + return; + } + + const matched: EmojiDef[] = []; + const max = 30; + + emojiDb.some(x => { + if (x.name.startsWith(props.q ?? '') && !x.aliasOf && !matched.some(y => y.emoji === x.emoji)) matched.push(x); + return matched.length === max; + }); + + if (matched.length < max) { + emojiDb.some(x => { + if (x.name.startsWith(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x); + return matched.length === max; + }); + } + + if (matched.length < max) { + emojiDb.some(x => { + if (x.name.includes(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x); + return matched.length === max; + }); + } + + emojis.value = matched; + } else if (props.type === 'mfmTag') { + if (!props.q || props.q === '') { + mfmTags.value = MFM_TAGS; + return; + } + + mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? '')); + } +} + +function onMousedown(event: Event) { + if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close(); +} + +function onKeydown(event: KeyboardEvent) { + const cancel = () => { + event.preventDefault(); + event.stopPropagation(); + }; + + switch (event.key) { + case 'Enter': + if (select.value !== -1) { + cancel(); + (items.value[select.value] as any).click(); + } else { + props.close(); + } + break; + + case 'Escape': + cancel(); + props.close(); + break; + + case 'ArrowUp': + if (select.value !== -1) { + cancel(); + selectPrev(); + } else { + props.close(); + } + break; + + case 'Tab': + case 'ArrowDown': + cancel(); + selectNext(); + break; + + default: + event.stopPropagation(); + props.textarea.focus(); + } +} + +function selectNext() { + if (++select.value >= items.value.length) select.value = 0; + if (items.value.length === 0) select.value = -1; + applySelect(); +} + +function selectPrev() { + if (--select.value < 0) select.value = items.value.length - 1; + applySelect(); +} + +function applySelect() { + for (const el of Array.from(items.value)) { + el.removeAttribute('data-selected'); + } + + if (select.value !== -1) { + items.value[select.value].setAttribute('data-selected', 'true'); + (items.value[select.value] as any).focus(); + } +} + +function chooseUser() { + props.close(); + os.selectUser().then(user => { + complete('user', user); + props.textarea.focus(); + }); +} + +onUpdated(() => { + setPosition(); + items.value = suggests.value?.children ?? []; +}); + +onMounted(() => { + setPosition(); + + props.textarea.addEventListener('keydown', onKeydown); + + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.addEventListener('mousedown', onMousedown); + } + + nextTick(() => { + exec(); + + watch(() => props.q, () => { + nextTick(() => { + exec(); + }); + }); + }); +}); + +onBeforeUnmount(() => { + props.textarea.removeEventListener('keydown', onKeydown); + + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.removeEventListener('mousedown', onMousedown); + } +}); +</script> + +<style lang="scss" scoped> +.swhvrteh { + position: fixed; + max-width: 100%; + margin-top: calc(1em + 8px); + overflow: hidden; + transition: top 0.1s ease, left 0.1s ease; + + > ol { + display: block; + margin: 0; + padding: 4px 0; + max-height: 190px; + max-width: 500px; + overflow: auto; + list-style: none; + + > li { + display: flex; + align-items: center; + padding: 4px 12px; + white-space: nowrap; + overflow: hidden; + font-size: 0.9em; + cursor: default; + + &, * { + user-select: none; + } + + * { + overflow: hidden; + text-overflow: ellipsis; + } + + &:hover { + background: var(--X3); + } + + &[data-selected='true'] { + background: var(--accent); + + &, * { + color: #fff !important; + } + } + + &:active { + background: var(--accentDarken); + + &, * { + color: #fff !important; + } + } + } + } + + > .users > li { + + .avatar { + min-width: 28px; + min-height: 28px; + max-width: 28px; + max-height: 28px; + margin: 0 8px 0 0; + border-radius: 100%; + } + + .name { + margin: 0 8px 0 0; + } + } + + > .emojis > li { + + .emoji { + display: inline-block; + margin: 0 4px 0 0; + width: 24px; + + > img { + width: 24px; + vertical-align: bottom; + } + } + + .alias { + margin: 0 0 0 8px; + } + } + + > .mfmTags > li { + + .name { + } + } +} +</style> diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue new file mode 100644 index 0000000000..162338b639 --- /dev/null +++ b/packages/frontend/src/components/MkAvatars.vue @@ -0,0 +1,24 @@ +<template> +<div> + <div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;"> + <MkAvatar :user="user" style="width:32px;height:32px;" :show-indicator="true"/> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref } from 'vue'; +import * as os from '@/os'; + +const props = defineProps<{ + userIds: string[]; +}>(); + +const users = ref([]); + +onMounted(async () => { + users.value = await os.api('users/show', { + userIds: props.userIds, + }); +}); +</script> diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue new file mode 100644 index 0000000000..891645bb2a --- /dev/null +++ b/packages/frontend/src/components/MkButton.vue @@ -0,0 +1,227 @@ +<template> +<button + v-if="!link" + ref="el" class="bghgjjyj _button" + :class="{ inline, primary, gradate, danger, rounded, full }" + :type="type" + @click="emit('click', $event)" + @mousedown="onMousedown" +> + <div ref="ripples" class="ripples"></div> + <div class="content"> + <slot></slot> + </div> +</button> +<MkA + v-else class="bghgjjyj _button" + :class="{ inline, primary, gradate, danger, rounded, full }" + :to="to" + @mousedown="onMousedown" +> + <div ref="ripples" class="ripples"></div> + <div class="content"> + <slot></slot> + </div> +</MkA> +</template> + +<script lang="ts" setup> +import { nextTick, onMounted } from 'vue'; + +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; +}>(); + +const emit = defineEmits<{ + (ev: 'click', payload: MouseEvent): void; +}>(); + +let el = $ref<HTMLElement | null>(null); +let ripples = $ref<HTMLElement | null>(null); + +onMounted(() => { + if (props.autofocus) { + nextTick(() => { + el!.focus(); + }); + } +}); + +function distance(p, q): number { + return Math.hypot(p.x - q.x, p.y - q.y); +} + +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; +} + +function onMousedown(evt: MouseEvent): void { + const target = evt.target! as HTMLElement; + const rect = target.getBoundingClientRect(); + + 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> +.bghgjjyj { + position: relative; + z-index: 1; // 他コンポーネントのbox-shadowに隠されないようにするため + display: block; + min-width: 100px; + width: max-content; + padding: 7px 14px; + text-align: center; + font-weight: normal; + font-size: 95%; + box-shadow: none; + text-decoration: none; + background: var(--buttonBg); + border-radius: 5px; + overflow: clip; + box-sizing: border-box; + transition: background 0.1s ease; + + &:not(:disabled):hover { + background: var(--buttonHoverBg); + } + + &:not(:disabled):active { + background: var(--buttonHoverBg); + } + + &.full { + width: 100%; + } + + &.rounded { + border-radius: 999px; + } + + &.primary { + font-weight: bold; + color: var(--fgOnAccent) !important; + background: var(--accent); + + &:not(:disabled):hover { + background: var(--X8); + } + + &:not(:disabled):active { + background: var(--X8); + } + } + + &.gradate { + font-weight: bold; + color: var(--fgOnAccent) !important; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + + &:not(:disabled):hover { + background: linear-gradient(90deg, var(--X8), var(--X8)); + } + + &:not(:disabled):active { + background: linear-gradient(90deg, var(--X8), var(--X8)); + } + } + + &.danger { + color: #ff2a2a; + + &.primary { + color: #fff; + background: #ff2a2a; + + &:not(:disabled):hover { + background: #ff4242; + } + + &:not(:disabled):active { + background: #d42e2e; + } + } + } + + &:disabled { + opacity: 0.7; + } + + &:focus-visible { + outline: solid 2px var(--focus); + outline-offset: 2px; + } + + &.inline { + display: inline-block; + width: auto; + min-width: 100px; + } + + > .ripples { + position: absolute; + z-index: 0; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 6px; + overflow: hidden; + + ::v-deep(div) { + position: absolute; + width: 2px; + height: 2px; + border-radius: 100%; + background: rgba(0, 0, 0, 0.1); + opacity: 1; + transform: scale(1); + transition: all 0.5s cubic-bezier(0,.5,0,1); + } + } + + &.primary > .ripples ::v-deep(div) { + background: rgba(0, 0, 0, 0.15); + } + + > .content { + position: relative; + z-index: 1; + } +} +</style> diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue new file mode 100644 index 0000000000..6d218389fc --- /dev/null +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -0,0 +1,118 @@ +<template> +<div> + <span v-if="!available">{{ i18n.ts.waiting }}<MkEllipsis/></span> + <div ref="captchaEl"></div> +</div> +</template> + +<script lang="ts" setup> +import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; + +type Captcha = { + render(container: string | Node, options: { + readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown; + }): string; + remove(id: string): void; + execute(id: string): void; + reset(id?: string): void; + getResponse(id: string): string; +}; + +type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile'; + +type CaptchaContainer = { + readonly [_ in CaptchaProvider]?: Captcha; +}; + +declare global { + interface Window extends CaptchaContainer { } +} + +const props = defineProps<{ + provider: CaptchaProvider; + sitekey: string; + modelValue?: string | null; +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', v: string | null): void; +}>(); + +const available = ref(false); + +const captchaEl = ref<HTMLDivElement | undefined>(); + +const variable = computed(() => { + switch (props.provider) { + case 'hcaptcha': return 'hcaptcha'; + case 'recaptcha': return 'grecaptcha'; + case 'turnstile': return 'turnstile'; + } +}); + +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 scriptId = computed(() => `script-${props.provider}`) + +const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha); + +if (loaded) { + available.value = true; +} else { + (document.getElementById(scriptId.value) || document.head.appendChild(Object.assign(document.createElement('script'), { + async: true, + id: scriptId.value, + src: src.value, + }))) + .addEventListener('load', () => available.value = true); +} + +function reset() { + if (captcha.value.reset) captcha.value.reset(); +} + +function requestRender() { + if (captcha.value.render && captchaEl.value instanceof Element) { + captcha.value.render(captchaEl.value, { + sitekey: props.sitekey, + theme: defaultStore.state.darkMode ? 'dark' : 'light', + callback: callback, + 'expired-callback': callback, + 'error-callback': callback, + }); + } else { + window.setTimeout(requestRender, 1); + } +} + +function callback(response?: string) { + emit('update:modelValue', typeof response === 'string' ? response : null); +} + +onMounted(() => { + if (available.value) { + requestRender(); + } else { + watch(available, requestRender); + } +}); + +onBeforeUnmount(() => { + reset(); +}); + +defineExpose({ + reset, +}); + +</script> diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue new file mode 100644 index 0000000000..9e275d6172 --- /dev/null +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -0,0 +1,129 @@ +<template> +<button + class="hdcaacmi _button" + :class="{ wait, active: isFollowing, full }" + :disabled="wait" + @click="onClick" +> + <template v-if="!wait"> + <template v-if="isFollowing"> + <span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i> + </template> + <template v-else> + <span v-if="full">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i> + </template> + </template> + <template v-else> + <span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true"/> + </template> +</button> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + channel: Record<string, any>; + full?: boolean; +}>(), { + full: false, +}); + +const isFollowing = ref<boolean>(props.channel.isFollowing); +const wait = ref(false); + +async function onClick() { + wait.value = true; + + try { + if (isFollowing.value) { + await os.api('channels/unfollow', { + channelId: props.channel.id, + }); + isFollowing.value = false; + } else { + await os.api('channels/follow', { + channelId: props.channel.id, + }); + isFollowing.value = true; + } + } catch (err) { + console.error(err); + } finally { + wait.value = false; + } +} +</script> + +<style lang="scss" scoped> +.hdcaacmi { + position: relative; + display: inline-block; + font-weight: bold; + color: var(--accent); + background: transparent; + border: solid 1px var(--accent); + padding: 0; + height: 31px; + font-size: 16px; + border-radius: 32px; + background: #fff; + + &.full { + padding: 0 8px 0 12px; + font-size: 14px; + } + + &:not(.full) { + width: 31px; + } + + &:focus-visible { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -5px; + right: -5px; + bottom: -5px; + left: -5px; + border: 2px solid var(--focus); + border-radius: 32px; + } + } + + &:hover { + //background: mix($primary, #fff, 20); + } + + &:active { + //background: mix($primary, #fff, 40); + } + + &.active { + color: #fff; + background: var(--accent); + + &:hover { + background: var(--accentLighten); + border-color: var(--accentLighten); + } + + &:active { + background: var(--accentDarken); + border-color: var(--accentDarken); + } + } + + &.wait { + cursor: wait !important; + opacity: 0.7; + } + + > span { + margin-right: 6px; + } +} +</style> diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue new file mode 100644 index 0000000000..6ef50bddcf --- /dev/null +++ b/packages/frontend/src/components/MkChannelPreview.vue @@ -0,0 +1,154 @@ +<template> +<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1"> + <div class="banner" :style="bannerStyle"> + <div class="fade"></div> + <div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div> + <div class="status"> + <div> + <i class="ti ti-users ti-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="ti ti-pencil ti-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> + <article v-if="channel.description"> + <p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p> + </article> + <footer> + <span v-if="channel.lastNotedAt"> + {{ i18n.ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/> + </span> + </footer> +</MkA> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + channel: Record<string, any>; +}>(); + +const bannerStyle = computed(() => { + if (props.channel.bannerUrl) { + return { backgroundImage: `url(${props.channel.bannerUrl})` }; + } else { + return { backgroundColor: '#4c5e6d' }; + } +}); +</script> + +<style lang="scss" scoped> +.eftoefju { + display: block; + overflow: hidden; + width: 100%; + + &:hover { + text-decoration: none; + } + + > .banner { + position: relative; + width: 100%; + height: 200px; + background-position: center; + background-size: cover; + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + } + + > .name { + position: absolute; + top: 16px; + left: 16px; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.7); + color: #fff; + font-size: 1.2em; + } + + > .status { + position: absolute; + z-index: 1; + bottom: 16px; + right: 16px; + padding: 8px 12px; + font-size: 80%; + background: rgba(0, 0, 0, 0.7); + border-radius: 6px; + color: #fff; + } + } + + > article { + padding: 16px; + + > p { + margin: 0; + font-size: 1em; + } + } + + > footer { + padding: 12px 16px; + border-top: solid 0.5px var(--divider); + + > span { + opacity: 0.7; + font-size: 0.9em; + } + } + + @media (max-width: 550px) { + font-size: 0.9em; + + > .banner { + height: 80px; + + > .status { + display: none; + } + } + + > article { + padding: 12px; + } + + > footer { + display: none; + } + } + + @media (max-width: 500px) { + font-size: 0.8em; + + > .banner { + height: 70px; + } + + > article { + padding: 8px; + } + } +} + +</style> diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue new file mode 100644 index 0000000000..fbbc231b88 --- /dev/null +++ b/packages/frontend/src/components/MkChart.vue @@ -0,0 +1,859 @@ +<template> +<div class="cbbedffa"> + <canvas ref="chartEl"></canvas> + <div v-if="fetching" class="fetching"> + <MkLoading/> + </div> +</div> +</template> + +<script lang="ts" setup> +/* eslint-disable id-denylist -- + Chart.js has a `data` attribute in most chart definitions, which triggers the + id-denylist violation when setting it. This is causing about 60+ lint issues. + As this is part of Chart.js's API it makes sense to disable the check here. +*/ +import { onMounted, ref, watch, PropType, onUnmounted } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import 'chartjs-adapter-date-fns'; +import { enUS } from 'date-fns/locale'; +import zoomPlugin from 'chartjs-plugin-zoom'; +import gradient from 'chartjs-plugin-gradient'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import { chartVLine } from '@/scripts/chart-vline'; + +const props = defineProps({ + src: { + type: String, + required: true, + }, + args: { + type: Object, + required: false, + }, + limit: { + type: Number, + required: false, + default: 90, + }, + span: { + type: String as PropType<'hour' | 'day'>, + required: true, + }, + detailed: { + type: Boolean, + required: false, + default: false, + }, + stacked: { + type: Boolean, + required: false, + default: false, + }, + bar: { + type: Boolean, + required: false, + default: false, + }, + aspectRatio: { + type: Number, + required: false, + default: null, + }, +}); + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + zoomPlugin, + gradient, +); + +const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); +const negate = arr => arr.map(x => -x); +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 colors = { + blue: '#008FFB', + green: '#00E396', + yellow: '#FEB019', + red: '#FF4560', + purple: '#e300db', + orange: '#fe6919', + lime: '#bde800', + cyan: '#00e0e0', +}; +const colorSets = [colors.blue, colors.green, colors.yellow, colors.red, colors.purple]; +const getColor = (i) => { + return colorSets[i % colorSets.length]; +}; + +const now = new Date(); +let chartInstance: Chart = null; +let chartData: { + series: { + name: string; + type: 'line' | 'area'; + color?: string; + dashed?: boolean; + hidden?: boolean; + data: { + x: number; + y: number; + }[]; + }[]; +} = null; + +const chartEl = ref<HTMLCanvasElement>(null); +const fetching = ref(true); + +const getDate = (ago: number) => { + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + const h = now.getHours(); + + return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); +}; + +const format = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: v, + })); +}; + +const { handler: externalTooltipHandler } = useChartTooltip(); + +const render = () => { + if (chartInstance) { + chartInstance.destroy(); + } + + 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 maxes = chartData.series.map((x, i) => Math.max(...x.data.map(d => d.y))); + + chartInstance = new Chart(chartEl.value, { + type: props.bar ? 'bar' : 'line', + data: { + labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(), + datasets: chartData.series.map((x, i) => ({ + parsing: false, + label: x.name, + data: x.data.slice().reverse(), + tension: 0.3, + pointRadius: 0, + borderWidth: props.bar ? 0 : 2, + borderColor: x.color ? x.color : getColor(i), + borderDash: x.dashed ? [5, 5] : [], + borderJoinStyle: 'round', + borderRadius: props.bar ? 3 : undefined, + backgroundColor: props.bar ? (x.color ? x.color : getColor(i)) : alpha(x.color ? x.color : getColor(i), 0.1), + 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.35), + }, + }, + }, + barPercentage: 0.9, + categoryPercentage: 0.9, + fill: x.type === 'area', + clip: 8, + hidden: !!x.hidden, + })), + }, + options: { + aspectRatio: props.aspectRatio || 2.5, + layout: { + padding: { + left: 0, + right: 8, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + stacked: props.stacked, + offset: false, + time: { + stepSize: 1, + unit: props.span === 'day' ? 'month' : 'day', + }, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: props.detailed, + maxRotation: 0, + autoSkipPadding: 16, + }, + adapters: { + date: { + locale: enUS, + }, + }, + min: getDate(props.limit).getTime(), + }, + y: { + position: 'left', + stacked: props.stacked, + suggestedMax: 50, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: props.detailed, + //mirror: true, + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + elements: { + point: { + hoverRadius: 5, + hoverBorderWidth: 2, + }, + }, + animation: false, + plugins: { + legend: { + display: props.detailed, + position: 'bottom', + labels: { + boxWidth: 16, + }, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + zoom: props.detailed ? { + pan: { + enabled: true, + }, + zoom: { + wheel: { + enabled: true, + }, + pinch: { + enabled: true, + }, + drag: { + enabled: false, + }, + mode: 'x', + }, + limits: { + x: { + min: 'original', + max: 'original', + }, + y: { + min: 'original', + max: 'original', + }, + }, + } : undefined, + gradient, + }, + }, + plugins: [chartVLine(vLineColor)], + }); +}; + +const exportData = () => { + // TODO +}; + +const fetchFederationChart = async (): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/federation', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Received', + type: 'area', + data: format(raw.inboxInstances), + color: colors.blue, + }, { + name: 'Delivered', + type: 'area', + data: format(raw.deliveredInstances), + color: colors.green, + }, { + name: 'Stalled', + type: 'area', + data: format(raw.stalled), + color: colors.red, + }, { + name: 'Pub Active', + type: 'line', + data: format(raw.pubActive), + color: colors.purple, + }, { + name: 'Sub Active', + type: 'line', + data: format(raw.subActive), + color: colors.orange, + }, { + name: 'Pub & Sub', + type: 'line', + data: format(raw.pubsub), + dashed: true, + color: colors.cyan, + }, { + name: 'Pub', + type: 'line', + data: format(raw.pub), + dashed: true, + color: colors.purple, + }, { + name: 'Sub', + type: 'line', + data: format(raw.sub), + dashed: true, + color: colors.orange, + }], + }; +}; + +const fetchApRequestChart = async (): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/ap-request', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'In', + type: 'area', + color: '#008FFB', + data: format(raw.inboxReceived), + }, { + name: 'Out (succ)', + type: 'area', + color: '#00E396', + data: format(raw.deliverSucceeded), + }, { + name: 'Out (fail)', + type: 'area', + color: '#FEB019', + data: format(raw.deliverFailed), + }], + }; +}; + +const fetchNotesChart = async (type: string): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'All', + type: 'line', + data: format(type === 'combined' + ? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) + : sum(raw[type].inc, negate(raw[type].dec)), + ), + color: '#888888', + }, { + name: 'Renotes', + type: 'area', + data: format(type === 'combined' + ? sum(raw.local.diffs.renote, raw.remote.diffs.renote) + : raw[type].diffs.renote, + ), + color: colors.green, + }, { + name: 'Replies', + type: 'area', + data: format(type === 'combined' + ? sum(raw.local.diffs.reply, raw.remote.diffs.reply) + : raw[type].diffs.reply, + ), + color: colors.yellow, + }, { + name: 'Normal', + type: 'area', + data: format(type === 'combined' + ? sum(raw.local.diffs.normal, raw.remote.diffs.normal) + : raw[type].diffs.normal, + ), + color: colors.blue, + }, { + name: 'With file', + type: 'area', + data: format(type === 'combined' + ? sum(raw.local.diffs.withFile, raw.remote.diffs.withFile) + : raw[type].diffs.withFile, + ), + color: colors.purple, + }], + }; +}; + +const fetchNotesTotalChart = async (): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(sum(raw.local.total, raw.remote.total)), + }, { + name: 'Local', + type: 'area', + data: format(raw.local.total), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.total), + }], + }; +}; + +const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/users', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(total + ? sum(raw.local.total, raw.remote.total) + : sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)), + ), + }, { + name: 'Local', + type: 'area', + data: format(total + ? raw.local.total + : sum(raw.local.inc, negate(raw.local.dec)), + ), + }, { + name: 'Remote', + type: 'area', + data: format(total + ? raw.remote.total + : sum(raw.remote.inc, negate(raw.remote.dec)), + ), + }], + }; +}; + +const fetchActiveUsersChart = async (): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/active-users', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Read & Write', + type: 'area', + data: format(raw.readWrite), + color: colors.orange, + }, { + name: 'Write', + type: 'area', + data: format(raw.write), + color: colors.lime, + }, { + name: 'Read', + type: 'area', + data: format(raw.read), + color: colors.blue, + }, { + name: '< Week', + type: 'area', + data: format(raw.registeredWithinWeek), + color: colors.green, + }, { + name: '< Month', + type: 'area', + data: format(raw.registeredWithinMonth), + color: colors.yellow, + }, { + name: '< Year', + type: 'area', + data: format(raw.registeredWithinYear), + color: colors.red, + }, { + name: '> Week', + type: 'area', + data: format(raw.registeredOutsideWeek), + color: colors.yellow, + }, { + name: '> Month', + type: 'area', + data: format(raw.registeredOutsideMonth), + color: colors.red, + }, { + name: '> Year', + type: 'area', + data: format(raw.registeredOutsideYear), + color: colors.purple, + }], + }; +}; + +const fetchDriveChart = async (): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span }); + return { + bytes: true, + series: [{ + name: 'All', + type: 'line', + dashed: true, + data: format( + sum( + raw.local.incSize, + negate(raw.local.decSize), + raw.remote.incSize, + negate(raw.remote.decSize), + ), + ), + }, { + name: 'Local +', + type: 'area', + data: format(raw.local.incSize), + }, { + name: 'Local -', + type: 'area', + data: format(negate(raw.local.decSize)), + }, { + name: 'Remote +', + type: 'area', + data: format(raw.remote.incSize), + }, { + name: 'Remote -', + type: 'area', + data: format(negate(raw.remote.decSize)), + }], + }; +}; + +const fetchDriveFilesChart = async (): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'All', + type: 'line', + dashed: true, + data: format( + sum( + raw.local.incCount, + negate(raw.local.decCount), + raw.remote.incCount, + negate(raw.remote.decCount), + ), + ), + }, { + name: 'Local +', + type: 'area', + data: format(raw.local.incCount), + }, { + name: 'Local -', + type: 'area', + data: format(negate(raw.local.decCount)), + }, { + name: 'Remote +', + type: 'area', + data: format(raw.remote.incCount), + }, { + name: 'Remote -', + type: 'area', + data: format(negate(raw.remote.decCount)), + }], + }; +}; + +const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'In', + type: 'area', + color: '#008FFB', + data: format(raw.requests.received), + }, { + name: 'Out (succ)', + type: 'area', + color: '#00E396', + data: format(raw.requests.succeeded), + }, { + name: 'Out (fail)', + type: 'area', + color: '#FEB019', + data: format(raw.requests.failed), + }], + }; +}; + +const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Users', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.users.total + : sum(raw.users.inc, negate(raw.users.dec)), + ), + }], + }; +}; + +const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Notes', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.notes.total + : sum(raw.notes.inc, negate(raw.notes.dec)), + ), + }], + }; +}; + +const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Following', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.following.total + : sum(raw.following.inc, negate(raw.following.dec)), + ), + }, { + name: 'Followers', + type: 'area', + color: '#00E396', + data: format(total + ? raw.followers.total + : sum(raw.followers.inc, negate(raw.followers.dec)), + ), + }], + }; +}; + +const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + bytes: true, + series: [{ + name: 'Drive usage', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.drive.totalUsage + : sum(raw.drive.incUsage, negate(raw.drive.decUsage)), + ), + }], + }; +}; + +const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Drive files', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.drive.totalFiles + : sum(raw.drive.incFiles, negate(raw.drive.decFiles)), + ), + }], + }; +}; + +const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { + 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', + type: 'line', + data: format(sum(raw.inc, negate(raw.dec))), + color: '#888888', + }]), { + name: 'With file', + type: 'area', + data: format(raw.diffs.withFile), + color: colors.purple, + }, { + name: 'Renotes', + type: 'area', + data: format(raw.diffs.renote), + color: colors.green, + }, { + name: 'Replies', + type: 'area', + data: format(raw.diffs.reply), + color: colors.yellow, + }, { + name: 'Normal', + type: 'area', + data: format(raw.diffs.normal), + color: colors.blue, + }], + }; +}; + +const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Local', + type: 'area', + data: format(raw.local.followings.total), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.followings.total), + }], + }; +}; + +const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Local', + type: 'area', + data: format(raw.local.followers.total), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.followers.total), + }], + }; +}; + +const fetchPerUserDriveChart = async (): Promise<typeof chartData> => { + const raw = await os.apiGet('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Inc', + type: 'area', + data: format(raw.incSize), + }, { + name: 'Dec', + type: 'area', + data: format(raw.decSize), + }], + }; +}; + +const fetchAndRender = async () => { + const fetchData = () => { + switch (props.src) { + case 'federation': return fetchFederationChart(); + case 'ap-request': return fetchApRequestChart(); + case 'users': return fetchUsersChart(false); + case 'users-total': return fetchUsersChart(true); + case 'active-users': return fetchActiveUsersChart(); + case 'notes': return fetchNotesChart('combined'); + case 'local-notes': return fetchNotesChart('local'); + case 'remote-notes': return fetchNotesChart('remote'); + case 'notes-total': return fetchNotesTotalChart(); + case 'drive': return fetchDriveChart(); + case 'drive-files': return fetchDriveFilesChart(); + case 'instance-requests': return fetchInstanceRequestsChart(); + case 'instance-users': return fetchInstanceUsersChart(false); + case 'instance-users-total': return fetchInstanceUsersChart(true); + case 'instance-notes': return fetchInstanceNotesChart(false); + case 'instance-notes-total': return fetchInstanceNotesChart(true); + case 'instance-ff': return fetchInstanceFfChart(false); + case 'instance-ff-total': return fetchInstanceFfChart(true); + case 'instance-drive-usage': return fetchInstanceDriveUsageChart(false); + case 'instance-drive-usage-total': return fetchInstanceDriveUsageChart(true); + case 'instance-drive-files': return fetchInstanceDriveFilesChart(false); + case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true); + + case 'per-user-notes': return fetchPerUserNotesChart(); + case 'per-user-following': return fetchPerUserFollowingChart(); + case 'per-user-followers': return fetchPerUserFollowersChart(); + case 'per-user-drive': return fetchPerUserDriveChart(); + } + }; + fetching.value = true; + chartData = await fetchData(); + fetching.value = false; + render(); +}; + +watch(() => [props.src, props.span], fetchAndRender); + +onMounted(() => { + fetchAndRender(); +}); +/* eslint-enable id-denylist */ +</script> + +<style lang="scss" scoped> +.cbbedffa { + position: relative; + + > .fetching { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + -webkit-backdrop-filter: var(--blur, blur(12px)); + backdrop-filter: var(--blur, blur(12px)); + display: flex; + justify-content: center; + align-items: center; + cursor: wait; + } +} +</style> diff --git a/packages/frontend/src/components/MkChartTooltip.vue b/packages/frontend/src/components/MkChartTooltip.vue new file mode 100644 index 0000000000..d36f45463c --- /dev/null +++ b/packages/frontend/src/components/MkChartTooltip.vue @@ -0,0 +1,53 @@ +<template> +<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 './MkTooltip.vue'; + +defineProps<{ + showing: boolean; + x: number; + y: number; + title?: string; + series?: { + backgroundColor: string; + borderColor: string; + text: string; + }[]; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); +</script> + +<style lang="scss" scoped> +.qpcyisrl { + > .title { + margin-bottom: 4px; + } + + > .series { + > .color { + display: inline-block; + width: 8px; + height: 8px; + border-width: 1px; + border-style: solid; + margin-right: 8px; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue new file mode 100644 index 0000000000..b074028821 --- /dev/null +++ b/packages/frontend/src/components/MkCode.core.vue @@ -0,0 +1,20 @@ +<!-- 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> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import Prism from 'prismjs'; +import 'prismjs/themes/prism-okaidia.css'; + +const props = defineProps<{ + code: string; + lang?: string; + inline?: boolean; +}>(); + +const prismLang = computed(() => Prism.languages[props.lang] ? props.lang : 'js'); +const html = computed(() => Prism.highlight(props.code, Prism.languages[prismLang.value], prismLang.value)); +</script> diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue new file mode 100644 index 0000000000..1640258d5b --- /dev/null +++ b/packages/frontend/src/components/MkCode.vue @@ -0,0 +1,15 @@ +<template> +<XCode :code="code" :lang="lang" :inline="inline"/> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; + +defineProps<{ + code: string; + lang?: string; + inline?: boolean; +}>(); + +const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')); +</script> diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue new file mode 100644 index 0000000000..6d4d5be2bc --- /dev/null +++ b/packages/frontend/src/components/MkContainer.vue @@ -0,0 +1,275 @@ +<template> +<div v-size="{ max: [380] }" class="ukygtjoj _panel" :class="{ naked, thin, hideHeader: !showHeader, scrollable, closed: !showBody }"> + <header v-if="showHeader" ref="header"> + <div class="title"><slot name="header"></slot></div> + <div class="sub"> + <slot name="func"></slot> + <button v-if="foldable" class="_button" @click="() => showBody = !showBody"> + <template v-if="showBody"><i class="ti ti-chevron-up"></i></template> + <template v-else><i class="ti ti-chevron-down"></i></template> + </button> + </div> + </header> + <transition + :name="$store.state.animation ? 'container-toggle' : ''" + @enter="enter" + @after-enter="afterEnter" + @leave="leave" + @after-leave="afterLeave" + > + <div v-show="showBody" ref="content" class="content" :class="{ omitted }"> + <slot></slot> + <button v-if="omitted" class="fade _button" @click="() => { ignoreOmit = true; omitted = false; }"> + <span>{{ $ts.showMore }}</span> + </button> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + showHeader: { + type: Boolean, + required: false, + default: true, + }, + thin: { + type: Boolean, + required: false, + default: false, + }, + naked: { + type: Boolean, + required: false, + default: false, + }, + foldable: { + type: Boolean, + required: false, + default: false, + }, + expanded: { + type: Boolean, + required: false, + default: true, + }, + scrollable: { + type: Boolean, + required: false, + default: false, + }, + maxHeight: { + type: Number, + required: false, + default: null, + }, + }, + data() { + return { + showBody: this.expanded, + omitted: null, + ignoreOmit: false, + }; + }, + mounted() { + this.$watch('showBody', showBody => { + const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0; + this.$el.style.minHeight = `${headerHeight}px`; + if (showBody) { + this.$el.style.flexBasis = 'auto'; + } else { + this.$el.style.flexBasis = `${headerHeight}px`; + } + }, { + immediate: true, + }); + + this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px'); + + const calcOmit = () => { + if (this.omitted || this.ignoreOmit || this.maxHeight == null) return; + const height = this.$refs.content.offsetHeight; + this.omitted = height > this.maxHeight; + }; + + calcOmit(); + new ResizeObserver((entries, observer) => { + calcOmit(); + }).observe(this.$refs.content); + }, + methods: { + toggleContent(show: boolean) { + if (!this.foldable) return; + this.showBody = show; + }, + + enter(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = 0; + el.offsetHeight; // reflow + el.style.height = elementHeight + 'px'; + }, + afterEnter(el) { + el.style.height = null; + }, + leave(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = elementHeight + 'px'; + el.offsetHeight; // reflow + el.style.height = 0; + }, + afterLeave(el) { + el.style.height = null; + }, + }, +}); +</script> + +<style lang="scss" scoped> +.container-toggle-enter-active, .container-toggle-leave-active { + overflow-y: hidden; + transition: opacity 0.5s, height 0.5s !important; +} +.container-toggle-enter-from { + opacity: 0; +} +.container-toggle-leave-to { + opacity: 0; +} + +.ukygtjoj { + position: relative; + overflow: clip; + contain: content; + + &.naked { + background: transparent !important; + box-shadow: none !important; + } + + &.scrollable { + display: flex; + flex-direction: column; + + > .content { + overflow: auto; + } + } + + > header { + position: sticky; + top: var(--stickyTop, 0px); + left: 0; + color: var(--panelHeaderFg); + background: var(--panelHeaderBg); + border-bottom: solid 0.5px var(--panelHeaderDivider); + z-index: 2; + line-height: 1.4em; + + > .title { + margin: 0; + padding: 12px 16px; + + > ::v-deep(i) { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > .sub { + position: absolute; + z-index: 2; + top: 0; + right: 0; + height: 100%; + + > ::v-deep(button) { + width: 42px; + height: 100%; + } + } + } + + > .content { + --stickyTop: 0px; + + &.omitted { + position: relative; + max-height: var(--maxHeight); + overflow: hidden; + + > .fade { + display: block; + position: absolute; + z-index: 10; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + + > span { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); + } + + &:hover { + > span { + background: var(--panelHighlight); + } + } + } + } + } + + &.max-width_380px, &.thin { + > header { + > .title { + padding: 8px 10px; + font-size: 0.9em; + } + } + + > .content { + } + } +} + +@container (max-width: 380px) { + .ukygtjoj { + > header { + > .title { + padding: 8px 10px; + font-size: 0.9em; + } + } + } +} + +._forceContainerFull_ .ukygtjoj { + > header { + > .title { + padding: 12px 16px !important; + } + } +} + +._forceContainerFull_.ukygtjoj { + > header { + > .title { + padding: 12px 16px !important; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue new file mode 100644 index 0000000000..cfc9502b41 --- /dev/null +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -0,0 +1,85 @@ +<template> +<transition :name="$store.state.animation ? 'fade' : ''" appear> + <div ref="rootEl" class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> + <MkMenu :items="items" :align="'left'" @close="$emit('closed')"/> + </div> +</transition> +</template> + +<script lang="ts" setup> +import { onMounted, onBeforeUnmount } from '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<{ + items: MenuItem[]; + ev: MouseEvent; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +let rootEl = $ref<HTMLDivElement>(); + +let zIndex = $ref<number>(os.claimZIndex('high')); + +onMounted(() => { + let left = props.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 + let top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 + + const width = rootEl.offsetWidth; + const height = rootEl.offsetHeight; + + if (left + width - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - width + window.pageXOffset; + } + + if (top + height - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - height + window.pageYOffset; + } + + if (top < 0) { + top = 0; + } + + if (left < 0) { + left = 0; + } + + rootEl.style.top = `${top}px`; + rootEl.style.left = `${left}px`; + + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.addEventListener('mousedown', onMousedown); + } +}); + +onBeforeUnmount(() => { + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.removeEventListener('mousedown', onMousedown); + } +}); + +function onMousedown(evt: Event) { + if (!contains(rootEl, evt.target) && (rootEl !== evt.target)) emit('closed'); +} +</script> + +<style lang="scss" scoped> +.nvlagfpb { + position: absolute; +} + +.fade-enter-active, .fade-leave-active { + transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: left top; +} + +.fade-enter-from, .fade-leave-to { + opacity: 0; + transform: scale(0.9); +} +</style> diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue new file mode 100644 index 0000000000..ae18160dea --- /dev/null +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -0,0 +1,174 @@ +<template> +<XModalWindow + ref="dialogEl" + :width="800" + :height="500" + :scroll="false" + :with-ok-button="true" + @close="cancel()" + @ok="ok()" + @closed="$emit('closed')" +> + <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"> + <div v-if="loading" class="loading"> + <MkLoading/> + </div> + </Transition> + <div class="container"> + <img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad"> + </div> + </div> + </template> +</XModalWindow> +</template> + +<script lang="ts" setup> +import { nextTick, onMounted } from 'vue'; +import * as misskey from 'misskey-js'; +import Cropper from 'cropperjs'; +import tinycolor from 'tinycolor2'; +import XModalWindow from '@/components/MkModalWindow.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { defaultStore } from '@/store'; +import { apiUrl } from '@/config'; +import { i18n } from '@/i18n'; +import { getProxiedImageUrl } from '@/scripts/media-proxy'; + +const emit = defineEmits<{ + (ev: 'ok', cropped: misskey.entities.DriveFile): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); + +const props = defineProps<{ + file: misskey.entities.DriveFile; + aspectRatio: number; +}>(); + +const imgUrl = getProxiedImageUrl(props.file.url); +let dialogEl = $ref<InstanceType<typeof XModalWindow>>(); +let imgEl = $ref<HTMLImageElement>(); +let cropper: Cropper | null = null; +let loading = $ref(true); + +const ok = async () => { + const promise = new Promise<misskey.entities.DriveFile>(async (res) => { + const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas(); + croppedCanvas.toBlob(blob => { + const formData = new FormData(); + formData.append('file', blob); + formData.append('i', $i.token); + if (defaultStore.state.uploadFolder) { + formData.append('folderId', defaultStore.state.uploadFolder); + } + + window.fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: formData, + }) + .then(response => response.json()) + .then(f => { + res(f); + }); + }); + }); + + os.promiseDialog(promise); + + const f = await promise; + + emit('ok', f); + dialogEl.close(); +}; + +const cancel = () => { + emit('cancel'); + dialogEl.close(); +}; + +const onImageLoad = () => { + loading = false; + + if (cropper) { + cropper.getCropperImage()!.$center('contain'); + cropper.getCropperSelection()!.$center(); + } +}; + +onMounted(() => { + cropper = new Cropper(imgEl, { + }); + + const computedStyle = getComputedStyle(document.documentElement); + + const selection = cropper.getCropperSelection()!; + selection.themeColor = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(); + selection.aspectRatio = props.aspectRatio; + selection.initialAspectRatio = props.aspectRatio; + selection.outlined = true; + + window.setTimeout(() => { + cropper.getCropperImage()!.$center('contain'); + selection.$center(); + }, 100); + + // モーダルオープンアニメーションが終わったあとで再度調整 + window.setTimeout(() => { + cropper.getCropperImage()!.$center('contain'); + selection.$center(); + }, 500); +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.5s ease 0.5s; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.mk-cropper-dialog { + display: flex; + flex-direction: column; + width: var(--vw); + height: var(--vh); + position: relative; + + > .loading { + position: absolute; + z-index: 10; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + -webkit-backdrop-filter: var(--blur, blur(10px)); + backdrop-filter: var(--blur, blur(10px)); + background: rgba(0, 0, 0, 0.5); + } + + > .container { + flex: 1; + width: 100%; + height: 100%; + + > ::v-deep(cropper-canvas) { + width: 100%; + height: 100%; + + > cropper-selection > cropper-handle[action="move"] { + background: transparent; + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue new file mode 100644 index 0000000000..ee611921ef --- /dev/null +++ b/packages/frontend/src/components/MkCwButton.vue @@ -0,0 +1,62 @@ +<template> +<button class="nrvgflfu _button" @click="toggle"> + <b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b> + <span v-if="!modelValue">{{ label }}</span> +</button> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import { length } from 'stringz'; +import * as misskey from 'misskey-js'; +import { concat } from '@/scripts/array'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + modelValue: boolean; + note: misskey.entities.Note; +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', v: boolean): void; +}>(); + +const label = computed(() => { + return concat([ + props.note.text ? [i18n.t('_cw.chars', { count: length(props.note.text) })] : [], + props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length })] : [], + props.note.poll != null ? [i18n.ts.poll] : [], + ] as string[][]).join(' / '); +}); + +const toggle = () => { + emit('update:modelValue', !props.modelValue); +}; +</script> + +<style lang="scss" scoped> +.nrvgflfu { + display: inline-block; + padding: 4px 8px; + font-size: 0.7em; + color: var(--cwFg); + background: var(--cwBg); + border-radius: 2px; + + &:hover { + background: var(--cwHoverBg); + } + + > span { + margin-left: 4px; + + &:before { + content: '('; + } + + &:after { + content: ')'; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue new file mode 100644 index 0000000000..1f88bdf137 --- /dev/null +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -0,0 +1,189 @@ +<script lang="ts"> +import { defineComponent, h, PropType, TransitionGroup } from 'vue'; +import MkAd from '@/components/global/MkAd.vue'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; + +export default defineComponent({ + props: { + items: { + type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>, + required: true, + }, + direction: { + type: String, + required: false, + default: 'down', + }, + reversed: { + type: Boolean, + required: false, + default: false, + }, + noGap: { + type: Boolean, + required: false, + default: false, + }, + ad: { + type: Boolean, + required: false, + default: false, + }, + }, + + setup(props, { slots, expose }) { + function getDateText(time: string) { + const date = new Date(time).getDate(); + const month = new Date(time).getMonth() + 1; + return i18n.t('monthAndDay', { + month: month.toString(), + day: date.toString(), + }); + } + + if (props.items.length === 0) return; + + const renderChildren = () => props.items.map((item, i) => { + if (!slots || !slots.default) return; + + const el = slots.default({ + item: item, + })[0]; + if (el.key == null && item.id) el.key = item.id; + + if ( + i !== props.items.length - 1 && + new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate() + ) { + const separator = h('div', { + class: 'separator', + key: item.id + ':separator', + }, h('p', { + class: 'date', + }, [ + h('span', [ + h('i', { + class: 'ti ti-chevron-up icon', + }), + getDateText(item.createdAt), + ]), + h('span', [ + getDateText(props.items[i + 1].createdAt), + h('i', { + class: 'ti ti-chevron-down icon', + }), + ]), + ])); + + return [el, separator]; + } else { + if (props.ad && item._shouldInsertAd_) { + return [h(MkAd, { + class: 'a', // advertiseの意(ブロッカー対策) + key: item.id + ':ad', + prefer: ['horizontal', 'horizontal-big'], + }), el]; + } else { + return el; + } + } + }); + + 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' : ''), + }, + { default: renderChildren }); + }, +}); +</script> + +<style lang="scss"> +.sqadhkmv { + container-type: inline-size; + + > *:empty { + display: none; + } + + > *:not(:last-child) { + margin-bottom: var(--margin); + } + + > .list-move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } + + > .list-enter-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } + + &[data-direction="up"] { + > .list-enter-from { + opacity: 0; + transform: translateY(64px); + } + } + + &[data-direction="down"] { + > .list-enter-from { + opacity: 0; + transform: translateY(-64px); + } + } + + > .separator { + text-align: center; + + > .date { + display: inline-block; + position: relative; + margin: 0; + padding: 0 16px; + line-height: 32px; + text-align: center; + font-size: 12px; + color: var(--dateLabelFg); + + > span { + &:first-child { + margin-right: 8px; + + > .icon { + margin-right: 8px; + } + } + + &:last-child { + margin-left: 8px; + + > .icon { + margin-left: 8px; + } + } + } + } + } + + &.noGap { + > * { + margin: 0 !important; + border: none; + border-radius: 0; + box-shadow: none; + + &:not(:last-child) { + border-bottom: solid 0.5px var(--divider); + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue new file mode 100644 index 0000000000..374ecd8abf --- /dev/null +++ b/packages/frontend/src/components/MkDialog.vue @@ -0,0 +1,208 @@ +<template> +<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="emit('closed')"> + <div class="mk-dialog"> + <div v-if="icon" class="icon"> + <i :class="icon"></i> + </div> + <div v-else-if="!input && !select" class="icon" :class="type"> + <i v-if="type === 'success'" class="ti ti-check"></i> + <i v-else-if="type === 'error'" class="ti ti-circle-x"></i> + <i v-else-if="type === 'warning'" class="ti ti-alert-triangle"></i> + <i v-else-if="type === 'info'" class="ti ti-info-circle"></i> + <i v-else-if="type === 'question'" class="ti ti-question-circle"></i> + <MkLoading v-else-if="type === 'waiting'" :em="true"/> + </div> + <header v-if="title"><Mfm :text="title"/></header> + <div v-if="text" class="body"><Mfm :text="text"/></div> + <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown"> + <template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template> + </MkInput> + <MkSelect v-if="select" v-model="selectedValue" autofocus> + <template v-if="select.items"> + <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> + </template> + <template v-else> + <optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label"> + <option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option> + </optgroup> + </template> + </MkSelect> + <div v-if="(showOkButton || showCancelButton) && !actions" class="buttons"> + <MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ (showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt }}</MkButton> + <MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ i18n.ts.cancel }}</MkButton> + </div> + <div v-if="actions" class="buttons"> + <MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); close(); }">{{ action.text }}</MkButton> + </div> + </div> +</MkModal> +</template> + +<script lang="ts" setup> +import { onBeforeUnmount, onMounted, ref } from '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'; + +type Input = { + type: HTMLInputElement['type']; + placeholder?: string | null; + default: any | null; +}; + +type Select = { + items: { + value: string; + text: string; + }[]; + groupedItems: { + label: string; + items: { + value: string; + text: string; + }[]; + }[]; + default: string | null; +}; + +const props = withDefaults(defineProps<{ + type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting'; + title: string; + text?: string; + input?: Input; + select?: Select; + icon?: string; + actions?: { + text: string; + primary?: boolean, + callback: (...args: any[]) => void; + }[]; + showOkButton?: boolean; + showCancelButton?: boolean; + cancelableByBgClick?: boolean; +}>(), { + type: 'info', + showOkButton: true, + showCancelButton: false, + cancelableByBgClick: true, +}); + +const emit = defineEmits<{ + (ev: 'done', v: { canceled: boolean; result: any }): void; + (ev: 'closed'): void; +}>(); + +const modal = ref<InstanceType<typeof MkModal>>(); + +const inputValue = ref(props.input?.default || null); +const selectedValue = ref(props.select?.default || null); + +function done(canceled: boolean, result?) { + emit('done', { canceled, result }); + modal.value?.close(); +} + +async function ok() { + if (!props.showOkButton) return; + + const result = + props.input ? inputValue.value : + props.select ? selectedValue.value : + true; + done(false, result); +} + +function cancel() { + done(true); +} +/* +function onBgClick() { + if (props.cancelableByBgClick) cancel(); +} +*/ +function onKeydown(evt: KeyboardEvent) { + if (evt.key === 'Escape') cancel(); +} + +function onInputKeydown(evt: KeyboardEvent) { + if (evt.key === 'Enter') { + evt.preventDefault(); + evt.stopPropagation(); + ok(); + } +} + +onMounted(() => { + document.addEventListener('keydown', onKeydown); +}); + +onBeforeUnmount(() => { + document.removeEventListener('keydown', onKeydown); +}); +</script> + +<style lang="scss" scoped> +.mk-dialog { + position: relative; + padding: 32px; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + + > .icon { + font-size: 24px; + + &.info { + color: #55c4dd; + } + + &.success { + color: var(--success); + } + + &.error { + color: var(--error); + } + + &.warning { + color: var(--warn); + } + + > * { + display: block; + margin: 0 auto; + } + + & + header { + margin-top: 8px; + } + } + + > header { + margin: 0 0 8px 0; + font-weight: bold; + font-size: 1.1em; + + & + .body { + margin-top: 8px; + } + } + + > .body { + margin: 16px 0 0 0; + } + + > .buttons { + margin-top: 16px; + + > * { + margin: 0 8px; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkDigitalClock.vue b/packages/frontend/src/components/MkDigitalClock.vue new file mode 100644 index 0000000000..9ed8d63d19 --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue new file mode 100644 index 0000000000..8c17c0530a --- /dev/null +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -0,0 +1,334 @@ +<template> +<div + class="ncvczrfv" + :class="{ isSelected }" + draggable="true" + :title="title" + @click="onClick" + @contextmenu.stop="onContextmenu" + @dragstart="onDragstart" + @dragend="onDragend" +> + <div v-if="$i?.avatarId == file.id" class="label"> + <img src="/client-assets/label.svg"/> + <p>{{ i18n.ts.avatar }}</p> + </div> + <div v-if="$i?.bannerId == file.id" class="label"> + <img src="/client-assets/label.svg"/> + <p>{{ i18n.ts.banner }}</p> + </div> + <div v-if="file.isSensitive" class="label red"> + <img src="/client-assets/label-red.svg"/> + <p>{{ i18n.ts.nsfw }}</p> + </div> + + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + + <p class="name"> + <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> + <span v-if="file.name.lastIndexOf('.') != -1" class="ext">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> + </p> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; + +const props = withDefaults(defineProps<{ + file: Misskey.entities.DriveFile; + isSelected?: boolean; + selectMode?: boolean; +}>(), { + isSelected: false, + selectMode: false, +}); + +const emit = defineEmits<{ + (ev: 'chosen', r: Misskey.entities.DriveFile): void; + (ev: 'dragstart'): void; + (ev: 'dragend'): void; +}>(); + +const isDragging = ref(false); + +const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`); + +function getMenu() { + return [{ + text: i18n.ts.rename, + icon: 'ti ti-forms', + action: rename, + }, { + text: props.file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, + icon: props.file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-off', + action: toggleSensitive, + }, { + text: i18n.ts.describeFile, + icon: 'ti ti-text-caption', + action: describe, + }, null, { + text: i18n.ts.copyUrl, + icon: 'ti ti-link', + action: copyUrl, + }, { + type: 'a', + href: props.file.url, + target: '_blank', + text: i18n.ts.download, + icon: 'ti ti-download', + download: props.file.name, + }, null, { + text: i18n.ts.delete, + icon: 'ti ti-trash', + danger: true, + action: deleteFile, + }]; +} + +function onClick(ev: MouseEvent) { + if (props.selectMode) { + emit('chosen', props.file); + } else { + os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); + } +} + +function onContextmenu(ev: MouseEvent) { + os.contextMenu(getMenu(), ev); +} + +function onDragstart(ev: DragEvent) { + if (ev.dataTransfer) { + ev.dataTransfer.effectAllowed = 'move'; + ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file)); + } + isDragging.value = true; + + emit('dragstart'); +} + +function onDragend() { + isDragging.value = false; + emit('dragend'); +} + +function rename() { + os.inputText({ + title: i18n.ts.renameFile, + placeholder: i18n.ts.inputNewFileName, + default: props.file.name, + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/files/update', { + fileId: props.file.id, + name: name, + }); + }); +} + +function describe() { + os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), { + default: props.file.comment != null ? props.file.comment : '', + file: props.file, + }, { + done: caption => { + os.api('drive/files/update', { + fileId: props.file.id, + comment: caption.length === 0 ? null : caption, + }); + }, + }, 'closed'); +} + +function toggleSensitive() { + os.api('drive/files/update', { + fileId: props.file.id, + isSensitive: !props.file.isSensitive, + }); +} + +function copyUrl() { + copyToClipboard(props.file.url); + os.success(); +} +/* +function addApp() { + alert('not implemented yet'); +} +*/ +async function deleteFile() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('driveFileDeleteConfirm', { name: props.file.name }), + }); + + if (canceled) return; + os.api('drive/files/delete', { + fileId: props.file.id, + }); +} +</script> + +<style lang="scss" scoped> +.ncvczrfv { + position: relative; + padding: 8px 0 0 0; + min-height: 180px; + border-radius: 8px; + + &, * { + cursor: pointer; + } + + > * { + pointer-events: none; + } + + &:hover { + background: rgba(#000, 0.05); + + > .label { + &:before, + &:after { + background: #0b65a5; + } + + &.red { + &:before, + &:after { + background: #c12113; + } + } + } + } + + &:active { + background: rgba(#000, 0.1); + + > .label { + &:before, + &:after { + background: #0b588c; + } + + &.red { + &:before, + &:after { + background: #ce2212; + } + } + } + } + + &.isSelected { + background: var(--accent); + + &:hover { + background: var(--accentLighten); + } + + &:active { + background: var(--accentDarken); + } + + > .label { + &:before, + &:after { + display: none; + } + } + + > .name { + color: #fff; + } + + > .thumbnail { + color: #fff; + } + } + + > .label { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + + &:before, + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1; + background: #0c7ac9; + } + + &:before { + top: 0; + left: 57px; + width: 28px; + height: 8px; + } + + &:after { + top: 57px; + left: 0; + width: 8px; + height: 28px; + } + + &.red { + &:before, + &:after { + background: #c12113; + } + } + + > img { + position: absolute; + z-index: 2; + top: 0; + left: 0; + } + + > p { + position: absolute; + z-index: 3; + top: 19px; + left: -28px; + width: 120px; + margin: 0; + text-align: center; + line-height: 28px; + color: #fff; + transform: rotate(-45deg); + } + } + + > .thumbnail { + width: 110px; + height: 110px; + margin: auto; + } + + > .name { + display: block; + margin: 4px 0 0 0; + font-size: 0.8em; + text-align: center; + word-break: break-all; + color: var(--fg); + overflow: hidden; + + > .ext { + opacity: 0.5; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue new file mode 100644 index 0000000000..82653ca0b4 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -0,0 +1,330 @@ +<template> +<div + class="rghtznwe" + :class="{ draghover }" + draggable="true" + :title="title" + @click="onClick" + @contextmenu.stop="onContextmenu" + @mouseover="onMouseover" + @mouseout="onMouseout" + @dragover.prevent.stop="onDragover" + @dragenter.prevent="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + @dragstart="onDragstart" + @dragend="onDragend" +> + <p class="name"> + <template v-if="hover"><i class="ti ti-folder ti-fw"></i></template> + <template v-if="!hover"><i class="ti ti-folder ti-fw"></i></template> + {{ folder.name }} + </p> + <p v-if="defaultStore.state.uploadFolder == folder.id" class="upload"> + {{ i18n.ts.uploadFolder }} + </p> + <button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; + +const props = withDefaults(defineProps<{ + folder: Misskey.entities.DriveFolder; + isSelected?: boolean; + selectMode?: boolean; +}>(), { + isSelected: false, + selectMode: false, +}); + +const emit = defineEmits<{ + (ev: 'chosen', v: Misskey.entities.DriveFolder): void; + (ev: 'move', v: Misskey.entities.DriveFolder): void; + (ev: 'upload', file: File, folder: Misskey.entities.DriveFolder); + (ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void; + (ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void; + (ev: 'dragstart'): void; + (ev: 'dragend'): void; +}>(); + +const hover = ref(false); +const draghover = ref(false); +const isDragging = ref(false); + +const title = computed(() => props.folder.name); + +function checkboxClicked() { + emit('chosen', props.folder); +} + +function onClick() { + emit('move', props.folder); +} + +function onMouseover() { + hover.value = true; +} + +function onMouseout() { + hover.value = false; +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + // 自分自身がドラッグされている場合 + if (isDragging.value) { + // 自分自身にはドロップさせない + ev.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; + + if (isFile || isDriveFile || isDriveFolder) { + 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'; + } +} + +function onDragenter() { + if (!isDragging.value) draghover.value = true; +} + +function onDragleave() { + draghover.value = false; +} + +function onDrop(ev: DragEvent) { + draghover.value = false; + + if (!ev.dataTransfer) return; + + // ファイルだったら + if (ev.dataTransfer.files.length > 0) { + for (const file of Array.from(ev.dataTransfer.files)) { + emit('upload', file, props.folder); + } + return; + } + + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + const file = JSON.parse(driveFile); + emit('removeFile', file.id); + os.api('drive/files/update', { + fileId: file.id, + folderId: props.folder.id, + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); + if (driveFolder != null && driveFolder !== '') { + const folder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (folder.id === props.folder.id) return; + + emit('removeFolder', folder.id); + os.api('drive/folders/update', { + folderId: folder.id, + parentId: props.folder.id, + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + os.alert({ + title: i18n.ts.unableToProcess, + text: i18n.ts.circularReferenceFolder, + }); + break; + default: + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + } + }); + } + //#endregion +} + +function onDragstart(ev: DragEvent) { + if (!ev.dataTransfer) return; + + ev.dataTransfer.effectAllowed = 'move'; + ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder)); + isDragging.value = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + emit('dragstart'); +} + +function onDragend() { + isDragging.value = false; + emit('dragend'); +} + +function go() { + emit('move', props.folder.id); +} + +function rename() { + os.inputText({ + title: i18n.ts.renameFolder, + placeholder: i18n.ts.inputNewFolderName, + default: props.folder.name, + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/folders/update', { + folderId: props.folder.id, + name: name, + }); + }); +} + +function deleteFolder() { + os.api('drive/folders/delete', { + folderId: props.folder.id, + }).then(() => { + if (defaultStore.state.uploadFolder === props.folder.id) { + defaultStore.set('uploadFolder', null); + } + }).catch(err => { + switch (err.id) { + case 'b0fc8a17-963c-405d-bfbc-859a487295e1': + os.alert({ + type: 'error', + title: i18n.ts.unableToDelete, + text: i18n.ts.hasChildFilesOrFolders, + }); + break; + default: + os.alert({ + type: 'error', + text: i18n.ts.unableToDelete, + }); + } + }); +} + +function setAsUploadFolder() { + defaultStore.set('uploadFolder', props.folder.id); +} + +function onContextmenu(ev: MouseEvent) { + os.contextMenu([{ + text: i18n.ts.openInWindow, + icon: 'ti ti-app-window', + action: () => { + os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), { + initialFolder: props.folder, + }, { + }, 'closed'); + }, + }, null, { + text: i18n.ts.rename, + icon: 'ti ti-forms', + action: rename, + }, null, { + text: i18n.ts.delete, + icon: 'ti ti-trash', + danger: true, + action: deleteFolder, + }], ev); +} +</script> + +<style lang="scss" scoped> +.rghtznwe { + position: relative; + padding: 8px; + height: 64px; + background: var(--driveFolderBg); + border-radius: 4px; + + &, * { + cursor: pointer; + } + + *:not(.checkbox) { + pointer-events: none; + } + + > .checkbox { + position: absolute; + bottom: 8px; + right: 8px; + width: 16px; + height: 16px; + background: #fff; + border: solid 1px #000; + + &.checked { + background: var(--accent); + } + } + + &.draghover { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -4px; + right: -4px; + bottom: -4px; + left: -4px; + border: 2px dashed var(--focus); + border-radius: 4px; + } + } + + > .name { + margin: 0; + font-size: 0.9em; + color: var(--desktopDriveFolderFg); + + > i { + margin-right: 4px; + margin-left: 2px; + text-align: left; + } + } + + > .upload { + margin: 4px 4px; + font-size: 0.8em; + text-align: right; + color: var(--desktopDriveFolderFg); + } +} +</style> diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue new file mode 100644 index 0000000000..dbbfef5f05 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.navFolder.vue @@ -0,0 +1,147 @@ +<template> +<div class="drylbebk" + :class="{ draghover }" + @click="onClick" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <i v-if="folder == null" class="ti ti-cloud"></i> + <span>{{ folder == null ? i18n.ts.drive : folder.name }}</span> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + folder?: Misskey.entities.DriveFolder; + parentFolder: Misskey.entities.DriveFolder | null; +}>(); + +const emit = defineEmits<{ + (ev: 'move', v?: Misskey.entities.DriveFolder): void; + (ev: 'upload', file: File, folder?: Misskey.entities.DriveFolder | null): void; + (ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void; + (ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void; +}>(); + +const hover = ref(false); +const draghover = ref(false); + +function onClick() { + emit('move', props.folder); +} + +function onMouseover() { + hover.value = true; +} + +function onMouseout() { + hover.value = false; +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + // このフォルダがルートかつカレントディレクトリならドロップ禁止 + if (props.folder == null && props.parentFolder == null) { + ev.dataTransfer.dropEffect = 'none'; + } + + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; + + if (isFile || isDriveFile || isDriveFolder) { + 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'; + } + + return false; +} + +function onDragenter() { + if (props.folder || props.parentFolder) draghover.value = true; +} + +function onDragleave() { + if (props.folder || props.parentFolder) draghover.value = false; +} + +function onDrop(ev: DragEvent) { + draghover.value = false; + + if (!ev.dataTransfer) return; + + // ファイルだったら + if (ev.dataTransfer.files.length > 0) { + for (const file of Array.from(ev.dataTransfer.files)) { + emit('upload', file, props.folder); + } + return; + } + + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + const file = JSON.parse(driveFile); + emit('removeFile', file.id); + os.api('drive/files/update', { + fileId: file.id, + folderId: props.folder ? props.folder.id : null, + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); + if (driveFolder != null && driveFolder !== '') { + const folder = JSON.parse(driveFolder); + // 移動先が自分自身ならreject + if (props.folder && folder.id === props.folder.id) return; + emit('removeFolder', folder.id); + os.api('drive/folders/update', { + folderId: folder.id, + parentId: props.folder ? props.folder.id : null, + }); + } + //#endregion +} +</script> + +<style lang="scss" scoped> +.drylbebk { + > * { + pointer-events: none; + } + + &.draghover { + background: #eee; + } + + > i { + margin-right: 4px; + } +} +</style> diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue new file mode 100644 index 0000000000..4053870950 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.vue @@ -0,0 +1,801 @@ +<template> +<div class="yfudmmck"> + <nav> + <div class="path" @contextmenu.prevent.stop="() => {}"> + <XNavFolder + :class="{ current: folder == null }" + :parent-folder="folder" + @move="move" + @upload="upload" + @remove-file="removeFile" + @remove-folder="removeFolder" + /> + <template v-for="f in hierarchyFolders"> + <span class="separator"><i class="ti ti-chevron-right"></i></span> + <XNavFolder + :folder="f" + :parent-folder="folder" + @move="move" + @upload="upload" + @remove-file="removeFile" + @remove-folder="removeFolder" + /> + </template> + <span v-if="folder != null" class="separator"><i class="ti ti-chevron-right"></i></span> + <span v-if="folder != null" class="folder current">{{ folder.name }}</span> + </div> + <button class="menu _button" @click="showMenu"><i class="ti ti-dots"></i></button> + </nav> + <div + ref="main" class="main" + :class="{ uploading: uploadings.length > 0, fetching }" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + @contextmenu.stop="onContextmenu" + > + <div ref="contents" class="contents"> + <div v-show="folders.length > 0" ref="foldersContainer" class="folders"> + <XFolder + v-for="(f, i) in folders" + :key="f.id" + v-anim="i" + class="folder" + :folder="f" + :select-mode="select === 'folder'" + :is-selected="selectedFolders.some(x => x.id === f.id)" + @chosen="chooseFolder" + @move="move" + @upload="upload" + @remove-file="removeFile" + @remove-folder="removeFolder" + @dragstart="isDragSource = true" + @dragend="isDragSource = false" + /> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div v-for="(n, i) in 16" :key="i" class="padding"></div> + <MkButton v-if="moreFolders" ref="moreFolders">{{ i18n.ts.loadMore }}</MkButton> + </div> + <div v-show="files.length > 0" ref="filesContainer" class="files"> + <XFile + v-for="(file, i) in files" + :key="file.id" + v-anim="i" + class="file" + :file="file" + :select-mode="select === 'file'" + :is-selected="selectedFiles.some(x => x.id === file.id)" + @chosen="chooseFile" + @dragstart="isDragSource = true" + @dragend="isDragSource = false" + /> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div v-for="(n, i) in 16" :key="i" class="padding"></div> + <MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton> + </div> + <div v-if="files.length == 0 && folders.length == 0 && !fetching" class="empty"> + <p v-if="draghover">{{ i18n.t('empty-draghover') }}</p> + <p v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</p> + <p v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</p> + </div> + </div> + <MkLoading v-if="fetching"/> + </div> + <div v-if="draghover" class="dropzone"></div> + <input ref="fileInput" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/> +</div> +</template> + +<script lang="ts" setup> +import { markRaw, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +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'; +import { i18n } from '@/i18n'; +import { uploadFile, uploads } from '@/scripts/upload'; + +const props = withDefaults(defineProps<{ + initialFolder?: Misskey.entities.DriveFolder; + type?: string; + multiple?: boolean; + select?: 'file' | 'folder' | null; +}>(), { + multiple: false, + select: null, +}); + +const emit = defineEmits<{ + (ev: 'selected', v: Misskey.entities.DriveFile | Misskey.entities.DriveFolder): void; + (ev: 'change-selection', v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void; + (ev: 'move-root'): void; + (ev: 'cd', v: Misskey.entities.DriveFolder | null): void; + (ev: 'open-folder', v: Misskey.entities.DriveFolder): void; +}>(); + +const loadMoreFiles = ref<InstanceType<typeof MkButton>>(); +const fileInput = ref<HTMLInputElement>(); + +const folder = ref<Misskey.entities.DriveFolder | null>(null); +const files = ref<Misskey.entities.DriveFile[]>([]); +const folders = ref<Misskey.entities.DriveFolder[]>([]); +const moreFiles = ref(false); +const moreFolders = ref(false); +const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]); +const selectedFiles = ref<Misskey.entities.DriveFile[]>([]); +const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]); +const uploadings = uploads; +const connection = stream.useChannel('drive'); +const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい + +// ドロップされようとしているか +const draghover = ref(false); + +// 自身の所有するアイテムがドラッグをスタートさせたか +// (自分自身の階層にドロップできないようにするためのフラグ) +const isDragSource = ref(false); + +const fetching = ref(true); + +const ilFilesObserver = new IntersectionObserver( + (entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(), +); + +watch(folder, () => emit('cd', folder.value)); + +function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) { + addFile(file, true); +} + +function onStreamDriveFileUpdated(file: Misskey.entities.DriveFile) { + const current = folder.value ? folder.value.id : null; + if (current !== file.folderId) { + removeFile(file); + } else { + addFile(file, true); + } +} + +function onStreamDriveFileDeleted(fileId: string) { + removeFile(fileId); +} + +function onStreamDriveFolderCreated(createdFolder: Misskey.entities.DriveFolder) { + addFolder(createdFolder, true); +} + +function onStreamDriveFolderUpdated(updatedFolder: Misskey.entities.DriveFolder) { + const current = folder.value ? folder.value.id : null; + if (current !== updatedFolder.parentId) { + removeFolder(updatedFolder); + } else { + addFolder(updatedFolder, true); + } +} + +function onStreamDriveFolderDeleted(folderId: string) { + removeFolder(folderId); +} + +function onDragover(ev: DragEvent): any { + if (!ev.dataTransfer) return; + + // ドラッグ元が自分自身の所有するアイテムだったら + if (isDragSource.value) { + // 自分自身にはドロップさせない + ev.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; + if (isFile || isDriveFile || isDriveFolder) { + 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'; + } + + return false; +} + +function onDragenter() { + if (!isDragSource.value) draghover.value = true; +} + +function onDragleave() { + draghover.value = false; +} + +function onDrop(ev: DragEvent): any { + draghover.value = false; + + if (!ev.dataTransfer) return; + + // ドロップされてきたものがファイルだったら + if (ev.dataTransfer.files.length > 0) { + for (const file of Array.from(ev.dataTransfer.files)) { + upload(file, folder.value); + } + return; + } + + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + const file = JSON.parse(driveFile); + if (files.value.some(f => f.id === file.id)) return; + removeFile(file.id); + os.api('drive/files/update', { + fileId: file.id, + folderId: folder.value ? folder.value.id : null, + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); + if (driveFolder != null && driveFolder !== '') { + const droppedFolder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (folder.value && droppedFolder.id === folder.value.id) return false; + if (folders.value.some(f => f.id === droppedFolder.id)) return false; + removeFolder(droppedFolder.id); + os.api('drive/folders/update', { + folderId: droppedFolder.id, + parentId: folder.value ? folder.value.id : null, + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + os.alert({ + title: i18n.ts.unableToProcess, + text: i18n.ts.circularReferenceFolder, + }); + break; + default: + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + } + }); + } + //#endregion +} + +function selectLocalFile() { + fileInput.value?.click(); +} + +function urlUpload() { + os.inputText({ + title: i18n.ts.uploadFromUrl, + type: 'url', + 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, + }); + + os.alert({ + title: i18n.ts.uploadFromUrlRequested, + text: i18n.ts.uploadFromUrlMayTakeTime, + }); + }); +} + +function createFolder() { + os.inputText({ + title: i18n.ts.createFolder, + 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, + }).then(createdFolder => { + addFolder(createdFolder, true); + }); + }); +} + +function renameFolder(folderToRename: Misskey.entities.DriveFolder) { + os.inputText({ + title: i18n.ts.renameFolder, + placeholder: i18n.ts.inputNewFolderName, + default: folderToRename.name, + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/folders/update', { + folderId: folderToRename.id, + name: name, + }).then(updatedFolder => { + // FIXME: 画面を更新するために自分自身に移動 + move(updatedFolder); + }); + }); +} + +function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { + os.api('drive/folders/delete', { + folderId: folderToDelete.id, + }).then(() => { + // 削除時に親フォルダに移動 + move(folderToDelete.parentId); + }).catch(err => { + switch (err.id) { + case 'b0fc8a17-963c-405d-bfbc-859a487295e1': + os.alert({ + type: 'error', + title: i18n.ts.unableToDelete, + text: i18n.ts.hasChildFilesOrFolders, + }); + break; + default: + os.alert({ + type: 'error', + text: i18n.ts.unableToDelete, + }); + } + }); +} + +function onChangeFileInput() { + if (!fileInput.value?.files) return; + for (const file of Array.from(fileInput.value.files)) { + upload(file, folder.value); + } +} + +function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null) { + uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal.value).then(res => { + addFile(res, true); + }); +} + +function chooseFile(file: Misskey.entities.DriveFile) { + const isAlreadySelected = selectedFiles.value.some(f => f.id === file.id); + if (props.multiple) { + if (isAlreadySelected) { + selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id); + } else { + selectedFiles.value.push(file); + } + emit('change-selection', selectedFiles.value); + } else { + if (isAlreadySelected) { + emit('selected', file); + } else { + selectedFiles.value = [file]; + emit('change-selection', [file]); + } + } +} + +function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) { + const isAlreadySelected = selectedFolders.value.some(f => f.id === folderToChoose.id); + if (props.multiple) { + if (isAlreadySelected) { + selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToChoose.id); + } else { + selectedFolders.value.push(folderToChoose); + } + emit('change-selection', selectedFolders.value); + } else { + if (isAlreadySelected) { + emit('selected', folderToChoose); + } else { + selectedFolders.value = [folderToChoose]; + emit('change-selection', [folderToChoose]); + } + } +} + +function move(target?: Misskey.entities.DriveFolder) { + if (!target) { + goRoot(); + return; + } else if (typeof target === 'object') { + target = target.id; + } + + fetching.value = true; + + os.api('drive/folders/show', { + folderId: target, + }).then(folderToMove => { + folder.value = folderToMove; + hierarchyFolders.value = []; + + const dive = folderToDive => { + hierarchyFolders.value.unshift(folderToDive); + if (folderToDive.parent) dive(folderToDive.parent); + }; + + if (folderToMove.parent) dive(folderToMove.parent); + + emit('open-folder', folderToMove); + fetch(); + }); +} + +function addFolder(folderToAdd: Misskey.entities.DriveFolder, unshift = false) { + const current = folder.value ? folder.value.id : null; + if (current !== folderToAdd.parentId) return; + + if (folders.value.some(f => f.id === folderToAdd.id)) { + const exist = folders.value.map(f => f.id).indexOf(folderToAdd.id); + folders.value[exist] = folderToAdd; + return; + } + + if (unshift) { + folders.value.unshift(folderToAdd); + } else { + folders.value.push(folderToAdd); + } +} + +function addFile(fileToAdd: Misskey.entities.DriveFile, unshift = false) { + const current = folder.value ? folder.value.id : null; + if (current !== fileToAdd.folderId) return; + + if (files.value.some(f => f.id === fileToAdd.id)) { + const exist = files.value.map(f => f.id).indexOf(fileToAdd.id); + files.value[exist] = fileToAdd; + return; + } + + if (unshift) { + files.value.unshift(fileToAdd); + } else { + files.value.push(fileToAdd); + } +} + +function removeFolder(folderToRemove: Misskey.entities.DriveFolder | string) { + const folderIdToRemove = typeof folderToRemove === 'object' ? folderToRemove.id : folderToRemove; + folders.value = folders.value.filter(f => f.id !== folderIdToRemove); +} + +function removeFile(file: Misskey.entities.DriveFile | string) { + const fileId = typeof file === 'object' ? file.id : file; + files.value = files.value.filter(f => f.id !== fileId); +} + +function appendFile(file: Misskey.entities.DriveFile) { + addFile(file); +} + +function appendFolder(folderToAppend: Misskey.entities.DriveFolder) { + addFolder(folderToAppend); +} +/* +function prependFile(file: Misskey.entities.DriveFile) { + addFile(file, true); +} + +function prependFolder(folderToPrepend: Misskey.entities.DriveFolder) { + addFolder(folderToPrepend, true); +} +*/ +function goRoot() { + // 既にrootにいるなら何もしない + if (folder.value == null) return; + + folder.value = null; + hierarchyFolders.value = []; + emit('move-root'); + fetch(); +} + +async function fetch() { + folders.value = []; + files.value = []; + moreFolders.value = false; + moreFiles.value = false; + fetching.value = true; + + const foldersMax = 30; + const filesMax = 30; + + const foldersPromise = os.api('drive/folders', { + folderId: folder.value ? folder.value.id : null, + limit: foldersMax + 1, + }).then(fetchedFolders => { + if (fetchedFolders.length === foldersMax + 1) { + moreFolders.value = true; + fetchedFolders.pop(); + } + return fetchedFolders; + }); + + const filesPromise = os.api('drive/files', { + folderId: folder.value ? folder.value.id : null, + type: props.type, + limit: filesMax + 1, + }).then(fetchedFiles => { + if (fetchedFiles.length === filesMax + 1) { + moreFiles.value = true; + fetchedFiles.pop(); + } + return fetchedFiles; + }); + + const [fetchedFolders, fetchedFiles] = await Promise.all([foldersPromise, filesPromise]); + + for (const x of fetchedFolders) appendFolder(x); + for (const x of fetchedFiles) appendFile(x); + + fetching.value = false; +} + +function fetchMoreFiles() { + fetching.value = true; + + const max = 30; + + // ファイル一覧取得 + os.api('drive/files', { + folderId: folder.value ? folder.value.id : null, + type: props.type, + untilId: files.value[files.value.length - 1].id, + limit: max + 1, + }).then(files => { + if (files.length === max + 1) { + moreFiles.value = true; + files.pop(); + } else { + moreFiles.value = false; + } + for (const x of files) appendFile(x); + fetching.value = false; + }); +} + +function getMenu() { + return [{ + type: 'switch', + text: i18n.ts.keepOriginalUploading, + ref: keepOriginal, + }, null, { + text: i18n.ts.addFile, + type: 'label', + }, { + text: i18n.ts.upload, + icon: 'ti ti-upload', + action: () => { selectLocalFile(); }, + }, { + text: i18n.ts.fromUrl, + icon: 'ti ti-link', + action: () => { urlUpload(); }, + }, null, { + text: folder.value ? folder.value.name : i18n.ts.drive, + type: 'label', + }, folder.value ? { + text: i18n.ts.renameFolder, + icon: 'ti ti-forms', + action: () => { renameFolder(folder.value); }, + } : undefined, folder.value ? { + text: i18n.ts.deleteFolder, + icon: 'ti ti-trash', + action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); }, + } : undefined, { + text: i18n.ts.createFolder, + icon: 'ti ti-folder-plus', + action: () => { createFolder(); }, + }]; +} + +function showMenu(ev: MouseEvent) { + os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); +} + +function onContextmenu(ev: MouseEvent) { + os.contextMenu(getMenu(), ev); +} + +onMounted(() => { + if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) { + nextTick(() => { + ilFilesObserver.observe(loadMoreFiles.value?.$el); + }); + } + + connection.on('fileCreated', onStreamDriveFileCreated); + connection.on('fileUpdated', onStreamDriveFileUpdated); + connection.on('fileDeleted', onStreamDriveFileDeleted); + connection.on('folderCreated', onStreamDriveFolderCreated); + connection.on('folderUpdated', onStreamDriveFolderUpdated); + connection.on('folderDeleted', onStreamDriveFolderDeleted); + + if (props.initialFolder) { + move(props.initialFolder); + } else { + fetch(); + } +}); + +onActivated(() => { + if (defaultStore.state.enableInfiniteScroll) { + nextTick(() => { + ilFilesObserver.observe(loadMoreFiles.value?.$el); + }); + } +}); + +onBeforeUnmount(() => { + connection.dispose(); + ilFilesObserver.disconnect(); +}); +</script> + +<style lang="scss" scoped> +.yfudmmck { + display: flex; + flex-direction: column; + height: 100%; + + > nav { + display: flex; + z-index: 2; + width: 100%; + padding: 0 8px; + box-sizing: border-box; + overflow: auto; + font-size: 0.9em; + box-shadow: 0 1px 0 var(--divider); + + &, * { + user-select: none; + } + + > .path { + display: inline-block; + vertical-align: bottom; + line-height: 42px; + white-space: nowrap; + + > * { + display: inline-block; + margin: 0; + padding: 0 8px; + line-height: 42px; + cursor: pointer; + + * { + pointer-events: none; + } + + &:hover { + text-decoration: underline; + } + + &.current { + font-weight: bold; + cursor: default; + + &:hover { + text-decoration: none; + } + } + + &.separator { + margin: 0; + padding: 0; + opacity: 0.5; + cursor: default; + + > i { + margin: 0; + } + } + } + } + + > .menu { + margin-left: auto; + padding: 0 12px; + } + } + + > .main { + flex: 1; + overflow: auto; + padding: var(--margin); + + &, * { + user-select: none; + } + + &.fetching { + cursor: wait !important; + + * { + pointer-events: none; + } + + > .contents { + opacity: 0.5; + } + } + + &.uploading { + height: calc(100% - 38px - 100px); + } + + > .contents { + + > .folders, + > .files { + display: flex; + flex-wrap: wrap; + + > .folder, + > .file { + flex-grow: 1; + width: 128px; + margin: 4px; + box-sizing: border-box; + } + + > .padding { + flex-grow: 1; + pointer-events: none; + width: 128px + 8px; + } + } + + > .empty { + padding: 16px; + text-align: center; + pointer-events: none; + opacity: 0.5; + + > p { + margin: 0; + } + } + } + } + + > .dropzone { + position: absolute; + left: 0; + top: 38px; + width: 100%; + height: calc(100% - 38px); + border: dashed 2px var(--focus); + pointer-events: none; + } + + > input { + display: none; + } +} +</style> diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue new file mode 100644 index 0000000000..33379ed5ca --- /dev/null +++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue @@ -0,0 +1,80 @@ +<template> +<div ref="thumbnail" class="zdjebgpv"> + <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/> + <i v-else-if="is === 'image'" class="ti ti-photo icon"></i> + <i v-else-if="is === 'video'" class="ti ti-video icon"></i> + <i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music icon"></i> + <i v-else-if="is === 'csv'" class="ti ti-file-text icon"></i> + <i v-else-if="is === 'pdf'" class="ti ti-file-text icon"></i> + <i v-else-if="is === 'textfile'" class="ti ti-file-text icon"></i> + <i v-else-if="is === 'archive'" class="ti ti-file-zip icon"></i> + <i v-else class="ti ti-file icon"></i> + + <i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video icon-sub"></i> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as Misskey from 'misskey-js'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; + +const props = defineProps<{ + file: Misskey.entities.DriveFile; + fit: string; +}>(); + +const is = computed(() => { + if (props.file.type.startsWith('image/')) return 'image'; + if (props.file.type.startsWith('video/')) return 'video'; + if (props.file.type === 'audio/midi') return 'midi'; + if (props.file.type.startsWith('audio/')) return 'audio'; + if (props.file.type.endsWith('/csv')) return 'csv'; + if (props.file.type.endsWith('/pdf')) return 'pdf'; + if (props.file.type.startsWith('text/')) return 'textfile'; + if ([ + 'application/zip', + 'application/x-cpio', + 'application/x-bzip', + 'application/x-bzip2', + 'application/java-archive', + 'application/x-rar-compressed', + 'application/x-tar', + 'application/gzip', + 'application/x-7z-compressed', + ].some(archiveType => archiveType === props.file.type)) return 'archive'; + return 'unknown'; +}); + +const isThumbnailAvailable = computed(() => { + return props.file.thumbnailUrl + ? (is.value === 'image' as const || is.value === 'video') + : false; +}); +</script> + +<style lang="scss" scoped> +.zdjebgpv { + position: relative; + display: flex; + background: var(--panel); + border-radius: 8px; + overflow: clip; + + > .icon-sub { + position: absolute; + width: 30%; + height: auto; + margin: 0; + right: 4%; + bottom: 4%; + } + + > .icon { + pointer-events: none; + margin: auto; + font-size: 32px; + color: #777; + } +} +</style> diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue new file mode 100644 index 0000000000..3ee821b539 --- /dev/null +++ b/packages/frontend/src/components/MkDriveSelectDialog.vue @@ -0,0 +1,58 @@ +<template> +<XModalWindow + ref="dialog" + :width="800" + :height="500" + :with-ok-button="true" + :ok-button-disabled="(type === 'file') && (selected.length === 0)" + @click="cancel()" + @close="cancel()" + @ok="ok()" + @closed="emit('closed')" +> + <template #header> + {{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }} + <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span> + </template> + <XDrive :multiple="multiple" :select="type" @change-selection="onChangeSelection" @selected="ok()"/> +</XModalWindow> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import XDrive from '@/components/MkDrive.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; + +withDefaults(defineProps<{ + type?: 'file' | 'folder'; + multiple: boolean; +}>(), { + type: 'file', +}); + +const emit = defineEmits<{ + (ev: 'done', r?: Misskey.entities.DriveFile[]): void; + (ev: 'closed'): void; +}>(); + +const dialog = ref<InstanceType<typeof XModalWindow>>(); + +const selected = ref<Misskey.entities.DriveFile[]>([]); + +function ok() { + emit('done', selected.value); + dialog.value?.close(); +} + +function cancel() { + emit('done'); + dialog.value?.close(); +} + +function onChangeSelection(files: Misskey.entities.DriveFile[]) { + selected.value = files; +} +</script> diff --git a/packages/frontend/src/components/MkDriveWindow.vue b/packages/frontend/src/components/MkDriveWindow.vue new file mode 100644 index 0000000000..617200321b --- /dev/null +++ b/packages/frontend/src/components/MkDriveWindow.vue @@ -0,0 +1,30 @@ +<template> +<XWindow + ref="window" + :initial-width="800" + :initial-height="500" + :can-resize="true" + @closed="emit('closed')" +> + <template #header> + {{ i18n.ts.drive }} + </template> + <XDrive :initial-folder="initialFolder"/> +</XWindow> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as Misskey from 'misskey-js'; +import XDrive from '@/components/MkDrive.vue'; +import XWindow from '@/components/MkWindow.vue'; +import { i18n } from '@/i18n'; + +defineProps<{ + initialFolder?: Misskey.entities.DriveFolder; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); +</script> diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue new file mode 100644 index 0000000000..f6ba7abfc4 --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -0,0 +1,36 @@ +<template> +<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと --> +<section> + <header class="_acrylic" @click="shown = !shown"> + <i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> ({{ emojis.length }}) + </header> + <div v-if="shown" class="body"> + <button + v-for="emoji in emojis" + :key="emoji" + class="_button item" + @click="emit('chosen', emoji, $event)" + > + <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> + </button> + </div> +</section> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; + +const props = defineProps<{ + emojis: string[]; + initialShown?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'chosen', v: string, event: MouseEvent): void; +}>(); + +const shown = ref(!!props.initialShown); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue new file mode 100644 index 0000000000..814f71168a --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -0,0 +1,569 @@ +<template> +<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }"> + <input ref="search" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @input="input()" @paste.stop="paste" @keyup.enter="done()"> + <div ref="emojis" class="emojis"> + <section class="result"> + <div v-if="searchResultCustom.length > 0" class="body"> + <button + v-for="emoji in searchResultCustom" + :key="emoji.id" + class="_button item" + :title="emoji.name" + tabindex="0" + @click="chosen(emoji, $event)" + > + <!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>--> + <img class="emoji" :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + </button> + </div> + <div v-if="searchResultUnicode.length > 0" class="body"> + <button + v-for="emoji in searchResultUnicode" + :key="emoji.name" + class="_button item" + :title="emoji.name" + tabindex="0" + @click="chosen(emoji, $event)" + > + <MkEmoji class="emoji" :emoji="emoji.char"/> + </button> + </div> + </section> + + <div v-if="tab === 'index'" class="group index"> + <section v-if="showPinned"> + <div class="body"> + <button + v-for="emoji in pinned" + :key="emoji" + class="_button item" + tabindex="0" + @click="chosen(emoji, $event)" + > + <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> + </button> + </div> + </section> + + <section> + <header class="_acrylic"><i class="ti ti-clock ti-fw"></i> {{ i18n.ts.recentUsed }}</header> + <div class="body"> + <button + v-for="emoji in recentlyUsedEmojis" + :key="emoji" + class="_button item" + @click="chosen(emoji, $event)" + > + <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> + </button> + </div> + </section> + </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 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> + </div> + <div class="tabs"> + <button class="_button tab" :class="{ active: tab === 'index' }" @click="tab = 'index'"><i class="ti ti-asterisk ti-fw"></i></button> + <button class="_button tab" :class="{ active: tab === 'custom' }" @click="tab = 'custom'"><i class="ti ti-mood-happy ti-fw"></i></button> + <button class="_button tab" :class="{ active: tab === 'unicode' }" @click="tab = 'unicode'"><i class="ti ti-leaf ti-fw"></i></button> + <button class="_button tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'"><i class="ti ti-hash ti-fw"></i></button> + </div> +</div> +</template> + +<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/MkRipple.vue'; +import * as os from '@/os'; +import { isTouchUsing } from '@/scripts/touch'; +import { deviceKind } from '@/scripts/device-kind'; +import { emojiCategories, instance } from '@/instance'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; + +const props = withDefaults(defineProps<{ + showPinned?: boolean; + asReactionPicker?: boolean; + maxHeight?: number; + asDrawer?: boolean; +}>(), { + showPinned: true, +}); + +const emit = defineEmits<{ + (ev: 'chosen', v: string): void; +}>(); + +const search = ref<HTMLInputElement>(); +const emojis = ref<HTMLDivElement>(); + +const { + reactions: pinned, + reactionPickerSize, + reactionPickerWidth, + reactionPickerHeight, + disableShowingAnimatedImages, + recentlyUsedEmojis, +} = defaultStore.reactiveState; + +const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1); +const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3); +const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2); +const customEmojiCategories = emojiCategories; +const customEmojis = instance.emojis; +const q = ref<string>(''); +const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]); +const searchResultUnicode = ref<UnicodeEmojiDef[]>([]); +const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index'); + +watch(q, () => { + if (emojis.value) emojis.value.scrollTop = 0; + + if (q.value === '') { + searchResultCustom.value = []; + searchResultUnicode.value = []; + return; + } + + const newQ = q.value.replace(/:/g, '').toLowerCase(); + + const searchCustom = () => { + const max = 8; + const emojis = customEmojis; + const matches = new Set<Misskey.entities.CustomEmoji>(); + + const exactMatch = emojis.find(emoji => emoji.name === newQ); + if (exactMatch) matches.add(exactMatch); + + if (newQ.includes(' ')) { // AND検索 + const keywords = newQ.split(' '); + + // 名前にキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + // 名前またはエイリアスにキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } else { + for (const emoji of emojis) { + if (emoji.name.startsWith(newQ)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.aliases.some(alias => alias.startsWith(newQ))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.name.includes(newQ)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.aliases.some(alias => alias.includes(newQ))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } + + return matches; + }; + + const searchUnicode = () => { + const max = 8; + const emojis = emojilist; + const matches = new Set<UnicodeEmojiDef>(); + + const exactMatch = emojis.find(emoji => emoji.name === newQ); + if (exactMatch) matches.add(exactMatch); + + if (newQ.includes(' ')) { // AND検索 + const keywords = newQ.split(' '); + + // 名前にキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + // 名前またはエイリアスにキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } else { + for (const emoji of emojis) { + if (emoji.name.startsWith(newQ)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.keywords.some(keyword => keyword.startsWith(newQ))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.name.includes(newQ)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.keywords.some(keyword => keyword.includes(newQ))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } + + return matches; + }; + + searchResultCustom.value = Array.from(searchCustom()); + searchResultUnicode.value = Array.from(searchUnicode()); +}); + +function focus() { + if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) { + search.value?.focus({ + preventScroll: true, + }); + } +} + +function reset() { + if (emojis.value) emojis.value.scrollTop = 0; + q.value = ''; +} + +function getKey(emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef): string { + return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`; +} + +function chosen(emoji: any, ev?: MouseEvent) { + const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(Ripple, { x, y }, {}, 'end'); + } + + const key = getKey(emoji); + emit('chosen', key); + + // 最近使った絵文字更新 + if (!pinned.value.includes(key)) { + let recents = defaultStore.state.recentlyUsedEmojis; + recents = recents.filter((emoji: any) => emoji !== key); + recents.unshift(key); + defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); + } +} + +function input(): void { + // Using custom input event instead of v-model to respond immediately on + // Android, where composition happens on all languages + // (v-model does not update during composition) + q.value = search.value?.value.trim() ?? ''; +} + +function paste(event: ClipboardEvent): void { + const pasted = event.clipboardData?.getData('text') ?? ''; + if (done(pasted)) { + event.preventDefault(); + } +} + +function done(query?: string): boolean | void { + if (query == null) query = q.value; + if (query == null || typeof query !== 'string') return; + + const q2 = query.replace(/:/g, ''); + const exactMatchCustom = customEmojis.find(emoji => emoji.name === q2); + if (exactMatchCustom) { + chosen(exactMatchCustom); + return true; + } + const exactMatchUnicode = emojilist.find(emoji => emoji.char === q2 || emoji.name === q2); + if (exactMatchUnicode) { + chosen(exactMatchUnicode); + return true; + } + if (searchResultCustom.value.length > 0) { + chosen(searchResultCustom.value[0]); + return true; + } + if (searchResultUnicode.value.length > 0) { + chosen(searchResultUnicode.value[0]); + return true; + } +} + +onMounted(() => { + focus(); +}); + +defineExpose({ + focus, + reset, +}); +</script> + +<style lang="scss" scoped> +.omfetrab { + $pad: 8px; + + display: flex; + flex-direction: column; + + &.s1 { + --eachSize: 40px; + } + + &.s2 { + --eachSize: 45px; + } + + &.s3 { + --eachSize: 50px; + } + + &.w1 { + width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); + --columns: 1fr 1fr 1fr 1fr 1fr; + } + + &.w2 { + width: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + --columns: 1fr 1fr 1fr 1fr 1fr 1fr; + } + + &.w3 { + width: calc((var(--eachSize) * 7) + (#{$pad} * 2)); + --columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr; + } + + &.w4 { + width: calc((var(--eachSize) * 8) + (#{$pad} * 2)); + --columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; + } + + &.w5 { + width: calc((var(--eachSize) * 9) + (#{$pad} * 2)); + --columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; + } + + &.h1 { + height: calc((var(--eachSize) * 4) + (#{$pad} * 2)); + } + + &.h2 { + height: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.h3 { + height: calc((var(--eachSize) * 8) + (#{$pad} * 2)); + } + + &.h4 { + height: calc((var(--eachSize) * 10) + (#{$pad} * 2)); + } + + &.asDrawer { + width: 100% !important; + + > .emojis { + ::v-deep(section) { + > header { + height: 32px; + line-height: 32px; + padding: 0 12px; + font-size: 15px; + } + + > .body { + display: grid; + grid-template-columns: var(--columns); + font-size: 30px; + + > .item { + aspect-ratio: 1 / 1; + width: auto; + height: auto; + min-width: 0; + } + } + } + } + } + + > .search { + width: 100%; + padding: 12px; + box-sizing: border-box; + font-size: 1em; + outline: none; + border: none; + background: transparent; + color: var(--fg); + + &:not(.filled) { + order: 1; + z-index: 2; + box-shadow: 0px -1px 0 0px var(--divider); + } + } + + > .tabs { + display: flex; + display: none; + + > .tab { + flex: 1; + height: 38px; + border-top: solid 0.5px var(--divider); + + &.active { + border-top: solid 1px var(--accent); + color: var(--accent); + } + } + } + + > .emojis { + height: 100%; + overflow-y: auto; + overflow-x: hidden; + + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + > .group { + &:not(.index) { + padding: 4px 0 8px 0; + border-top: solid 0.5px var(--divider); + } + + > header { + /*position: sticky; + top: 0; + left: 0;*/ + height: 32px; + line-height: 32px; + z-index: 2; + padding: 0 8px; + font-size: 12px; + } + } + + ::v-deep(section) { + > header { + position: sticky; + top: 0; + left: 0; + height: 32px; + line-height: 32px; + z-index: 1; + padding: 0 8px; + font-size: 12px; + cursor: pointer; + + &:hover { + color: var(--accent); + } + } + + > .body { + position: relative; + padding: $pad; + + > .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); + z-index: 1; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: var(--accent); + box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); + } + + > .emoji { + height: 1.25em; + vertical-align: -.25em; + pointer-events: none; + } + } + } + + &.result { + border-bottom: solid 0.5px var(--divider); + + &:empty { + display: none; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue new file mode 100644 index 0000000000..3b41f9d75b --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -0,0 +1,73 @@ +<template> +<MkModal + ref="modal" + v-slot="{ type, maxHeight }" + :z-priority="'middle'" + :prefer-type="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'" + :transparent-bg="true" + :manual-showing="manualShowing" + :src="src" + @click="modal?.close()" + @opening="opening" + @close="emit('close')" + @closed="emit('closed')" +> + <MkEmojiPicker + ref="picker" + class="ryghynhb _popup _shadow" + :class="{ drawer: type === 'drawer' }" + :show-pinned="showPinned" + :as-reaction-picker="asReactionPicker" + :as-drawer="type === 'drawer'" + :max-height="maxHeight" + @chosen="chosen" + /> +</MkModal> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import MkModal from '@/components/MkModal.vue'; +import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; +import { defaultStore } from '@/store'; + +withDefaults(defineProps<{ + manualShowing?: boolean | null; + src?: HTMLElement; + showPinned?: boolean; + asReactionPicker?: boolean; +}>(), { + manualShowing: null, + showPinned: true, + asReactionPicker: false, +}); + +const emit = defineEmits<{ + (ev: 'done', v: any): void; + (ev: 'close'): void; + (ev: 'closed'): void; +}>(); + +const modal = ref<InstanceType<typeof MkModal>>(); +const picker = ref<InstanceType<typeof MkEmojiPicker>>(); + +function chosen(emoji: any) { + emit('done', emoji); + modal.value?.close(); +} + +function opening() { + picker.value?.reset(); + picker.value?.focus(); +} +</script> + +<style lang="scss" scoped> +.ryghynhb { + &.drawer { + border-radius: 24px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } +} +</style> diff --git a/packages/frontend/src/components/MkEmojiPickerWindow.vue b/packages/frontend/src/components/MkEmojiPickerWindow.vue new file mode 100644 index 0000000000..523e4ba695 --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPickerWindow.vue @@ -0,0 +1,180 @@ +<template> +<MkWindow ref="window" + :initial-width="null" + :initial-height="null" + :can-resize="false" + :mini="true" + :front="true" + @closed="emit('closed')" +> + <MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/> +</MkWindow> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkWindow from '@/components/MkWindow.vue'; +import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; + +withDefaults(defineProps<{ + src?: HTMLElement; + showPinned?: boolean; + asReactionPicker?: boolean; +}>(), { + showPinned: true, +}); + +const emit = defineEmits<{ + (ev: 'chosen', v: any): void; + (ev: 'closed'): void; +}>(); + +function chosen(emoji: any) { + emit('chosen', emoji); +} +</script> + +<style lang="scss" scoped> +.omfetrab { + $pad: 8px; + --eachSize: 40px; + + display: flex; + flex-direction: column; + contain: content; + + &.big { + --eachSize: 44px; + } + + &.w1 { + width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); + } + + &.w2 { + width: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.w3 { + width: calc((var(--eachSize) * 7) + (#{$pad} * 2)); + } + + &.h1 { + --height: calc((var(--eachSize) * 4) + (#{$pad} * 2)); + } + + &.h2 { + --height: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.h3 { + --height: calc((var(--eachSize) * 8) + (#{$pad} * 2)); + } + + > .search { + width: 100%; + padding: 12px; + box-sizing: border-box; + font-size: 1em; + outline: none; + border: none; + background: transparent; + color: var(--fg); + + &:not(.filled) { + order: 1; + z-index: 2; + box-shadow: 0px -1px 0 0px var(--divider); + } + } + + > .emojis { + height: var(--height); + overflow-y: auto; + overflow-x: hidden; + + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + > .index { + min-height: var(--height); + position: relative; + border-bottom: solid 0.5px var(--divider); + + > .arrow { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 16px 0; + text-align: center; + opacity: 0.5; + pointer-events: none; + } + } + + section { + > header { + position: sticky; + top: 0; + left: 0; + z-index: 1; + padding: 8px; + font-size: 12px; + } + + > div { + padding: $pad; + + > button { + position: relative; + padding: 0; + width: var(--eachSize); + height: var(--eachSize); + border-radius: 4px; + + &:focus-visible { + outline: solid 2px var(--focus); + z-index: 1; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: var(--accent); + box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); + } + + > * { + font-size: 24px; + height: 1.25em; + vertical-align: -.25em; + pointer-events: none; + } + } + } + + &.result { + border-bottom: solid 0.5px var(--divider); + + &:empty { + display: none; + } + } + + &.unicode { + min-height: 384px; + } + + &.custom { + min-height: 64px; + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkFeaturedPhotos.vue b/packages/frontend/src/components/MkFeaturedPhotos.vue new file mode 100644 index 0000000000..e58b5d2849 --- /dev/null +++ b/packages/frontend/src/components/MkFeaturedPhotos.vue @@ -0,0 +1,22 @@ +<template> +<div v-if="meta" class="xfbouadm" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import * as os from '@/os'; + +const meta = ref<Misskey.entities.DetailedInstanceMetadata>(); + +os.api('meta', { detail: true }).then(gotMeta => { + meta.value = gotMeta; +}); +</script> + +<style lang="scss" scoped> +.xfbouadm { + background-position: center; + background-size: cover; +} +</style> diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue new file mode 100644 index 0000000000..73875251f0 --- /dev/null +++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue @@ -0,0 +1,175 @@ +<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.describeFile }}</template> + <div> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain" style="height: 100px;"/> + <MkTextarea v-model="caption" autofocus :placeholder="i18n.ts.inputNewDescription"> + <template #label>{{ i18n.ts.caption }}</template> + </MkTextarea> + </div> +</XModalWindow> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as Misskey from 'misskey-js'; +import XModalWindow from '@/components/MkModalWindow.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + file: Misskey.entities.DriveFile; + default: string; +}>(); + +const emit = defineEmits<{ + (ev: 'done', v: string): void; + (ev: 'closed'): void; +}>(); + +const dialog = $ref<InstanceType<typeof XModalWindow>>(); + +let caption = $ref(props.default); + +async function ok() { + emit('done', caption); + dialog.close(); +} +</script> + +<style lang="scss" scoped> +.container { + display: flex; + width: 100%; + height: 100%; + flex-direction: row; + overflow: scroll; + position: fixed; + left: 0; + top: 0; +} +@media (max-width: 850px) { + .container { + flex-direction: column; + } + .top-caption { + padding-bottom: 8px; + } +} +.fullwidth { + width: 100%; + margin: auto; +} +.mk-dialog { + position: relative; + padding: 32px; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + margin: auto; + + > header { + margin: 0 0 8px 0; + position: relative; + + > .title { + font-weight: bold; + font-size: 20px; + } + + > .text-count { + opacity: 0.7; + position: absolute; + right: 0; + } + } + + > .buttons { + margin-top: 16px; + + > * { + margin: 0 8px; + } + } + + > textarea { + display: block; + box-sizing: border-box; + padding: 0 24px; + margin: 0; + width: 100%; + font-size: 16px; + border: none; + border-radius: 0; + background: transparent; + color: var(--fg); + font-family: inherit; + max-width: 100%; + min-width: 100%; + min-height: 90px; + + &:focus-visible { + outline: none; + } + + &:disabled { + opacity: 0.5; + } + } +} +.hdrwpsaf { + display: flex; + flex-direction: column; + height: 100%; + + > header, + > footer { + align-self: center; + display: inline-block; + padding: 6px 9px; + font-size: 90%; + background: rgba(0, 0, 0, 0.5); + border-radius: 6px; + color: #fff; + } + + > header { + margin-bottom: 8px; + opacity: 0.9; + } + + > img { + display: block; + flex: 1; + min-height: 0; + object-fit: contain; + width: 100%; + cursor: zoom-out; + image-orientation: from-image; + } + + > footer { + margin-top: 8px; + opacity: 0.8; + + > span + span { + margin-left: 0.5em; + padding-left: 0.5em; + border-left: solid 1px rgba(255, 255, 255, 0.5); + } + } +} +</style> diff --git a/packages/frontend/src/components/MkFileListForAdmin.vue b/packages/frontend/src/components/MkFileListForAdmin.vue new file mode 100644 index 0000000000..4910506a95 --- /dev/null +++ b/packages/frontend/src/components/MkFileListForAdmin.vue @@ -0,0 +1,117 @@ +<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 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/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue new file mode 100644 index 0000000000..9e83b07cd7 --- /dev/null +++ b/packages/frontend/src/components/MkFolder.vue @@ -0,0 +1,159 @@ +<template> +<div v-size="{ max: [500] }" class="ssazuxis"> + <header class="_button" :style="{ background: bg }" @click="showBody = !showBody"> + <div class="title"><slot name="header"></slot></div> + <div class="divider"></div> + <button class="_button"> + <template v-if="showBody"><i class="ti ti-chevron-up"></i></template> + <template v-else><i class="ti ti-chevron-down"></i></template> + </button> + </header> + <transition + :name="$store.state.animation ? 'folder-toggle' : ''" + @enter="enter" + @after-enter="afterEnter" + @leave="leave" + @after-leave="afterLeave" + > + <div v-show="showBody"> + <slot></slot> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import tinycolor from 'tinycolor2'; + +const localStoragePrefix = 'ui:folder:'; + +export default defineComponent({ + props: { + expanded: { + type: Boolean, + required: false, + default: true, + }, + persistKey: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + bg: null, + showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded, + }; + }, + watch: { + showBody() { + if (this.persistKey) { + localStorage.setItem(localStoragePrefix + this.persistKey, this.showBody ? 't' : 'f'); + } + }, + }, + mounted() { + function getParentBg(el: Element | null): string { + if (el == null || el.tagName === 'BODY') return 'var(--bg)'; + const bg = el.style.background || el.style.backgroundColor; + if (bg) { + return bg; + } else { + return getParentBg(el.parentElement); + } + } + const rawBg = getParentBg(this.$el); + const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + bg.setAlpha(0.85); + this.bg = bg.toRgbString(); + }, + methods: { + toggleContent(show: boolean) { + this.showBody = show; + }, + + enter(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = 0; + el.offsetHeight; // reflow + el.style.height = elementHeight + 'px'; + }, + afterEnter(el) { + el.style.height = null; + }, + leave(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = elementHeight + 'px'; + el.offsetHeight; // reflow + el.style.height = 0; + }, + afterLeave(el) { + el.style.height = null; + }, + }, +}); +</script> + +<style lang="scss" scoped> +.folder-toggle-enter-active, .folder-toggle-leave-active { + overflow-y: hidden; + transition: opacity 0.5s, height 0.5s !important; +} +.folder-toggle-enter-from { + opacity: 0; +} +.folder-toggle-leave-to { + opacity: 0; +} + +.ssazuxis { + position: relative; + + > header { + display: flex; + position: relative; + z-index: 10; + position: sticky; + top: var(--stickyTop, 0px); + padding: var(--x-padding); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(20px)); + + > .title { + display: grid; + place-content: center; + margin: 0; + padding: 12px 16px 12px 0; + + > i { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > .divider { + flex: 1; + margin: auto; + height: 1px; + background: var(--divider); + } + + > button { + padding: 12px 0 12px 16px; + } + } + + &.max-width_500px { + > header { + > .title { + padding: 8px 10px 8px 0; + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue new file mode 100644 index 0000000000..ee256d9263 --- /dev/null +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -0,0 +1,187 @@ +<template> +<button + class="kpoogebi _button" + :class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }" + :disabled="wait" + @click="onClick" +> + <template v-if="!wait"> + <template v-if="hasPendingFollowRequestFromYou && user.isLocked"> + <span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="ti ti-hourglass-empty"></i> + </template> + <template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"> + <!-- つまりリモートフォローの場合。 --> + <span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/> + </template> + <template v-else-if="isFollowing"> + <span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i> + </template> + <template v-else-if="!isFollowing && user.isLocked"> + <span v-if="full">{{ i18n.ts.followRequest }}</span><i class="ti ti-plus"></i> + </template> + <template v-else-if="!isFollowing && !user.isLocked"> + <span v-if="full">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i> + </template> + </template> + <template v-else> + <span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/> + </template> +</button> +</template> + +<script lang="ts" setup> +import { onBeforeUnmount, onMounted } from 'vue'; +import * as Misskey from 'misskey-js'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + user: Misskey.entities.UserDetailed, + full?: boolean, + large?: boolean, +}>(), { + full: false, + large: false, +}); + +let isFollowing = $ref(props.user.isFollowing); +let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou); +let wait = $ref(false); +const connection = stream.useChannel('main'); + +if (props.user.isFollowing == null) { + os.api('users/show', { + userId: props.user.id, + }) + .then(onFollowChange); +} + +function onFollowChange(user: Misskey.entities.UserDetailed) { + if (user.id === props.user.id) { + isFollowing = user.isFollowing; + hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; + } +} + +async function onClick() { + wait = true; + + try { + if (isFollowing) { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }), + }); + + if (canceled) return; + + await os.api('following/delete', { + userId: props.user.id, + }); + } else { + if (hasPendingFollowRequestFromYou) { + await os.api('following/requests/cancel', { + userId: props.user.id, + }); + hasPendingFollowRequestFromYou = false; + } else { + await os.api('following/create', { + userId: props.user.id, + }); + hasPendingFollowRequestFromYou = true; + } + } + } catch (err) { + console.error(err); + } finally { + wait = false; + } +} + +onMounted(() => { + connection.on('follow', onFollowChange); + connection.on('unfollow', onFollowChange); +}); + +onBeforeUnmount(() => { + connection.dispose(); +}); +</script> + +<style lang="scss" scoped> +.kpoogebi { + position: relative; + display: inline-block; + font-weight: bold; + color: var(--accent); + background: transparent; + border: solid 1px var(--accent); + padding: 0; + height: 31px; + font-size: 16px; + border-radius: 32px; + background: #fff; + + &.full { + padding: 0 8px 0 12px; + font-size: 14px; + } + + &.large { + font-size: 16px; + height: 38px; + padding: 0 12px 0 16px; + } + + &:not(.full) { + width: 31px; + } + + &:focus-visible { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -5px; + right: -5px; + bottom: -5px; + left: -5px; + border: 2px solid var(--focus); + border-radius: 32px; + } + } + + &:hover { + //background: mix($primary, #fff, 20); + } + + &:active { + //background: mix($primary, #fff, 40); + } + + &.active { + color: #fff; + background: var(--accent); + + &:hover { + background: var(--accentLighten); + border-color: var(--accentLighten); + } + + &:active { + background: var(--accentDarken); + border-color: var(--accentDarken); + } + } + + &.wait { + cursor: wait !important; + opacity: 0.7; + } + + > span { + margin-right: 6px; + } +} +</style> diff --git a/packages/frontend/src/components/MkForgotPassword.vue b/packages/frontend/src/components/MkForgotPassword.vue new file mode 100644 index 0000000000..1b55451c94 --- /dev/null +++ b/packages/frontend/src/components/MkForgotPassword.vue @@ -0,0 +1,80 @@ +<template> +<XModalWindow ref="dialog" + :width="370" + :height="400" + @close="dialog.close()" + @closed="emit('closed')" +> + <template #header>{{ i18n.ts.forgotPassword }}</template> + + <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> + <template #label>{{ i18n.ts.username }}</template> + <template #prefix>@</template> + </MkInput> + + <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> + + <MkButton class="_formBlock" type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ i18n.ts.send }}</MkButton> + </div> + <div class="sub"> + <MkA to="/about" class="_link">{{ i18n.ts._forgotPassword.ifNoEmail }}</MkA> + </div> + </form> + <div v-else class="bafecedb"> + {{ i18n.ts._forgotPassword.contactAdmin }} + </div> +</XModalWindow> +</template> + +<script lang="ts" setup> +import { } from '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'; +import { i18n } from '@/i18n'; + +const emit = defineEmits<{ + (ev: 'done'): void; + (ev: 'closed'): void; +}>(); + +let dialog: InstanceType<typeof XModalWindow> = $ref(); + +let username = $ref(''); +let email = $ref(''); +let processing = $ref(false); + +async function onSubmit() { + processing = true; + await os.apiWithDialog('request-reset-password', { + username, + email, + }); + emit('done'); + dialog.close(); +} +</script> + +<style lang="scss" scoped> +.bafeceda { + > .main { + padding: 24px; + } + + > .sub { + border-top: solid 0.5px var(--divider); + padding: 24px; + } +} + +.bafecedb { + padding: 24px; +} +</style> diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue new file mode 100644 index 0000000000..b2bf76a8c7 --- /dev/null +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -0,0 +1,127 @@ +<template> +<XModalWindow + ref="dialog" + :width="450" + :can-close="false" + :with-ok-button="true" + :ok-button-disabled="false" + @click="cancel()" + @ok="ok()" + @close="cancel()" + @closed="$emit('closed')" +> + <template #header> + {{ title }} + </template> + + <MkSpacer :margin-min="20" :margin-max="32"> + <div class="xkpnjxcv _formRoot"> + <template v-for="item in Object.keys(form).filter(item => !form[item].hidden)"> + <FormInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1" 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> + </FormInput> + <FormInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" 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> + </FormInput> + <FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" 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> + </FormTextarea> + <FormSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]" class="_formBlock"> + <span v-text="form[item].label || item"></span> + <template v-if="form[item].description" #caption>{{ form[item].description }}</template> + </FormSwitch> + <FormSelect v-else-if="form[item].type === 'enum'" v-model="values[item]" class="_formBlock"> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <option v-for="item in form[item].enum" :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 #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].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> + <MkButton v-else-if="form[item].type === 'button'" class="_formBlock" @click="form[item].action($event, values)"> + <span v-text="form[item].content || item"></span> + </MkButton> + </template> + </div> + </MkSpacer> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from '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 './MkButton.vue'; +import FormRadios from './form/radios.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; + +export default defineComponent({ + components: { + XModalWindow, + FormInput, + FormTextarea, + FormSwitch, + FormSelect, + FormRange, + MkButton, + FormRadios, + }, + + props: { + title: { + type: String, + required: true, + }, + form: { + type: Object, + required: true, + }, + }, + + emits: ['done'], + + data() { + return { + values: {}, + }; + }, + + created() { + for (const item in this.form) { + this.values[item] = this.form[item].default ?? null; + } + }, + + methods: { + ok() { + this.$emit('done', { + result: this.values, + }); + this.$refs.dialog.close(); + }, + + cancel() { + this.$emit('done', { + canceled: true, + }); + this.$refs.dialog.close(); + }, + }, +}); +</script> + +<style lang="scss" scoped> +.xkpnjxcv { + +} +</style> diff --git a/packages/frontend/src/components/MkFormula.vue b/packages/frontend/src/components/MkFormula.vue new file mode 100644 index 0000000000..65a2fee930 --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkFormulaCore.vue b/packages/frontend/src/components/MkFormulaCore.vue new file mode 100644 index 0000000000..6028db9e64 --- /dev/null +++ b/packages/frontend/src/components/MkFormulaCore.vue @@ -0,0 +1,34 @@ +<!-- eslint-disable vue/no-v-html --> +<template> +<div v-if="block" v-html="compiledFormula"></div> +<span v-else v-html="compiledFormula"></span> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import katex from 'katex'; + +export default defineComponent({ + props: { + formula: { + type: String, + required: true, + }, + block: { + type: Boolean, + required: true, + }, + }, + computed: { + compiledFormula(): any { + return katex.renderToString(this.formula, { + throwOnError: false, + } as any); + }, + }, +}); +</script> + +<style> +@import "../../node_modules/katex/dist/katex.min.css"; +</style> diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue new file mode 100644 index 0000000000..a133f6431b --- /dev/null +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -0,0 +1,115 @@ +<template> +<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1"> + <div class="thumbnail"> + <ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/> + </div> + <article> + <header> + <MkAvatar :user="post.user" class="avatar"/> + </header> + <footer> + <span class="title">{{ post.title }}</span> + </footer> + </article> +</MkA> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import { userName } from '@/filters/user'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import * as os from '@/os'; + +const props = defineProps<{ + post: any; +}>(); +</script> + +<style lang="scss" scoped> +.ttasepnz { + display: block; + position: relative; + height: 200px; + + &:hover { + text-decoration: none; + color: var(--accent); + + > .thumbnail { + transform: scale(1.1); + } + + > article { + > footer { + &:before { + opacity: 1; + } + } + } + } + + > .thumbnail { + width: 100%; + height: 100%; + position: absolute; + transition: all 0.5s ease; + + > .img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + > article { + position: absolute; + z-index: 1; + width: 100%; + height: 100%; + + > header { + position: absolute; + top: 0; + width: 100%; + padding: 12px; + box-sizing: border-box; + display: flex; + + > .avatar { + margin-left: auto; + width: 32px; + height: 32px; + } + } + + > footer { + position: absolute; + bottom: 0; + width: 100%; + padding: 16px; + box-sizing: border-box; + color: #fff; + text-shadow: 0 0 8px #000; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + + &:before { + content: ""; + display: block; + position: absolute; + z-index: -1; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(rgba(0, 0, 0, 0.4), transparent); + opacity: 0; + transition: opacity 0.5s ease; + } + + > .title { + font-weight: bold; + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkGoogle.vue b/packages/frontend/src/components/MkGoogle.vue new file mode 100644 index 0000000000..d104cd4cd4 --- /dev/null +++ b/packages/frontend/src/components/MkGoogle.vue @@ -0,0 +1,51 @@ +<template> +<div class="mk-google"> + <input v-model="query" type="search" :placeholder="q"> + <button @click="search"><i class="ti ti-search"></i> {{ $ts.searchByGoogle }}</button> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; + +const props = defineProps<{ + q: string; +}>(); + +const query = ref(props.q); + +const search = () => { + window.open(`https://www.google.com/search?q=${query.value}`, '_blank'); +}; +</script> + +<style lang="scss" scoped> +.mk-google { + display: flex; + margin: 8px 0; + + > input { + flex-shrink: 1; + padding: 10px; + width: 100%; + height: 40px; + font-size: 16px; + border: solid 1px var(--divider); + border-radius: 4px 0 0 4px; + -webkit-appearance: textfield; + } + + > button { + flex-shrink: 0; + margin: 0; + padding: 0 16px; + border: solid 1px var(--divider); + border-left: none; + border-radius: 0 4px 4px 0; + + &:active { + box-shadow: 0 2px 4px rgba(#000, 0.15) inset; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkImageViewer.vue b/packages/frontend/src/components/MkImageViewer.vue new file mode 100644 index 0000000000..f074b1a2f2 --- /dev/null +++ b/packages/frontend/src/components/MkImageViewer.vue @@ -0,0 +1,77 @@ +<template> +<MkModal ref="modal" :z-priority="'middle'" @click="modal.close()" @closed="emit('closed')"> + <div class="xubzgfga"> + <header>{{ image.name }}</header> + <img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/> + <footer> + <span>{{ image.type }}</span> + <span>{{ bytes(image.size) }}</span> + <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span> + </footer> + </div> +</MkModal> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as misskey from 'misskey-js'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; +import MkModal from '@/components/MkModal.vue'; + +const props = withDefaults(defineProps<{ + image: misskey.entities.DriveFile; +}>(), { +}); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +const modal = $ref<InstanceType<typeof MkModal>>(); +</script> + +<style lang="scss" scoped> +.xubzgfga { + display: flex; + flex-direction: column; + height: 100%; + + > header, + > footer { + align-self: center; + display: inline-block; + padding: 6px 9px; + font-size: 90%; + background: rgba(0, 0, 0, 0.5); + border-radius: 6px; + color: #fff; + } + + > header { + margin-bottom: 8px; + opacity: 0.9; + } + + > img { + display: block; + flex: 1; + min-height: 0; + object-fit: contain; + width: 100%; + cursor: zoom-out; + image-orientation: from-image; + } + + > footer { + margin-top: 8px; + opacity: 0.8; + + > span + span { + margin-left: 0.5em; + padding-left: 0.5em; + border-left: solid 1px rgba(255, 255, 255, 0.5); + } + } +} +</style> diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue new file mode 100644 index 0000000000..80d7c201a4 --- /dev/null +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -0,0 +1,76 @@ +<template> +<div class="xubzgfgb" :class="{ cover }" :title="title"> + <canvas v-if="!loaded" ref="canvas" :width="size" :height="size" :title="title"/> + <img v-if="src" :src="src" :title="title" :alt="alt" @load="onLoad"/> +</div> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import { decode } from 'blurhash'; + +const props = withDefaults(defineProps<{ + src?: string | null; + hash?: string; + alt?: string; + title?: string | null; + size?: number; + cover?: boolean; +}>(), { + src: null, + alt: '', + title: null, + size: 64, + cover: true, +}); + +const canvas = $ref<HTMLCanvasElement>(); +let loaded = $ref(false); + +function draw() { + if (props.hash == null) return; + const pixels = decode(props.hash, props.size, props.size); + const ctx = canvas.getContext('2d'); + const imageData = ctx!.createImageData(props.size, props.size); + imageData.data.set(pixels); + ctx!.putImageData(imageData, 0, 0); +} + +function onLoad() { + loaded = true; +} + +onMounted(() => { + draw(); +}); +</script> + +<style lang="scss" scoped> +.xubzgfgb { + position: relative; + width: 100%; + height: 100%; + + > canvas, + > img { + display: block; + width: 100%; + height: 100%; + } + + > canvas { + position: absolute; + object-fit: cover; + } + + > img { + object-fit: contain; + } + + &.cover { + > img { + object-fit: cover; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue new file mode 100644 index 0000000000..7aaf2c5bcb --- /dev/null +++ b/packages/frontend/src/components/MkInfo.vue @@ -0,0 +1,34 @@ +<template> +<div class="fpezltsf" :class="{ warn }"> + <i v-if="warn" class="ti ti-alert-triangle"></i> + <i v-else class="ti ti-info-circle"></i> + <slot></slot> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; + +const props = defineProps<{ + warn?: boolean; +}>(); +</script> + +<style lang="scss" scoped> +.fpezltsf { + padding: 12px 14px; + font-size: 90%; + background: var(--infoBg); + color: var(--infoFg); + border-radius: var(--radius); + + &.warn { + background: var(--infoWarnBg); + color: var(--infoWarnFg); + } + + > i { + margin-right: 4px; + } +} +</style> diff --git a/packages/frontend/src/components/MkInstanceCardMini.vue b/packages/frontend/src/components/MkInstanceCardMini.vue new file mode 100644 index 0000000000..4625de40af --- /dev/null +++ b/packages/frontend/src/components/MkInstanceCardMini.vue @@ -0,0 +1,105 @@ +<template> +<div :class="[$style.root, { yellow: instance.isNotResponding, red: instance.isBlocked, gray: instance.isSuspended }]"> + <img class="icon" :src="getInstanceIcon(instance)" alt=""/> + <div class="body"> + <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="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 { getProxiedImageUrlNullable } from '@/scripts/media-proxy'; + +const props = defineProps<{ + instance: misskey.entities.Instance; +}>(); + +let chartValues = $ref<number[] | null>(null); + +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; +}); + +function getInstanceIcon(instance): string { + return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png'; +} +</script> + +<style lang="scss" module> +.root { + $bodyTitleHieght: 18px; + $bodyInfoHieght: 16px; + + display: flex; + align-items: center; + padding: 16px; + background: var(--panel); + border-radius: 8px; + + > :global(.icon) { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + object-fit: cover; + border-radius: 4px; + margin-right: 10px; + } + + > :global(.body) { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; + + > :global(.host) { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $bodyTitleHieght; + } + + > :global(.sub) { + display: block; + width: 100%; + font-size: 80%; + 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/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue new file mode 100644 index 0000000000..41f6f9ffd5 --- /dev/null +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -0,0 +1,255 @@ +<template> +<div :class="$style.root"> + <MkFolder class="item"> + <template #header>Chart</template> + <div :class="$style.chart"> + <div class="selects"> + <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 _panel"> + <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart> + </div> + </div> + </MkFolder> + + <MkFolder class="item"> + <template #header>Active users heatmap</template> + <div class="_panel" :class="$style.heatmap"> + <MkActiveUsersHeatmap/> + </div> + </MkFolder> + + <MkFolder class="item"> + <template #header>Federation</template> + <div :class="$style.federation"> + <div class="pies"> + <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> + </MkFolder> +</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'; +import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue'; +import MkFolder from '@/components/MkFolder.vue'; + +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({ + position: 'middle', +}); +const { handler: externalTooltipHandler2 } = useChartTooltip({ + position: 'middle', +}); + +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" module> +.root { + &:global { + > .item { + margin-bottom: 16px; + } + } +} + +.chart { + &:global { + > .selects { + display: flex; + margin-bottom: 12px; + } + + > .chart { + padding: 16px; + } + } +} + +.heatmap { + padding: 16px; + margin-bottom: 16px; +} + +.federation { + &:global { + > .pies { + 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/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue new file mode 100644 index 0000000000..646172fe8d --- /dev/null +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -0,0 +1,80 @@ +<template> +<div :class="$style.root" :style="bg"> + <img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/> + <div :class="$style.name">{{ instance.name }}</div> +</div> +</template> + +<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?: { + faviconUrl?: string + name: string + themeColor?: string + } +}>(); + +// if no instance data is given, this is for the local instance +const instance = props.instance ?? { + name: instanceName, + themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content, +}; + +const faviconUrl = $computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico'); + +const themeColor = instance.themeColor ?? '#777777'; + +const bg = { + background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`, +}; +</script> + +<style lang="scss" module> +$height: 2ex; + +.root { + display: flex; + align-items: center; + height: $height; + border-radius: 4px 0 0 4px; + overflow: clip; + color: #fff; + text-shadow: /* .866 ≈ sin(60deg) */ + 1px 0 1px #000, + .866px .5px 1px #000, + .5px .866px 1px #000, + 0 1px 1px #000, + -.5px .866px 1px #000, + -.866px .5px 1px #000, + -1px 0 1px #000, + -.866px -.5px 1px #000, + -.5px -.866px 1px #000, + 0 -1px 1px #000, + .5px -.866px 1px #000, + .866px -.5px 1px #000; + mask-image: linear-gradient(90deg, + rgb(0,0,0), + rgb(0,0,0) calc(100% - 16px), + rgba(0,0,0,0) 100% + ); +} + +.icon { + height: $height; + flex-shrink: 0; +} + +.name { + margin-left: 4px; + line-height: 1; + font-size: 0.9em; + font-weight: bold; + white-space: nowrap; + overflow: visible; +} +</style> diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue new file mode 100644 index 0000000000..ff69c79641 --- /dev/null +++ b/packages/frontend/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="ti ti-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/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue new file mode 100644 index 0000000000..1ccc648c72 --- /dev/null +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -0,0 +1,138 @@ +<template> +<MkModal ref="modal" v-slot="{ type, maxHeight }" :prefer-type="preferedModalType" :anchor="anchor" :transparent-bg="true" :src="src" @click="modal.close()" @closed="emit('closed')"> + <div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }"> + <div class="main"> + <template v-for="item in items"> + <button v-if="item.action" v-click-anime class="_button" @click="$event => { item.action($event); close(); }"> + <i class="icon" :class="item.icon"></i> + <div class="text">{{ item.text }}</div> + <span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span> + </button> + <MkA v-else v-click-anime :to="item.to" @click.passive="close()"> + <i class="icon" :class="item.icon"></i> + <div class="text">{{ item.text }}</div> + <span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span> + </MkA> + </template> + </div> + </div> +</MkModal> +</template> + +<script lang="ts" setup> +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; + anchor?: { x: string; y: string; }; +}>(), { + anchor: () => ({ x: 'right', y: 'center' }), +}); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'popup' : + deviceKind === 'smartphone' ? 'drawer' : + 'dialog'; + +const modal = $ref<InstanceType<typeof MkModal>>(); + +const menu = defaultStore.state.menu; + +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, + to: def.to, + action: def.action, + indicate: def.indicated, +})); + +function close() { + modal.close(); +} +</script> + +<style lang="scss" scoped> +.szkkfdyq { + max-height: 100%; + width: min(460px, 100vw); + padding: 24px; + box-sizing: border-box; + overflow: auto; + overscroll-behavior: contain; + text-align: left; + border-radius: 16px; + + &.asDrawer { + width: 100%; + padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 16px) 16px; + border-radius: 24px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + text-align: center; + } + + > .main, > .sub { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + + > * { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + vertical-align: bottom; + height: 100px; + border-radius: 10px; + + &:hover { + color: var(--accent); + background: var(--accentedBg); + text-decoration: none; + } + + > .icon { + font-size: 24px; + height: 24px; + } + + > .text { + margin-top: 12px; + font-size: 0.8em; + line-height: 1.5em; + } + + > .indicator { + position: absolute; + top: 32px; + left: 32px; + color: var(--indicator); + font-size: 8px; + animation: blink 1s infinite; + + @media (max-width: 500px) { + top: 16px; + left: 16px; + } + } + } + } + + > .sub { + margin-top: 8px; + padding-top: 8px; + border-top: solid 0.5px var(--divider); + } +} +</style> diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue new file mode 100644 index 0000000000..6148ec6195 --- /dev/null +++ b/packages/frontend/src/components/MkLink.vue @@ -0,0 +1,47 @@ +<template> +<component + :is="self ? 'MkA' : 'a'" ref="el" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target" + :title="url" +> + <slot></slot> + <i v-if="target === '_blank'" class="ti ti-external-link icon"></i> +</component> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import { url as local } from '@/config'; +import { useTooltip } from '@/scripts/use-tooltip'; +import * as os from '@/os'; + +const props = withDefaults(defineProps<{ + url: string; + rel?: null | string; +}>(), { +}); + +const self = props.url.startsWith(local); +const attr = self ? 'to' : 'href'; +const target = self ? null : '_blank'; + +const el = $ref(); + +useTooltip($$(el), (showing) => { + os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { + showing, + url: props.url, + source: el, + }, {}, 'closed'); +}); +</script> + +<style lang="scss" scoped> +.xlcxczvw { + word-break: break-all; + + > .icon { + padding-left: 2px; + font-size: .9em; + } +} +</style> diff --git a/packages/frontend/src/components/MkMarquee.vue b/packages/frontend/src/components/MkMarquee.vue new file mode 100644 index 0000000000..5ca04b0b48 --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue new file mode 100644 index 0000000000..aa06c00fc6 --- /dev/null +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -0,0 +1,102 @@ +<template> +<div class="mk-media-banner"> + <div v-if="media.isSensitive && hide" class="sensitive" @click="hide = false"> + <span class="icon"><i class="ti ti-alert-triangle"></i></span> + <b>{{ $ts.sensitive }}</b> + <span>{{ $ts.clickToShow }}</span> + </div> + <div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" class="audio"> + <audio + ref="audioEl" + class="audio" + :src="media.url" + :title="media.name" + controls + preload="metadata" + @volumechange="volumechange" + /> + </div> + <a + v-else class="download" + :href="media.url" + :title="media.name" + :download="media.name" + > + <span class="icon"><i class="ti ti-download"></i></span> + <b>{{ media.name }}</b> + </a> +</div> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import * as misskey from 'misskey-js'; +import { ColdDeviceStorage } from '@/store'; + +const props = withDefaults(defineProps<{ + media: misskey.entities.DriveFile; +}>(), { +}); + +const audioEl = $ref<HTMLAudioElement | null>(); +let hide = $ref(true); + +function volumechange() { + if (audioEl) ColdDeviceStorage.set('mediaVolume', audioEl.volume); +} + +onMounted(() => { + if (audioEl) audioEl.volume = ColdDeviceStorage.get('mediaVolume'); +}); +</script> + +<style lang="scss" scoped> +.mk-media-banner { + width: 100%; + border-radius: 4px; + margin-top: 4px; + overflow: hidden; + + > .download, + > .sensitive { + display: flex; + align-items: center; + font-size: 12px; + padding: 8px 12px; + white-space: nowrap; + + > * { + display: block; + } + + > b { + overflow: hidden; + text-overflow: ellipsis; + } + + > *:not(:last-child) { + margin-right: .2em; + } + + > .icon { + font-size: 1.6em; + } + } + + > .download { + background: var(--noteAttachedFile); + } + + > .sensitive { + background: #111; + color: #fff; + } + + > .audio { + .audio { + display: block; + width: 100%; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue new file mode 100644 index 0000000000..56570eaa05 --- /dev/null +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -0,0 +1,130 @@ +<template> +<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 class="wrapper"> + <b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ $ts.sensitive }}</b> + <span style="display: block;">{{ $ts.clickToShow }}</span> + </div> + </div> +</div> +<div v-else class="gqnyydlz"> + <a + :href="image.url" + :title="image.name" + > + <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/> + <div v-if="image.type === 'image/gif'" class="gif">GIF</div> + </a> + <button v-tooltip="$ts.hide" class="_button hide" @click="hide = true"><i class="ti ti-eye-off"></i></button> +</div> +</template> + +<script lang="ts" setup> +import { watch } from 'vue'; +import * as misskey from 'misskey-js'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import { defaultStore } from '@/store'; + +const props = defineProps<{ + image: misskey.entities.DriveFile; + raw?: boolean; +}>(); + +let hide = $ref(true); + +const url = (props.raw || defaultStore.state.loadRawImages) + ? props.image.url + : defaultStore.state.disableShowingAnimatedImages + ? getStaticImageUrl(props.image.thumbnailUrl) + : props.image.thumbnailUrl; + +// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする +watch(() => props.image, () => { + hide = (defaultStore.state.nsfw === 'force') ? true : props.image.isSensitive && (defaultStore.state.nsfw !== 'ignore'); +}, { + deep: true, + immediate: true, +}); +</script> + +<style lang="scss" scoped> +.qjewsnkg { + position: relative; + + > .bg { + filter: brightness(0.5); + } + + > .text { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + display: flex; + justify-content: center; + align-items: center; + + > .wrapper { + display: table-cell; + text-align: center; + font-size: 0.8em; + color: #fff; + } + } +} + +.gqnyydlz { + position: relative; + //box-shadow: 0 0 0 1px var(--divider) inset; + background: var(--bg); + + > .hide { + display: block; + position: absolute; + border-radius: 6px; + background-color: var(--accentedBg); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + color: var(--accent); + font-size: 0.8em; + padding: 6px 8px; + text-align: center; + top: 12px; + right: 12px; + + > i { + display: block; + } + } + + > a { + display: block; + cursor: zoom-in; + overflow: hidden; + width: 100%; + height: 100%; + background-position: center; + background-size: contain; + background-repeat: no-repeat; + + > .gif { + background-color: var(--fg); + border-radius: 6px; + color: var(--accentLighten); + display: inline-block; + font-size: 14px; + font-weight: bold; + left: 12px; + opacity: .5; + padding: 0 6px; + text-align: center; + top: 12px; + pointer-events: none; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue new file mode 100644 index 0000000000..c6f8612182 --- /dev/null +++ b/packages/frontend/src/components/MkMediaList.vue @@ -0,0 +1,189 @@ +<template> +<div class="hoawjimk"> + <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/> + <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container"> + <div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length"> + <template v-for="media in mediaList.filter(media => previewable(media))"> + <XVideo v-if="media.type.startsWith('video')" :key="media.id" :video="media"/> + <XImage v-else-if="media.type.startsWith('image')" :key="media.id" class="image" :data-id="media.id" :image="media" :raw="raw"/> + </template> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref } from 'vue'; +import * as misskey from 'misskey-js'; +import PhotoSwipeLightbox from 'photoswipe/lightbox'; +import PhotoSwipe from 'photoswipe'; +import 'photoswipe/style.css'; +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'; + +const props = defineProps<{ + mediaList: misskey.entities.DriveFile[]; + raw?: boolean; +}>(); + +const gallery = ref(null); +const pswpZIndex = os.claimZIndex('middle'); + +onMounted(() => { + const lightbox = new PhotoSwipeLightbox({ + dataSource: props.mediaList + .filter(media => { + if (media.type === 'image/svg+xml') return true; // svgのwebpublicはpngなのでtrue + return media.type.startsWith('image') && FILE_TYPE_BROWSERSAFE.includes(media.type); + }) + .map(media => { + const item = { + src: media.url, + w: media.properties.width, + h: media.properties.height, + alt: media.name, + }; + if (media.properties.orientation != null && media.properties.orientation >= 5) { + [item.w, item.h] = [item.h, item.w]; + } + return item; + }), + gallery: gallery.value, + children: '.image', + thumbSelector: '.image', + loop: false, + padding: window.innerWidth > 500 ? { + top: 32, + bottom: 32, + left: 32, + right: 32, + } : { + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + imageClickAction: 'close', + tapAction: 'toggle-controls', + pswpModule: PhotoSwipe, + }); + + lightbox.on('itemData', (ev) => { + const { itemData } = ev; + + // element is children + const { element } = itemData; + + const id = element.dataset.id; + const file = props.mediaList.find(media => media.id === id); + + itemData.src = file.url; + itemData.w = Number(file.properties.width); + itemData.h = Number(file.properties.height); + if (file.properties.orientation != null && file.properties.orientation >= 5) { + [itemData.w, itemData.h] = [itemData.h, itemData.w]; + } + itemData.msrc = file.thumbnailUrl; + itemData.thumbCropped = true; + }); + + lightbox.init(); +}); + +const previewable = (file: misskey.entities.DriveFile): boolean => { + if (file.type === 'image/svg+xml') return true; // svgのwebpublic/thumbnailはpngなのでtrue + // FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切 + return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type); +}; +</script> + +<style lang="scss" scoped> +.hoawjimk { + > .gird-container { + position: relative; + width: 100%; + margin-top: 4px; + + &:before { + content: ''; + display: block; + padding-top: 56.25% // 16:9; + } + + > div { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: grid; + grid-gap: 8px; + + > * { + overflow: hidden; + border-radius: 6px; + } + + &[data-count="1"] { + grid-template-rows: 1fr; + } + + &[data-count="2"] { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr; + } + + &[data-count="3"] { + grid-template-columns: 1fr 0.5fr; + grid-template-rows: 1fr 1fr; + + > *:nth-child(1) { + grid-row: 1 / 3; + } + + > *:nth-child(3) { + grid-column: 2 / 3; + grid-row: 2 / 3; + } + } + + &[data-count="4"] { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + } + + > *:nth-child(1) { + grid-column: 1 / 2; + grid-row: 1 / 2; + } + + > *:nth-child(2) { + grid-column: 2 / 3; + grid-row: 1 / 2; + } + + > *:nth-child(3) { + grid-column: 1 / 2; + grid-row: 2 / 3; + } + + > *:nth-child(4) { + grid-column: 2 / 3; + grid-row: 2 / 3; + } + } + } +} +</style> + +<style lang="scss"> +.pswp { + // なぜか機能しない + //z-index: v-bind(pswpZIndex); + z-index: 2000000; +} +</style> diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue new file mode 100644 index 0000000000..df0bf84116 --- /dev/null +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -0,0 +1,88 @@ +<template> +<div v-if="hide" class="icozogqfvdetwohsdglrbswgrejoxbdj" @click="hide = false"> + <div> + <b><i class="ti ti-alert-triangle"></i> {{ $ts.sensitive }}</b> + <span>{{ $ts.clickToShow }}</span> + </div> +</div> +<div v-else class="kkjnbbplepmiyuadieoenjgutgcmtsvu"> + <video + :poster="video.thumbnailUrl" + :title="video.comment" + :alt="video.comment" + preload="none" + controls + @contextmenu.stop + > + <source + :src="video.url" + :type="video.type" + > + </video> + <i class="ti ti-eye-off" @click="hide = true"></i> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import * as misskey from 'misskey-js'; +import { defaultStore } from '@/store'; + +const props = defineProps<{ + video: misskey.entities.DriveFile; +}>(); + +const hide = ref((defaultStore.state.nsfw === 'force') ? true : props.video.isSensitive && (defaultStore.state.nsfw !== 'ignore')); +</script> + +<style lang="scss" scoped> +.kkjnbbplepmiyuadieoenjgutgcmtsvu { + position: relative; + + > i { + display: block; + position: absolute; + border-radius: 6px; + background-color: var(--fg); + color: var(--accentLighten); + font-size: 14px; + opacity: .5; + padding: 3px 6px; + text-align: center; + cursor: pointer; + top: 12px; + right: 12px; + } + + > video { + display: flex; + justify-content: center; + align-items: center; + + font-size: 3.5em; + overflow: hidden; + background-position: center; + background-size: cover; + width: 100%; + height: 100%; + } +} + +.icozogqfvdetwohsdglrbswgrejoxbdj { + display: flex; + justify-content: center; + align-items: center; + background: #111; + color: #fff; + + > div { + display: table-cell; + text-align: center; + font-size: 12px; + + > b { + display: block; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue new file mode 100644 index 0000000000..3091b435e4 --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue new file mode 100644 index 0000000000..3ada4afbdc --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue new file mode 100644 index 0000000000..64d18b6b7c --- /dev/null +++ b/packages/frontend/src/components/MkMenu.vue @@ -0,0 +1,367 @@ +<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="ti-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="_indicatorCircle"></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="ti-fw" :class="item.icon"></i> + <span>{{ item.text }}</span> + <span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></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="_indicatorCircle"></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="ti-fw" :class="item.icon"></i> + <span>{{ item.text }}</span> + <span class="caret"><i class="ti ti-caret-right ti-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="ti-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="_indicatorCircle"></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); + }); + } + + // TODO: アクティブな要素までスクロール + //itemsEl.scrollTo(); + + 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/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue new file mode 100644 index 0000000000..c64ce163f9 --- /dev/null +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -0,0 +1,73 @@ +<template> +<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="color" stop-opacity="0"></stop> + <stop offset="100%" :stop-color="color" stop-opacity="0.65"></stop> + </linearGradient> + </defs> + <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> + +<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[]; +}>(); + +const viewBoxX = 50; +const viewBoxY = 50; +const gradientId = 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(); + const peak = Math.max.apply(null, stats) || 1; + + const _polylinePoints = stats.map((n, i) => [ + i * (viewBoxX / (stats.length - 1)), + (1 - (n / peak)) * viewBoxY, + ]); + + polylinePoints = _polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); + + polygonPoints = `0,${ viewBoxY } ${ polylinePoints } ${ viewBoxX },${ viewBoxY }`; + + headX = _polylinePoints[_polylinePoints.length - 1][0]; + headY = _polylinePoints[_polylinePoints.length - 1][1]; +} + +watch(() => props.src, draw, { immediate: true }); + +// Vueが何故かWatchを発動させない場合があるので +useInterval(draw, 1000, { + immediate: false, + afterMounted: true, +}); +</script> diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue new file mode 100644 index 0000000000..2305a02794 --- /dev/null +++ b/packages/frontend/src/components/MkModal.vue @@ -0,0 +1,406 @@ +<template> +<transition :name="$store.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? 200 : 0" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened"> + <div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" class="qzhlnise" :class="{ drawer: type === 'drawer', dialog: type === 'dialog' || type === 'dialog:top', popup: type === 'popup' }" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> + <div class="bg _modalBg" :class="{ transparent: transparentBg && (type === 'popup') }" :style="{ zIndex }" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div> + <div ref="content" class="content" :class="{ fixed, top: type === 'dialog:top' }" :style="{ zIndex }" @click.self="onBgClick"> + <slot :max-height="maxHeight" :type="type"></slot> + </div> + </div> +</transition> +</template> + +<script lang="ts" setup> +import { nextTick, onMounted, watch, provide } from 'vue'; +import * as os from '@/os'; +import { isTouchUsing } from '@/scripts/touch'; +import { defaultStore } from '@/store'; +import { deviceKind } from '@/scripts/device-kind'; + +function getFixedContainer(el: Element | null): Element | null { + if (el == null || el.tagName === 'BODY') return null; + const position = window.getComputedStyle(el).getPropertyValue('position'); + if (position === 'fixed') { + return el; + } else { + return getFixedContainer(el.parentElement); + } +} + +type ModalTypes = 'popup' | 'dialog' | 'dialog:top' | 'drawer'; + +const props = withDefaults(defineProps<{ + manualShowing?: boolean | null; + anchor?: { x: string; y: string; }; + src?: HTMLElement; + preferType?: ModalTypes | 'auto'; + zPriority?: 'low' | 'middle' | 'high'; + noOverlap?: boolean; + transparentBg?: boolean; +}>(), { + manualShowing: null, + src: null, + anchor: () => ({ x: 'center', y: 'bottom' }), + preferType: 'auto', + zPriority: 'low', + noOverlap: true, + transparentBg: false, +}); + +const emit = defineEmits<{ + (ev: 'opening'): void; + (ev: 'opened'): void; + (ev: 'click'): void; + (ev: 'esc'): void; + (ev: 'close'): void; + (ev: 'closed'): void; +}>(); + +provide('modal', true); + +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(() => { + if (props.preferType === 'auto') { + if (!defaultStore.state.disableDrawer && isTouchUsing && deviceKind === 'smartphone') { + return 'drawer'; + } else { + return props.src != null ? 'popup' : 'dialog'; + } + } else { + return props.preferType!; + } +}); + +let contentClicking = false; + +const close = () => { + // eslint-disable-next-line vue/no-mutating-props + if (props.src) props.src.style.pointerEvents = 'auto'; + showing = false; + emit('close'); +}; + +const onBgClick = () => { + if (contentClicking) return; + emit('click'); +}; + +if (type === 'drawer') { + maxHeight = window.innerHeight / 1.5; +} + +const keymap = { + 'esc': () => emit('esc'), +}; + +const MARGIN = 16; + +const align = () => { + if (props.src == null) return; + if (type === 'drawer') return; + if (type === 'dialog') return; + + if (content == null) return; + + const srcRect = props.src.getBoundingClientRect(); + + const width = content!.offsetWidth; + const height = content!.offsetHeight; + + let left; + let top; + + 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); + } else if (props.anchor.x === 'left') { + // TODO + } else if (props.anchor.x === 'right') { + left = x + props.src.offsetWidth; + } + + if (props.anchor.y === 'center') { + top = (y - (height / 2)); + } else if (props.anchor.y === 'top') { + // TODO + } else if (props.anchor.y === 'bottom') { + top = y + props.src.offsetHeight; + } + + if (fixed) { + // 画面から横にはみ出る場合 + if (left + width > window.innerWidth) { + left = window.innerWidth - width; + } + + const underSpace = (window.innerHeight - MARGIN) - top; + const upperSpace = (srcRect.top - MARGIN); + + // 画面から縦にはみ出る場合 + if (top + height > (window.innerHeight - MARGIN)) { + if (props.noOverlap && props.anchor.x === 'center') { + if (underSpace >= (upperSpace / 3)) { + maxHeight = underSpace; + } else { + maxHeight = upperSpace; + top = (upperSpace + MARGIN) - height; + } + } else { + top = (window.innerHeight - MARGIN) - height; + } + } else { + maxHeight = underSpace; + } + } else { + // 画面から横にはみ出る場合 + if (left + width - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - width + window.pageXOffset - 1; + } + + const underSpace = (window.innerHeight - MARGIN) - (top - window.pageYOffset); + const upperSpace = (srcRect.top - MARGIN); + + // 画面から縦にはみ出る場合 + if (top + height - window.pageYOffset > (window.innerHeight - MARGIN)) { + if (props.noOverlap && props.anchor.x === 'center') { + if (underSpace >= (upperSpace / 3)) { + maxHeight = underSpace; + } else { + maxHeight = upperSpace; + top = window.pageYOffset + ((upperSpace + MARGIN) - height); + } + } else { + top = (window.innerHeight - MARGIN) - height + window.pageYOffset - 1; + } + } else { + maxHeight = underSpace; + } + } + + if (top < 0) { + top = MARGIN; + } + + if (left < 0) { + left = 0; + } + + let transformOriginX = 'center'; + let transformOriginY = 'center'; + + if (top >= srcRect.top + props.src.offsetHeight + (fixed ? 0 : window.pageYOffset)) { + transformOriginY = 'top'; + } else if ((top + height) <= srcRect.top + (fixed ? 0 : window.pageYOffset)) { + transformOriginY = 'bottom'; + } + + if (left >= srcRect.left + props.src.offsetWidth + (fixed ? 0 : window.pageXOffset)) { + transformOriginX = 'left'; + } else if ((left + width) <= srcRect.left + (fixed ? 0 : window.pageXOffset)) { + transformOriginX = 'right'; + } + + transformOrigin = `${transformOriginX} ${transformOriginY}`; + + content.style.left = left + 'px'; + content.style.top = top + 'px'; +}; + +const onOpened = () => { + emit('opened'); + + // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する + const el = content!.children[0]; + el.addEventListener('mousedown', ev => { + contentClicking = true; + window.addEventListener('mouseup', ev => { + // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ + window.setTimeout(() => { + contentClicking = false; + }, 100); + }, { passive: true, once: true }); + }, { passive: true }); +}; + +onMounted(() => { + watch(() => props.src, async () => { + if (props.src) { + // eslint-disable-next-line vue/no-mutating-props + props.src.style.pointerEvents = 'none'; + } + fixed = (type === 'drawer') || (getFixedContainer(props.src) != null); + + await nextTick(); + + align(); + }, { immediate: true }); + + nextTick(() => { + new ResizeObserver((entries, observer) => { + align(); + }).observe(content!); + }); +}); + +defineExpose({ + close, +}); +</script> + +<style lang="scss" scoped> +.modal-enter-active, .modal-leave-active { + > .bg { + transition: opacity 0.2s !important; + } + + > .content { + transform-origin: var(--transformOrigin); + transition: opacity 0.2s, transform 0.2s !important; + } +} +.modal-enter-from, .modal-leave-to { + > .bg { + opacity: 0; + } + + > .content { + pointer-events: none; + opacity: 0; + transform-origin: var(--transformOrigin); + transform: scale(0.9); + } +} + +.modal-popup-enter-active, .modal-popup-leave-active { + > .bg { + transition: opacity 0.2s !important; + } + + > .content { + transform-origin: var(--transformOrigin); + transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), transform 0.2s cubic-bezier(0, 0, 0.2, 1) !important; + } +} +.modal-popup-enter-from, .modal-popup-leave-to { + > .bg { + opacity: 0; + } + + > .content { + pointer-events: none; + opacity: 0; + transform-origin: var(--transformOrigin); + transform: scale(0.9); + } +} + +.modal-drawer-enter-active { + > .bg { + transition: opacity 0.2s !important; + } + + > .content { + transition: transform 0.2s cubic-bezier(0,.5,0,1) !important; + } +} +.modal-drawer-leave-active { + > .bg { + transition: opacity 0.2s !important; + } + + > .content { + transition: transform 0.2s cubic-bezier(0,.5,0,1) !important; + } +} +.modal-drawer-enter-from, .modal-drawer-leave-to { + > .bg { + opacity: 0; + } + + > .content { + pointer-events: none; + transform: translateY(100%); + } +} + +.qzhlnise { + > .bg { + &.transparent { + background: transparent; + -webkit-backdrop-filter: none; + backdrop-filter: none; + } + } + + &.dialog { + > .content { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: auto; + padding: 32px; + // TODO: mask-imageはiOSだとやたら重い。なんとかしたい + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%); + overflow: auto; + display: flex; + + @media (max-width: 500px) { + padding: 16px; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%); + } + + > ::v-deep(*) { + margin: auto; + } + + &.top { + > ::v-deep(*) { + margin-top: 0; + } + } + } + } + + &.popup { + > .content { + position: absolute; + + &.fixed { + position: fixed; + } + } + } + + &.drawer { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: clip; + + > .content { + position: fixed; + bottom: 0; + left: 0; + right: 0; + margin: auto; + + > ::v-deep(*) { + margin: auto; + } + } + } + +} +</style> diff --git a/packages/frontend/src/components/MkModalPageWindow.vue b/packages/frontend/src/components/MkModalPageWindow.vue new file mode 100644 index 0000000000..ced8a7a714 --- /dev/null +++ b/packages/frontend/src/components/MkModalPageWindow.vue @@ -0,0 +1,181 @@ +<template> +<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> + <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="ti ti-arrow-left"></i></button> + <span v-else style="display: inline-block; width: 20px"></span> + <span v-if="pageMetadata?.value" class="title"> + <i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i> + <span>{{ pageMetadata?.value.title }}</span> + </span> + <button class="_button" @click="$refs.modal.close()"><i class="ti ti-x"></i></button> + </div> + <div class="body"> + <MkStickyContainer> + <template #header><MkPageHeader v-if="pageMetadata?.value && !pageMetadata?.value.hideHeader" :info="pageMetadata?.value"/></template> + <RouterView :router="router"/> + </MkStickyContainer> + </div> + </div> +</MkModal> +</template> + +<script lang="ts" setup> +import { ComputedRef, provide } from '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'; +import * as os from '@/os'; +import { mainRouter, routes } from '@/router'; +import { i18n } from '@/i18n'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import { Router } from '@/nirax'; + +const props = defineProps<{ + initialPath: string; +}>(); + +defineEmits<{ + (ev: 'closed'): void; + (ev: 'click'): void; +}>(); + +const router = new Router(routes, props.initialPath); + +router.addListener('push', ctx => { + +}); + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); +let rootEl = $ref(); +let modal = $ref<InstanceType<typeof MkModal>>(); +let path = $ref(props.initialPath); +let width = $ref(860); +let height = $ref(660); +const history = []; + +provide('router', router); +provideMetadataReceiver((info) => { + pageMetadata = info; +}); +provide('shouldOmitHeaderTitle', true); +provide('shouldHeaderThin', true); + +const pageUrl = $computed(() => url + path); +const contextmenu = $computed(() => { + return [{ + type: 'label', + text: path, + }, { + icon: 'ti ti-player-eject', + text: i18n.ts.showInPage, + action: expand, + }, { + icon: 'ti ti-window-maximize', + text: i18n.ts.popout, + action: popout, + }, null, { + icon: 'ti ti-external-link', + text: i18n.ts.openInNewTab, + action: () => { + window.open(pageUrl, '_blank'); + modal.close(); + }, + }, { + icon: 'ti ti-link', + text: i18n.ts.copyLink, + action: () => { + copyToClipboard(pageUrl); + }, + }]; +}); + +function navigate(path, record = true) { + if (record) history.push(router.getCurrentPath()); + router.push(path); +} + +function back() { + navigate(history.pop(), false); +} + +function expand() { + mainRouter.push(path); + modal.close(); +} + +function popout() { + _popout(path, rootEl); + modal.close(); +} + +function onContextmenu(ev: MouseEvent) { + os.contextMenu(contextmenu, ev); +} +</script> + +<style lang="scss" scoped> +.hrmcaedk { + overflow: hidden; + display: flex; + flex-direction: column; + contain: content; + border-radius: var(--radius); + + --root-margin: 24px; + + @media (max-width: 500px) { + --root-margin: 16px; + } + + > .header { + $height: 52px; + $height-narrow: 42px; + display: flex; + flex-shrink: 0; + height: $height; + line-height: $height; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + + > button { + height: $height; + width: $height; + + &:hover { + color: var(--fgHighlighted); + } + } + + @media (max-width: 500px) { + height: $height-narrow; + line-height: $height-narrow; + padding-left: 16px; + + > button { + height: $height-narrow; + width: $height-narrow; + } + } + + > .title { + flex: 1; + + > .icon { + margin-right: 0.5em; + } + } + } + + > .body { + overflow: auto; + background: var(--bg); + } +} +</style> diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue new file mode 100644 index 0000000000..d977ca6e9c --- /dev/null +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -0,0 +1,146 @@ +<template> +<MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')"> + <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="ti ti-x"></i></button> + <span class="title"> + <slot name="header"></slot> + </span> + <button v-if="!withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button> + <button v-if="withOkButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="ti ti-check"></i></button> + </div> + <div class="body"> + <slot :width="bodyWidth" :height="bodyHeight"></slot> + </div> + </div> +</MkModal> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted } from 'vue'; +import MkModal from './MkModal.vue'; + +const props = withDefaults(defineProps<{ + withOkButton: boolean; + okButtonDisabled: boolean; + width: number; + height: number | null; + scroll: boolean; +}>(), { + withOkButton: false, + okButtonDisabled: false, + width: 400, + height: null, + scroll: true, +}); + +const emit = defineEmits<{ + (event: 'click'): void; + (event: 'close'): void; + (event: 'closed'): void; + (event: 'ok'): void; +}>(); + +let modal = $ref<InstanceType<typeof MkModal>>(); +let rootEl = $ref<HTMLElement>(); +let headerEl = $ref<HTMLElement>(); +let bodyWidth = $ref(0); +let bodyHeight = $ref(0); + +const close = () => { + modal.close(); +}; + +const onBgClick = () => { + emit('click'); +}; + +const onKeydown = (evt) => { + if (evt.which === 27) { // Esc + evt.preventDefault(); + evt.stopPropagation(); + close(); + } +}; + +const ro = new ResizeObserver((entries, observer) => { + bodyWidth = rootEl.offsetWidth; + bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight; +}); + +onMounted(() => { + bodyWidth = rootEl.offsetWidth; + bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight; + ro.observe(rootEl); +}); + +onUnmounted(() => { + ro.disconnect(); +}); + +defineExpose({ + close, +}); +</script> + +<style lang="scss" scoped> +.ebkgoccj { + overflow: hidden; + display: flex; + flex-direction: column; + contain: content; + border-radius: var(--radius); + + --root-margin: 24px; + + @media (max-width: 500px) { + --root-margin: 16px; + } + + > .header { + $height: 46px; + $height-narrow: 42px; + display: flex; + flex-shrink: 0; + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + + > button { + height: $height; + width: $height; + + @media (max-width: 500px) { + height: $height-narrow; + width: $height-narrow; + } + } + + > .title { + flex: 1; + line-height: $height; + padding-left: 32px; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + + @media (max-width: 500px) { + line-height: $height-narrow; + padding-left: 16px; + } + } + + > button + .title { + padding-left: 0; + } + } + + > .body { + flex: 1; + overflow: auto; + background: var(--panel); + } +} +</style> diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue new file mode 100644 index 0000000000..a4100e1f2c --- /dev/null +++ b/packages/frontend/src/components/MkNote.vue @@ -0,0 +1,658 @@ +<template> +<div + v-if="!muted" + v-show="!isDeleted" + ref="el" + v-hotkey="keymap" + v-size="{ max: [500, 450, 350, 300] }" + class="tkcbzcuz" + :tabindex="!isDeleted ? '-1' : null" + :class="{ renote: isRenote }" +> + <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/> + <div v-if="pinned" class="info"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div> + <div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div> + <div v-if="appearNote._featuredId_" class="info"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div> + <div v-if="isRenote" class="renote"> + <MkAvatar class="avatar" :user="note.user"/> + <i class="ti ti-repeat"></i> + <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"/> + </MkA> + </template> + </I18n> + <div class="info"> + <button ref="renoteTime" class="_button time" @click="showRenoteMenu()"> + <i v-if="isMyRenote" class="ti ti-dots dropdownIcon"></i> + <MkTime :time="note.createdAt"/> + </button> + <MkVisibility :note="note"/> + </div> + </div> + <article class="article" @contextmenu.stop="onContextmenu"> + <MkAvatar class="avatar" :user="appearNote.user"/> + <div class="main"> + <XNoteHeader class="header" :note="appearNote" :mini="true"/> + <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> + <div class="body"> + <p v-if="appearNote.cw != null" class="cw"> + <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <XCwButton v-model="showContent" :note="appearNote"/> + </p> + <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="ti ti-arrow-back-up"></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> + <div v-if="translating || translation" class="translation"> + <MkLoading v-if="translating" mini/> + <div v-else class="translated"> + <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + </div> + </div> + </div> + <div v-if="appearNote.files.length > 0" class="files"> + <XMediaList :media-list="appearNote.files"/> + </div> + <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="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="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA> + </div> + <footer class="footer"> + <XReactionsViewer ref="reactionsViewer" :note="appearNote"/> + <button class="button _button" @click="reply()"> + <i class="ti ti-arrow-back-up"></i> + <p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p> + </button> + <XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/> + <button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @click="react()"> + <i class="ti ti-plus"></i> + </button> + <button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)"> + <i class="ti ti-minus"></i> + </button> + <button ref="menuButton" class="button _button" @click="menu()"> + <i class="ti ti-dots"></i> + </button> + </footer> + </div> + </article> +</div> +<div v-else class="muted" @click="muted = false"> + <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"/> + </MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts" setup> +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 '@/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'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { defaultStore, noteViewInterruptors } from '@/store'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; +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; + pinned?: boolean; +}>(); + +const inChannel = inject('inChannel', null); + +let note = $ref(deepClone(props.note)); + +// plugin +if (noteViewInterruptors.length > 0) { + onMounted(async () => { + let result = deepClone(note); + for (const interruptor of noteViewInterruptors) { + result = await interruptor.handler(result); + } + note = result; + }); +} + +const isRenote = ( + note.renote != null && + note.text == null && + note.fileIds.length === 0 && + note.poll == null +); + +const el = ref<HTMLElement>(); +const menuButton = ref<HTMLElement>(); +const renoteButton = ref<InstanceType<typeof XRenoteButton>>(); +const renoteTime = ref<HTMLElement>(); +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 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); +const translating = ref(false); +const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; +const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); + +const keymap = { + 'r': () => reply(true), + 'e|a|plus': () => react(true), + 'q': () => renoteButton.value.renote(true), + 'up|k|shift+tab': focusBefore, + 'down|j|tab': focusAfter, + 'esc': blur, + 'm|o': () => menu(true), + 's': () => showContent.value !== showContent.value, +}; + +useNoteCapture({ + rootEl: el, + note: $$(appearNote), + isDeletedRef: isDeleted, +}); + +function reply(viaKeyboard = false): void { + pleaseLogin(); + os.post({ + reply: appearNote, + animation: !viaKeyboard, + }, () => { + focus(); + }); +} + +function react(viaKeyboard = false): void { + pleaseLogin(); + blur(); + reactionPicker.show(reactButton.value, reaction => { + os.api('notes/reactions/create', { + noteId: appearNote.id, + reaction: reaction, + }); + }, () => { + focus(); + }); +} + +function undoReact(note): void { + const oldReaction = note.myReaction; + if (!oldReaction) return; + os.api('notes/reactions/delete', { + noteId: note.id, + }); +} + +const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null); + +function onContextmenu(ev: MouseEvent): void { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(ev.target)) return; + if (window.getSelection().toString() !== '') return; + + if (defaultStore.state.useReactionPickerForContextMenu) { + ev.preventDefault(); + react(); + } else { + os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), ev).then(focus); + } +} + +function menu(viaKeyboard = false): void { + os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, { + viaKeyboard, + }).then(focus); +} + +function showRenoteMenu(viaKeyboard = false): void { + if (!isMyRenote) return; + os.popupMenu([{ + text: i18n.ts.unrenote, + icon: 'ti ti-trash', + danger: true, + action: () => { + os.api('notes/delete', { + noteId: note.id, + }); + isDeleted.value = true; + }, + }], renoteTime.value, { + viaKeyboard: viaKeyboard, + }); +} + +function focus() { + el.value.focus(); +} + +function blur() { + el.value.blur(); +} + +function focusBefore() { + focusPrev(el.value); +} + +function focusAfter() { + focusNext(el.value); +} + +function readPromo() { + os.api('promo/read', { + noteId: appearNote.id, + }); + isDeleted.value = true; +} +</script> + +<style lang="scss" scoped> +.tkcbzcuz { + position: relative; + transition: box-shadow 0.1s ease; + font-size: 1.05em; + overflow: clip; + contain: content; + + // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 + // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう + // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 + // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる + // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) + //content-visibility: auto; + //contain-intrinsic-size: 0 128px; + + &:focus-visible { + outline: none; + + &:after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: calc(100% - 8px); + height: calc(100% - 8px); + border: dashed 1px var(--focus); + border-radius: var(--radius); + box-sizing: border-box; + } + } + + &:hover > .article > .main > .footer > .button { + opacity: 1; + } + + > .info { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 24px; + font-size: 90%; + white-space: pre; + color: #d28a3f; + + > i { + margin-right: 4px; + } + + > .hide { + margin-left: auto; + color: inherit; + } + } + + > .info + .article { + padding-top: 8px; + } + + > .reply-to { + opacity: 0.7; + padding-bottom: 0; + } + + > .renote { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 28px; + white-space: pre; + color: var(--renote); + + > .avatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: 6px; + } + + > i { + margin-right: 4px; + } + + > span { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + + > .name { + font-weight: bold; + } + } + + > .info { + margin-left: auto; + font-size: 0.9em; + + > .time { + flex-shrink: 0; + color: inherit; + + > .dropdownIcon { + margin-right: 4px; + } + } + } + } + + > .renote + .article { + padding-top: 8px; + } + + > .article { + display: flex; + padding: 28px 32px 18px; + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 14px 8px 0; + width: 58px; + height: 58px; + position: sticky; + top: calc(22px + var(--stickyTop, 0px)); + left: 0; + } + + > .main { + flex: 1; + min-width: 0; + + > .body { + container-type: inline-size; + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .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; + overflow: hidden; + + > .fade { + display: block; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + + > span { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); + } + + &:hover { + > span { + background: var(--panelHighlight); + } + } + } + } + + > .text { + overflow-wrap: break-word; + + > .reply { + color: var(--accent); + margin-right: 0.5em; + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + + > .translation { + border: solid 0.5px var(--divider); + border-radius: var(--radius); + padding: 12px; + margin-top: 8px; + } + } + + > .url-preview { + margin-top: 8px; + } + + > .poll { + font-size: 80%; + } + + > .renote { + padding: 8px 0; + + > * { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + } + } + } + + > .channel { + opacity: 0.7; + font-size: 80%; + } + } + + > .footer { + > .button { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 28px; + } + + &:hover { + color: var(--fgHighlighted); + } + + > .count { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + } + + &.reacted { + color: var(--accent); + } + } + } + } + } + + > .reply { + border-top: solid 0.5px var(--divider); + } +} + +@container (max-width: 500px) { + .tkcbzcuz { + font-size: 0.9em; + + > .article { + > .avatar { + width: 50px; + height: 50px; + } + } + } +} + +@container (max-width: 450px) { + .tkcbzcuz { + > .renote { + padding: 8px 16px 0 16px; + } + + > .info { + padding: 8px 16px 0 16px; + } + + > .article { + padding: 14px 16px 9px; + + > .avatar { + margin: 0 10px 8px 0; + width: 46px; + height: 46px; + top: calc(14px + var(--stickyTop, 0px)); + } + } + } +} + +@container (max-width: 350px) { + .tkcbzcuz { + > .article { + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 18px; + } + } + } + } + } + } +} + +@container (max-width: 300px) { + .tkcbzcuz { + > .article { + > .avatar { + width: 44px; + height: 44px; + } + + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 12px; + } + } + } + } + } + } +} + +.muted { + padding: 8px; + text-align: center; + opacity: 0.7; +} +</style> diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue new file mode 100644 index 0000000000..7ce8e039d9 --- /dev/null +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -0,0 +1,677 @@ +<template> +<div + v-if="!muted" + v-show="!isDeleted" + ref="el" + v-hotkey="keymap" + v-size="{ max: [500, 450, 350, 300] }" + class="lxwezrsl _block" + :tabindex="!isDeleted ? '-1' : null" + :class="{ renote: isRenote }" +> + <MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/> + <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/> + <div v-if="isRenote" class="renote"> + <MkAvatar class="avatar" :user="note.user"/> + <i class="ti ti-repeat"></i> + <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"/> + </MkA> + </template> + </I18n> + <div class="info"> + <button ref="renoteTime" class="_button time" @click="showRenoteMenu()"> + <i v-if="isMyRenote" class="ti ti-dots dropdownIcon"></i> + <MkTime :time="note.createdAt"/> + </button> + <MkVisibility :note="note"/> + </div> + </div> + <article class="article" @contextmenu.stop="onContextmenu"> + <header class="header"> + <MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/> + <div class="body"> + <div class="top"> + <MkA v-user-preview="appearNote.user.id" class="name" :to="userPage(appearNote.user)"> + <MkUserName :nowrap="false" :user="appearNote.user"/> + </MkA> + <span v-if="appearNote.user.isBot" class="is-bot">bot</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"/> + </div> + </header> + <div class="main"> + <div class="body"> + <p v-if="appearNote.cw != null" class="cw"> + <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <XCwButton v-model="showContent" :note="appearNote"/> + </p> + <div v-show="appearNote.cw == null || showContent" class="content"> + <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="ti ti-arrow-back-up"></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> + <div v-if="translating || translation" class="translation"> + <MkLoading v-if="translating" mini/> + <div v-else class="translated"> + <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + </div> + </div> + </div> + <div v-if="appearNote.files.length > 0" class="files"> + <XMediaList :media-list="appearNote.files"/> + </div> + <XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" class="url-preview"/> + <div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div> + </div> + <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA> + </div> + <footer class="footer"> + <div class="info"> + <MkA class="created-at" :to="notePage(appearNote)"> + <MkTime :time="appearNote.createdAt" mode="detail"/> + </MkA> + </div> + <XReactionsViewer ref="reactionsViewer" :note="appearNote"/> + <button class="button _button" @click="reply()"> + <i class="ti ti-arrow-back-up"></i> + <p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p> + </button> + <XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/> + <button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @click="react()"> + <i class="ti ti-plus"></i> + </button> + <button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)"> + <i class="ti ti-minus"></i> + </button> + <button ref="menuButton" class="button _button" @click="menu()"> + <i class="ti ti-dots"></i> + </button> + </footer> + </div> + </article> + <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="i18n.ts.userSaysSomething" tag="small"> + <template #name> + <MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts" setup> +import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue'; +import * as mfm from 'mfm-js'; +import * as misskey from 'misskey-js'; +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'; +import { notePage } from '@/filters/note'; +import * as os from '@/os'; +import { defaultStore, noteViewInterruptors } from '@/store'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; +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; + pinned?: boolean; +}>(); + +const inChannel = inject('inChannel', null); + +let note = $ref(deepClone(props.note)); + +// plugin +if (noteViewInterruptors.length > 0) { + onMounted(async () => { + let result = deepClone(note); + for (const interruptor of noteViewInterruptors) { + result = await interruptor.handler(result); + } + note = result; + }); +} + +const isRenote = ( + note.renote != null && + note.text == null && + note.fileIds.length === 0 && + note.poll == null +); + +const el = ref<HTMLElement>(); +const menuButton = ref<HTMLElement>(); +const renoteButton = ref<InstanceType<typeof XRenoteButton>>(); +const renoteTime = ref<HTMLElement>(); +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 isDeleted = ref(false); +const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); +const translation = ref(null); +const translating = ref(false); +const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; +const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); +const conversation = ref<misskey.entities.Note[]>([]); +const replies = ref<misskey.entities.Note[]>([]); + +const keymap = { + 'r': () => reply(true), + 'e|a|plus': () => react(true), + 'q': () => renoteButton.value.renote(true), + 'esc': blur, + 'm|o': () => menu(true), + 's': () => showContent.value !== showContent.value, +}; + +useNoteCapture({ + rootEl: el, + note: $$(appearNote), + isDeletedRef: isDeleted, +}); + +function reply(viaKeyboard = false): void { + pleaseLogin(); + os.post({ + reply: appearNote, + animation: !viaKeyboard, + }, () => { + focus(); + }); +} + +function react(viaKeyboard = false): void { + pleaseLogin(); + blur(); + reactionPicker.show(reactButton.value, reaction => { + os.api('notes/reactions/create', { + noteId: appearNote.id, + reaction: reaction, + }); + }, () => { + focus(); + }); +} + +function undoReact(note): void { + const oldReaction = note.myReaction; + if (!oldReaction) return; + os.api('notes/reactions/delete', { + noteId: note.id, + }); +} + +function onContextmenu(ev: MouseEvent): void { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(ev.target)) return; + if (window.getSelection().toString() !== '') return; + + if (defaultStore.state.useReactionPickerForContextMenu) { + ev.preventDefault(); + react(); + } else { + os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), ev).then(focus); + } +} + +function menu(viaKeyboard = false): void { + os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), menuButton.value, { + viaKeyboard, + }).then(focus); +} + +function showRenoteMenu(viaKeyboard = false): void { + if (!isMyRenote) return; + os.popupMenu([{ + text: i18n.ts.unrenote, + icon: 'ti ti-trash', + danger: true, + action: () => { + os.api('notes/delete', { + noteId: note.id, + }); + isDeleted.value = true; + }, + }], renoteTime.value, { + viaKeyboard: viaKeyboard, + }); +} + +function focus() { + el.value.focus(); +} + +function blur() { + el.value.blur(); +} + +os.api('notes/children', { + noteId: appearNote.id, + limit: 30, +}).then(res => { + replies.value = res; +}); + +if (appearNote.replyId) { + os.api('notes/conversation', { + noteId: appearNote.replyId, + }).then(res => { + conversation.value = res.reverse(); + }); +} +</script> + +<style lang="scss" scoped> +.lxwezrsl { + position: relative; + transition: box-shadow 0.1s ease; + overflow: hidden; + contain: content; + + &:focus-visible { + outline: none; + + &:after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: calc(100% - 8px); + height: calc(100% - 8px); + border: dashed 1px var(--focus); + border-radius: var(--radius); + box-sizing: border-box; + } + } + + &:hover > .article > .main > .footer > .button { + opacity: 1; + } + + > .reply-to { + opacity: 0.7; + padding-bottom: 0; + } + + > .reply-to-more { + opacity: 0.7; + } + + > .renote { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 28px; + white-space: pre; + color: var(--renote); + + > .avatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: 6px; + } + + > i { + margin-right: 4px; + } + + > span { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + + > .name { + font-weight: bold; + } + } + + > .info { + margin-left: auto; + font-size: 0.9em; + + > .time { + flex-shrink: 0; + color: inherit; + + > .dropdownIcon { + margin-right: 4px; + } + } + } + } + + > .renote + .article { + padding-top: 8px; + } + + > .article { + padding: 32px; + font-size: 1.2em; + + > .header { + display: flex; + position: relative; + margin-bottom: 16px; + align-items: center; + + > .avatar { + display: block; + flex-shrink: 0; + width: 58px; + height: 58px; + } + + > .body { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + padding-left: 16px; + font-size: 0.95em; + + > .top { + > .name { + font-weight: bold; + line-height: 1.3; + } + + > .is-bot { + display: inline-block; + margin: 0 0.5em; + padding: 4px 6px; + font-size: 80%; + line-height: 1; + border: solid 0.5px var(--divider); + border-radius: 4px; + } + + > .info { + float: right; + } + } + + > .username { + margin-bottom: 2px; + line-height: 1.3; + word-wrap: anywhere; + } + } + } + + > .main { + > .body { + container-type: inline-size; + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + overflow-wrap: break-word; + + > .reply { + color: var(--accent); + margin-right: 0.5em; + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + + > .translation { + border: solid 0.5px var(--divider); + border-radius: var(--radius); + padding: 12px; + margin-top: 8px; + } + } + + > .url-preview { + margin-top: 8px; + } + + > .poll { + font-size: 80%; + } + + > .renote { + padding: 8px 0; + + > * { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + } + } + } + + > .channel { + opacity: 0.7; + font-size: 80%; + } + } + + > .footer { + > .info { + margin: 16px 0; + opacity: 0.7; + font-size: 0.9em; + } + + > .button { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 28px; + } + + &:hover { + color: var(--fgHighlighted); + } + + > .count { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + } + + &.reacted { + color: var(--accent); + } + } + } + } + } + + > .reply { + border-top: solid 0.5px var(--divider); + } + + &.max-width_500px { + font-size: 0.9em; + } + + &.max-width_450px { + > .renote { + padding: 8px 16px 0 16px; + } + + > .article { + padding: 16px; + + > .header { + > .avatar { + width: 50px; + height: 50px; + } + } + } + } + + &.max-width_350px { + > .article { + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 18px; + } + } + } + } + } + } + + &.max-width_300px { + font-size: 0.825em; + + > .article { + > .header { + > .avatar { + width: 50px; + height: 50px; + } + } + + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 12px; + } + } + } + } + } + } +} + +@container (max-width: 500px) { + .lxwezrsl { + font-size: 0.9em; + } +} + +@container (max-width: 450px) { + .lxwezrsl { + > .renote { + padding: 8px 16px 0 16px; + } + + > .article { + padding: 16px; + + > .header { + > .avatar { + width: 50px; + height: 50px; + } + } + } + } +} + +@container (max-width: 350px) { + .lxwezrsl { + > .article { + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 18px; + } + } + } + } + } + } +} + +@container (max-width: 300px) { + .lxwezrsl { + font-size: 0.825em; + + > .article { + > .header { + > .avatar { + width: 50px; + height: 50px; + } + } + + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 12px; + } + } + } + } + } + } +} + +.muted { + padding: 8px; + text-align: center; + opacity: 0.7; +} +</style> diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue new file mode 100644 index 0000000000..333c3ddbd9 --- /dev/null +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -0,0 +1,75 @@ +<template> +<header class="kkwtjztg"> + <MkA v-user-preview="note.user.id" class="name" :to="userPage(note.user)"> + <MkUserName :user="note.user"/> + </MkA> + <div v-if="note.user.isBot" class="is-bot">bot</div> + <div class="username"><MkAcct :user="note.user"/></div> + <div class="info"> + <MkA class="created-at" :to="notePage(note)"> + <MkTime :time="note.createdAt"/> + </MkA> + <MkVisibility :note="note"/> + </div> +</header> +</template> + +<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'; + +defineProps<{ + note: misskey.entities.Note; + pinned?: boolean; +}>(); +</script> + +<style lang="scss" scoped> +.kkwtjztg { + display: flex; + align-items: baseline; + white-space: nowrap; + + > .name { + flex-shrink: 1; + display: block; + margin: 0 .5em 0 0; + padding: 0; + overflow: hidden; + font-size: 1em; + font-weight: bold; + text-decoration: none; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } + } + + > .is-bot { + flex-shrink: 0; + align-self: center; + margin: 0 .5em 0 0; + padding: 1px 6px; + font-size: 80%; + border: solid 0.5px var(--divider); + border-radius: 3px; + } + + > .username { + flex-shrink: 9999999; + margin: 0 .5em 0 0; + overflow: hidden; + text-overflow: ellipsis; + } + + > .info { + flex-shrink: 0; + margin-left: auto; + font-size: 0.9em; + } +} +</style> diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue new file mode 100644 index 0000000000..0c81059091 --- /dev/null +++ b/packages/frontend/src/components/MkNotePreview.vue @@ -0,0 +1,112 @@ +<template> +<div v-size="{ min: [350, 500] }" class="fefdfafb"> + <MkAvatar class="avatar" :user="$i"/> + <div class="main"> + <div class="header"> + <MkUserName :user="$i"/> + </div> + <div class="body"> + <div class="content"> + <Mfm :text="text.trim()" :author="$i" :i="$i"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; + +const props = defineProps<{ + text: string; +}>(); +</script> + +<style lang="scss" scoped> +.fefdfafb { + display: flex; + margin: 0; + padding: 0; + overflow: clip; + font-size: 0.95em; + + &.min-width_350px { + > .avatar { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + } + + &.min-width_500px { + > .avatar { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 40px; + height: 40px; + border-radius: 8px; + pointer-events: none; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + font-weight: bold; + } + + > .body { + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + cursor: default; + margin: 0; + padding: 0; + } + } + } + } +} + +@container (min-width: 350px) { + .fefdfafb { + > .avatar { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + } +} + +@container (min-width: 500px) { + .fefdfafb { + > .avatar { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue new file mode 100644 index 0000000000..96d29831d2 --- /dev/null +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -0,0 +1,119 @@ +<template> +<div v-size="{ min: [350, 500] }" class="yohlumlk"> + <MkAvatar class="avatar" :user="note.user"/> + <div class="main"> + <XNoteHeader class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <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"> + <MkSubNoteContent class="text" :note="note"/> + </div> + </div> + </div> +</div> +</template> + +<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'; + +const props = defineProps<{ + note: misskey.entities.Note; + pinned?: boolean; +}>(); + +const showContent = $ref(false); +</script> + +<style lang="scss" scoped> +.yohlumlk { + display: flex; + margin: 0; + padding: 0; + overflow: clip; + font-size: 0.95em; + + &.min-width_350px { + > .avatar { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + } + + &.min-width_500px { + > .avatar { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 40px; + height: 40px; + border-radius: 8px; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + cursor: default; + margin: 0; + padding: 0; + } + } + } + } +} + +@container (min-width: 350px) { + .yohlumlk { + > .avatar { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + } +} + +@container (min-width: 500px) { + .yohlumlk { + > .avatar { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue new file mode 100644 index 0000000000..d03ce7c434 --- /dev/null +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -0,0 +1,140 @@ +<template> +<div v-size="{ max: [450] }" class="wrpstxzv" :class="{ children: depth > 1 }"> + <div class="main"> + <MkAvatar class="avatar" :user="note.user"/> + <div class="body"> + <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"/> + <XCwButton v-model="showContent" :note="note"/> + </p> + <div v-show="note.cw == null || showContent" class="content"> + <MkSubNoteContent class="text" :note="note"/> + </div> + </div> + </div> + </div> + <template v-if="depth < 5"> + <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)">{{ i18n.ts.continueThread }} <i class="ti ti-chevron-double-right"></i></MkA> + </div> +</div> +</template> + +<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 * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + note: misskey.entities.Note; + detail?: boolean; + + // how many notes are in between this one and the note being viewed in detail + depth?: number; +}>(), { + depth: 1, +}); + +let showContent = $ref(false); +let replies: misskey.entities.Note[] = $ref([]); + +if (props.detail) { + os.api('notes/children', { + noteId: props.note.id, + limit: 5, + }).then(res => { + replies = res; + }); +} +</script> + +<style lang="scss" scoped> +.wrpstxzv { + padding: 16px 32px; + font-size: 0.9em; + + &.max-width_450px { + padding: 14px 16px; + } + + &.children { + padding: 10px 0 0 16px; + font-size: 1em; + + &.max-width_450px { + padding: 10px 0 0 8px; + } + } + + > .main { + display: flex; + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 8px 0 0; + width: 38px; + height: 38px; + border-radius: 8px; + } + + > .body { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + margin: 0; + padding: 0; + } + } + } + } + } + + > .reply, > .more { + border-left: solid 0.5px var(--divider); + margin-top: 10px; + } + + > .more { + padding: 10px 0 0 16px; + } +} + +@container (max-width: 450px) { + .wrpstxzv { + padding: 14px 16px; + + &.children { + padding: 10px 0 0 8px; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue new file mode 100644 index 0000000000..5abcdc2298 --- /dev/null +++ b/packages/frontend/src/components/MkNotes.vue @@ -0,0 +1,58 @@ +<template> +<MkPagination ref="pagingComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noNotes }}</div> + </div> + </template> + + <template #default="{ items: notes }"> + <div class="giivymft" :class="{ noGap }"> + <XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes"> + <XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note"/> + </XList> + </div> + </template> +</MkPagination> +</template> + +<script lang="ts" setup> +import { ref } from '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; + noGap?: boolean; +}>(); + +const pagingComponent = ref<InstanceType<typeof MkPagination>>(); + +defineExpose({ + pagingComponent, +}); +</script> + +<style lang="scss" scoped> +.giivymft { + &.noGap { + > .notes { + background: var(--panel); + } + } + + &:not(.noGap) { + > .notes { + background: var(--bg); + + .qtqtichx { + background: var(--panel); + border-radius: var(--radius); + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue new file mode 100644 index 0000000000..8b8d3f452d --- /dev/null +++ b/packages/frontend/src/components/MkNotification.vue @@ -0,0 +1,323 @@ +<template> +<div ref="elRef" v-size="{ max: [500, 600] }" class="qglefbjs" :class="notification.type"> + <div class="head"> + <MkAvatar v-if="notification.type === 'pollEnded'" class="icon" :user="notification.note.user"/> + <MkAvatar v-else-if="notification.user" class="icon" :user="notification.user"/> + <img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/> + <div class="sub-icon" :class="notification.type"> + <i v-if="notification.type === 'follow'" class="ti ti-plus"></i> + <i v-else-if="notification.type === 'receiveFollowRequest'" class="ti ti-clock"></i> + <i v-else-if="notification.type === 'followRequestAccepted'" class="ti ti-check"></i> + <i v-else-if="notification.type === 'groupInvited'" class="ti ti-certificate-2"></i> + <i v-else-if="notification.type === 'renote'" class="ti ti-repeat"></i> + <i v-else-if="notification.type === 'reply'" class="ti ti-arrow-back-up"></i> + <i v-else-if="notification.type === 'mention'" class="ti ti-at"></i> + <i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i> + <i v-else-if="notification.type === 'pollVote'" class="ti ti-chart-arrows"></i> + <i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i> + <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> + <XReactionIcon + v-else-if="notification.type === 'reaction'" + ref="reactionRef" + :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction" + :custom-emojis="notification.note.emojis" + :no-style="true" + /> + </div> + </div> + <div class="tail"> + <header> + <span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span> + <MkA v-else-if="notification.user" v-user-preview="notification.user.id" class="name" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> + <span v-else>{{ notification.header }}</span> + <MkTime v-if="withTime" :time="notification.createdAt" class="time"/> + </header> + <MkA v-if="notification.type === 'reaction'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <i class="ti ti-quote"></i> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + <i class="ti ti-quote"></i> + </MkA> + <MkA v-if="notification.type === 'renote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)"> + <i class="ti ti-quote"></i> + <Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.renote.emojis"/> + <i class="ti ti-quote"></i> + </MkA> + <MkA v-if="notification.type === 'reply'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + </MkA> + <MkA v-if="notification.type === 'mention'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + </MkA> + <MkA v-if="notification.type === 'quote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + </MkA> + <MkA v-if="notification.type === 'pollVote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <i class="ti ti-quote"></i> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + <i class="ti ti-quote"></i> + </MkA> + <MkA v-if="notification.type === 'pollEnded'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <i class="ti ti-quote"></i> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + <i class="ti ti-quote"></i> + </MkA> + <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> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref, onMounted, onUnmounted, watch } from 'vue'; +import * as misskey from 'misskey-js'; +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'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { useTooltip } from '@/scripts/use-tooltip'; + +const props = withDefaults(defineProps<{ + notification: misskey.entities.Notification; + withTime?: boolean; + full?: boolean; +}>(), { + withTime: false, + full: false, +}); + +const elRef = ref<HTMLElement>(null); +const reactionRef = ref(null); + +let readObserver: IntersectionObserver | undefined; +let connection; + +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(); + }); + + readObserver.observe(elRef.value); + + connection = stream.useChannel('main'); + connection.on('readAllNotifications', () => readObserver.disconnect()); + + watch(props.notification.isRead, () => { + readObserver.disconnect(); + }); + } +}); + +onUnmounted(() => { + if (readObserver) readObserver.disconnect(); + if (connection) connection.dispose(); +}); + +const followRequestDone = ref(false); +const groupInviteDone = ref(false); + +const acceptFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/accept', { userId: props.notification.user.id }); +}; + +const rejectFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/reject', { userId: props.notification.user.id }); +}; + +const acceptGroupInvitation = () => { + groupInviteDone.value = true; + os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id }); +}; + +const rejectGroupInvitation = () => { + groupInviteDone.value = true; + os.api('users/groups/invitations/reject', { 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'); +}); +</script> + +<style lang="scss" scoped> +.qglefbjs { + position: relative; + box-sizing: border-box; + padding: 24px 32px; + font-size: 0.9em; + overflow-wrap: break-word; + display: flex; + contain: content; + + &.max-width_600px { + padding: 16px; + font-size: 0.9em; + } + + &.max-width_500px { + padding: 12px; + font-size: 0.85em; + } + + > .head { + position: sticky; + top: 0; + flex-shrink: 0; + width: 42px; + height: 42px; + margin-right: 8px; + + > .icon { + display: block; + width: 100%; + height: 100%; + border-radius: 6px; + } + + > .sub-icon { + position: absolute; + z-index: 1; + bottom: -2px; + right: -2px; + width: 20px; + height: 20px; + box-sizing: border-box; + border-radius: 100%; + background: var(--panel); + box-shadow: 0 0 0 3px var(--panel); + font-size: 12px; + text-align: center; + + &:empty { + display: none; + } + + > * { + color: #fff; + width: 100%; + height: 100%; + } + + &.follow, &.followRequestAccepted, &.receiveFollowRequest, &.groupInvited { + padding: 3px; + background: #36aed2; + pointer-events: none; + } + + &.renote { + padding: 3px; + background: #36d298; + pointer-events: none; + } + + &.quote { + padding: 3px; + background: #36d298; + pointer-events: none; + } + + &.reply { + padding: 3px; + background: #007aff; + pointer-events: none; + } + + &.mention { + padding: 3px; + background: #88a6b7; + pointer-events: none; + } + + &.pollVote { + padding: 3px; + background: #88a6b7; + pointer-events: none; + } + + &.pollEnded { + padding: 3px; + background: #88a6b7; + pointer-events: none; + } + } + } + + > .tail { + flex: 1; + min-width: 0; + + > header { + display: flex; + align-items: baseline; + white-space: nowrap; + + > .name { + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + overflow: hidden; + } + + > .time { + margin-left: auto; + font-size: 0.9em; + } + } + + > .text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + > i { + vertical-align: super; + font-size: 50%; + opacity: 0.5; + } + + > i:first-child { + margin-right: 4px; + } + + > i:last-child { + margin-left: 4px; + } + } + } +} + +@container (max-width: 600px) { + .qglefbjs { + padding: 16px; + font-size: 0.9em; + } +} + +@container (max-width: 500px) { + .qglefbjs { + padding: 12px; + font-size: 0.85em; + } +} +</style> diff --git a/packages/frontend/src/components/MkNotificationSettingWindow.vue b/packages/frontend/src/components/MkNotificationSettingWindow.vue new file mode 100644 index 0000000000..75bea2976c --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkNotificationToast.vue b/packages/frontend/src/components/MkNotificationToast.vue new file mode 100644 index 0000000000..07640792c0 --- /dev/null +++ b/packages/frontend/src/components/MkNotificationToast.vue @@ -0,0 +1,68 @@ +<template> +<div class="mk-notification-toast" :style="{ zIndex }"> + <transition :name="$store.state.animation ? 'notification-toast' : ''" appear @after-leave="$emit('closed')"> + <XNotification v-if="showing" :notification="notification" class="notification _acrylic"/> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import XNotification from '@/components/MkNotification.vue'; +import * as os from '@/os'; + +defineProps<{ + notification: any; // TODO +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +const zIndex = os.claimZIndex('high'); +let showing = $ref(true); + +onMounted(() => { + window.setTimeout(() => { + showing = false; + }, 6000); +}); +</script> + +<style lang="scss" scoped> +.notification-toast-enter-active, .notification-toast-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.notification-toast-enter-from, .notification-toast-leave-to { + opacity: 0; + transform: translateX(-250px); +} + +.mk-notification-toast { + position: fixed; + left: 0; + width: 250px; + top: 32px; + padding: 0 32px; + pointer-events: none; + container-type: inline-size; + + @media (max-width: 700px) { + top: initial; + bottom: 112px; + padding: 0 16px; + } + + @media (max-width: 500px) { + bottom: calc(env(safe-area-inset-bottom, 0px) + 92px); + padding: 0 8px; + } + + > .notification { + height: 100%; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + border-radius: 8px; + overflow: hidden; + } +} +</style> diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue new file mode 100644 index 0000000000..0e1cc06743 --- /dev/null +++ b/packages/frontend/src/components/MkNotifications.vue @@ -0,0 +1,104 @@ +<template> +<MkPagination ref="pagingComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noNotifications }}</div> + </div> + </template> + + <template #default="{ items: notifications }"> + <XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true"> + <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> + <XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> + </XList> + </template> +</MkPagination> +</template> + +<script lang="ts" setup> +import { defineComponent, markRaw, onUnmounted, onMounted, computed, ref } from 'vue'; +import { notificationTypes } from 'misskey-js'; +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][]; + unreadOnly?: boolean; +}>(); + +const pagingComponent = ref<InstanceType<typeof MkPagination>>(); + +const pagination: Paging = { + endpoint: 'i/notifications' as const, + limit: 10, + params: computed(() => ({ + includeTypes: props.includeTypes ?? undefined, + excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes, + unreadOnly: props.unreadOnly, + })), +}; + +const onNotification = (notification) => { + const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type); + if (isMuted || document.visibilityState === 'visible') { + stream.send('readNotification', { + id: notification.id, + }); + } + + if (!isMuted) { + pagingComponent.value.prepend({ + ...notification, + isRead: document.visibilityState === 'visible', + }); + } +}; + +let connection; + +onMounted(() => { + connection = stream.useChannel('main'); + connection.on('notification', onNotification); + connection.on('readAllNotifications', () => { + if (pagingComponent.value) { + for (const item of pagingComponent.value.queue) { + item.isRead = true; + } + for (const item of pagingComponent.value.items) { + item.isRead = true; + } + } + }); + connection.on('readNotifications', notificationIds => { + if (pagingComponent.value) { + for (let i = 0; i < pagingComponent.value.queue.length; i++) { + if (notificationIds.includes(pagingComponent.value.queue[i].id)) { + pagingComponent.value.queue[i].isRead = true; + } + } + for (let i = 0; i < (pagingComponent.value.items || []).length; i++) { + if (notificationIds.includes(pagingComponent.value.items[i].id)) { + pagingComponent.value.items[i].isRead = true; + } + } + } + }); +}); + +onUnmounted(() => { + if (connection) connection.dispose(); +}); +</script> + +<style lang="scss" scoped> +.elsfgstc { + background: var(--panel); +} +</style> diff --git a/packages/frontend/src/components/MkNumberDiff.vue b/packages/frontend/src/components/MkNumberDiff.vue new file mode 100644 index 0000000000..e7d4a5472a --- /dev/null +++ b/packages/frontend/src/components/MkNumberDiff.vue @@ -0,0 +1,47 @@ +<template> +<span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }"> + <slot name="before"></slot>{{ isPlus ? '+' : '' }}{{ number(value) }}<slot name="after"></slot> +</span> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import number from '@/filters/number'; + +export default defineComponent({ + props: { + value: { + type: Number, + required: true, + }, + }, + + setup(props) { + const isPlus = computed(() => props.value > 0); + const isMinus = computed(() => props.value < 0); + const isZero = computed(() => props.value === 0); + return { + isPlus, + isMinus, + isZero, + number, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.ceaaebcd { + &.isPlus { + color: var(--success); + } + + &.isMinus { + color: var(--error); + } + + &.isZero { + opacity: 0.5; + } +} +</style> diff --git a/packages/frontend/src/components/MkObjectView.value.vue b/packages/frontend/src/components/MkObjectView.value.vue new file mode 100644 index 0000000000..0c7230d783 --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkObjectView.vue b/packages/frontend/src/components/MkObjectView.vue new file mode 100644 index 0000000000..55578a37f6 --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkPagePreview.vue b/packages/frontend/src/components/MkPagePreview.vue new file mode 100644 index 0000000000..009582e540 --- /dev/null +++ b/packages/frontend/src/components/MkPagePreview.vue @@ -0,0 +1,162 @@ +<template> +<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> + <h1 :title="page.title">{{ page.title }}</h1> + </header> + <p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p> + <footer> + <img class="icon" :src="page.user.avatarUrl"/> + <p>{{ userName(page.user) }}</p> + </footer> + </article> +</MkA> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { userName } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + page: { + type: Object, + required: true, + }, + }, + methods: { + userName, + }, +}); +</script> + +<style lang="scss" scoped> +.vhpxefrj { + display: block; + + &:hover { + text-decoration: none; + color: var(--accent); + } + + > .thumbnail { + width: 100%; + height: 200px; + background-position: center; + background-size: cover; + display: flex; + justify-content: center; + align-items: center; + + > button { + font-size: 3.5em; + opacity: 0.7; + + &:hover { + font-size: 4em; + opacity: 0.9; + } + } + + & + article { + left: 100px; + width: calc(100% - 100px); + } + } + + > article { + padding: 16px; + + > header { + margin-bottom: 8px; + + > h1 { + margin: 0; + font-size: 1em; + color: var(--urlPreviewTitle); + } + } + + > p { + margin: 0; + color: var(--urlPreviewText); + font-size: 0.8em; + } + + > footer { + margin-top: 8px; + height: 16px; + + > img { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 4px; + vertical-align: top; + } + + > p { + display: inline-block; + margin: 0; + color: var(--urlPreviewInfo); + font-size: 0.8em; + line-height: 16px; + vertical-align: top; + } + } + } + + @media (max-width: 700px) { + > .thumbnail { + position: relative; + width: 100%; + height: 100px; + + & + article { + left: 0; + width: 100%; + } + } + } + + @media (max-width: 550px) { + font-size: 12px; + + > .thumbnail { + height: 80px; + } + + > article { + padding: 12px; + } + } + + @media (max-width: 500px) { + font-size: 10px; + + > .thumbnail { + height: 70px; + } + + > article { + padding: 8px; + + > header { + margin-bottom: 4px; + } + + > footer { + margin-top: 4px; + + > img { + width: 12px; + height: 12px; + } + } + } + } +} + +</style> diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue new file mode 100644 index 0000000000..29d45558a7 --- /dev/null +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -0,0 +1,140 @@ +<template> +<XWindow + ref="windowEl" + :initial-width="500" + :initial-height="500" + :can-resize="true" + :close-button="true" + :buttons-left="buttonsLeft" + :buttons-right="buttonsRight" + :contextmenu="contextmenu" + @closed="$emit('closed')" +> + <template #header> + <template v-if="pageMetadata?.value"> + <i v-if="pageMetadata.value.icon" class="icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i> + <span>{{ pageMetadata.value.title }}</span> + </template> + </template> + + <div class="yrolvcoq" :style="{ background: pageMetadata?.value?.bg }"> + <RouterView :router="router"/> + </div> +</XWindow> +</template> + +<script lang="ts" setup> +import { ComputedRef, inject, provide } from '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'; +import * as os from '@/os'; +import { mainRouter, routes } from '@/router'; +import { Router } from '@/nirax'; +import { i18n } from '@/i18n'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + initialPath: string; +}>(); + +defineEmits<{ + (ev: 'closed'): void; +}>(); + +const router = new Router(routes, props.initialPath); + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); +let windowEl = $ref<InstanceType<typeof XWindow>>(); +const history = $ref<{ path: string; key: any; }[]>([{ + path: router.getCurrentPath(), + key: router.getCurrentKey(), +}]); +const buttonsLeft = $computed(() => { + const buttons = []; + + if (history.length > 1) { + buttons.push({ + icon: 'ti ti-arrow-left', + onClick: back, + }); + } + + return buttons; +}); +const buttonsRight = $computed(() => { + const buttons = [{ + icon: 'ti ti-player-eject', + title: i18n.ts.showInPage, + onClick: expand, + }]; + + return buttons; +}); + +router.addListener('push', ctx => { + history.push({ path: ctx.path, key: ctx.key }); +}); + +provide('router', router); +provideMetadataReceiver((info) => { + pageMetadata = info; +}); +provide('shouldOmitHeaderTitle', true); +provide('shouldHeaderThin', true); + +const contextmenu = $computed(() => ([{ + icon: 'ti ti-player-eject', + text: i18n.ts.showInPage, + action: expand, +}, { + icon: 'ti ti-window-maximize', + text: i18n.ts.popout, + action: popout, +}, { + icon: 'ti ti-external-link', + text: i18n.ts.openInNewTab, + action: () => { + window.open(url + router.getCurrentPath(), '_blank'); + windowEl.close(); + }, +}, { + icon: 'ti ti-link', + text: i18n.ts.copyLink, + action: () => { + copyToClipboard(url + router.getCurrentPath()); + }, +}])); + +function back() { + history.pop(); + router.replace(history[history.length - 1].path, history[history.length - 1].key); +} + +function close() { + windowEl.close(); +} + +function expand() { + mainRouter.push(router.getCurrentPath(), 'forcePage'); + windowEl.close(); +} + +function popout() { + _popout(router.getCurrentPath(), windowEl.$el); + windowEl.close(); +} + +defineExpose({ + close, +}); +</script> + +<style lang="scss" scoped> +.yrolvcoq { + min-height: 100%; + background: var(--bg); +} +</style> diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue new file mode 100644 index 0000000000..291409171a --- /dev/null +++ b/packages/frontend/src/components/MkPagination.vue @@ -0,0 +1,317 @@ +<template> +<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <MkLoading v-if="fetching"/> + + <MkError v-else-if="error" @retry="init()"/> + + <div v-else-if="empty" key="_empty_" class="empty"> + <slot name="empty"> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.nothing }}</div> + </div> + </slot> + </div> + + <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"> + {{ 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"> + {{ i18n.ts.loadMore }} + </MkButton> + <MkLoading v-else class="loading"/> + </div> + </div> +</transition> +</template> + +<script lang="ts" setup> +import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, ref, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import * as os from '@/os'; +import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from '@/scripts/scroll'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; + +const SECOND_FETCH_LIMIT = 30; + +export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = { + endpoint: E; + limit: number; + params?: misskey.Endpoints[E]['req'] | ComputedRef<misskey.Endpoints[E]['req']>; + + /** + * 検索APIのような、ページング不可なエンドポイントを利用する場合 + * (そのようなAPIをこの関数で使うのは若干矛盾してるけど) + */ + noPaging?: boolean; + + /** + * items 配列の中身を逆順にする(新しい方が最後) + */ + reversed?: boolean; + + offsetMode?: boolean; +}; + +const props = withDefaults(defineProps<{ + pagination: Paging; + disableAutoLoad?: boolean; + displayLimit?: number; +}>(), { + displayLimit: 30, +}); + +const emit = defineEmits<{ + (ev: 'queue', count: number): void; +}>(); + +type Item = { id: string; [another: string]: unknown; }; + +const rootEl = ref<HTMLElement>(); +const items = ref<Item[]>([]); +const queue = ref<Item[]>([]); +const offset = ref(0); +const fetching = ref(true); +const moreFetching = ref(false); +const more = ref(false); +const backed = ref(false); // 遡り中か否か +const isBackTop = ref(false); +const empty = computed(() => items.value.length === 0); +const error = ref(false); + +const init = async (): Promise<void> => { + queue.value = []; + fetching.value = true; + const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; + await os.api(props.pagination.endpoint, { + ...params, + limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1, + }).then(res => { + for (let i = 0; i < res.length; i++) { + const item = res[i]; + if (props.pagination.reversed) { + if (i === res.length - 2) item._shouldInsertAd_ = true; + } else { + if (i === 3) item._shouldInsertAd_ = true; + } + } + if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) { + res.pop(); + items.value = props.pagination.reversed ? [...res].reverse() : res; + more.value = true; + } else { + items.value = props.pagination.reversed ? [...res].reverse() : res; + more.value = false; + } + offset.value = res.length; + error.value = false; + fetching.value = false; + }, err => { + error.value = true; + fetching.value = false; + }); +}; + +const reload = (): void => { + items.value = []; + init(); +}; + +const fetchMore = async (): Promise<void> => { + if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return; + moreFetching.value = true; + backed.value = true; + const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; + await os.api(props.pagination.endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT + 1, + ...(props.pagination.offsetMode ? { + offset: offset.value, + } : props.pagination.reversed ? { + sinceId: items.value[0].id, + } : { + untilId: items.value[items.value.length - 1].id, + }), + }).then(res => { + for (let i = 0; i < res.length; i++) { + const item = res[i]; + if (props.pagination.reversed) { + if (i === res.length - 9) item._shouldInsertAd_ = true; + } else { + if (i === 10) item._shouldInsertAd_ = true; + } + } + if (res.length > SECOND_FETCH_LIMIT) { + res.pop(); + items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); + more.value = true; + } else { + items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); + more.value = false; + } + offset.value += res.length; + moreFetching.value = false; + }, err => { + moreFetching.value = false; + }); +}; + +const fetchMoreAhead = async (): Promise<void> => { + if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return; + moreFetching.value = true; + const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; + await os.api(props.pagination.endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT + 1, + ...(props.pagination.offsetMode ? { + offset: offset.value, + } : props.pagination.reversed ? { + untilId: items.value[0].id, + } : { + sinceId: items.value[items.value.length - 1].id, + }), + }).then(res => { + if (res.length > SECOND_FETCH_LIMIT) { + res.pop(); + items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); + more.value = true; + } else { + items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); + more.value = false; + } + offset.value += res.length; + moreFetching.value = false; + }, err => { + moreFetching.value = false; + }); +}; + +const prepend = (item: Item): void => { + if (props.pagination.reversed) { + if (rootEl.value) { + const container = getScrollContainer(rootEl.value); + 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; + } + } + } + } + items.value.push(item); + // TODO + } else { + // 初回表示時はunshiftだけでOK + if (!rootEl.value) { + items.value.unshift(item); + return; + } + + const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value)); + + if (isTop) { + // Prepend the item + items.value.unshift(item); + + // オーバーフローしたら古いアイテムは捨てる + if (items.value.length >= props.displayLimit) { + // このやり方だとVue 3.2以降アニメーションが動かなくなる + //this.items = items.value.slice(0, props.displayLimit); + while (items.value.length >= props.displayLimit) { + items.value.pop(); + } + more.value = true; + } + } else { + queue.value.push(item); + onScrollTop(rootEl.value, () => { + for (const item of queue.value) { + prepend(item); + } + queue.value = []; + }); + } + } +}; + +const append = (item: Item): void => { + items.value.push(item); +}; + +const removeItem = (finder: (item: Item) => boolean) => { + const i = items.value.findIndex(finder); + items.value.splice(i, 1); +}; + +const updateItem = (id: Item['id'], replacer: (old: Item) => Item): void => { + const i = items.value.findIndex(item => item.id === id); + items.value[i] = replacer(items.value[i]); +}; + +if (props.pagination.params && isRef(props.pagination.params)) { + watch(props.pagination.params, init, { deep: true }); +} + +watch(queue, (a, b) => { + if (a.length === 0 && b.length === 0) return; + emit('queue', queue.value.length); +}, { deep: true }); + +init(); + +onActivated(() => { + isBackTop.value = false; +}); + +onDeactivated(() => { + isBackTop.value = window.scrollY === 0; +}); + +defineExpose({ + items, + queue, + backed, + reload, + prepend, + append, + removeItem, + updateItem, +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.cxiknjgy { + > .button { + margin-left: auto; + margin-right: auto; + } +} +</style> diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue new file mode 100644 index 0000000000..a1b927e42a --- /dev/null +++ b/packages/frontend/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="ti ti-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/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue new file mode 100644 index 0000000000..556abc5fd0 --- /dev/null +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -0,0 +1,219 @@ +<template> +<div class="zmdxowus"> + <p v-if="choices.length < 2" class="caution"> + <i class="ti ti-alert-triangle"></i>{{ i18n.ts._poll.noOnlyOneChoice }} + </p> + <ul> + <li v-for="(choice, i) in choices" :key="i"> + <MkInput class="input" small :model-value="choice" :placeholder="$t('_poll.choiceN', { n: i + 1 })" @update:model-value="onInput(i, $event)"> + </MkInput> + <button class="_button" @click="remove(i)"> + <i class="ti ti-x"></i> + </button> + </li> + </ul> + <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" 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" small type="date" class="input"> + <template #label>{{ i18n.ts._poll.deadlineDate }}</template> + </MkInput> + <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" small type="number" class="input"> + <template #label>{{ i18n.ts._poll.duration }}</template> + </MkInput> + <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> + </section> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import MkInput from './form/input.vue'; +import MkSelect from './form/select.vue'; +import MkSwitch from './form/switch.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: { + expiresAt: string; + expiredAfter: number; + choices: string[]; + multiple: boolean; + }; +}>(); +const emit = defineEmits<{ + (ev: 'update:modelValue', v: { + expiresAt: string; + expiredAfter: number; + choices: string[]; + multiple: boolean; + }): void; +}>(); + +const choices = ref(props.modelValue.choices); +const multiple = ref(props.modelValue.multiple); +const expiration = ref('infinite'); +const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd')); +const atTime = ref('00:00'); +const after = ref(0); +const unit = ref('second'); + +if (props.modelValue.expiresAt) { + expiration.value = 'at'; + atDate.value = atTime.value = props.modelValue.expiresAt; +} else if (typeof props.modelValue.expiredAfter === 'number') { + expiration.value = 'after'; + after.value = props.modelValue.expiredAfter / 1000; +} else { + expiration.value = 'infinite'; +} + +function onInput(i, value) { + choices.value[i] = value; +} + +function add() { + choices.value.push(''); + // TODO + // nextTick(() => { + // (this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus(); + // }); +} + +function remove(i) { + choices.value = choices.value.filter((_, _i) => _i !== i); +} + +function get() { + const calcAt = () => { + return new Date(`${atDate.value} ${atTime.value}`).getTime(); + }; + + const calcAfter = () => { + 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; + } + }; + + return { + choices: choices.value, + multiple: multiple.value, + ...( + expiration.value === 'at' ? { expiresAt: calcAt() } : + expiration.value === 'after' ? { expiredAfter: calcAfter() } : {} + ), + }; +} + +watch([choices, multiple, expiration, atDate, atTime, after, unit], () => emit('update:modelValue', get()), { + deep: true, +}); +</script> + +<style lang="scss" scoped> +.zmdxowus { + padding: 8px 16px; + + > .caution { + margin: 0 0 8px 0; + font-size: 0.8em; + color: #f00; + + > i { + margin-right: 4px; + } + } + + > ul { + display: block; + margin: 0; + padding: 0; + list-style: none; + + > li { + display: flex; + margin: 8px 0; + padding: 0; + width: 100%; + + > .input { + flex: 1; + } + + > button { + width: 32px; + padding: 4px 0; + } + } + } + + > .add { + margin: 8px 0; + z-index: 1; + } + + > section { + margin: 16px 0 0 0; + + > div { + margin: 0 8px; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 12px; + + &:last-child { + flex: 1 0 auto; + + > div { + flex-grow: 1; + } + + > section { + // MAGIC: Prevent div above from growing unless wrapped to its own line + flex-grow: 9999; + align-items: end; + display: flex; + gap: 4px; + + > .input { + flex: 1 1 auto; + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue new file mode 100644 index 0000000000..f04c7f5618 --- /dev/null +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -0,0 +1,36 @@ +<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" :class="{ drawer: type === 'drawer' }" @close="modal.close()"/> +</MkModal> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkModal from './MkModal.vue'; +import MkMenu from './MkMenu.vue'; +import { MenuItem } from '@/types/menu'; + +defineProps<{ + items: MenuItem[]; + align?: 'center' | string; + width?: number; + viaKeyboard?: boolean; + src?: any; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +let modal = $ref<InstanceType<typeof MkModal>>(); +</script> + +<style lang="scss" scoped> +.sfhdhdhq { + &.drawer { + border-radius: 24px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } +} +</style> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue new file mode 100644 index 0000000000..f79e5a32cd --- /dev/null +++ b/packages/frontend/src/components/MkPostForm.vue @@ -0,0 +1,1050 @@ +<template> +<div + v-size="{ max: [310, 500] }" class="gafaadew" + :class="{ modal, _popup: modal }" + @dragover.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <header> + <button v-if="!fixed" class="cancel _button" @click="cancel"><i class="ti ti-x"></i></button> + <button v-click-anime v-tooltip="i18n.ts.switchAccount" class="account _button" @click="openAccountMenu"> + <MkAvatar :user="postAccount ?? $i" class="avatar"/> + </button> + <div class="right"> + <span class="text-count" :class="{ over: textLength > maxTextLength }">{{ maxTextLength - textLength }}</span> + <span v-if="localOnly" class="local-only"><i class="ti ti-world-off"></i></span> + <button ref="visibilityButton" v-tooltip="i18n.ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility"> + <span v-if="visibility === 'public'"><i class="ti ti-world"></i></span> + <span v-if="visibility === 'home'"><i class="ti ti-home"></i></span> + <span v-if="visibility === 'followers'"><i class="ti ti-lock-open"></i></span> + <span v-if="visibility === 'specified'"><i class="ti ti-mail"></i></span> + </button> + <button v-tooltip="i18n.ts.previewNoteText" class="_button preview" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button> + <button class="submit _buttonGradate" :disabled="!canPost" data-cy-open-post-form-submit @click="post">{{ submitText }}<i :class="reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i></button> + </div> + </header> + <div class="form" :class="{ fixed }"> + <XNoteSimple v-if="reply" class="preview" :note="reply"/> + <XNoteSimple v-if="renote" class="preview" :note="renote"/> + <div v-if="quoteId" class="with-quote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="ti ti-x"></i></button></div> + <div v-if="visibility === 'specified'" class="to-specified"> + <span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span> + <div class="visibleUsers"> + <span v-for="u in visibleUsers" :key="u.id"> + <MkAcct :user="u"/> + <button class="_button" @click="removeVisibleUser(u)"><i class="ti ti-x"></i></button> + </span> + <button class="_buttonPrimary" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button> + </div> + </div> + <MkInfo v-if="hasNotSpecifiedMentions" warn class="hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> + <input v-show="useCw" ref="cwInputEl" v-model="cw" class="cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown"> + <textarea ref="textareaEl" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> + <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> + <XPostFormAttaches v-model="files" class="attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/> + <XPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> + <XNotePreview v-if="showPreview" class="preview" :text="text"/> + <footer> + <button v-tooltip="i18n.ts.attachFile" class="_button" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button> + <button v-tooltip="i18n.ts.poll" class="_button" :class="{ active: poll }" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button> + <button v-tooltip="i18n.ts.useCw" class="_button" :class="{ active: useCw }" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button> + <button v-tooltip="i18n.ts.mention" class="_button" @click="insertMention"><i class="ti ti-at"></i></button> + <button v-tooltip="i18n.ts.hashtags" class="_button" :class="{ active: withHashtags }" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button> + <button v-tooltip="i18n.ts.emoji" class="_button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> + <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugin" class="_button" @click="showActions"><i class="ti ti-plug"></i></button> + </footer> + <datalist id="hashtags"> + <option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/> + </datalist> + </div> +</div> +</template> + +<script lang="ts" setup> +import { inject, watch, nextTick, onMounted, defineAsyncComponent } from 'vue'; +import * as mfm from 'mfm-js'; +import * as misskey from 'misskey-js'; +import insertTextAtCursor from 'insert-text-at-cursor'; +import { length } from 'stringz'; +import { toASCII } from 'punycode/'; +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 { 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 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'); + +const props = withDefaults(defineProps<{ + reply?: misskey.entities.Note; + renote?: misskey.entities.Note; + channel?: any; // TODO + mention?: misskey.entities.User; + specified?: misskey.entities.User; + initialText?: string; + initialVisibility?: typeof misskey.noteVisibilities; + initialFiles?: misskey.entities.DriveFile[]; + initialLocalOnly?: boolean; + initialVisibleUsers?: misskey.entities.User[]; + initialNote?: misskey.entities.Note; + instant?: boolean; + fixed?: boolean; + autofocus?: boolean; +}>(), { + initialVisibleUsers: () => [], + autofocus: true, +}); + +const emit = defineEmits<{ + (ev: 'posted'): void; + (ev: 'cancel'): void; + (ev: 'esc'): void; +}>(); + +const textareaEl = $ref<HTMLTextAreaElement | null>(null); +const cwInputEl = $ref<HTMLInputElement | null>(null); +const hashtagsInputEl = $ref<HTMLInputElement | null>(null); +const visibilityButton = $ref<HTMLElement | null>(null); + +let posting = $ref(false); +let text = $ref(props.initialText ?? ''); +let files = $ref(props.initialFiles ?? []); +let poll = $ref<{ + choices: string[]; + multiple: boolean; + expiresAt: string | null; + expiredAfter: string | null; +} | null>(null); +let useCw = $ref(false); +let showPreview = $ref(false); +let cw = $ref<string | null>(null); +let localOnly = $ref<boolean>(props.initialLocalOnly ?? defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly); +let visibility = $ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility) as typeof misskey.noteVisibilities[number]); +let visibleUsers = $ref([]); +if (props.initialVisibleUsers) { + props.initialVisibleUsers.forEach(pushVisibleUser); +} +let autocomplete = $ref(null); +let draghover = $ref(false); +let quoteId = $ref(null); +let hasNotSpecifiedMentions = $ref(false); +let recentHashtags = $ref(JSON.parse(localStorage.getItem('hashtags') || '[]')); +let imeText = $ref(''); + +const typing = throttle(3000, () => { + if (props.channel) { + stream.send('typingOnChannel', { channel: props.channel.id }); + } +}); + +const draftKey = $computed((): string => { + let key = props.channel ? `channel:${props.channel.id}` : ''; + + if (props.renote) { + key += `renote:${props.renote.id}`; + } else if (props.reply) { + key += `reply:${props.reply.id}`; + } else { + key += 'note'; + } + + return key; +}); + +const placeholder = $computed((): string => { + if (props.renote) { + return i18n.ts._postForm.quotePlaceholder; + } else if (props.reply) { + return i18n.ts._postForm.replyPlaceholder; + } else if (props.channel) { + return i18n.ts._postForm.channelPlaceholder; + } else { + const xs = [ + i18n.ts._postForm._placeholders.a, + i18n.ts._postForm._placeholders.b, + i18n.ts._postForm._placeholders.c, + i18n.ts._postForm._placeholders.d, + i18n.ts._postForm._placeholders.e, + i18n.ts._postForm._placeholders.f, + ]; + return xs[Math.floor(Math.random() * xs.length)]; + } +}); + +const submitText = $computed((): string => { + return props.renote + ? i18n.ts.quote + : props.reply + ? i18n.ts.reply + : i18n.ts.note; +}); + +const textLength = $computed((): number => { + return length((text + imeText).trim()); +}); + +const maxTextLength = $computed((): number => { + return instance ? instance.maxNoteTextLength : 1000; +}); + +const canPost = $computed((): boolean => { + return !posting && + (1 <= textLength || 1 <= files.length || !!poll || !!props.renote) && + (textLength <= maxTextLength) && + (!poll || poll.choices.length >= 2); +}); + +const withHashtags = $computed(defaultStore.makeGetterSetter('postFormWithHashtags')); +const hashtags = $computed(defaultStore.makeGetterSetter('postFormHashtags')); + +watch($$(text), () => { + checkMissingMention(); +}); + +watch($$(visibleUsers), () => { + checkMissingMention(); +}, { + deep: true, +}); + +if (props.mention) { + text = props.mention.host ? `@${props.mention.username}@${toASCII(props.mention.host)}` : `@${props.mention.username}`; + text += ' '; +} + +if (props.reply && (props.reply.user.username !== $i.username || (props.reply.user.host != null && props.reply.user.host !== host))) { + text = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `; +} + +if (props.reply && props.reply.text != null) { + const ast = mfm.parse(props.reply.text); + const otherHost = props.reply.user.host; + + 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)}`; + + // 自分は除外 + if ($i.username === x.username && (x.host == null || x.host === host)) continue; + + // 重複は除外 + if (text.includes(`${mention} `)) continue; + + text += `${mention} `; + } +} + +if (props.channel) { + visibility = 'public'; + localOnly = true; // TODO: チャンネルが連合するようになった折には消す +} + +// 公開以外へのリプライ時は元の公開範囲を引き継ぐ +if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visibility)) { + if (props.reply.visibility === 'home' && visibility === 'followers') { + visibility = 'followers'; + } else if (['home', 'followers'].includes(props.reply.visibility) && visibility === 'specified') { + visibility = 'specified'; + } else { + visibility = props.reply.visibility; + } + + if (visibility === 'specified') { + if (props.reply.visibleUserIds) { + os.api('users/show', { + userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId), + }).then(users => { + users.forEach(pushVisibleUser); + }); + } + + if (props.reply.userId !== $i.id) { + os.api('users/show', { userId: props.reply.userId }).then(user => { + pushVisibleUser(user); + }); + } + } +} + +if (props.specified) { + visibility = 'specified'; + pushVisibleUser(props.specified); +} + +// keep cw when reply +if (defaultStore.state.keepCw && props.reply && props.reply.cw) { + useCw = true; + cw = props.reply.cw; +} + +function watchForDraft() { + watch($$(text), () => saveDraft()); + watch($$(useCw), () => saveDraft()); + watch($$(cw), () => saveDraft()); + watch($$(poll), () => saveDraft()); + watch($$(files), () => saveDraft(), { deep: true }); + watch($$(visibility), () => saveDraft()); + watch($$(localOnly), () => saveDraft()); +} + +function checkMissingMention() { + if (visibility === 'specified') { + const ast = mfm.parse(text); + + for (const x of extractMentions(ast)) { + if (!visibleUsers.some(u => (u.username === x.username) && (u.host === x.host))) { + hasNotSpecifiedMentions = true; + return; + } + } + hasNotSpecifiedMentions = false; + } +} + +function addMissingMention() { + const ast = mfm.parse(text); + + for (const x of extractMentions(ast)) { + if (!visibleUsers.some(u => (u.username === x.username) && (u.host === x.host))) { + os.api('users/show', { username: x.username, host: x.host }).then(user => { + visibleUsers.push(user); + }); + } + } +} + +function togglePoll() { + if (poll) { + poll = null; + } else { + poll = { + choices: ['', ''], + multiple: false, + expiresAt: null, + expiredAfter: null, + }; + } +} + +function addTag(tag: string) { + insertTextAtCursor(textareaEl, ` #${tag} `); +} + +function focus() { + if (textareaEl) { + textareaEl.focus(); + textareaEl.setSelectionRange(textareaEl.value.length, textareaEl.value.length); + } +} + +function chooseFileFrom(ev) { + selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => { + for (const file of files_) { + files.push(file); + } + }); +} + +function detachFile(id) { + files = files.filter(x => x.id !== id); +} + +function updateFileSensitive(file, sensitive) { + files[files.findIndex(x => x.id === file.id)].isSensitive = sensitive; +} + +function updateFileName(file, name) { + files[files.findIndex(x => x.id === file.id)].name = name; +} + +function upload(file: File, name?: string) { + uploadFile(file, defaultStore.state.uploadFolder, name).then(res => { + files.push(res); + }); +} + +function setVisibility() { + if (props.channel) { + // TODO: information dialog + return; + } + + os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), { + currentVisibility: visibility, + currentLocalOnly: localOnly, + src: visibilityButton, + }, { + changeVisibility: v => { + visibility = v; + if (defaultStore.state.rememberNoteVisibility) { + defaultStore.set('visibility', visibility); + } + }, + changeLocalOnly: v => { + localOnly = v; + if (defaultStore.state.rememberNoteVisibility) { + defaultStore.set('localOnly', localOnly); + } + }, + }, 'closed'); +} + +function pushVisibleUser(user) { + if (!visibleUsers.some(u => u.username === user.username && u.host === user.host)) { + visibleUsers.push(user); + } +} + +function addVisibleUser() { + os.selectUser().then(user => { + pushVisibleUser(user); + }); +} + +function removeVisibleUser(user) { + visibleUsers = erase(user, visibleUsers); +} + +function clear() { + text = ''; + files = []; + poll = null; + quoteId = null; +} + +function onKeydown(ev: KeyboardEvent) { + if ((ev.which === 10 || ev.which === 13) && (ev.ctrlKey || ev.metaKey) && canPost) post(); + if (ev.which === 27) emit('esc'); + typing(); +} + +function onCompositionUpdate(ev: CompositionEvent) { + imeText = ev.data; + typing(); +} + +function onCompositionEnd(ev: CompositionEvent) { + imeText = ''; +} + +async function onPaste(ev: ClipboardEvent) { + for (const { item, i } of Array.from(ev.clipboardData.items).map((item, i) => ({ item, i }))) { + if (item.kind === 'file') { + const file = item.getAsFile(); + const lio = file.name.lastIndexOf('.'); + const ext = lio >= 0 ? file.name.slice(lio) : ''; + const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + upload(file, formatted); + } + } + + const paste = ev.clipboardData.getData('text'); + + if (!props.renote && !quoteId && paste.startsWith(url + '/notes/')) { + ev.preventDefault(); + + os.confirm({ + type: 'info', + text: i18n.ts.quoteQuestion, + }).then(({ canceled }) => { + if (canceled) { + insertTextAtCursor(textareaEl, paste); + return; + } + + quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; + }); + } +} + +function onDragover(ev) { + if (!ev.dataTransfer.items[0]) return; + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + ev.preventDefault(); + draghover = true; + 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; + } + } +} + +function onDragenter(ev) { + draghover = true; +} + +function onDragleave(ev) { + draghover = false; +} + +function onDrop(ev): void { + draghover = false; + + // ファイルだったら + if (ev.dataTransfer.files.length > 0) { + ev.preventDefault(); + for (const x of Array.from(ev.dataTransfer.files)) upload(x); + return; + } + + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + const file = JSON.parse(driveFile); + files.push(file); + ev.preventDefault(); + } + //#endregion +} + +function saveDraft() { + const draftData = JSON.parse(localStorage.getItem('drafts') || '{}'); + + draftData[draftKey] = { + updatedAt: new Date(), + data: { + text: text, + useCw: useCw, + cw: cw, + visibility: visibility, + localOnly: localOnly, + files: files, + poll: poll, + }, + }; + + localStorage.setItem('drafts', JSON.stringify(draftData)); +} + +function deleteDraft() { + const draftData = JSON.parse(localStorage.getItem('drafts') ?? '{}'); + + delete draftData[draftKey]; + + localStorage.setItem('drafts', JSON.stringify(draftData)); +} + +async function post() { + let postData = { + text: text === '' ? undefined : text, + fileIds: files.length > 0 ? files.map(f => f.id) : undefined, + replyId: props.reply ? props.reply.id : undefined, + renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined, + channelId: props.channel ? props.channel.id : undefined, + poll: poll, + cw: useCw ? cw ?? '' : undefined, + localOnly: localOnly, + visibility: visibility, + visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined, + }; + + if (withHashtags && hashtags && hashtags.trim() !== '') { + const hashtags_ = hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' '); + postData.text = postData.text ? `${postData.text} ${hashtags_}` : hashtags_; + } + + // plugin + if (notePostInterruptors.length > 0) { + for (const interruptor of notePostInterruptors) { + postData = await interruptor.handler(deepClone(postData)); + } + } + + let token = undefined; + + if (postAccount) { + const storedAccounts = await getAccounts(); + token = storedAccounts.find(x => x.id === postAccount.id)?.token; + } + + posting = true; + os.api('notes/create', postData, token).then(() => { + clear(); + nextTick(() => { + deleteDraft(); + emit('posted'); + if (postData.text && postData.text !== '') { + const hashtags_ = mfm.parse(postData.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag); + const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; + localStorage.setItem('hashtags', JSON.stringify(unique(hashtags_.concat(history)))); + } + posting = false; + postAccount = null; + }); + }).catch(err => { + posting = false; + os.alert({ + type: 'error', + text: err.message + '\n' + (err as any).id, + }); + }); +} + +function cancel() { + emit('cancel'); +} + +function insertMention() { + os.selectUser().then(user => { + insertTextAtCursor(textareaEl, '@' + Acct.toString(user) + ' '); + }); +} + +async function insertEmoji(ev: MouseEvent) { + os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textareaEl); +} + +function showActions(ev) { + os.popupMenu(postFormActions.map(action => ({ + text: action.title, + action: () => { + action.handler({ + text: text, + }, (key, value) => { + if (key === 'text') { text = value; } + }); + }, + })), ev.currentTarget ?? ev.target); +} + +let postAccount = $ref<misskey.entities.UserDetailed | null>(null); + +function openAccountMenu(ev: MouseEvent) { + openAccountMenu_({ + withExtraOperation: false, + includeCurrentAccount: true, + active: postAccount != null ? postAccount.id : $i.id, + onChoose: (account) => { + if (account.id === $i.id) { + postAccount = null; + } else { + postAccount = account; + } + }, + }, ev); +} + +onMounted(() => { + if (props.autofocus) { + focus(); + + nextTick(() => { + focus(); + }); + } + + // TODO: detach when unmount + new Autocomplete(textareaEl, $$(text)); + new Autocomplete(cwInputEl, $$(cw)); + new Autocomplete(hashtagsInputEl, $$(hashtags)); + + nextTick(() => { + // 書きかけの投稿を復元 + if (!props.instant && !props.mention && !props.specified) { + const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[draftKey]; + if (draft) { + text = draft.data.text; + useCw = draft.data.useCw; + cw = draft.data.cw; + visibility = draft.data.visibility; + localOnly = draft.data.localOnly; + files = (draft.data.files || []).filter(draftFile => draftFile); + if (draft.data.poll) { + poll = draft.data.poll; + } + } + } + + // 削除して編集 + if (props.initialNote) { + const init = props.initialNote; + text = init.text ? init.text : ''; + files = init.files; + cw = init.cw; + useCw = init.cw != null; + if (init.poll) { + poll = { + choices: init.poll.choices.map(x => x.text), + multiple: init.poll.multiple, + expiresAt: init.poll.expiresAt, + expiredAfter: init.poll.expiredAfter, + }; + } + visibility = init.visibility; + localOnly = init.localOnly; + quoteId = init.renote ? init.renote.id : null; + } + + nextTick(() => watchForDraft()); + }); +}); +</script> + +<style lang="scss" scoped> +.gafaadew { + position: relative; + + &.modal { + width: 100%; + max-width: 520px; + } + + > header { + z-index: 1000; + height: 66px; + + > .cancel { + padding: 0; + font-size: 1em; + width: 64px; + line-height: 66px; + } + + > .account { + height: 100%; + aspect-ratio: 1/1; + display: inline-flex; + vertical-align: bottom; + + > .avatar { + width: 28px; + height: 28px; + margin: auto; + } + } + + > .right { + position: absolute; + top: 0; + right: 0; + + > .text-count { + opacity: 0.7; + line-height: 66px; + } + + > .visibility { + height: 34px; + width: 34px; + margin: 0 0 0 8px; + + & + .localOnly { + margin-left: 0 !important; + } + } + + > .local-only { + margin: 0 0 0 12px; + opacity: 0.7; + } + + > .preview { + display: inline-block; + padding: 0; + margin: 0 8px 0 0; + font-size: 16px; + width: 34px; + height: 34px; + border-radius: 6px; + + &:hover { + background: var(--X5); + } + + &.active { + color: var(--accent); + } + } + + > .submit { + margin: 16px 16px 16px 0; + padding: 0 12px; + line-height: 34px; + font-weight: bold; + vertical-align: bottom; + border-radius: 4px; + font-size: 0.9em; + + &:disabled { + opacity: 0.7; + } + + > i { + margin-left: 6px; + } + } + } + } + + > .form { + > .preview { + padding: 16px; + } + + > .with-quote { + margin: 0 0 8px 0; + color: var(--accent); + + > button { + padding: 4px 8px; + color: var(--accentAlpha04); + + &:hover { + color: var(--accentAlpha06); + } + + &:active { + color: var(--accentDarken30); + } + } + } + + > .to-specified { + padding: 6px 24px; + margin-bottom: 8px; + overflow: auto; + white-space: nowrap; + + > .visibleUsers { + display: inline; + top: -1px; + font-size: 14px; + + > button { + padding: 4px; + border-radius: 8px; + } + + > span { + margin-right: 14px; + padding: 8px 0 8px 8px; + border-radius: 8px; + background: var(--X4); + + > button { + padding: 4px 8px; + } + } + } + } + + > .hasNotSpecifiedMentions { + margin: 0 20px 16px 20px; + } + + > .cw, + > .hashtags, + > .text { + display: block; + box-sizing: border-box; + padding: 0 24px; + margin: 0; + width: 100%; + font-size: 16px; + border: none; + border-radius: 0; + background: transparent; + color: var(--fg); + font-family: inherit; + + &:focus { + outline: none; + } + + &:disabled { + opacity: 0.5; + } + } + + > .cw { + z-index: 1; + padding-bottom: 8px; + border-bottom: solid 0.5px var(--divider); + } + + > .hashtags { + z-index: 1; + padding-top: 8px; + padding-bottom: 8px; + border-top: solid 0.5px var(--divider); + } + + > .text { + max-width: 100%; + min-width: 100%; + min-height: 90px; + + &.withCw { + padding-top: 8px; + } + } + + > footer { + padding: 0 16px 16px 16px; + + > button { + display: inline-block; + padding: 0; + margin: 0; + font-size: 1em; + width: 46px; + height: 46px; + border-radius: 6px; + + &:hover { + background: var(--X5); + } + + &.active { + color: var(--accent); + } + } + } + } + + &.max-width_500px { + > header { + height: 50px; + + > .cancel { + width: 50px; + line-height: 50px; + } + + > .right { + > .text-count { + line-height: 50px; + } + + > .submit { + margin: 8px; + } + } + } + + > .form { + > .to-specified { + padding: 6px 16px; + } + + > .cw, + > .hashtags, + > .text { + padding: 0 16px; + } + + > .text { + min-height: 80px; + } + + > footer { + padding: 0 8px 8px 8px; + } + } + } + + &.max-width_310px { + > .form { + > footer { + > button { + font-size: 14px; + width: 44px; + height: 44px; + } + } + } + } +} + +@container (max-width: 500px) { + .gafaadew { + > header { + height: 50px; + + > .cancel { + width: 50px; + line-height: 50px; + } + + > .right { + > .text-count { + line-height: 50px; + } + + > .submit { + margin: 8px; + } + } + } + + > .form { + > .to-specified { + padding: 6px 16px; + } + + > .cw, + > .hashtags, + > .text { + padding: 0 16px; + } + + > .text { + min-height: 80px; + } + + > footer { + padding: 0 8px 8px 8px; + } + } + } +} + +@container (max-width: 310px) { + .gafaadew { + > .form { + > footer { + > button { + font-size: 14px; + width: 44px; + height: 44px; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue new file mode 100644 index 0000000000..766cc9a06c --- /dev/null +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -0,0 +1,168 @@ +<template> +<div v-show="props.modelValue.length != 0" class="skeikyzd"> + <Sortable :model-value="props.modelValue" class="files" item-key="id" :animation="150" :delay="100" :delay-on-touch-only="true" @update:model-value="v => emit('update:modelValue', v)"> + <template #item="{element}"> + <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="ti ti-alert-triangle icon"></i> + </div> + </div> + </template> + </Sortable> + <p class="remain">{{ 16 - props.modelValue.length }}/16</p> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, watch } from 'vue'; +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; +import * as os from '@/os'; +import { deepClone } from '@/scripts/clone'; +import { i18n } from '@/i18n'; + +const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); + +const props = defineProps<{ + modelValue: any[]; + detachMediaFn: () => void; +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: any[]): void; + (ev: 'detach'): void; + (ev: 'changeSensitive'): void; + (ev: 'changeName'): void; +}>(); + +let menuShowing = false; + +function detachMedia(id) { + if (props.detachMediaFn) { + props.detachMediaFn(id); + } else { + emit('detach', id); + } +} + +function toggleSensitive(file) { + os.api('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive, + }).then(() => { + emit('changeSensitive', file, !file.isSensitive); + }); +} +async function rename(file) { + const { canceled, result } = await os.inputText({ + title: i18n.ts.enterFileName, + default: file.name, + allowEmpty: false, + }); + if (canceled) return; + os.api('drive/files/update', { + fileId: file.id, + name: result, + }).then(() => { + emit('changeName', file, result); + file.name = result; + }); +} + +async function describe(file) { + os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), { + default: file.comment !== null ? file.comment : '', + file: file, + }, { + done: caption => { + let comment = caption.length === 0 ? null : caption; + os.api('drive/files/update', { + fileId: file.id, + comment: comment, + }).then(() => { + file.comment = comment; + }); + }, + }, 'closed'); +} + +function showFileMenu(file, ev: MouseEvent) { + if (menuShowing) return; + os.popupMenu([{ + text: i18n.ts.renameFile, + icon: 'ti ti-forms', + action: () => { rename(file); }, + }, { + text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, + icon: file.isSensitive ? 'ti ti-eye-off' : 'ti ti-eye', + action: () => { toggleSensitive(file); }, + }, { + text: i18n.ts.describeFile, + icon: 'ti ti-text-caption', + action: () => { describe(file); }, + }, { + text: i18n.ts.attachCancel, + icon: 'ti ti-circle-x', + action: () => { detachMedia(file.id); }, + }], ev.currentTarget ?? ev.target).then(() => menuShowing = false); + menuShowing = true; +} +</script> + +<style lang="scss" scoped> +.skeikyzd { + padding: 8px 16px; + position: relative; + + > .files { + display: flex; + flex-wrap: wrap; + + > .file { + position: relative; + width: 64px; + height: 64px; + margin-right: 4px; + border-radius: 4px; + overflow: hidden; + cursor: move; + + &:hover > .remove { + display: block; + } + + > .thumbnail { + width: 100%; + height: 100%; + z-index: 1; + color: var(--fg); + } + + > .sensitive { + display: flex; + position: absolute; + width: 64px; + height: 64px; + top: 0; + left: 0; + z-index: 2; + background: rgba(17, 17, 17, .7); + color: #fff; + + > .icon { + margin: auto; + } + } + } + } + + > .remain { + display: block; + position: absolute; + top: 8px; + right: 8px; + margin: 0; + padding: 0; + } +} +</style> diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue new file mode 100644 index 0000000000..6dabb1db14 --- /dev/null +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -0,0 +1,19 @@ +<template> +<MkModal ref="modal" :prefer-type="'dialog:top'" @click="$refs.modal.close()" @closed="$emit('closed')"> + <MkPostForm v-bind="$attrs" @posted="$refs.modal.close()" @cancel="$refs.modal.close()" @esc="$refs.modal.close()"/> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/MkModal.vue'; +import MkPostForm from '@/components/MkPostForm.vue'; + +export default defineComponent({ + components: { + MkModal, + MkPostForm, + }, + emits: ['closed'], +}); +</script> diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue new file mode 100644 index 0000000000..b356fd3b89 --- /dev/null +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -0,0 +1,167 @@ +<template> +<MkButton + v-if="supported && !pushRegistrationInServer" + type="button" + primary + :gradate="gradate" + :rounded="rounded" + :inline="inline" + :autofocus="autofocus" + :wait="wait" + :full="full" + @click="subscribe" +> + {{ i18n.ts.subscribePushNotification }} +</MkButton> +<MkButton + v-else-if="!showOnlyToRegister && ($i ? pushRegistrationInServer : pushSubscription)" + type="button" + :primary="false" + :gradate="gradate" + :rounded="rounded" + :inline="inline" + :autofocus="autofocus" + :wait="wait" + :full="full" + @click="unsubscribe" +> + {{ i18n.ts.unsubscribePushNotification }} +</MkButton> +<MkButton v-else-if="$i && pushRegistrationInServer" disabled :rounded="rounded" :inline="inline" :wait="wait" :full="full"> + {{ i18n.ts.pushNotificationAlreadySubscribed }} +</MkButton> +<MkButton v-else-if="!supported" disabled :rounded="rounded" :inline="inline" :wait="wait" :full="full"> + {{ i18n.ts.pushNotificationNotSupported }} +</MkButton> +</template> + +<script setup lang="ts"> +import { $i, getAccounts } from '@/account'; +import MkButton from '@/components/MkButton.vue'; +import { instance } from '@/instance'; +import { api, apiWithDialog, promiseDialog } from '@/os'; +import { i18n } from '@/i18n'; + +defineProps<{ + primary?: boolean; + gradate?: boolean; + rounded?: boolean; + inline?: boolean; + link?: boolean; + to?: string; + autofocus?: boolean; + wait?: boolean; + danger?: boolean; + full?: boolean; + showOnlyToRegister?: boolean; +}>(); + +// ServiceWorker registration +let registration = $ref<ServiceWorkerRegistration | undefined>(); +// If this browser supports push notification +let supported = $ref(false); +// If this browser has already subscribed to push notification +let pushSubscription = $ref<PushSubscription | null>(null); +let pushRegistrationInServer = $ref<{ state?: string; key?: string; userId: string; endpoint: string; sendReadMessage: boolean; } | undefined>(); + +function subscribe() { + if (!registration || !supported || !instance.swPublickey) return; + + // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters + return promiseDialog(registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(instance.swPublickey), + }) + .then(async subscription => { + pushSubscription = subscription; + + // Register + pushRegistrationInServer = await api('sw/register', { + endpoint: subscription.endpoint, + auth: encode(subscription.getKey('auth')), + publickey: encode(subscription.getKey('p256dh')), + }); + }, async err => { // When subscribe failed + // 通知が許可されていなかったとき + if (err?.name === 'NotAllowedError') { + console.info('User denied the notification permission request.'); + return; + } + + // 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが + // 既に存在していることが原因でエラーになった可能性があるので、 + // そのサブスクリプションを解除しておく + // (これは実行されなさそうだけど、おまじない的に古い実装から残してある) + await unsubscribe(); + }), null, null); +} + +async function unsubscribe() { + if (!pushSubscription) return; + + const endpoint = pushSubscription.endpoint; + const accounts = await getAccounts(); + + pushRegistrationInServer = undefined; + + if ($i && accounts.length >= 2) { + apiWithDialog('sw/unregister', { + i: $i.token, + endpoint, + }); + } else { + pushSubscription.unsubscribe(); + apiWithDialog('sw/unregister', { + endpoint, + }); + pushSubscription = null; + } +} + +function encode(buffer: ArrayBuffer | null) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); +} + +/** + * Convert the URL safe base64 string to a Uint8Array + * @param base64String base64 string + */ + function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +navigator.serviceWorker.ready.then(async swr => { + registration = swr; + + pushSubscription = await registration.pushManager.getSubscription(); + + if (instance.swPublickey && ('PushManager' in window) && $i && $i.token) { + supported = true; + + if (pushSubscription) { + const res = await api('sw/show-registration', { + endpoint: pushSubscription.endpoint, + }); + + if (res) { + pushRegistrationInServer = res; + } + } + } +}); + +defineExpose({ + pushRegistrationInServer: $$(pushRegistrationInServer), +}); +</script> diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue new file mode 100644 index 0000000000..5638c9a816 --- /dev/null +++ b/packages/frontend/src/components/MkReactionIcon.vue @@ -0,0 +1,13 @@ +<template> +<MkEmoji :emoji="reaction" :custom-emojis="customEmojis || []" :is-reaction="true" :normal="true" :no-style="noStyle"/> +</template> + +<script lang="ts" setup> +import { } from 'vue'; + +const props = defineProps<{ + reaction: string; + customEmojis?: any[]; // TODO + noStyle?: boolean; +}>(); +</script> diff --git a/packages/frontend/src/components/MkReactionTooltip.vue b/packages/frontend/src/components/MkReactionTooltip.vue new file mode 100644 index 0000000000..310d5954fc --- /dev/null +++ b/packages/frontend/src/components/MkReactionTooltip.vue @@ -0,0 +1,43 @@ +<template> +<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="340" @closed="emit('closed')"> + <div class="beeadbfb"> + <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/> + <div class="name">{{ reaction.replace('@.', '') }}</div> + </div> +</MkTooltip> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkTooltip from './MkTooltip.vue'; +import XReactionIcon from '@/components/MkReactionIcon.vue'; + +defineProps<{ + showing: boolean; + reaction: string; + emojis: any[]; // TODO + targetElement: HTMLElement; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); +</script> + +<style lang="scss" scoped> +.beeadbfb { + text-align: center; + + > .icon { + display: block; + width: 60px; + font-size: 60px; // unicodeな絵文字についてはwidthが効かないため + margin: 0 auto; + object-fit: contain; + } + + > .name { + font-size: 0.9em; + } +} +</style> diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue new file mode 100644 index 0000000000..29902a5075 --- /dev/null +++ b/packages/frontend/src/components/MkReactionsViewer.details.vue @@ -0,0 +1,96 @@ +<template> +<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="340" @closed="emit('closed')"> + <div class="bqxuuuey"> + <div class="reaction"> + <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/> + <div class="name">{{ getReactionName(reaction) }}</div> + </div> + <div class="users"> + <div v-for="u in users" :key="u.id" class="user"> + <MkAvatar class="avatar" :user="u"/> + <MkUserName class="name" :user="u" :nowrap="true"/> + </div> + <div v-if="users.length > 10" class="omitted">+{{ count - 10 }}</div> + </div> + </div> +</MkTooltip> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkTooltip from './MkTooltip.vue'; +import XReactionIcon from '@/components/MkReactionIcon.vue'; +import { getEmojiName } from '@/scripts/emojilist'; + +defineProps<{ + showing: boolean; + reaction: string; + users: any[]; // TODO + count: number; + emojis: any[]; // TODO + targetElement: HTMLElement; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +function getReactionName(reaction: string): string { + const trimLocal = reaction.replace('@.', ''); + if (trimLocal.startsWith(':')) { + return trimLocal; + } + return getEmojiName(reaction) ?? reaction; +} +</script> + +<style lang="scss" scoped> +.bqxuuuey { + display: flex; + + > .reaction { + max-width: 100px; + text-align: center; + + > .icon { + display: block; + width: 60px; + font-size: 60px; // unicodeな絵文字についてはwidthが効かないため + object-fit: contain; + margin: 0 auto; + } + + > .name { + font-size: 1em; + } + } + + > .users { + flex: 1; + min-width: 0; + font-size: 0.95em; + border-left: solid 0.5px var(--divider); + padding-left: 10px; + margin-left: 10px; + margin-right: 14px; + text-align: left; + + > .user { + line-height: 24px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:not(:last-child) { + margin-bottom: 3px; + } + + > .avatar { + width: 24px; + height: 24px; + margin-right: 3px; + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue new file mode 100644 index 0000000000..31342b0b48 --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue new file mode 100644 index 0000000000..a88311efa1 --- /dev/null +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -0,0 +1,36 @@ +<template> +<div class="tdflqwzn" :class="{ isMe }"> + <XReaction v-for="(count, reaction) in note.reactions" :key="reaction" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note"/> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import { $i } from '@/account'; +import XReaction from '@/components/MkReactionsViewer.reaction.vue'; + +const props = defineProps<{ + note: misskey.entities.Note; +}>(); + +const initialReactions = new Set(Object.keys(props.note.reactions)); + +const isMe = computed(() => $i && $i.id === props.note.userId); +</script> + +<style lang="scss" scoped> +.tdflqwzn { + margin: 4px -2px 0 -2px; + + &:empty { + display: none; + } + + &.isMe { + > span { + cursor: default !important; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkRemoteCaution.vue b/packages/frontend/src/components/MkRemoteCaution.vue new file mode 100644 index 0000000000..d5dc01c1f8 --- /dev/null +++ b/packages/frontend/src/components/MkRemoteCaution.vue @@ -0,0 +1,25 @@ +<template> +<div class="jmgmzlwq _block"><i class="ti ti-alert-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; +}>(); +</script> + +<style lang="scss" scoped> +.jmgmzlwq { + font-size: 0.8em; + padding: 16px; + background: var(--infoWarnBg); + color: var(--infoWarnFg); + + > .link { + margin-left: 4px; + color: var(--accent); + } +} +</style> diff --git a/packages/frontend/src/components/MkRenoteButton.vue b/packages/frontend/src/components/MkRenoteButton.vue new file mode 100644 index 0000000000..e0b1eaafc9 --- /dev/null +++ b/packages/frontend/src/components/MkRenoteButton.vue @@ -0,0 +1,99 @@ +<template> +<button + v-if="canRenote" + ref="buttonRef" + class="eddddedb _button canRenote" + @click="renote()" +> + <i class="ti ti-repeat"></i> + <p v-if="count > 0" class="count">{{ count }}</p> +</button> +<button v-else class="eddddedb _button"> + <i class="ti ti-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: 'ti ti-repeat', + action: () => { + os.api('notes/create', { + renoteId: props.note.id, + }); + }, + }, { + text: i18n.ts.quote, + icon: 'ti ti-quote', + 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/frontend/src/components/MkRipple.vue b/packages/frontend/src/components/MkRipple.vue new file mode 100644 index 0000000000..9d93211d5f --- /dev/null +++ b/packages/frontend/src/components/MkRipple.vue @@ -0,0 +1,116 @@ +<template> +<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" + begin="0s" dur="0.5s" + values="4; 32" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.165, 0.84, 0.44, 1" + repeatCount="1" + /> + <animate + attributeName="stroke-width" + begin="0s" dur="0.5s" + values="16; 0" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.3, 0.61, 0.355, 1" + repeatCount="1" + /> + </circle> + <g fill="none" fill-rule="evenodd"> + <circle v-for="(particle, i) in particles" :key="i" :fill="particle.color"> + <animate + attributeName="r" + begin="0s" dur="0.8s" + :values="`${particle.size}; 0`" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.165, 0.84, 0.44, 1" + repeatCount="1" + /> + <animate + attributeName="cx" + begin="0s" dur="0.8s" + :values="`${particle.xA}; ${particle.xB}`" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.3, 0.61, 0.355, 1" + repeatCount="1" + /> + <animate + attributeName="cy" + begin="0s" dur="0.8s" + :values="`${particle.yA}; ${particle.yB}`" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.3, 0.61, 0.355, 1" + repeatCount="1" + /> + </circle> + </g> + </svg> +</div> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import * as os from '@/os'; + +const props = withDefaults(defineProps<{ + x: number; + y: number; + particle?: boolean; +}>(), { + particle: true, +}); + +const emit = defineEmits<{ + (ev: 'end'): void; +}>(); + +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)], + }); + } +} + +onMounted(() => { + window.setTimeout(() => { + emit('end'); + }, 1100); +}); +</script> + +<style lang="scss" scoped> +.vswabwbm { + pointer-events: none; + position: fixed; + width: 128px; + height: 128px; + + > svg { + > circle { + stroke: var(--accent); + } + } +} +</style> diff --git a/packages/frontend/src/components/MkSample.vue b/packages/frontend/src/components/MkSample.vue new file mode 100644 index 0000000000..1d25ab54b5 --- /dev/null +++ b/packages/frontend/src/components/MkSample.vue @@ -0,0 +1,116 @@ +<template> +<div class="_card"> + <div class="_content"> + <MkInput v-model="text"> + <template #label>Text</template> + </MkInput> + <MkSwitch v-model="flag"> + <span>Switch is now {{ flag ? 'on' : 'off' }}</span> + </MkSwitch> + <div style="margin: 32px 0;"> + <MkRadio v-model="radio" value="misskey">Misskey</MkRadio> + <MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio> + <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio> + </div> + <MkButton inline>This is</MkButton> + <MkButton inline primary>the button</MkButton> + </div> + <div class="_content" style="pointer-events: none;"> + <Mfm :text="mfm"/> + </div> + <div class="_content"> + <MkButton inline primary @click="openMenu">Open menu</MkButton> + <MkButton inline primary @click="openDialog">Open dialog</MkButton> + <MkButton inline primary @click="openForm">Open form</MkButton> + <MkButton inline primary @click="openDrive">Open drive</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from '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'; +import MkRadio from '@/components/form/radio.vue'; +import * as os from '@/os'; +import * as config from '@/config'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSwitch, + MkTextarea, + MkRadio, + }, + + data() { + return { + text: '', + flag: true, + radio: 'misskey', + mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`, + }; + }, + + methods: { + async openDialog() { + os.alert({ + type: 'warning', + title: 'Oh my Aichan', + text: 'Lorem ipsum dolor sit amet, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }); + }, + + async openForm() { + os.form('Example form', { + foo: { + type: 'boolean', + default: true, + label: 'This is a boolean property', + }, + bar: { + type: 'number', + default: 300, + label: 'This is a number property', + }, + baz: { + type: 'string', + default: 'Misskey makes you happy.', + label: 'This is a string property', + }, + }); + }, + + async openDrive() { + os.selectDriveFile(); + }, + + async selectUser() { + os.selectUser(); + }, + + async openMenu(ev) { + os.popupMenu([{ + type: 'label', + text: 'Fruits', + }, { + text: 'Create some apples', + action: () => {}, + }, { + text: 'Read some oranges', + action: () => {}, + }, { + text: 'Update some melons', + action: () => {}, + }, null, { + text: 'Delete some bananas', + danger: true, + action: () => {}, + }], ev.currentTarget ?? ev.target); + }, + }, +}); +</script> diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue new file mode 100644 index 0000000000..96f18f8d61 --- /dev/null +++ b/packages/frontend/src/components/MkSignin.vue @@ -0,0 +1,259 @@ +<template> +<form class="eppvobhk _monolithic_" :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> + <div class="auth _section _formRoot"> + <div v-show="withAvatar" class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null, marginBottom: message ? '1.5em' : null }"></div> + <MkInfo v-if="message"> + {{ 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:model-value="onUsernameChange"> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + </MkInput> + <MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" class="_formBlock" :placeholder="i18n.ts.password" type="password" :with-password-toggle="true" required data-cy-signin-password> + <template #prefix><i class="ti ti-lock"></i></template> + <template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template> + </MkInput> + <MkButton class="_formBlock" type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton> + </div> + <div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }"> + <div v-if="user && user.securityKeys" class="twofa-group tap-group"> + <p>{{ i18n.ts.tapSecurityKey }}</p> + <MkButton v-if="!queryingKey" @click="queryKey"> + {{ i18n.ts.retry }} + </MkButton> + </div> + <div v-if="user && user.securityKeys" class="or-hr"> + <p class="or-msg">{{ i18n.ts.or }}</p> + </div> + <div class="twofa-group totp-group"> + <p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p> + <MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" :with-password-toggle="true" required> + <template #label>{{ i18n.ts.password }}</template> + <template #prefix><i class="ti ti-lock"></i></template> + </MkInput> + <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="ti ti-123"></i></template> + </MkInput> + <MkButton type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton> + </div> + </div> + </div> + <div class="social _section"> + <a v-if="meta && meta.enableTwitterIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/twitter`"><i class="ti ti-brand-twitter" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Twitter' }) }}</a> + <a v-if="meta && meta.enableGithubIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/github`"><i class="ti ti-brand-github" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'GitHub' }) }}</a> + <a v-if="meta && meta.enableDiscordIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/discord`"><i class="ti ti-brand-discord" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Discord' }) }}</a> + </div> +</form> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import { showSuspendedDialog } from '../scripts/show-suspended-dialog'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.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'; +import { login } from '@/account'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; + +let signing = $ref(false); +let user = $ref(null); +let username = $ref(''); +let password = $ref(''); +let token = $ref(''); +let host = $ref(toUnicode(configHost)); +let totpLogin = $ref(false); +let credential = $ref(null); +let challengeData = $ref(null); +let queryingKey = $ref(false); +let hCaptchaResponse = $ref(null); +let reCaptchaResponse = $ref(null); + +const meta = $computed(() => instance); + +const emit = defineEmits<{ + (ev: 'login', v: any): void; +}>(); + +const props = defineProps({ + withAvatar: { + type: Boolean, + required: false, + default: true, + }, + autoSet: { + type: Boolean, + required: false, + default: false, + }, + message: { + type: String, + required: false, + default: '', + }, +}); + +function onUsernameChange() { + os.api('users/show', { + username: username, + }).then(userResponse => { + user = userResponse; + }, () => { + user = null; + }); +} + +function onLogin(res) { + if (props.autoSet) { + return login(res.i); + } +} + +function queryKey() { + queryingKey = true; + return navigator.credentials.get({ + publicKey: { + challenge: byteify(challengeData.challenge, 'base64'), + allowCredentials: challengeData.securityKeys.map(key => ({ + id: byteify(key.id, 'hex'), + type: 'public-key', + transports: ['usb', 'nfc', 'ble', 'internal'], + })), + timeout: 60 * 1000, + }, + }).catch(() => { + queryingKey = false; + return Promise.reject(null); + }).then(credential => { + queryingKey = false; + signing = true; + return os.api('signin', { + username, + password, + signature: hexify(credential.response.signature), + authenticatorData: hexify(credential.response.authenticatorData), + clientDataJSON: hexify(credential.response.clientDataJSON), + credentialId: credential.id, + challengeId: challengeData.challengeId, + 'hcaptcha-response': hCaptchaResponse, + 'g-recaptcha-response': reCaptchaResponse, + }); + }).then(res => { + emit('login', res); + return onLogin(res); + }).catch(err => { + if (err === null) return; + os.alert({ + type: 'error', + text: i18n.ts.signinFailed, + }); + signing = false; + }); +} + +function onSubmit() { + signing = true; + console.log('submit'); + if (!totpLogin && user && user.twoFactorEnabled) { + if (window.PublicKeyCredential && user.securityKeys) { + os.api('signin', { + username, + password, + 'hcaptcha-response': hCaptchaResponse, + 'g-recaptcha-response': reCaptchaResponse, + }).then(res => { + totpLogin = true; + signing = false; + challengeData = res; + return queryKey(); + }).catch(loginFailed); + } else { + totpLogin = true; + signing = false; + } + } else { + os.api('signin', { + username, + password, + 'hcaptcha-response': hCaptchaResponse, + 'g-recaptcha-response': reCaptchaResponse, + token: user && user.twoFactorEnabled ? token : undefined, + }).then(res => { + emit('login', res); + onLogin(res); + }).catch(loginFailed); + } +} + +function loginFailed(err) { + switch (err.id) { + case '6cc579cc-885d-43d8-95c2-b8c7fc963280': { + os.alert({ + type: 'error', + title: i18n.ts.loginFailed, + text: i18n.ts.noSuchUser, + }); + break; + } + case '932c904e-9460-45b7-9ce6-7ed33be7eb2c': { + os.alert({ + type: 'error', + title: i18n.ts.loginFailed, + text: i18n.ts.incorrectPassword, + }); + break; + } + case 'e03a5f46-d309-4865-9b69-56282d94e1eb': { + showSuspendedDialog(); + break; + } + case '22d05606-fbcf-421a-a2db-b32610dcfd1b': { + os.alert({ + type: 'error', + title: i18n.ts.loginFailed, + text: i18n.ts.rateLimitExceeded, + }); + break; + } + default: { + console.log(err); + os.alert({ + type: 'error', + title: i18n.ts.loginFailed, + text: JSON.stringify(err), + }); + } + } + + challengeData = null; + totpLogin = false; + signing = false; +} + +function resetPassword() { + os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { + }, 'closed'); +} +</script> + +<style lang="scss" scoped> +.eppvobhk { + > .auth { + > .avatar { + margin: 0 auto 0 auto; + width: 64px; + height: 64px; + background: #ddd; + background-position: center; + background-size: cover; + border-radius: 100%; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue new file mode 100644 index 0000000000..fd27244516 --- /dev/null +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -0,0 +1,46 @@ +<template> +<XModalWindow + ref="dialog" + :width="370" + :height="400" + @close="onClose" + @closed="emit('closed')" +> + <template #header>{{ i18n.ts.login }}</template> + + <MkSignin :auto-set="autoSet" :message="message" @login="onLogin"/> +</XModalWindow> +</template> + +<script lang="ts" setup> +import { } from '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: '', +}); + +const emit = defineEmits<{ + (ev: 'done'): void; + (ev: 'closed'): void; + (ev: 'cancelled'): void; +}>(); + +const dialog = $ref<InstanceType<typeof XModalWindow>>(); + +function onClose() { + emit('cancelled'); + dialog.close(); +} + +function onLogin(res) { + emit('done', res); + dialog.close(); +} +</script> diff --git a/packages/frontend/src/components/MkSignup.vue b/packages/frontend/src/components/MkSignup.vue new file mode 100644 index 0000000000..d987425ca3 --- /dev/null +++ b/packages/frontend/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="ti ti-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:model-value="onChangeUsername"> + <template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + <template #caption> + <span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span> + <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span> + <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span> + <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span> + <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.usernameInvalidFormat }}</span> + <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooShort }}</span> + <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-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:model-value="onChangeEmail"> + <template #label>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template> + <template #prefix><i class="ti ti-mail"></i></template> + <template #caption> + <span v-if="emailState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span> + <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span> + <span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.used }}</span> + <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.format }}</span> + <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.disposable }}</span> + <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.mx }}</span> + <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.smtp }}</span> + <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span> + <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-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:model-value="onChangePassword"> + <template #label>{{ i18n.ts.password }}</template> + <template #prefix><i class="ti ti-lock"></i></template> + <template #caption> + <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span> + <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span> + <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-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:model-value="onChangePasswordRetype"> + <template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template> + <template #prefix><i class="ti ti-lock"></i></template> + <template #caption> + <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span> + <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-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/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue new file mode 100644 index 0000000000..77497021c3 --- /dev/null +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -0,0 +1,46 @@ +<template> +<XModalWindow + ref="dialog" + :width="366" + :height="500" + @close="dialog.close()" + @closed="$emit('closed')" +> + <template #header>{{ i18n.ts.signup }}</template> + + <div class="_monolithic_"> + <div class="_section"> + <XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XSignup from '@/components/MkSignup.vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + autoSet?: boolean; +}>(), { + autoSet: false, +}); + +const emit = defineEmits<{ + (ev: 'done'): void; + (ev: 'closed'): void; +}>(); + +const dialog = $ref<InstanceType<typeof XModalWindow>>(); + +function onSignup(res) { + emit('done', res); + dialog.close(); +} + +function onSignupEmailPending() { + dialog.close(); +} +</script> diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue new file mode 100644 index 0000000000..cdeaf9c417 --- /dev/null +++ b/packages/frontend/src/components/MkSparkle.vue @@ -0,0 +1,130 @@ +<template> +<span class="mk-sparkle"> + <span ref="el"> + <slot></slot> + </span> + <!-- なぜか path に対する key が機能しないため + <svg :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg"> + <path v-for="particle in particles" :key="particle.id" 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" + > + <animateTransform + attributeName="transform" + attributeType="XML" + type="rotate" + from="0 0 0" + to="360 0 0" + :dur="`${particle.dur}ms`" + repeatCount="indefinite" + additive="sum" + /> + <animateTransform + attributeName="transform" + attributeType="XML" + type="scale" + :values="`0; ${particle.size}; 0`" + :dur="`${particle.dur}ms`" + repeatCount="indefinite" + additive="sum" + /> + </path> + </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;" + :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" + > + <animateTransform + attributeName="transform" + attributeType="XML" + type="rotate" + from="0 0 0" + to="360 0 0" + :dur="`${particle.dur}ms`" + repeatCount="1" + additive="sum" + /> + <animateTransform + attributeName="transform" + attributeType="XML" + type="scale" + :values="`0; ${particle.size}; 0`" + :dur="`${particle.dur}ms`" + repeatCount="1" + additive="sum" + /> + </path> + </svg> +</span> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; + +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(() => { + 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)); + }; + add(); +}); + +onUnmounted(() => { + if (ro) ro.disconnect(); + stop = true; +}); +</script> + +<style lang="scss" scoped> +.mk-sparkle { + position: relative; + display: inline-block; + + > span { + display: inline-block; + } + + > svg { + position: absolute; + top: -32px; + left: -32px; + pointer-events: none; + } +} +</style> diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue new file mode 100644 index 0000000000..210923be46 --- /dev/null +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -0,0 +1,90 @@ +<template> +<div class="wrmlmaau" :class="{ collapsed }"> + <div class="body"> + <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="ti ti-arrow-back-up"></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> + </div> + <details v-if="note.files.length > 0"> + <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary> + <XMediaList :media-list="note.files"/> + </details> + <details v-if="note.poll"> + <summary>{{ i18n.ts.poll }}</summary> + <XPoll :note="note"/> + </details> + <button v-if="collapsed" class="fade _button" @click="collapsed = false"> + <span>{{ i18n.ts.showMore }}</span> + </button> +</div> +</template> + +<script lang="ts" setup> +import { } from '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; +}>(); + +const collapsed = $ref( + props.note.cw == null && props.note.text != null && ( + (props.note.text.split('\n').length > 9) || + (props.note.text.length > 500) + )); +</script> + +<style lang="scss" scoped> +.wrmlmaau { + overflow-wrap: break-word; + + > .body { + > .reply { + margin-right: 6px; + color: var(--accent); + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + } + + &.collapsed { + position: relative; + max-height: 9em; + overflow: hidden; + + > .fade { + display: block; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + + > span { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); + } + + &:hover { + > span { + background: var(--panelHighlight); + } + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue new file mode 100644 index 0000000000..e79794aea4 --- /dev/null +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -0,0 +1,161 @@ +<template> +<div class="rrevdjwu" :class="{ grid }"> + <div v-for="group in def" class="group"> + <div v-if="group.title" class="title">{{ group.title }}</div> + + <div class="items"> + <template v-for="(item, i) in group.items"> + <a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <i v-if="item.icon" class="icon ti-fw" :class="item.icon"></i> + <span class="text">{{ item.text }}</span> + </a> + <button v-else-if="item.type === 'button'" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)"> + <i v-if="item.icon" class="icon ti-fw" :class="item.icon"></i> + <span class="text">{{ item.text }}</span> + </button> + <MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <i v-if="item.icon" class="icon ti-fw" :class="item.icon"></i> + <span class="text">{{ item.text }}</span> + </MkA> + </template> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, ref, unref } from 'vue'; + +export default defineComponent({ + props: { + def: { + type: Array, + required: true, + }, + grid: { + type: Boolean, + required: false, + default: false, + }, + }, +}); +</script> + +<style lang="scss" scoped> +.rrevdjwu { + > .group { + & + .group { + margin-top: 16px; + padding-top: 16px; + border-top: solid 0.5px var(--divider); + } + + > .title { + opacity: 0.7; + margin: 0 0 8px 0; + font-size: 0.9em; + } + + > .items { + > .item { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 10px 16px 10px 8px; + border-radius: 9px; + font-size: 0.9em; + + &:hover { + text-decoration: none; + background: var(--panelHighlight); + } + + &.active { + color: var(--accent); + background: var(--accentedBg); + } + + &.danger { + color: var(--error); + } + + > .icon { + width: 32px; + margin-right: 2px; + flex-shrink: 0; + text-align: center; + opacity: 0.8; + } + + > .text { + white-space: normal; + padding-right: 12px; + flex-shrink: 1; + } + + } + } + } + + &.grid { + > .group { + & + .group { + padding-top: 0; + border-top: none; + } + + margin-left: 0; + margin-right: 0; + + > .title { + font-size: 1em; + opacity: 0.7; + margin: 0 0 8px 16px; + } + + > .items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); + grid-gap: 16px; + padding: 0 16px; + + > .item { + flex-direction: column; + text-align: center; + padding: 0; + + &:hover { + text-decoration: none; + background: none; + color: var(--accent); + + > .icon { + background: var(--accentedBg); + } + } + + > .icon { + display: grid; + place-content: center; + margin-right: 0; + margin-bottom: 6px; + font-size: 1.5em; + width: 54px; + height: 54px; + aspect-ratio: 1; + background: var(--panel); + border-radius: 100%; + } + + > .text { + padding-right: 0; + width: 100%; + font-size: 0.8em; + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkTab.vue b/packages/frontend/src/components/MkTab.vue new file mode 100644 index 0000000000..669e9e2e11 --- /dev/null +++ b/packages/frontend/src/components/MkTab.vue @@ -0,0 +1,73 @@ +<script lang="ts"> +import { defineComponent, h, resolveDirective, withDirectives } from 'vue'; + +export default defineComponent({ + props: { + modelValue: { + required: true, + }, + }, + render() { + const options = this.$slots.default(); + + return withDirectives(h('div', { + class: 'pxhvhrfw', + }, options.map(option => withDirectives(h('button', { + class: ['_button', { active: this.modelValue === option.props.value }], + key: option.key, + disabled: this.modelValue === option.props.value, + onClick: () => { + this.$emit('update:modelValue', option.props.value); + }, + }, option.children), [ + [resolveDirective('click-anime')], + ]))), [ + [resolveDirective('size'), { max: [500] }], + ]); + }, +}); +</script> + +<style lang="scss"> +.pxhvhrfw { + display: flex; + font-size: 90%; + + > button { + flex: 1; + padding: 10px 8px; + border-radius: var(--radius); + + &:disabled { + opacity: 1 !important; + cursor: default; + } + + &.active { + color: var(--accent); + background: var(--accentedBg); + } + + &:not(.active):hover { + color: var(--fgHighlighted); + background: var(--panelHighlight); + } + + &:not(:first-child) { + margin-left: 8px; + } + + > .icon { + margin-right: 6px; + } + } + + &.max-width_500px { + font-size: 80%; + + > button { + padding: 11px 8px; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue new file mode 100644 index 0000000000..2dfd26edb0 --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue new file mode 100644 index 0000000000..831a194ce3 --- /dev/null +++ b/packages/frontend/src/components/MkTimeline.vue @@ -0,0 +1,143 @@ +<template> +<XNotes ref="tlComponent" :no-gap="!$store.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/> +</template> + +<script lang="ts" setup> +import { ref, computed, provide, onUnmounted } from 'vue'; +import XNotes from '@/components/MkNotes.vue'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import * as sound from '@/scripts/sound'; +import { $i } from '@/account'; + +const props = defineProps<{ + src: string; + list?: string; + antenna?: string; + channel?: string; + sound?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'note'): void; + (ev: 'queue', count: number): void; +}>(); + +provide('inChannel', computed(() => props.src === 'channel')); + +const tlComponent: InstanceType<typeof XNotes> = $ref(); + +const prepend = note => { + tlComponent.pagingComponent?.prepend(note); + + emit('note'); + + if (props.sound) { + sound.play($i && (note.userId === $i.id) ? 'noteMy' : 'note'); + } +}; + +const onUserAdded = () => { + tlComponent.pagingComponent?.reload(); +}; + +const onUserRemoved = () => { + tlComponent.pagingComponent?.reload(); +}; + +const onChangeFollowing = () => { + if (!tlComponent.pagingComponent?.backed) { + tlComponent.pagingComponent?.reload(); + } +}; + +let endpoint; +let query; +let connection; +let connection2; + +if (props.src === 'antenna') { + endpoint = 'antennas/notes'; + query = { + antennaId: props.antenna, + }; + connection = stream.useChannel('antenna', { + antennaId: props.antenna, + }); + connection.on('note', prepend); +} else if (props.src === 'home') { + endpoint = 'notes/timeline'; + connection = stream.useChannel('homeTimeline'); + connection.on('note', prepend); + + connection2 = stream.useChannel('main'); + connection2.on('follow', onChangeFollowing); + connection2.on('unfollow', onChangeFollowing); +} else if (props.src === 'local') { + endpoint = 'notes/local-timeline'; + connection = stream.useChannel('localTimeline'); + connection.on('note', prepend); +} else if (props.src === 'social') { + endpoint = 'notes/hybrid-timeline'; + connection = stream.useChannel('hybridTimeline'); + connection.on('note', prepend); +} else if (props.src === 'global') { + endpoint = 'notes/global-timeline'; + connection = stream.useChannel('globalTimeline'); + connection.on('note', prepend); +} else if (props.src === 'mentions') { + endpoint = 'notes/mentions'; + connection = stream.useChannel('main'); + connection.on('mention', prepend); +} else if (props.src === 'directs') { + endpoint = 'notes/mentions'; + query = { + visibility: 'specified', + }; + const onNote = note => { + if (note.visibility === 'specified') { + prepend(note); + } + }; + connection = stream.useChannel('main'); + connection.on('mention', onNote); +} else if (props.src === 'list') { + endpoint = 'notes/user-list-timeline'; + query = { + listId: props.list, + }; + connection = stream.useChannel('userList', { + listId: props.list, + }); + connection.on('note', prepend); + connection.on('userAdded', onUserAdded); + connection.on('userRemoved', onUserRemoved); +} else if (props.src === 'channel') { + endpoint = 'channels/timeline'; + query = { + channelId: props.channel, + }; + connection = stream.useChannel('channel', { + channelId: props.channel, + }); + connection.on('note', prepend); +} + +const pagination = { + endpoint: endpoint, + limit: 10, + params: query, +}; + +onUnmounted(() => { + connection.dispose(); + if (connection2) connection2.dispose(); +}); + +/* TODO +const timetravel = (date?: Date) => { + this.date = date; + this.$refs.tl.reload(); +}; +*/ +</script> diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue new file mode 100644 index 0000000000..c9fad64eb6 --- /dev/null +++ b/packages/frontend/src/components/MkToast.vue @@ -0,0 +1,66 @@ +<template> +<div class="mk-toast"> + <transition :name="$store.state.animation ? 'toast' : ''" appear @after-leave="emit('closed')"> + <div v-if="showing" class="body _acrylic" :style="{ zIndex }"> + <div class="message"> + {{ message }} + </div> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref } from 'vue'; +import * as os from '@/os'; + +defineProps<{ + message: string; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +const zIndex = os.claimZIndex('high'); +let showing = $ref(true); + +onMounted(() => { + window.setTimeout(() => { + showing = false; + }, 4000); +}); +</script> + +<style lang="scss" scoped> +.toast-enter-active, .toast-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.toast-enter-from, .toast-leave-to { + opacity: 0; + transform: translateY(-100%); +} + +.mk-toast { + > .body { + position: fixed; + left: 0; + right: 0; + top: 0; + margin: 0 auto; + margin-top: 16px; + min-width: 300px; + max-width: calc(100% - 32px); + width: min-content; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + border-radius: 8px; + overflow: clip; + text-align: center; + pointer-events: none; + + > .message { + padding: 16px 24px; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue new file mode 100644 index 0000000000..b846034a24 --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue new file mode 100644 index 0000000000..4c6258d245 --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue new file mode 100644 index 0000000000..48aeb30224 --- /dev/null +++ b/packages/frontend/src/components/MkUpdated.vue @@ -0,0 +1,51 @@ +<template> +<MkModal ref="modal" :z-priority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')"> + <div class="ewlycnyt"> + <div class="title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div> + <div class="version">✨{{ version }}🚀</div> + <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/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<InstanceType<typeof MkModal>>(); + +const whatIsNew = () => { + modal.value.close(); + window.open(`https://misskey-hub.net/docs/releases.html#_${version.replace(/\./g, '-')}`, '_blank'); +}; +</script> + +<style lang="scss" scoped> +.ewlycnyt { + position: relative; + padding: 32px; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + + > .title { + font-weight: bold; + } + + > .version { + margin: 1em 0; + } + + > .gotIt { + margin: 8px 0 0 0; + } +} +</style> diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue new file mode 100644 index 0000000000..b2d16ddb01 --- /dev/null +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -0,0 +1,383 @@ +<template> +<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> + <button class="disablePlayer" :title="i18n.ts.disablePlayer" @click="playerEnabled = false"><i class="ti ti-x"></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="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="i18n.ts.enablePlayer" @click.prevent="isMobile? playerEnabled = true : openPlayer()"><i class="ti ti-player-play"></i></button> + </div> + <article> + <header> + <h1 :title="title">{{ title }}</h1> + </header> + <p v-if="description" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p> + <footer> + <img v-if="icon" class="icon" :src="icon"/> + <p :title="sitename">{{ sitename }}</p> + </footer> + </article> + </component> + </transition> + <div v-if="tweetId" class="expandTweet"> + <a @click="tweetExpanded = true"> + <i class="ti ti-brand-twitter"></i> {{ i18n.ts.expandTweet }} + </a> + </div> +</div> +</template> + +<script lang="ts" setup> +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; + detail?: boolean; + compact?: boolean; +}>(), { + detail: false, + 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'; +let fetching = $ref(true); +let title = $ref<string | null>(null); +let description = $ref<string | null>(null); +let thumbnail = $ref<string | null>(null); +let icon = $ref<string | null>(null); +let sitename = $ref<string | null>(null); +let player = $ref({ + url: null, + width: null, + height: null, +}); +let playerEnabled = $ref(false); +let tweetId = $ref<string | null>(null); +let tweetExpanded = $ref(props.detail); +const embedId = `embed${Math.random().toString().replace(/\D/, '')}`; +let tweetHeight = $ref(150); + +const requestUrl = new URL(props.url); + +if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twitter.com') { + const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/); + if (m) tweetId = m[1]; +} + +if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) { + requestUrl.hostname = 'www.youtube.com'; +} + +const requestLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP'); + +requestUrl.hash = ''; + +window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => { + res.json().then(info => { + if (info.url == null) return; + title = info.title; + description = info.description; + thumbnail = info.thumbnail; + icon = info.icon; + sitename = info.sitename; + fetching = false; + player = info.player; + }); +}); + +function adjustTweetHeight(message: any) { + if (message.origin !== 'https://platform.twitter.com') return; + const embed = message.data?.['twttr.embed']; + if (embed?.method !== 'twttr.private.resize') return; + if (embed?.id !== embedId) return; + const height = embed?.params[0]?.height; + if (height) tweetHeight = height; +} + +const openPlayer = (): void => { + os.popup(defineAsyncComponent(() => import('@/components/MkYoutubePlayer.vue')), { + url: requestUrl.href, + }); +}; + +(window as any).addEventListener('message', adjustTweetHeight); + +onUnmounted(() => { + (window as any).removeEventListener('message', adjustTweetHeight); +}); +</script> + +<style lang="scss" scoped> +.player { + position: relative; + width: 100%; + + > button { + position: absolute; + top: -1.5em; + right: 0; + font-size: 1em; + width: 1.5em; + height: 1.5em; + padding: 0; + margin: 0; + color: var(--fg); + background: rgba(128, 128, 128, 0.2); + opacity: 0.7; + + &:hover { + opacity: 0.9; + } + } + + > iframe { + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + } +} + +.mk-url-preview { + &.max-width_400px { + > .link { + font-size: 12px; + + > .thumbnail { + height: 80px; + } + + > article { + padding: 12px; + } + } + } + + &.max-width_350px { + > .link { + font-size: 10px; + + > .thumbnail { + height: 70px; + } + + > article { + padding: 8px; + + > header { + margin-bottom: 4px; + } + + > footer { + margin-top: 4px; + + > img { + width: 12px; + height: 12px; + } + } + } + + &.compact { + > .thumbnail { + position: absolute; + width: 56px; + height: 100%; + } + + > article { + left: 56px; + width: calc(100% - 56px); + padding: 4px; + + > header { + margin-bottom: 2px; + } + + > footer { + margin-top: 2px; + } + } + } + } + } + + > .link { + position: relative; + display: block; + font-size: 14px; + box-shadow: 0 0 0 1px var(--divider); + border-radius: 8px; + overflow: hidden; + + &:hover { + text-decoration: none; + border-color: rgba(0, 0, 0, 0.2); + + > article > header > h1 { + text-decoration: underline; + } + } + + > .thumbnail { + position: absolute; + width: 100px; + height: 100%; + background-position: center; + background-size: cover; + display: flex; + justify-content: center; + align-items: center; + + > button { + font-size: 3.5em; + opacity: 0.7; + + &:hover { + font-size: 4em; + opacity: 0.9; + } + } + + & + article { + left: 100px; + width: calc(100% - 100px); + } + } + + > article { + position: relative; + box-sizing: border-box; + padding: 16px; + + > header { + margin-bottom: 8px; + + > h1 { + margin: 0; + font-size: 1em; + } + } + + > p { + margin: 0; + font-size: 0.8em; + } + + > footer { + margin-top: 8px; + height: 16px; + + > img { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 4px; + vertical-align: top; + } + + > p { + display: inline-block; + margin: 0; + color: var(--urlPreviewInfo); + font-size: 0.8em; + line-height: 16px; + vertical-align: top; + } + } + } + + &.compact { + > article { + > header h1, p, footer { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + } + } +} + +@container (max-width: 400px) { + .mk-url-preview { + > .link { + font-size: 12px; + + > .thumbnail { + height: 80px; + } + + > article { + padding: 12px; + } + } + } +} + +@container (max-width: 350px) { + .mk-url-preview { + > .link { + font-size: 10px; + + > .thumbnail { + height: 70px; + } + + > article { + padding: 8px; + + > header { + margin-bottom: 4px; + } + + > footer { + margin-top: 4px; + + > img { + width: 12px; + height: 12px; + } + } + } + + &.compact { + > .thumbnail { + position: absolute; + width: 56px; + height: 100%; + } + + > article { + left: 56px; + width: calc(100% - 56px); + padding: 4px; + + > header { + margin-bottom: 2px; + } + + > footer { + margin-top: 2px; + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue new file mode 100644 index 0000000000..f343c6d8a6 --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue new file mode 100644 index 0000000000..1a4c494987 --- /dev/null +++ b/packages/frontend/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/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue new file mode 100644 index 0000000000..036cbea304 --- /dev/null +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -0,0 +1,137 @@ +<template> +<div class="_panel vjnjpkug"> + <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></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> + <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;">{{ i18n.ts.noAccountDescription }}</span> + </div> + <div class="status"> + <div> + <p>{{ i18n.ts.notes }}</p><span>{{ user.notesCount }}</span> + </div> + <div> + <p>{{ i18n.ts.following }}</p><span>{{ user.followingCount }}</span> + </div> + <div> + <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/> +</div> +</template> + +<script lang="ts" setup> +import * as misskey from 'misskey-js'; +import MkFollowButton from '@/components/MkFollowButton.vue'; +import { userPage } from '@/filters/user'; +import { i18n } from '@/i18n'; + +defineProps<{ + user: misskey.entities.UserDetailed; +}>(); +</script> + +<style lang="scss" scoped> +.vjnjpkug { + position: relative; + + > .banner { + height: 84px; + background-color: rgba(0, 0, 0, 0.1); + background-size: cover; + background-position: center; + } + + > .avatar { + display: block; + position: absolute; + top: 62px; + left: 13px; + z-index: 2; + width: 58px; + height: 58px; + border: solid 4px var(--panel); + } + + > .title { + display: block; + padding: 10px 0 10px 88px; + + > .name { + display: inline-block; + margin: 0; + font-weight: bold; + line-height: 16px; + word-break: break-all; + } + + > .username { + display: block; + margin: 0; + line-height: 16px; + font-size: 0.8em; + color: var(--fg); + 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; + border-top: solid 0.5px var(--divider); + + > .mfm { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + } + + > .status { + padding: 10px 16px; + border-top: solid 0.5px var(--divider); + + > div { + display: inline-block; + width: 33%; + + > p { + margin: 0; + font-size: 0.7em; + color: var(--fg); + } + + > span { + font-size: 1em; + color: var(--accent); + } + } + } + + > .koudoku-button { + position: absolute; + top: 8px; + right: 8px; + } +} +</style> diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue new file mode 100644 index 0000000000..e1f47c7673 --- /dev/null +++ b/packages/frontend/src/components/MkUserList.vue @@ -0,0 +1,39 @@ +<template> +<MkPagination ref="pagingComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noUsers }}</div> + </div> + </template> + + <template #default="{ items: users }"> + <div class="efvhhmdq"> + <MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/> + </div> + </template> +</MkPagination> +</template> + +<script lang="ts" setup> +import { ref } from '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; + noGap?: boolean; +}>(); + +const pagingComponent = ref<InstanceType<typeof MkPagination>>(); +</script> + +<style lang="scss" scoped> +.efvhhmdq { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); +} +</style> diff --git a/packages/frontend/src/components/MkUserOnlineIndicator.vue b/packages/frontend/src/components/MkUserOnlineIndicator.vue new file mode 100644 index 0000000000..a4f6f80383 --- /dev/null +++ b/packages/frontend/src/components/MkUserOnlineIndicator.vue @@ -0,0 +1,45 @@ +<template> +<div v-tooltip="text" class="fzgwjkgc" :class="user.onlineStatus"></div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as misskey from 'misskey-js'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +const text = $computed(() => { + switch (props.user.onlineStatus) { + case 'online': return i18n.ts.online; + case 'active': return i18n.ts.active; + case 'offline': return i18n.ts.offline; + case 'unknown': return i18n.ts.unknown; + } +}); +</script> + +<style lang="scss" scoped> +.fzgwjkgc { + box-shadow: 0 0 0 3px var(--panel); + border-radius: 120%; // Blinkのバグか知らんけど、100%ぴったりにすると何故か若干楕円でレンダリングされる + + &.online { + background: #58d4c9; + } + + &.active { + background: #e4bc48; + } + + &.offline { + background: #ea5353; + } + + &.unknown { + background: #888; + } +} +</style> diff --git a/packages/frontend/src/components/MkUserPreview.vue b/packages/frontend/src/components/MkUserPreview.vue new file mode 100644 index 0000000000..4de2e8baa2 --- /dev/null +++ b/packages/frontend/src/components/MkUserPreview.vue @@ -0,0 +1,184 @@ +<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="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> + <p class="username"><MkAcct :user="user"/></p> + </div> + <div class="description"> + <Mfm v-if="user.description" :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/> + </div> + <div class="status"> + <div> + <p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span> + </div> + <div> + <p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span> + </div> + <div> + <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span> + </div> + </div> + <MkFollowButton v-if="$i && user.id != $i.id" class="koudoku-button" :user="user" mini/> + </div> + <div v-else> + <MkLoading/> + </div> + </div> +</transition> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import MkFollowButton from '@/components/MkFollowButton.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; + +const props = defineProps<{ + showing: boolean; + q: string; + source: HTMLElement; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; + (ev: 'mouseover'): void; + (ev: 'mouseleave'): void; +}>(); + +const zIndex = os.claimZIndex('middle'); +let user = $ref<misskey.entities.UserDetailed | null>(null); +let top = $ref(0); +let left = $ref(0); + +onMounted(() => { + if (typeof props.q === 'object') { + user = props.q; + } else { + const query = props.q.startsWith('@') ? + Acct.parse(props.q.substr(1)) : + { userId: props.q }; + + os.api('users/show', query).then(res => { + if (!props.showing) return; + user = res; + }); + } + + 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; + + top = y; + left = x; +}); +</script> + +<style lang="scss" scoped> +.popup-enter-active, .popup-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.popup-enter-from, .popup-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.fxxzrfni { + position: absolute; + width: 300px; + overflow: hidden; + transform-origin: center top; + + > .info { + > .banner { + height: 84px; + 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 { + display: block; + position: absolute; + top: 62px; + left: 13px; + z-index: 2; + width: 58px; + height: 58px; + border: solid 3px var(--face); + border-radius: 8px; + } + + > .title { + display: block; + padding: 8px 0 8px 82px; + + > .name { + display: inline-block; + margin: 0; + font-weight: bold; + line-height: 16px; + word-break: break-all; + } + + > .username { + display: block; + margin: 0; + line-height: 16px; + font-size: 0.8em; + color: var(--fg); + opacity: 0.7; + } + } + + > .description { + padding: 0 16px; + font-size: 0.8em; + color: var(--fg); + } + + > .status { + padding: 8px 16px; + + > div { + display: inline-block; + width: 33%; + + > p { + margin: 0; + font-size: 0.7em; + color: var(--fg); + } + + > span { + font-size: 1em; + color: var(--accent); + } + } + } + + > .koudoku-button { + position: absolute; + top: 8px; + right: 8px; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue new file mode 100644 index 0000000000..1d31769c30 --- /dev/null +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -0,0 +1,190 @@ +<template> +<XModalWindow + ref="dialogEl" + :with-ok-button="true" + :ok-button-disabled="selected == null" + @click="cancel()" + @close="cancel()" + @ok="ok()" + @closed="$emit('closed')" +> + <template #header>{{ i18n.ts.selectUser }}</template> + <div class="tbhwbxda"> + <div class="form"> + <FormSplit :min-width="170"> + <MkInput v-model="username" :autofocus="true" @update:model-value="search"> + <template #label>{{ i18n.ts.username }}</template> + <template #prefix>@</template> + </MkInput> + <MkInput v-model="host" @update:model-value="search"> + <template #label>{{ i18n.ts.host }}</template> + <template #prefix>@</template> + </MkInput> + </FormSplit> + </div> + <div v-if="username != '' || host != ''" class="result" :class="{ hit: users.length > 0 }"> + <div v-if="users.length > 0" class="users"> + <div v-for="user in users" :key="user.id" class="user" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()"> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> + </div> + </div> + </div> + <div v-else class="empty"> + <span>{{ i18n.ts.noUsers }}</span> + </div> + </div> + <div v-if="username == '' && host == ''" class="recent"> + <div class="users"> + <div v-for="user in recentUsers" :key="user.id" class="user" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()"> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> + </div> + </div> + </div> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts" setup> +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/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; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); + +let username = $ref(''); +let host = $ref(''); +let users: misskey.entities.UserDetailed[] = $ref([]); +let recentUsers: misskey.entities.UserDetailed[] = $ref([]); +let selected: misskey.entities.UserDetailed | null = $ref(null); +let dialogEl = $ref(); + +const search = () => { + if (username === '' && host === '') { + users = []; + return; + } + os.api('users/search-by-username-and-host', { + username: username, + host: host, + limit: 10, + detail: false, + }).then(_users => { + users = _users; + }); +}; + +const ok = () => { + if (selected == null) return; + emit('ok', selected); + dialogEl.close(); + + // 最近使ったユーザー更新 + let recents = defaultStore.state.recentlyUsedUsers; + recents = recents.filter(x => x !== selected.id); + recents.unshift(selected.id); + defaultStore.set('recentlyUsedUsers', recents.splice(0, 16)); +}; + +const cancel = () => { + emit('cancel'); + dialogEl.close(); +}; + +onMounted(() => { + os.api('users/show', { + userIds: defaultStore.state.recentlyUsedUsers, + }).then(users => { + recentUsers = users; + }); +}); +</script> + +<style lang="scss" scoped> +.tbhwbxda { + > .form { + padding: 0 var(--root-margin); + } + + > .result, > .recent { + display: flex; + flex-direction: column; + overflow: auto; + height: 100%; + + &.result.hit { + padding: 0; + } + + &.recent { + padding: 0; + } + + > .users { + flex: 1; + overflow: auto; + padding: 8px 0; + + > .user { + display: flex; + align-items: center; + padding: 8px var(--root-margin); + font-size: 14px; + + &:hover { + background: var(--X7); + } + + &.selected { + background: var(--accent); + color: #fff; + } + + > * { + pointer-events: none; + user-select: none; + } + + > .avatar { + width: 45px; + height: 45px; + } + + > .body { + padding: 0 8px; + min-width: 0; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + + > .empty { + opacity: 0.7; + text-align: center; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkUsersTooltip.vue b/packages/frontend/src/components/MkUsersTooltip.vue new file mode 100644 index 0000000000..f0384e2d65 --- /dev/null +++ b/packages/frontend/src/components/MkUsersTooltip.vue @@ -0,0 +1,51 @@ +<template> +<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="250" @closed="emit('closed')"> + <div class="beaffaef"> + <div v-for="u in users" :key="u.id" class="user"> + <MkAvatar class="avatar" :user="u"/> + <MkUserName class="name" :user="u" :nowrap="true"/> + </div> + <div v-if="users.length < count" class="omitted">+{{ count - users.length }}</div> + </div> +</MkTooltip> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkTooltip from './MkTooltip.vue'; + +defineProps<{ + showing: boolean; + users: any[]; // TODO + count: number; + targetElement: HTMLElement; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); +</script> + +<style lang="scss" scoped> +.beaffaef { + font-size: 0.9em; + text-align: left; + + > .user { + line-height: 24px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:not(:last-child) { + margin-bottom: 3px; + } + + > .avatar { + width: 24px; + height: 24px; + margin-right: 3px; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkVisibility.vue b/packages/frontend/src/components/MkVisibility.vue new file mode 100644 index 0000000000..229907fbb8 --- /dev/null +++ b/packages/frontend/src/components/MkVisibility.vue @@ -0,0 +1,48 @@ +<template> +<span v-if="note.visibility !== 'public'" :class="$style.visibility" :title="i18n.ts._visibility[note.visibility]"> + <i v-if="note.visibility === 'home'" class="ti ti-home"></i> + <i v-else-if="note.visibility === 'followers'" class="ti ti-lock-open"></i> + <i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i> +</span> +<span v-if="note.localOnly" :class="$style.localOnly" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></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'; +import { i18n } from '@/i18n'; + +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/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue new file mode 100644 index 0000000000..8f0bcdeae8 --- /dev/null +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -0,0 +1,159 @@ +<template> +<MkModal ref="modal" :z-priority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')"> + <div class="gqyayizv _popup"> + <button key="public" class="_button" :class="{ active: v === 'public' }" data-index="1" @click="choose('public')"> + <div><i class="ti ti-world"></i></div> + <div> + <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="ti ti-home"></i></div> + <div> + <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="ti ti-lock-open"></i></div> + <div> + <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="ti ti-mail"></i></div> + <div> + <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="ti ti-world-off"></i></div> + <div> + <span>{{ i18n.ts._visibility.localOnly }}</span> + <span>{{ i18n.ts._visibility.localOnlyDescription }}</span> + </div> + <div><i :class="localOnly ? 'ti ti-toggle-right' : 'ti ti-toggle-left'"></i></div> + </button> + </div> +</MkModal> +</template> + +<script lang="ts" setup> +import { nextTick, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MkModal from '@/components/MkModal.vue'; +import { i18n } from '@/i18n'; + +const modal = $ref<InstanceType<typeof MkModal>>(); + +const props = withDefaults(defineProps<{ + currentVisibility: typeof misskey.noteVisibilities[number]; + currentLocalOnly: boolean; + src?: HTMLElement; +}>(), { +}); + +const emit = defineEmits<{ + (ev: 'changeVisibility', v: typeof misskey.noteVisibilities[number]): void; + (ev: 'changeLocalOnly', v: boolean): void; + (ev: 'closed'): void; +}>(); + +let v = $ref(props.currentVisibility); +let localOnly = $ref(props.currentLocalOnly); + +watch($$(localOnly), () => { + emit('changeLocalOnly', localOnly); +}); + +function choose(visibility: typeof misskey.noteVisibilities[number]): void { + v = visibility; + emit('changeVisibility', visibility); + nextTick(() => { + modal.close(); + }); +} +</script> + +<style lang="scss" scoped> +.gqyayizv { + width: 240px; + padding: 8px 0; + + > .divider { + margin: 8px 0; + border-top: solid 0.5px var(--divider); + } + + > button { + display: flex; + padding: 8px 14px; + font-size: 12px; + text-align: left; + width: 100%; + box-sizing: border-box; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: rgba(0, 0, 0, 0.1); + } + + &.active { + color: var(--fgOnAccent); + background: var(--accent); + } + + &.localOnly.active { + color: var(--accent); + background: inherit; + } + + > *:nth-child(1) { + display: flex; + justify-content: center; + align-items: center; + margin-right: 10px; + width: 16px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + } + + > *:nth-child(2) { + flex: 1 1 auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + > span:first-child { + display: block; + font-weight: bold; + } + + > span:last-child:not(:first-child) { + opacity: 0.6; + } + } + + > *:nth-child(3) { + display: flex; + justify-content: center; + align-items: center; + margin-left: 10px; + width: 16px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue new file mode 100644 index 0000000000..f4a9f4f22c --- /dev/null +++ b/packages/frontend/src/components/MkWaitingDialog.vue @@ -0,0 +1,73 @@ +<template> +<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="success ? done() : () => {}" @closed="emit('closed')"> + <div class="iuyakobc" :class="{ iconOnly: (text == null) || success }"> + <i v-if="success" class="ti ti-check icon success"></i> + <MkLoading v-else class="icon waiting" :em="true"/> + <div v-if="text && !success" class="text">{{ text }}<MkEllipsis/></div> + </div> +</MkModal> +</template> + +<script lang="ts" setup> +import { watch, ref } from 'vue'; +import MkModal from '@/components/MkModal.vue'; + +const modal = ref<InstanceType<typeof MkModal>>(); + +const props = defineProps<{ + success: boolean; + showing: boolean; + text?: string; +}>(); + +const emit = defineEmits<{ + (ev: 'done'); + (ev: 'closed'); +}>(); + +function done() { + emit('done'); + modal.value.close(); +} + +watch(() => props.showing, () => { + if (!props.showing) done(); +}); +</script> + +<style lang="scss" scoped> +.iuyakobc { + position: relative; + padding: 32px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + width: 250px; + + &.iconOnly { + padding: 0; + width: 96px; + height: 96px; + display: flex; + align-items: center; + justify-content: center; + } + + > .icon { + font-size: 32px; + + &.success { + color: var(--accent); + } + + &.waiting { + opacity: 0.7; + } + } + + > .text { + margin-top: 16px; + } +} +</style> diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue new file mode 100644 index 0000000000..fff89117ce --- /dev/null +++ b/packages/frontend/src/components/MkWidgets.vue @@ -0,0 +1,165 @@ +<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="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton> + </header> + <Sortable + :model-value="props.widgets" + item-key="id" + handle=".handle" + :animation="150" + @update:model-value="v => emit('updateWidgets', v)" + > + <template #item="{element}"> + <div class="customize-container"> + <button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button> + <button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button> + <div class="handle"> + <component :is="`mkw-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :widget="element" @update-props="updateWidget(element.id, $event)"/> + </div> + </div> + </template> + </Sortable> + </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" @update-props="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'; +import { deepClone } from '@/scripts/clone'; + +const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); + +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 }); +}; + +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: 'ti ti-settings', + text: i18n.ts.settings, + action: () => { + configWidget(widget.id); + }, + }], ev); +} +</script> + +<style lang="scss" scoped> +.vjoppmmu { + container-type: inline-size; + + > 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/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue new file mode 100644 index 0000000000..629c105810 --- /dev/null +++ b/packages/frontend/src/components/MkWindow.vue @@ -0,0 +1,571 @@ +<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" v-tooltip="i18n.ts.windowRestore" class="button _button" @click="unMaximize()"><i class="ti ti-picture-in-picture"></i></button> + <button v-else-if="canResize && !maximized" v-tooltip="i18n.ts.windowMaximize" class="button _button" @click="maximize()"><i class="ti ti-rectangle"></i></button> + <button v-if="closeButton" v-tooltip="i18n.ts.close" class="button _button" @click="close()"><i class="ti ti-x"></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'; +import { i18n } from '@/i18n'; + +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; + // どういうわけかnullになることがある + if (main == null) return; + + 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; + if (main == null) return; + + 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; + if (main == null) return; + + 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; + if (main == null) return; + + 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; + if (main == null) return; + + 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: 39px; + + &.mini { + --height: 32px; + } + + 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/frontend/src/components/MkYoutubePlayer.vue b/packages/frontend/src/components/MkYoutubePlayer.vue new file mode 100644 index 0000000000..8cd481a39c --- /dev/null +++ b/packages/frontend/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 ti ti-brand-youtube" style="margin-right: 0.5em;"></i> + <span>{{ title ?? 'YouTube' }}</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; + window.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/frontend/src/components/form/checkbox.vue b/packages/frontend/src/components/form/checkbox.vue new file mode 100644 index 0000000000..ba3b2dc146 --- /dev/null +++ b/packages/frontend/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 ti ti-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/frontend/src/components/form/folder.vue b/packages/frontend/src/components/form/folder.vue new file mode 100644 index 0000000000..1256dfcbb4 --- /dev/null +++ b/packages/frontend/src/components/form/folder.vue @@ -0,0 +1,107 @@ +<template> +<div class="dwzlatin" :class="{ opened }"> + <div class="header _button" @click="toggle"> + <span class="icon"><slot name="icon"></slot></span> + <span class="text"><slot name="label"></slot></span> + <span class="right"> + <span class="text"><slot name="suffix"></slot></span> + <i v-if="opened" class="ti ti-chevron-up icon"></i> + <i v-else class="ti ti-chevron-down icon"></i> + </span> + </div> + <KeepAlive> + <div v-if="openedAtLeastOnce" v-show="opened" class="body"> + <MkSpacer :margin-min="14" :margin-max="22"> + <slot></slot> + </MkSpacer> + </div> + </KeepAlive> +</div> +</template> + +<script lang="ts" setup> +const props = withDefaults(defineProps<{ + defaultOpen: boolean; +}>(), { + defaultOpen: false, +}); + +let opened = $ref(props.defaultOpen); +let openedAtLeastOnce = $ref(props.defaultOpen); + +const toggle = () => { + opened = !opened; + if (opened) { + openedAtLeastOnce = true; + } +}; +</script> + +<style lang="scss" scoped> +.dwzlatin { + display: block; + + > .header { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 10px 14px 10px 14px; + background: var(--buttonBg); + border-radius: 6px; + + &:hover { + text-decoration: none; + background: var(--buttonHoverBg); + } + + &.active { + color: var(--accent); + background: var(--buttonHoverBg); + } + + > .icon { + margin-right: 0.75em; + flex-shrink: 0; + text-align: center; + opacity: 0.8; + + &:empty { + display: none; + + & + .text { + padding-left: 4px; + } + } + } + + > .text { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + padding-right: 12px; + } + + > .right { + margin-left: auto; + opacity: 0.7; + white-space: nowrap; + + > .text:not(:empty) { + margin-right: 0.75em; + } + } + } + + > .body { + background: var(--panel); + border-radius: 0 0 6px 6px; + } + + &.opened { + > .header { + border-radius: 6px 6px 0 0; + } + } +} +</style> diff --git a/packages/frontend/src/components/form/input.vue b/packages/frontend/src/components/form/input.vue new file mode 100644 index 0000000000..939e9691a6 --- /dev/null +++ b/packages/frontend/src/components/form/input.vue @@ -0,0 +1,263 @@ +<template> +<div class="matxzzsk"> + <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" + v-model="v" + v-adaptive-border + :type="type" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + :step="step" + :list="id" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + > + <datalist v-if="datalist" :id="id"> + <option v-for="data in datalist" :value="data"/> + </datalist> + <div ref="suffixEl" class="suffix"><slot name="suffix"></slot></div> + </div> + <div class="caption"><slot name="caption"></slot></div> + + <MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> +</div> +</template> + +<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'; + +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; +}>(); + +const emit = defineEmits<{ + (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'keydown', _ev: KeyboardEvent): void; + (ev: 'enter'): void; + (ev: 'update:modelValue', value: string | number): void; +}>(); + +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; + +const focus = () => inputEl.value.focus(); +const onInput = (ev: KeyboardEvent) => { + changed.value = true; + emit('change', ev); +}; +const onKeydown = (ev: KeyboardEvent) => { + emit('keydown', ev); + + if (ev.code === 'Enter') { + emit('enter'); + } +}; + +const updated = () => { + changed.value = false; + if (type.value === 'number') { + emit('update:modelValue', parseFloat(v.value)); + } else { + emit('update:modelValue', v.value); + } +}; + +const debouncedUpdated = debounce(1000, updated); + +watch(modelValue, newValue => { + v.value = newValue; +}); + +watch(v, newValue => { + if (!props.manualSave) { + if (props.debounce) { + debouncedUpdated(); + } else { + updated(); + } + } + + invalid.value = inputEl.value.validity.badInput; +}); + +// このコンポーネントが作成された時、非表示状態である場合がある +// 非表示状態だと要素の幅などは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, +}); + +onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + }); +}); +</script> + +<style lang="scss" scoped> +.matxzzsk { + > .label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } + + > .input { + position: relative; + + > input { + appearance: none; + -webkit-appearance: none; + display: block; + height: v-bind("height + 'px'"); + width: 100%; + margin: 0; + padding: 0 12px; + font: inherit; + font-weight: normal; + font-size: 1em; + color: var(--fg); + background: var(--panel); + border: solid 1px var(--panel); + border-radius: 6px; + outline: none; + box-shadow: none; + box-sizing: border-box; + transition: border-color 0.1s ease-out; + + &:hover { + border-color: var(--inputBorderHover) !important; + } + } + + > .prefix, + > .suffix { + display: flex; + align-items: center; + position: absolute; + z-index: 1; + top: 0; + padding: 0 12px; + font-size: 1em; + height: v-bind("height + 'px'"); + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: inline-block; + min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + > .prefix { + left: 0; + padding-right: 6px; + } + + > .suffix { + right: 0; + padding-left: 6px; + } + + &.inline { + display: inline-block; + margin: 0; + } + + &.focused { + > input { + border-color: var(--accent) !important; + //box-shadow: 0 0 0 4px var(--focus); + } + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + } + + > .save { + margin: 8px 0 0 0; + } +} +</style> diff --git a/packages/frontend/src/components/form/link.vue b/packages/frontend/src/components/form/link.vue new file mode 100644 index 0000000000..a1775c0bdb --- /dev/null +++ b/packages/frontend/src/components/form/link.vue @@ -0,0 +1,95 @@ +<template> +<div class="ffcbddfc" :class="{ inline }"> + <a v-if="external" class="main _button" :href="to" target="_blank"> + <span class="icon"><slot name="icon"></slot></span> + <span class="text"><slot></slot></span> + <span class="right"> + <span class="text"><slot name="suffix"></slot></span> + <i class="ti ti-external-link icon"></i> + </span> + </a> + <MkA v-else class="main _button" :class="{ active }" :to="to" :behavior="behavior"> + <span class="icon"><slot name="icon"></slot></span> + <span class="text"><slot></slot></span> + <span class="right"> + <span class="text"><slot name="suffix"></slot></span> + <i class="ti ti-chevron-right icon"></i> + </span> + </MkA> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; + +const props = defineProps<{ + to: string; + active?: boolean; + external?: boolean; + behavior?: null | 'window' | 'browser' | 'modalWindow'; + inline?: boolean; +}>(); +</script> + +<style lang="scss" scoped> +.ffcbddfc { + display: block; + + &.inline { + display: inline-block; + } + + > .main { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 10px 14px; + background: var(--buttonBg); + border-radius: 6px; + font-size: 0.9em; + + &:hover { + text-decoration: none; + background: var(--buttonHoverBg); + } + + &.active { + color: var(--accent); + background: var(--buttonHoverBg); + } + + > .icon { + margin-right: 0.75em; + flex-shrink: 0; + text-align: center; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + + & + .text { + padding-left: 4px; + } + } + } + + > .text { + flex-shrink: 1; + white-space: normal; + padding-right: 12px; + text-align: center; + } + + > .right { + margin-left: auto; + opacity: 0.7; + white-space: nowrap; + + > .text:not(:empty) { + margin-right: 0.75em; + } + } + } +} +</style> diff --git a/packages/frontend/src/components/form/radio.vue b/packages/frontend/src/components/form/radio.vue new file mode 100644 index 0000000000..fcf454c77a --- /dev/null +++ b/packages/frontend/src/components/form/radio.vue @@ -0,0 +1,132 @@ +<template> +<div + v-adaptive-border + class="novjtctn" + :class="{ disabled, checked }" + :aria-checked="checked" + :aria-disabled="disabled" + @click="toggle" +> + <input + type="radio" + :disabled="disabled" + > + <span class="button"> + <span></span> + </span> + <span class="label"><slot></slot></span> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; + +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> +.novjtctn { + position: relative; + display: inline-block; + text-align: left; + cursor: pointer; + padding: 7px 10px; + min-width: 60px; + background-color: var(--panel); + background-clip: padding-box !important; + border: solid 1px var(--panel); + border-radius: 6px; + font-size: 90%; + transition: all 0.2s; + + > * { + user-select: none; + } + + &.disabled { + opacity: 0.6; + + &, * { + cursor: not-allowed !important; + } + } + + &:hover { + border-color: var(--inputBorderHover) !important; + } + + &.checked { + background-color: var(--accentedBg) !important; + border-color: var(--accentedBg) !important; + color: var(--accent); + + &, * { + cursor: default !important; + } + + > .button { + border-color: var(--accent); + + &:after { + background-color: var(--accent); + transform: scale(1); + opacity: 1; + } + } + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: absolute; + width: 14px; + height: 14px; + background: none; + border: solid 2px var(--inputBorder); + border-radius: 100%; + transition: inherit; + + &:after { + content: ''; + display: block; + position: absolute; + top: 3px; + right: 3px; + bottom: 3px; + left: 3px; + border-radius: 100%; + opacity: 0; + transform: scale(0); + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + } + } + + > .label { + margin-left: 28px; + display: block; + line-height: 20px; + cursor: pointer; + } +} +</style> diff --git a/packages/frontend/src/components/form/radios.vue b/packages/frontend/src/components/form/radios.vue new file mode 100644 index 0000000000..bde4a8fb00 --- /dev/null +++ b/packages/frontend/src/components/form/radios.vue @@ -0,0 +1,83 @@ +<script lang="ts"> +import { defineComponent, h } from 'vue'; +import MkRadio from './radio.vue'; + +export default defineComponent({ + components: { + MkRadio, + }, + props: { + modelValue: { + required: false, + }, + }, + data() { + return { + value: this.modelValue, + }; + }, + watch: { + value() { + this.$emit('update:modelValue', this.value); + }, + }, + render() { + let options = this.$slots.default(); + const label = this.$slots.label && this.$slots.label(); + const caption = this.$slots.caption && this.$slots.caption(); + + // なぜかFragmentになることがあるため + if (options.length === 1 && options[0].props == null) options = options[0].children; + + return h('div', { + class: 'novjtcto', + }, [ + ...(label ? [h('div', { + class: 'label', + }, [label])] : []), + h('div', { + 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)), + ), + ...(caption ? [h('div', { + class: 'caption', + }, [caption])] : []), + ]); + }, +}); +</script> + +<style lang="scss"> +.novjtcto { + > .label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; + + &:empty { + display: none; + } + } + + > .body { + display: flex; + gap: 12px; + flex-wrap: wrap; + } + + > .caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } +} +</style> diff --git a/packages/frontend/src/components/form/range.vue b/packages/frontend/src/components/form/range.vue new file mode 100644 index 0000000000..db21c35717 --- /dev/null +++ b/packages/frontend/src/components/form/range.vue @@ -0,0 +1,259 @@ +<template> +<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: (steppedRawValue * 100) + '%' }"></div> + </div> + <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" setup> +import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue'; +import * as os from '@/os'; + +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, +}); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: number): void; +}>(); + +const containerEl = ref<HTMLElement>(); +const thumbEl = ref<HTMLElement>(); + +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 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 steps = computed(() => { + if (props.step) { + return (props.max - props.min) / props.step; + } else { + return 0; + } +}); + +const onMousedown = (ev: MouseEvent | TouchEvent) => { + ev.preventDefault(); + + const tooltipShowing = ref(true); + os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), { + showing: tooltipShowing, + text: computed(() => { + return props.textConverter(finalValue.value); + }), + targetElement: thumbEl, + }, {}, 'closed'); + + const style = document.createElement('style'); + style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); + document.head.appendChild(style); + + 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))); + }; + + let beforeValue = finalValue.value; + + 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> +@use "sass:math"; + +.timctyfi { + position: relative; + + > .label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } + + $thumbHeight: 20px; + $thumbWidth: 20px; + + > .body { + padding: 10px 12px; + background: var(--panel); + border: solid 1px var(--panel); + border-radius: 6px; + + > .container { + position: relative; + height: $thumbHeight; + + > .track { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: auto; + width: calc(100% - #{$thumbWidth}); + height: 3px; + background: rgba(0, 0, 0, 0.1); + border-radius: 999px; + overflow: clip; + + > .highlight { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--accent); + opacity: 0.5; + } + } + + > .ticks { + $tickWidth: 3px; + + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: auto; + width: calc(100% - #{$thumbWidth}); + + > .tick { + position: absolute; + bottom: 0; + width: $tickWidth; + height: 3px; + margin-left: - math.div($tickWidth, 2); + background: var(--divider); + border-radius: 999px; + } + } + + > .thumb { + position: absolute; + width: $thumbWidth; + height: $thumbHeight; + cursor: grab; + background: var(--accent); + border-radius: 999px; + + &:hover { + background: var(--accentLighten); + } + } + } + } + + &.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/frontend/src/components/form/section.vue b/packages/frontend/src/components/form/section.vue new file mode 100644 index 0000000000..c6e34ef1cc --- /dev/null +++ b/packages/frontend/src/components/form/section.vue @@ -0,0 +1,43 @@ +<template> +<div class="vrtktovh _formBlock"> + <div class="label"><slot name="label"></slot></div> + <div class="main _formRoot"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts" setup> +</script> + +<style lang="scss" scoped> +.vrtktovh { + border-top: solid 0.5px var(--divider); + border-bottom: solid 0.5px var(--divider); + + & + .vrtktovh { + border-top: none; + } + + &:first-child { + border-top: none; + } + + &:last-child { + border-bottom: none; + } + + > .label { + font-weight: bold; + margin: 1.5em 0 16px 0; + + &:empty { + display: none; + } + } + + > .main { + margin: 1.5em 0; + } +} +</style> diff --git a/packages/frontend/src/components/form/select.vue b/packages/frontend/src/components/form/select.vue new file mode 100644 index 0000000000..eaf4b131cd --- /dev/null +++ b/packages/frontend/src/components/form/select.vue @@ -0,0 +1,279 @@ +<template> +<div class="vblkjoeq"> + <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" + v-model="v" + v-adaptive-border + class="select" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + @focus="focused = true" + @blur="focused = false" + @input="onInput" + > + <slot></slot> + </select> + <div ref="suffixEl" class="suffix"><i class="ti ti-chevron-down"></i></div> + </div> + <div class="caption"><slot name="caption"></slot></div> + + <MkButton v-if="manualSave && changed" primary @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> +</div> +</template> + +<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'; + +const props = defineProps<{ + modelValue: string; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + placeholder?: string; + autofocus?: boolean; + inline?: boolean; + manualSave?: boolean; + small?: boolean; + large?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'update:modelValue', value: string): void; +}>(); + +const slots = useSlots(); + +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; + emit('change', ev); +}; + +const updated = () => { + changed.value = false; + emit('update:modelValue', v.value); +}; + +watch(modelValue, newValue => { + v.value = newValue; +}); + +watch(v, newValue => { + if (!props.manualSave) { + updated(); + } + + invalid.value = inputEl.value.validity.badInput; +}); + +// このコンポーネントが作成された時、非表示状態である場合がある +// 非表示状態だと要素の幅などは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, +}); + +onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + }); +}); + +const onClick = (ev: MouseEvent) => { + focused.value = true; + + const menu = []; + let options = slots.default!(); + + const pushOption = (option: VNode) => { + menu.push({ + text: option.children, + active: v.value === option.props.value, + action: () => { + v.value = option.props.value; + }, + }); + }; + + 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 if (vnode.props == null) { // v-if で条件が false のときにこうなる + // nop? + } else { + const option = vnode; + pushOption(option); + } + } + }; + + scanOptions(options); + + os.popupMenu(menu, container.value, { + width: container.value.offsetWidth, + }).then(() => { + focused.value = false; + }); +}; +</script> + +<style lang="scss" scoped> +.vblkjoeq { + > .label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } + + > .input { + position: relative; + cursor: pointer; + + &:hover { + > .select { + border-color: var(--inputBorderHover) !important; + } + } + + > .select { + appearance: none; + -webkit-appearance: none; + display: block; + height: v-bind("height + 'px'"); + width: 100%; + margin: 0; + padding: 0 12px; + font: inherit; + font-weight: normal; + font-size: 1em; + color: var(--fg); + background: var(--panel); + border: solid 1px var(--panel); + border-radius: 6px; + outline: none; + box-shadow: none; + box-sizing: border-box; + cursor: pointer; + transition: border-color 0.1s ease-out; + pointer-events: none; + user-select: none; + } + + > .prefix, + > .suffix { + display: flex; + align-items: center; + position: absolute; + z-index: 1; + top: 0; + padding: 0 12px; + font-size: 1em; + height: v-bind("height + 'px'"); + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: inline-block; + min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + > .prefix { + left: 0; + padding-right: 6px; + } + + > .suffix { + right: 0; + padding-left: 6px; + } + + &.inline { + display: inline-block; + margin: 0; + } + + &.focused { + > select { + border-color: var(--accent) !important; + } + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + } +} +</style> diff --git a/packages/frontend/src/components/form/slot.vue b/packages/frontend/src/components/form/slot.vue new file mode 100644 index 0000000000..79ce8fe51f --- /dev/null +++ b/packages/frontend/src/components/form/slot.vue @@ -0,0 +1,41 @@ +<template> +<div class="adhpbeou"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="content"> + <slot></slot> + </div> + <div class="caption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; + +function focus() { + // TODO +} +</script> + +<style lang="scss" scoped> +.adhpbeou { + > .label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } +} +</style> diff --git a/packages/frontend/src/components/form/split.vue b/packages/frontend/src/components/form/split.vue new file mode 100644 index 0000000000..301a8a84e5 --- /dev/null +++ b/packages/frontend/src/components/form/split.vue @@ -0,0 +1,27 @@ +<template> +<div class="terlnhxf _formBlock"> + <slot></slot> +</div> +</template> + +<script lang="ts" setup> +const props = withDefaults(defineProps<{ + minWidth?: number; +}>(), { + minWidth: 210, +}); + +const minWidth = props.minWidth + 'px'; +</script> + +<style lang="scss" scoped> +.terlnhxf { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(v-bind('minWidth'), 1fr)); + grid-gap: 12px; + + > ::v-deep(*) { + margin: 0 !important; + } +} +</style> diff --git a/packages/frontend/src/components/form/suspense.vue b/packages/frontend/src/components/form/suspense.vue new file mode 100644 index 0000000000..7efa501f27 --- /dev/null +++ b/packages/frontend/src/components/form/suspense.vue @@ -0,0 +1,98 @@ +<template> +<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <div v-if="pending"> + <MkLoading/> + </div> + <div v-else-if="resolved"> + <slot :result="result"></slot> + </div> + <div v-else> + <div class="wszdbhzo"> + <div><i class="ti ti-alert-triangle"></i> {{ $ts.somethingHappened }}</div> + <MkButton inline class="retry" @click="retry"><i class="ti ti-reload"></i> {{ $ts.retry }}</MkButton> + </div> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent, PropType, ref, watch } from 'vue'; +import MkButton from '@/components/MkButton.vue'; + +export default defineComponent({ + components: { + MkButton, + }, + + props: { + p: { + type: Function as PropType<() => Promise<any>>, + required: true, + }, + }, + + setup(props, context) { + const pending = ref(true); + const resolved = ref(false); + const rejected = ref(false); + const result = ref(null); + + const process = () => { + if (props.p == null) { + return; + } + const promise = props.p(); + pending.value = true; + resolved.value = false; + rejected.value = false; + promise.then((_result) => { + pending.value = false; + resolved.value = true; + result.value = _result; + }); + promise.catch(() => { + pending.value = false; + rejected.value = true; + }); + }; + + watch(() => props.p, () => { + process(); + }, { + immediate: true, + }); + + const retry = () => { + process(); + }; + + return { + pending, + resolved, + rejected, + result, + retry, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.wszdbhzo { + padding: 16px; + text-align: center; + + > .retry { + margin-top: 16px; + } +} +</style> diff --git a/packages/frontend/src/components/form/switch.vue b/packages/frontend/src/components/form/switch.vue new file mode 100644 index 0000000000..1ed00ae655 --- /dev/null +++ b/packages/frontend/src/components/form/switch.vue @@ -0,0 +1,144 @@ +<template> +<div + class="ziffeomt" + :class="{ disabled, checked }" +> + <input + ref="input" + type="checkbox" + :disabled="disabled" + @keydown.enter="toggle" + > + <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の方は廃止 --> + <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 { 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) { + + } +}; +</script> + +<style lang="scss" scoped> +.ziffeomt { + 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: 32px; + height: 23px; + outline: none; + background: var(--swutchOffBg); + background-clip: content-box; + border: solid 1px var(--swutchOffBg); + border-radius: 999px; + cursor: pointer; + transition: inherit; + user-select: none; + + > .knob { + position: absolute; + top: 3px; + left: 3px; + width: 15px; + height: 15px; + background: var(--swutchOffFg); + border-radius: 999px; + 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(--swutchOnBg) !important; + border-color: var(--swutchOnBg) !important; + + > .knob { + left: 12px; + background: var(--swutchOnFg); + } + } + } +} +</style> diff --git a/packages/frontend/src/components/form/textarea.vue b/packages/frontend/src/components/form/textarea.vue new file mode 100644 index 0000000000..d34d7b1775 --- /dev/null +++ b/packages/frontend/src/components/form/textarea.vue @@ -0,0 +1,260 @@ +<template> +<div class="adhpbeos"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="input" :class="{ disabled, focused, tall, pre }"> + <textarea + ref="inputEl" + v-model="v" + v-adaptive-border + :class="{ code, _monospace: code }" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + ></textarea> + </div> + <div class="caption"><slot name="caption"></slot></div> + + <MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; +import { debounce } from 'throttle-debounce'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; + +export default defineComponent({ + components: { + MkButton, + }, + + props: { + modelValue: { + required: true, + }, + type: { + type: String, + required: false, + }, + required: { + type: Boolean, + required: false, + }, + readonly: { + type: Boolean, + required: false, + }, + disabled: { + type: Boolean, + required: false, + }, + pattern: { + type: String, + required: false, + }, + placeholder: { + type: String, + required: false, + }, + autofocus: { + type: Boolean, + required: false, + default: false, + }, + autocomplete: { + required: false, + }, + spellcheck: { + required: false, + }, + code: { + type: Boolean, + required: false, + }, + tall: { + type: Boolean, + required: false, + default: false, + }, + pre: { + type: Boolean, + required: false, + default: false, + }, + debounce: { + type: Boolean, + required: false, + default: false, + }, + manualSave: { + type: Boolean, + required: false, + default: false, + }, + }, + + emits: ['change', 'keydown', 'enter', 'update:modelValue'], + + setup(props, context) { + const { modelValue, autofocus } = toRefs(props); + const v = ref(modelValue.value); + const focused = ref(false); + const changed = ref(false); + const invalid = ref(false); + const filled = computed(() => v.value !== '' && v.value != null); + const inputEl = ref(null); + + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + const onKeydown = (ev: KeyboardEvent) => { + context.emit('keydown', ev); + + if (ev.code === 'Enter') { + context.emit('enter'); + } + }; + + const updated = () => { + changed.value = false; + context.emit('update:modelValue', v.value); + }; + + const debouncedUpdated = debounce(1000, updated); + + watch(modelValue, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + if (props.debounce) { + debouncedUpdated(); + } else { + updated(); + } + } + + invalid.value = inputEl.value.validity.badInput; + }); + + onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + }); + }); + + return { + v, + focused, + invalid, + changed, + filled, + inputEl, + focus, + onInput, + onKeydown, + updated, + i18n, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.adhpbeos { + > .label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } + + > .input { + position: relative; + + > textarea { + appearance: none; + -webkit-appearance: none; + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 130px; + margin: 0; + padding: 12px; + font: inherit; + font-weight: normal; + font-size: 1em; + color: var(--fg); + background: var(--panel); + border: solid 1px var(--panel); + border-radius: 6px; + outline: none; + box-shadow: none; + box-sizing: border-box; + transition: border-color 0.1s ease-out; + + &:hover { + border-color: var(--inputBorderHover) !important; + } + } + + &.focused { + > textarea { + border-color: var(--accent) !important; + } + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + + &.tall { + > textarea { + min-height: 200px; + } + } + + &.pre { + > textarea { + white-space: pre; + } + } + } + + > .save { + margin: 8px 0 0 0; + } +} +</style> diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue new file mode 100644 index 0000000000..5a0ba0d8d3 --- /dev/null +++ b/packages/frontend/src/components/global/MkA.vue @@ -0,0 +1,102 @@ +<template> +<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu"> + <slot></slot> +</a> +</template> + +<script lang="ts" setup> +import { inject } from 'vue'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { url } from '@/config'; +import { popout as popout_ } from '@/scripts/popout'; +import { i18n } from '@/i18n'; +import { useRouter } from '@/router'; + +const props = withDefaults(defineProps<{ + to: string; + activeClass?: null | string; + behavior?: null | 'window' | 'browser' | 'modalWindow'; +}>(), { + activeClass: null, + behavior: null, +}); + +const router = useRouter(); + +const active = $computed(() => { + if (props.activeClass == null) return false; + const resolved = router.resolve(props.to); + if (resolved == null) return false; + if (resolved.route.path === router.currentRoute.value.path) return true; + if (resolved.route.name == null) return false; + if (router.currentRoute.value.name == null) return false; + return resolved.route.name === router.currentRoute.value.name; +}); + +function onContextmenu(ev) { + const selection = window.getSelection(); + if (selection && selection.toString() !== '') return; + os.contextMenu([{ + type: 'label', + text: props.to, + }, { + icon: 'ti ti-app-window', + text: i18n.ts.openInWindow, + action: () => { + os.pageWindow(props.to); + }, + }, { + icon: 'ti ti-player-eject', + text: i18n.ts.showInPage, + action: () => { + router.push(props.to, 'forcePage'); + }, + }, null, { + icon: 'ti ti-external-link', + text: i18n.ts.openInNewTab, + action: () => { + window.open(props.to, '_blank'); + }, + }, { + icon: 'ti ti-link', + text: i18n.ts.copyLink, + action: () => { + copyToClipboard(`${url}${props.to}`); + }, + }], ev); +} + +function openWindow() { + os.pageWindow(props.to); +} + +function modalWindow() { + os.modalPageWindow(props.to); +} + +function popout() { + popout_(props.to); +} + +function nav(ev: MouseEvent) { + if (props.behavior === 'browser') { + location.href = props.to; + return; + } + + if (props.behavior) { + if (props.behavior === 'window') { + return openWindow(); + } else if (props.behavior === 'modalWindow') { + return modalWindow(); + } + } + + if (ev.shiftKey) { + return openWindow(); + } + + router.push(props.to, ev.ctrlKey ? 'forcePage' : null); +} +</script> diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue new file mode 100644 index 0000000000..c3e806b5fb --- /dev/null +++ b/packages/frontend/src/components/global/MkAcct.vue @@ -0,0 +1,27 @@ +<template> +<span class="mk-acct"> + <span class="name">@{{ user.username }}</span> + <span v-if="user.host || detail || $store.state.showFullAcct" class="host">@{{ user.host || host }}</span> +</span> +</template> + +<script lang="ts" setup> +import * as misskey from 'misskey-js'; +import { toUnicode } from 'punycode/'; +import { host as hostRaw } from '@/config'; + +defineProps<{ + user: misskey.entities.UserDetailed; + detail?: boolean; +}>(); + +const host = toUnicode(hostRaw); +</script> + +<style lang="scss" scoped> +.mk-acct { + > .host { + opacity: 0.5; + } +} +</style> diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue new file mode 100644 index 0000000000..a80efb142c --- /dev/null +++ b/packages/frontend/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="ti ti-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/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue new file mode 100644 index 0000000000..5f3e3c176d --- /dev/null +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -0,0 +1,143 @@ +<template> +<span v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :title="acct(user)" @click="onClick"> + <img class="inner" :src="url" decoding="async"/> + <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/> +</span> +<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target"> + <img class="inner" :src="url" decoding="async"/> + <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/> +</MkA> +</template> + +<script lang="ts" setup> +import { onMounted, watch } from 'vue'; +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/MkUserOnlineIndicator.vue'; +import { defaultStore } from '@/store'; + +const props = withDefaults(defineProps<{ + user: misskey.entities.User; + target?: string | null; + disableLink?: boolean; + disablePreview?: boolean; + showIndicator?: boolean; +}>(), { + target: null, + disableLink: false, + disablePreview: false, + showIndicator: false, +}); + +const emit = defineEmits<{ + (ev: 'click', v: MouseEvent): void; +}>(); + +const url = $computed(() => defaultStore.state.disableShowingAnimatedImages + ? getStaticImageUrl(props.user.avatarUrl) + : props.user.avatarUrl); + +function onClick(ev: MouseEvent) { + emit('click', ev); +} + +let color = $ref(); + +watch(() => props.user.avatarBlurhash, () => { + color = extractAvgColorFromBlurhash(props.user.avatarBlurhash); +}, { + immediate: true, +}); +</script> + +<style lang="scss" scoped> +@keyframes earwiggleleft { + from { transform: rotate(37.6deg) skew(30deg); } + 25% { transform: rotate(10deg) skew(30deg); } + 50% { transform: rotate(20deg) skew(30deg); } + 75% { transform: rotate(0deg) skew(30deg); } + to { transform: rotate(37.6deg) skew(30deg); } +} + +@keyframes earwiggleright { + from { transform: rotate(-37.6deg) skew(-30deg); } + 30% { transform: rotate(-10deg) skew(-30deg); } + 55% { transform: rotate(-20deg) skew(-30deg); } + 75% { transform: rotate(0deg) skew(-30deg); } + to { transform: rotate(-37.6deg) skew(-30deg); } +} + +.eiwwqkts { + position: relative; + display: inline-block; + vertical-align: bottom; + flex-shrink: 0; + border-radius: 100%; + line-height: 16px; + + > .inner { + position: absolute; + bottom: 0; + left: 0; + right: 0; + top: 0; + border-radius: 100%; + z-index: 1; + overflow: hidden; + object-fit: cover; + width: 100%; + height: 100%; + } + + > .indicator { + position: absolute; + z-index: 1; + bottom: 0; + left: 0; + width: 20%; + height: 20%; + } + + &.square { + border-radius: 20%; + + > .inner { + border-radius: 20%; + } + } + + &.cat { + &:before, &:after { + background: #df548f; + border: solid 4px currentColor; + box-sizing: border-box; + content: ''; + display: inline-block; + height: 50%; + width: 50%; + } + + &:before { + border-radius: 0 75% 75%; + transform: rotate(37.5deg) skew(30deg); + } + + &:after { + border-radius: 75% 0 75% 75%; + transform: rotate(-37.5deg) skew(-30deg); + } + + &:hover { + &:before { + animation: earwiggleleft 1s infinite; + } + + &:after { + animation: earwiggleright 1s infinite; + } + } + } +} +</style> diff --git a/packages/frontend/src/components/global/MkEllipsis.vue b/packages/frontend/src/components/global/MkEllipsis.vue new file mode 100644 index 0000000000..0a46f486d6 --- /dev/null +++ b/packages/frontend/src/components/global/MkEllipsis.vue @@ -0,0 +1,34 @@ +<template> + <span class="mk-ellipsis"> + <span>.</span><span>.</span><span>.</span> + </span> +</template> + +<style lang="scss" scoped> +.mk-ellipsis { + > span { + animation: ellipsis 1.4s infinite ease-in-out both; + + &:nth-child(1) { + animation-delay: 0s; + } + + &:nth-child(2) { + animation-delay: 0.16s; + } + + &:nth-child(3) { + animation-delay: 0.32s; + } + } +} + +@keyframes ellipsis { + 0%, 80%, 100% { + opacity: 1; + } + 40% { + opacity: 0; + } +} +</style> diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue new file mode 100644 index 0000000000..ce1299a39f --- /dev/null +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -0,0 +1,81 @@ +<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" decoding="async" @pointerenter="computeTitle"/> +<span v-else-if="char && useOsNativeEmojis" :alt="alt" @pointerenter="computeTitle">{{ char }}</span> +<span v-else>{{ emoji }}</span> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import { CustomEmoji } from 'misskey-js/built/entities'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base'; +import { defaultStore } from '@/store'; +import { instance } from '@/instance'; +import { getEmojiName } from '@/scripts/emojilist'; + +const props = defineProps<{ + emoji: string; + normal?: boolean; + noStyle?: boolean; + customEmojis?: CustomEmoji[]; + isReaction?: boolean; +}>(); + +const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; + +const isCustom = computed(() => props.emoji.startsWith(':')); +const char = computed(() => isCustom.value ? undefined : props.emoji); +const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native' && !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)) : undefined); +const url = computed(() => { + if (char.value) { + return char2path(char.value); + } else { + const rawUrl = (customEmoji.value as CustomEmoji).url; + return defaultStore.state.disableShowingAnimatedImages + ? getStaticImageUrl(rawUrl) + : rawUrl; + } +}); +const alt = computed(() => customEmoji.value ? `:${customEmoji.value.name}:` : char.value); + +// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter +function computeTitle(event: PointerEvent): void { + const title = customEmoji.value + ? `:${customEmoji.value.name}:` + : (getEmojiName(char.value as string) ?? char.value as string); + (event.target as HTMLElement).title = title; +} +</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/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue new file mode 100644 index 0000000000..e135d4184b --- /dev/null +++ b/packages/frontend/src/components/global/MkError.vue @@ -0,0 +1,36 @@ +<template> +<transition :name="$store.state.animation ? 'zoom' : ''" appear> + <div class="mjndxjcg"> + <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> + <p><i class="ti ti-alert-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/MkButton.vue'; +import { i18n } from '@/i18n'; +</script> + +<style lang="scss" scoped> +.mjndxjcg { + padding: 32px; + text-align: center; + + > p { + margin: 0 0 8px 0; + } + + > .button { + margin: 0 auto; + } + + > img { + vertical-align: bottom; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; + } +} +</style> diff --git a/packages/frontend/src/components/global/MkLoading.vue b/packages/frontend/src/components/global/MkLoading.vue new file mode 100644 index 0000000000..64e12e3b44 --- /dev/null +++ b/packages/frontend/src/components/global/MkLoading.vue @@ -0,0 +1,101 @@ +<template> +<div :class="[$style.root, { [$style.inline]: inline, [$style.colored]: colored, [$style.mini]: mini, [$style.em]: em }]"> + <div :class="$style.container"> + <svg :class="[$style.spinner, $style.bg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg"> + <g transform="matrix(1.125,0,0,1.125,12,12)"> + <circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/> + </g> + </svg> + <svg :class="[$style.spinner, $style.fg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg"> + <g transform="matrix(1.125,0,0,1.125,12,12)"> + <path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/> + </g> + </svg> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; + +const props = withDefaults(defineProps<{ + inline?: boolean; + colored?: boolean; + mini?: boolean; + em?: boolean; +}>(), { + inline: false, + colored: true, + mini: false, + em: false, +}); +</script> + +<style lang="scss" module> +@keyframes spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.root { + padding: 32px; + text-align: center; + cursor: wait; + + --size: 38px; + + &.colored { + color: var(--accent); + } + + &.inline { + display: inline; + padding: 0; + --size: 32px; + } + + &.mini { + padding: 16px; + --size: 32px; + } + + &.em { + display: inline-block; + vertical-align: middle; + padding: 0; + --size: 1em; + } +} + +.container { + position: relative; + width: var(--size); + height: var(--size); + margin: 0 auto; +} + +.spinner { + position: absolute; + top: 0; + left: 0; + width: var(--size); + height: var(--size); + fill-rule: evenodd; + clip-rule: evenodd; + stroke-linecap: round; + stroke-linejoin: round; + stroke-miterlimit: 1.5; +} + +.bg { + opacity: 0.275; +} + +.fg { + animation: spinner 0.5s linear infinite; +} +</style> diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue new file mode 100644 index 0000000000..70d0108e9f --- /dev/null +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue @@ -0,0 +1,191 @@ +<template> +<MfmCore :text="text" :plain="plain" :nowrap="nowrap" :author="author" :customEmojis="customEmojis" :isNote="isNote" class="havbbuyv" :class="{ nowrap }"/> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MfmCore from '@/components/mfm'; + +const props = withDefaults(defineProps<{ + text: string; + plain?: boolean; + nowrap?: boolean; + author?: any; + customEmojis?: any; + isNote?: boolean; +}>(), { + plain: false, + nowrap: false, + author: null, + isNote: true, +}); +</script> + +<style lang="scss"> +._mfm_blur_ { + filter: blur(6px); + transition: filter 0.3s; + + &:hover { + filter: blur(0px); + } +} + +.mfm-x2 { + --mfm-zoom-size: 200%; +} + +.mfm-x3 { + --mfm-zoom-size: 400%; +} + +.mfm-x4 { + --mfm-zoom-size: 600%; +} + +.mfm-x2, .mfm-x3, .mfm-x4 { + font-size: var(--mfm-zoom-size); + + .mfm-x2, .mfm-x3, .mfm-x4 { + /* only half effective */ + font-size: calc(var(--mfm-zoom-size) / 2 + 50%); + + .mfm-x2, .mfm-x3, .mfm-x4 { + /* disabled */ + font-size: 100%; + } + } +} + +@keyframes mfm-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes mfm-spinX { + 0% { transform: perspective(128px) rotateX(0deg); } + 100% { transform: perspective(128px) rotateX(360deg); } +} + +@keyframes mfm-spinY { + 0% { transform: perspective(128px) rotateY(0deg); } + 100% { transform: perspective(128px) rotateY(360deg); } +} + +@keyframes mfm-jump { + 0% { transform: translateY(0); } + 25% { transform: translateY(-16px); } + 50% { transform: translateY(0); } + 75% { transform: translateY(-8px); } + 100% { transform: translateY(0); } +} + +@keyframes mfm-bounce { + 0% { transform: translateY(0) scale(1, 1); } + 25% { transform: translateY(-16px) scale(1, 1); } + 50% { transform: translateY(0) scale(1, 1); } + 75% { transform: translateY(0) scale(1.5, 0.75); } + 100% { transform: translateY(0) scale(1, 1); } +} + +// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`; +// let css = ''; +// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } +@keyframes mfm-twitch { + 0% { transform: translate(7px, -2px) } + 5% { transform: translate(-3px, 1px) } + 10% { transform: translate(-7px, -1px) } + 15% { transform: translate(0px, -1px) } + 20% { transform: translate(-8px, 6px) } + 25% { transform: translate(-4px, -3px) } + 30% { transform: translate(-4px, -6px) } + 35% { transform: translate(-8px, -8px) } + 40% { transform: translate(4px, 6px) } + 45% { transform: translate(-3px, 1px) } + 50% { transform: translate(2px, -10px) } + 55% { transform: translate(-7px, 0px) } + 60% { transform: translate(-2px, 4px) } + 65% { transform: translate(3px, -8px) } + 70% { transform: translate(6px, 7px) } + 75% { transform: translate(-7px, -2px) } + 80% { transform: translate(-7px, -8px) } + 85% { transform: translate(9px, 3px) } + 90% { transform: translate(-3px, -2px) } + 95% { transform: translate(-10px, 2px) } + 100% { transform: translate(-2px, -6px) } +} + +// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`; +// let css = ''; +// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } +@keyframes mfm-shake { + 0% { transform: translate(-3px, -1px) rotate(-8deg) } + 5% { transform: translate(0px, -1px) rotate(-10deg) } + 10% { transform: translate(1px, -3px) rotate(0deg) } + 15% { transform: translate(1px, 1px) rotate(11deg) } + 20% { transform: translate(-2px, 1px) rotate(1deg) } + 25% { transform: translate(-1px, -2px) rotate(-2deg) } + 30% { transform: translate(-1px, 2px) rotate(-3deg) } + 35% { transform: translate(2px, 1px) rotate(6deg) } + 40% { transform: translate(-2px, -3px) rotate(-9deg) } + 45% { transform: translate(0px, -1px) rotate(-12deg) } + 50% { transform: translate(1px, 2px) rotate(10deg) } + 55% { transform: translate(0px, -3px) rotate(8deg) } + 60% { transform: translate(1px, -1px) rotate(8deg) } + 65% { transform: translate(0px, -1px) rotate(-7deg) } + 70% { transform: translate(-1px, -3px) rotate(6deg) } + 75% { transform: translate(0px, -2px) rotate(4deg) } + 80% { transform: translate(-2px, -1px) rotate(3deg) } + 85% { transform: translate(1px, -3px) rotate(-10deg) } + 90% { transform: translate(1px, 0px) rotate(3deg) } + 95% { transform: translate(-2px, 0px) rotate(-3deg) } + 100% { transform: translate(2px, 1px) rotate(2deg) } +} + +@keyframes mfm-rubberBand { + from { transform: scale3d(1, 1, 1); } + 30% { transform: scale3d(1.25, 0.75, 1); } + 40% { transform: scale3d(0.75, 1.25, 1); } + 50% { transform: scale3d(1.15, 0.85, 1); } + 65% { transform: scale3d(0.95, 1.05, 1); } + 75% { transform: scale3d(1.05, 0.95, 1); } + to { transform: scale3d(1, 1, 1); } +} + +@keyframes mfm-rainbow { + 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); } + 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); } +} +</style> + +<style lang="scss" scoped> +.havbbuyv { + white-space: pre-wrap; + + &.nowrap { + white-space: pre; + word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html + overflow: hidden; + text-overflow: ellipsis; + } + + ::v-deep(.quote) { + display: block; + margin: 8px; + padding: 6px 0 6px 12px; + color: var(--fg); + border-left: solid 3px var(--fg); + opacity: 0.7; + } + + ::v-deep(pre) { + font-size: 0.8em; + } + + > ::v-deep(code) { + font-size: 0.8em; + word-break: break-all; + padding: 4px 6px; + } +} +</style> diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue new file mode 100644 index 0000000000..a228dfe883 --- /dev/null +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -0,0 +1,368 @@ +<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"/> + <i v-else-if="metadata.icon" class="icon" :class="metadata.icon"></i> + + <div class="title"> + <MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="true" class="title"/> + <div v-else-if="metadata.title" class="title">{{ metadata.title }}</div> + <div v-if="!narrow && metadata.subtitle" class="subtitle"> + {{ metadata.subtitle }} + </div> + <div v-if="narrow && hasTabs" class="subtitle activeTab"> + {{ tabs.find(tab => tab.key === props.tab)?.title }} + <i class="chevron ti ti-chevron-down"></i> + </div> + </div> + </div> + <div v-if="!narrow || hideTitle" class="tabs"> + <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = (el as HTMLElement)" 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.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 { onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue'; +import tinycolor from 'tinycolor2'; +import { popupMenu } from '@/os'; +import { scrollToTop } from '@/scripts/scroll'; +import { globalEvents } from '@/events'; +import { injectPageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; + +type Tab = { + key: string; + title: string; + icon?: string; + iconOnly?: boolean; + onClick?: (ev: MouseEvent) => void; +}; + +const props = withDefaults(defineProps<{ + tabs?: Tab[]; + tab?: string; + actions?: { + text: string; + icon: string; + highlighted?: boolean; + handler: (ev: MouseEvent) => void; + }[]; + thin?: boolean; + displayMyAvatar?: boolean; +}>(), { + tabs: () => ([] as Tab[]) +}); + +const emit = defineEmits<{ + (ev: 'update:tab', key: string); +}>(); + +const metadata = injectPageMetadata(); + +const hideTitle = inject('shouldOmitHeaderTitle', false); +const thin_ = props.thin || inject('shouldHeaderThin', false); + +const el = $ref<HTMLElement | undefined>(undefined); +const tabRefs: Record<string, HTMLElement | null> = {}; +const tabHighlightEl = $ref<HTMLElement | null>(null); +const bg = ref<string | undefined>(undefined); +let narrow = $ref(false); +const hasTabs = $computed(() => props.tabs.length > 0); +const hasActions = $computed(() => props.actions && props.actions.length > 0); +const show = $computed(() => { + return !hideTitle || hasTabs || hasActions; +}); + +const showTabsPopup = (ev: MouseEvent) => { + if (!hasTabs) return; + if (!narrow) return; + ev.preventDefault(); + ev.stopPropagation(); + const menu = props.tabs.map(tab => ({ + text: tab.title, + icon: tab.icon, + active: tab.key != null && tab.key === props.tab, + action: (ev) => { + onTabClick(tab, ev); + }, + })); + popupMenu(menu, (ev.currentTarget ?? ev.target) as HTMLElement); +}; + +const preventDrag = (ev: TouchEvent) => { + ev.stopPropagation(); +}; + +const onClick = () => { + if (el) { + scrollToTop(el as HTMLElement, { 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); + tinyBg.setAlpha(0.85); + bg.value = tinyBg.toRgbString(); +}; + +let ro: ResizeObserver | null; + +onMounted(() => { + calcBg(); + globalEvents.on('themeChanged', calcBg); + + watch(() => [props.tab, props.tabs], () => { + nextTick(() => { + const tabEl = props.tab ? tabRefs[props.tab] : undefined; + if (tabEl && tabHighlightEl && tabEl.parentElement) { + // 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 && document.body.contains(el as HTMLElement)) { + narrow = el.parentElement.offsetWidth < 500; + } + }); + ro.observe(el.parentElement as HTMLElement); + } +}); + +onUnmounted(() => { + globalEvents.off('themeChanged', calcBg); + if (ro) ro.disconnect(); +}); +</script> + +<style lang="scss" scoped> +.fdidabkb { + --height: 52px; + display: flex; + 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: 42px; + + > .buttons { + > .button { + font-size: 0.9em; + } + } + } + + &.slim { + text-align: center; + + > .titleContainer { + flex: 1; + margin: 0 auto; + + > *:first-child { + margin-left: auto; + } + + > *:last-child { + margin-right: auto; + } + } + } + + > .buttons { + --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; + } + + &:empty { + width: var(--height); + } + + > .button { + display: flex; + align-items: center; + justify-content: center; + height: calc(var(--height) - (var(--margin) * 2)); + width: calc(var(--height) - (var(--margin) * 2)); + box-sizing: border-box; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.highlighted { + color: var(--accent); + } + } + + > .fullButton { + & + .fullButton { + margin-left: 12px; + } + } + } + + > .titleContainer { + display: flex; + align-items: center; + max-width: 400px; + overflow: auto; + white-space: nowrap; + text-align: left; + font-weight: bold; + flex-shrink: 0; + margin-left: 24px; + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: bottom; + margin: 0 8px; + pointer-events: none; + } + + > .icon { + margin-right: 8px; + width: 16px; + text-align: center; + } + + > .title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.1; + + > .subtitle { + opacity: 0.6; + font-size: 0.8em; + font-weight: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.activeTab { + text-align: center; + + > .chevron { + display: inline-block; + margin-left: 6px; + } + } + } + } + } + + > .tabs { + position: relative; + margin-left: 16px; + font-size: 0.8em; + overflow: auto; + white-space: nowrap; + + > .tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + } + + > .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/frontend/src/components/global/MkSpacer.vue b/packages/frontend/src/components/global/MkSpacer.vue new file mode 100644 index 0000000000..b3a42d77e7 --- /dev/null +++ b/packages/frontend/src/components/global/MkSpacer.vue @@ -0,0 +1,96 @@ +<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 widthHistory = [null, null] as [number | null, number | null]; +const heightHistory = [null, null] as [number | null, number | null]; +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, + }); + */ + + const width = root!.offsetWidth; + const height = root!.offsetHeight; + + //#region Prevent infinite resizing + // https://github.com/misskey-dev/misskey/issues/9076 + const pastWidth = widthHistory.pop(); + widthHistory.unshift(width); + const pastHeight = heightHistory.pop(); + heightHistory.unshift(height); + + + if (pastWidth === width && pastHeight === height) { + return; + } + //#endregion + + adjust({ + width, + height, + }); + }); + 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; + container-type: inline-size; +} +</style> diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue new file mode 100644 index 0000000000..44f4f065a6 --- /dev/null +++ b/packages/frontend/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/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue new file mode 100644 index 0000000000..f72b153f56 --- /dev/null +++ b/packages/frontend/src/components/global/MkTime.vue @@ -0,0 +1,56 @@ +<template> +<time :title="absolute"> + <template v-if="mode === 'relative'">{{ relative }}</template> + <template v-else-if="mode === 'absolute'">{{ absolute }}</template> + <template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template> +</time> +</template> + +<script lang="ts" setup> +import { onUnmounted } from 'vue'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + time: Date | string; + mode?: 'relative' | 'absolute' | 'detail'; +}>(), { + mode: 'relative', +}); + +const _time = typeof props.time === 'string' ? new Date(props.time) : props.time; +const absolute = _time.toLocaleString(); + +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 : + i18n.ts._ago.future); +}); + +function tick() { + // TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する + now = new Date(); + + tickId = window.setTimeout(() => { + window.requestAnimationFrame(tick); + }, 10000); +} + +let tickId: number; + +if (props.mode === 'relative' || props.mode === 'detail') { + tickId = window.requestAnimationFrame(tick); + + onUnmounted(() => { + window.cancelAnimationFrame(tickId); + }); +} +</script> diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue new file mode 100644 index 0000000000..9f5be96224 --- /dev/null +++ b/packages/frontend/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="ti ti-external-link 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/frontend/src/components/global/MkUserName.vue b/packages/frontend/src/components/global/MkUserName.vue new file mode 100644 index 0000000000..090de3df30 --- /dev/null +++ b/packages/frontend/src/components/global/MkUserName.vue @@ -0,0 +1,15 @@ +<template> +<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as misskey from 'misskey-js'; + +const props = withDefaults(defineProps<{ + user: misskey.entities.User; + nowrap?: boolean; +}>(), { + nowrap: true, +}); +</script> diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue new file mode 100644 index 0000000000..e21a57471c --- /dev/null +++ b/packages/frontend/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/frontend/src/components/global/i18n.ts b/packages/frontend/src/components/global/i18n.ts new file mode 100644 index 0000000000..1fd293ba10 --- /dev/null +++ b/packages/frontend/src/components/global/i18n.ts @@ -0,0 +1,42 @@ +import { h, defineComponent } from 'vue'; + +export default defineComponent({ + props: { + src: { + type: String, + required: true, + }, + tag: { + type: String, + required: false, + default: 'span', + }, + textTag: { + type: String, + required: false, + default: null, + }, + }, + render() { + let str = this.src; + const parsed = [] as (string | { arg: string; })[]; + while (true) { + const nextBracketOpen = str.indexOf('{'); + const nextBracketClose = str.indexOf('}'); + + if (nextBracketOpen === -1) { + parsed.push(str); + break; + } else { + if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen)); + parsed.push({ + arg: str.substring(nextBracketOpen + 1, nextBracketClose), + }); + } + + str = str.substr(nextBracketClose + 1); + } + + 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/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts new file mode 100644 index 0000000000..8639257003 --- /dev/null +++ b/packages/frontend/src/components/index.ts @@ -0,0 +1,61 @@ +import { App } from '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/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); + app.component('RouterView', RouterView); + app.component('Mfm', Mfm); + app.component('MkA', MkA); + app.component('MkAcct', MkAcct); + app.component('MkAvatar', MkAvatar); + app.component('MkEmoji', MkEmoji); + app.component('MkUserName', MkUserName); + app.component('MkEllipsis', MkEllipsis); + app.component('MkTime', MkTime); + app.component('MkUrl', MkUrl); + app.component('MkLoading', MkLoading); + app.component('MkError', MkError); + app.component('MkAd', MkAd); + app.component('MkPageHeader', MkPageHeader); + app.component('MkSpacer', MkSpacer); + app.component('MkStickyContainer', MkStickyContainer); +} + +declare module '@vue/runtime-core' { + export interface GlobalComponents { + I18n: typeof I18n; + RouterView: typeof RouterView; + Mfm: typeof Mfm; + MkA: typeof MkA; + MkAcct: typeof MkAcct; + MkAvatar: typeof MkAvatar; + MkEmoji: typeof MkEmoji; + MkUserName: typeof MkUserName; + MkEllipsis: typeof MkEllipsis; + MkTime: typeof MkTime; + MkUrl: typeof MkUrl; + MkLoading: typeof MkLoading; + MkError: typeof MkError; + MkAd: typeof MkAd; + MkPageHeader: typeof MkPageHeader; + MkSpacer: typeof MkSpacer; + MkStickyContainer: typeof MkStickyContainer; + } +} diff --git a/packages/frontend/src/components/mfm.ts b/packages/frontend/src/components/mfm.ts new file mode 100644 index 0000000000..5b5b1caae3 --- /dev/null +++ b/packages/frontend/src/components/mfm.ts @@ -0,0 +1,331 @@ +import { VNode, defineComponent, h } from 'vue'; +import * as mfm from 'mfm-js'; +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/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'; + +export default defineComponent({ + props: { + text: { + type: String, + required: true, + }, + plain: { + type: Boolean, + default: false, + }, + nowrap: { + type: Boolean, + default: false, + }, + author: { + type: Object, + default: null, + }, + i: { + type: Object, + default: null, + }, + customEmojis: { + required: false, + }, + isNote: { + type: Boolean, + default: true, + }, + }, + + render() { + if (this.text == null || this.text === '') return; + + const ast = (this.plain ? mfm.parseSimple : mfm.parse)(this.text, { fnNameList: MFM_TAGS }); + + const validTime = (t: string | null | undefined) => { + if (t == null) return null; + return t.match(/^[0-9.]+s$/) ? t : null; + }; + + const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode | string | (VNode | string)[] => { + switch (token.type) { + case 'text': { + const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); + + if (!this.plain) { + const res: (VNode | string)[] = []; + for (const t of text.split('\n')) { + res.push(h('br')); + res.push(t); + } + res.shift(); + return res; + } else { + return [text.replace(/\n/g, ' ')]; + } + } + + case 'bold': { + return [h('b', genEl(token.children))]; + } + + case 'strike': { + return [h('del', genEl(token.children))]; + } + + case 'italic': { + return h('i', { + style: 'font-style: oblique;', + }, genEl(token.children)); + } + + case 'fn': { + // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる + let style; + switch (token.props.name) { + case 'tada': { + const speed = validTime(token.props.args.speed) || '1s'; + style = 'font-size: 150%;' + (this.$store.state.animatedMfm ? `animation: tada ${speed} linear infinite both;` : ''); + break; + } + case 'jelly': { + const speed = validTime(token.props.args.speed) || '1s'; + style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : ''); + break; + } + case 'twitch': { + const speed = validTime(token.props.args.speed) || '0.5s'; + style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : ''; + break; + } + case 'shake': { + const speed = validTime(token.props.args.speed) || '0.5s'; + style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : ''; + break; + } + case 'spin': { + const direction = + token.props.args.left ? 'reverse' : + token.props.args.alternate ? 'alternate' : + 'normal'; + const anime = + token.props.args.x ? 'mfm-spinX' : + token.props.args.y ? 'mfm-spinY' : + 'mfm-spin'; + const speed = validTime(token.props.args.speed) || '1.5s'; + style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : ''; + break; + } + case 'jump': { + const speed = validTime(token.props.args.speed) || '0.75s'; + style = this.$store.state.animatedMfm ? `animation: mfm-jump ${speed} linear infinite;` : ''; + break; + } + case 'bounce': { + const speed = validTime(token.props.args.speed) || '0.75s'; + style = this.$store.state.animatedMfm ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : ''; + break; + } + case 'flip': { + const transform = + (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' : + token.props.args.v ? 'scaleY(-1)' : + 'scaleX(-1)'; + style = `transform: ${transform};`; + break; + } + case 'x2': { + return h('span', { + class: 'mfm-x2', + }, genEl(token.children)); + } + case 'x3': { + return h('span', { + class: 'mfm-x3', + }, genEl(token.children)); + } + case 'x4': { + return h('span', { + class: 'mfm-x4', + }, genEl(token.children)); + } + case 'font': { + const family = + token.props.args.serif ? 'serif' : + token.props.args.monospace ? 'monospace' : + token.props.args.cursive ? 'cursive' : + token.props.args.fantasy ? 'fantasy' : + token.props.args.emoji ? 'emoji' : + token.props.args.math ? 'math' : + null; + if (family) style = `font-family: ${family};`; + break; + } + case 'blur': { + return h('span', { + class: '_mfm_blur_', + }, genEl(token.children)); + } + case 'rainbow': { + const speed = validTime(token.props.args.speed) || '1s'; + style = this.$store.state.animatedMfm ? `animation: mfm-rainbow ${speed} linear infinite;` : ''; + break; + } + case 'sparkle': { + if (!this.$store.state.animatedMfm) { + return genEl(token.children); + } + return h(MkSparkle, {}, genEl(token.children)); + } + case 'rotate': { + const degrees = parseInt(token.props.args.deg) || '90'; + style = `transform: rotate(${degrees}deg); transform-origin: center center;`; + break; + } + } + if (style == null) { + return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']); + } else { + return h('span', { + style: 'display: inline-block;' + style, + }, genEl(token.children)); + } + } + + case 'small': { + return [h('small', { + style: 'opacity: 0.7;', + }, genEl(token.children))]; + } + + case 'center': { + return [h('div', { + style: 'text-align:center;', + }, genEl(token.children))]; + } + + case 'url': { + return [h(MkUrl, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + })]; + } + + case 'link': { + return [h(MkLink, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + }, genEl(token.children))]; + } + + case 'mention': { + 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, + })]; + } + + case 'hashtag': { + return [h(MkA, { + key: Math.random(), + to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`, + style: 'color:var(--hashtag);', + }, `#${token.props.hashtag}`)]; + } + + case 'blockCode': { + return [h(MkCode, { + key: Math.random(), + code: token.props.code, + lang: token.props.lang, + })]; + } + + case 'inlineCode': { + return [h(MkCode, { + key: Math.random(), + code: token.props.code, + inline: true, + })]; + } + + case 'quote': { + if (!this.nowrap) { + return [h('div', { + class: 'quote', + }, genEl(token.children))]; + } else { + return [h('span', { + class: 'quote', + }, genEl(token.children))]; + } + } + + case 'emojiCode': { + return [h(MkEmoji, { + key: Math.random(), + emoji: `:${token.props.name}:`, + customEmojis: this.customEmojis, + normal: this.plain, + })]; + } + + case 'unicodeEmoji': { + return [h(MkEmoji, { + key: Math.random(), + emoji: token.props.emoji, + customEmojis: this.customEmojis, + normal: this.plain, + })]; + } + + case 'mathInline': { + return [h(MkFormula, { + key: Math.random(), + formula: token.props.formula, + block: false, + })]; + } + + case 'mathBlock': { + return [h(MkFormula, { + key: Math.random(), + formula: token.props.formula, + block: true, + })]; + } + + case 'search': { + return [h(MkGoogle, { + key: Math.random(), + q: token.props.query, + })]; + } + + case 'plain': { + return [h('span', genEl(token.children))]; + } + + default: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + console.error('unrecognized ast type:', (token as any).type); + + return []; + } + } + }).flat(Infinity) as (VNode | string)[]; + + // Parse ast to DOM + return h('span', genEl(ast)); + }, +}); diff --git a/packages/frontend/src/components/page/page.block.vue b/packages/frontend/src/components/page/page.block.vue new file mode 100644 index 0000000000..f3e7764604 --- /dev/null +++ b/packages/frontend/src/components/page/page.block.vue @@ -0,0 +1,44 @@ +<template> +<component :is="'x-' + block.type" :key="block.id" :block="block" :hpml="hpml" :h="h"/> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; +import XText from './page.text.vue'; +import XSection from './page.section.vue'; +import XImage from './page.image.vue'; +import XButton from './page.button.vue'; +import XNumberInput from './page.number-input.vue'; +import XTextInput from './page.text-input.vue'; +import XTextareaInput from './page.textarea-input.vue'; +import XSwitch from './page.switch.vue'; +import XIf from './page.if.vue'; +import XTextarea from './page.textarea.vue'; +import XPost from './page.post.vue'; +import XCounter from './page.counter.vue'; +import XRadioButton from './page.radio-button.vue'; +import XCanvas from './page.canvas.vue'; +import XNote from './page.note.vue'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { Block } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton, XCanvas, XNote, + }, + props: { + block: { + type: Object as PropType<Block>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + h: { + type: Number, + required: true, + }, + }, +}); +</script> diff --git a/packages/frontend/src/components/page/page.button.vue b/packages/frontend/src/components/page/page.button.vue new file mode 100644 index 0000000000..83931021d8 --- /dev/null +++ b/packages/frontend/src/components/page/page.button.vue @@ -0,0 +1,66 @@ +<template> +<div> + <MkButton class="kudkigyw" :primary="block.primary" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent, PropType, unref } from 'vue'; +import MkButton from '../MkButton.vue'; +import * as os from '@/os'; +import { ButtonBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + components: { + MkButton, + }, + props: { + block: { + type: Object as PropType<ButtonBlock>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + }, + methods: { + click() { + if (this.block.action === 'dialog') { + this.hpml.eval(); + os.alert({ + text: this.hpml.interpolate(this.block.content), + }); + } else if (this.block.action === 'resetRandom') { + this.hpml.updateRandomSeed(Math.random()); + this.hpml.eval(); + } else if (this.block.action === 'pushEvent') { + os.api('page-push', { + pageId: this.hpml.page.id, + event: this.block.event, + ...(this.block.var ? { + var: unref(this.hpml.vars)[this.block.var], + } : {}), + }); + + os.alert({ + type: 'success', + text: this.hpml.interpolate(this.block.message), + }); + } else if (this.block.action === 'callAiScript') { + this.hpml.callAiScript(this.block.fn); + } + }, + }, +}); +</script> + +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 200px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/packages/frontend/src/components/page/page.canvas.vue b/packages/frontend/src/components/page/page.canvas.vue new file mode 100644 index 0000000000..80f6c8339c --- /dev/null +++ b/packages/frontend/src/components/page/page.canvas.vue @@ -0,0 +1,49 @@ +<template> +<div class="ysrxegms"> + <canvas ref="canvas" :width="block.width" :height="block.height"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, PropType, Ref, ref } from 'vue'; +import * as os from '@/os'; +import { CanvasBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + props: { + block: { + type: Object as PropType<CanvasBlock>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + }, + setup(props, ctx) { + const canvas: Ref<any> = ref(null); + + onMounted(() => { + props.hpml.registerCanvas(props.block.name, canvas.value); + }); + + return { + canvas, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.ysrxegms { + display: inline-block; + vertical-align: bottom; + overflow: auto; + max-width: 100%; + + > canvas { + display: block; + } +} +</style> diff --git a/packages/frontend/src/components/page/page.counter.vue b/packages/frontend/src/components/page/page.counter.vue new file mode 100644 index 0000000000..a9e1f41a54 --- /dev/null +++ b/packages/frontend/src/components/page/page.counter.vue @@ -0,0 +1,52 @@ +<template> +<div> + <MkButton class="llumlmnx" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkButton from '../MkButton.vue'; +import * as os from '@/os'; +import { CounterVarBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + components: { + MkButton, + }, + props: { + block: { + type: Object as PropType<CounterVarBlock>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function click() { + props.hpml.updatePageVar(props.block.name, value.value + (props.block.inc || 1)); + props.hpml.eval(); + } + + return { + click, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.llumlmnx { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/packages/frontend/src/components/page/page.if.vue b/packages/frontend/src/components/page/page.if.vue new file mode 100644 index 0000000000..372a15f0c6 --- /dev/null +++ b/packages/frontend/src/components/page/page.if.vue @@ -0,0 +1,31 @@ +<template> +<div v-show="hpml.vars.value[block.var]"> + <XBlock v-for="child in block.children" :key="child.id" :block="child" :hpml="hpml" :h="h"/> +</div> +</template> + +<script lang="ts"> +import { IfBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { defineComponent, defineAsyncComponent, PropType } from 'vue'; + +export default defineComponent({ + components: { + XBlock: defineAsyncComponent(() => import('./page.block.vue')), + }, + props: { + block: { + type: Object as PropType<IfBlock>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + h: { + type: Number, + required: true, + }, + }, +}); +</script> diff --git a/packages/frontend/src/components/page/page.image.vue b/packages/frontend/src/components/page/page.image.vue new file mode 100644 index 0000000000..8ba70c5855 --- /dev/null +++ b/packages/frontend/src/components/page/page.image.vue @@ -0,0 +1,28 @@ +<template> +<div class="lzyxtsnt"> + <ImgWithBlurhash v-if="image" :hash="image.blurhash" :src="image.url" :alt="image.comment" :title="image.comment" :cover="false"/> +</div> +</template> + +<script lang="ts" setup> +import { defineComponent, PropType } from 'vue'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import * as os from '@/os'; +import { ImageBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +const props = defineProps<{ + block: PropType<ImageBlock>, + hpml: PropType<Hpml>, +}>(); + +const image = props.hpml.page.attachedFiles.find(x => x.id === props.block.fileId); +</script> + +<style lang="scss" scoped> +.lzyxtsnt { + > img { + max-width: 100%; + } +} +</style> diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue new file mode 100644 index 0000000000..7d5c484a1b --- /dev/null +++ b/packages/frontend/src/components/page/page.note.vue @@ -0,0 +1,47 @@ +<template> +<div class="voxdxuby"> + <XNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/> + <XNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, PropType, Ref, ref } from 'vue'; +import XNote from '@/components/MkNote.vue'; +import XNoteDetailed from '@/components/MkNoteDetailed.vue'; +import * as os from '@/os'; +import { NoteBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + XNote, + XNoteDetailed, + }, + props: { + block: { + type: Object as PropType<NoteBlock>, + required: true, + }, + }, + setup(props, ctx) { + const note: Ref<Record<string, any> | null> = ref(null); + + onMounted(() => { + os.api('notes/show', { noteId: props.block.note }) + .then(result => { + note.value = result; + }); + }); + + return { + note, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.voxdxuby { + margin: 1em 0; +} +</style> diff --git a/packages/frontend/src/components/page/page.number-input.vue b/packages/frontend/src/components/page/page.number-input.vue new file mode 100644 index 0000000000..50cf6d0770 --- /dev/null +++ b/packages/frontend/src/components/page/page.number-input.vue @@ -0,0 +1,55 @@ +<template> +<div> + <MkInput class="kudkigyw" :model-value="value" type="number" @update:model-value="updateValue($event)"> + <template #label>{{ hpml.interpolate(block.text) }}</template> + </MkInput> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkInput from '../form/input.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { NumberInputVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkInput, + }, + props: { + block: { + type: Object as PropType<NumberInputVarBlock>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/packages/frontend/src/components/page/page.post.vue b/packages/frontend/src/components/page/page.post.vue new file mode 100644 index 0000000000..0ef50d65cd --- /dev/null +++ b/packages/frontend/src/components/page/page.post.vue @@ -0,0 +1,109 @@ +<template> +<div class="ngbfujlo"> + <MkTextarea :model-value="text" readonly style="margin: 0;"></MkTextarea> + <MkButton class="button" primary :disabled="posting || posted" @click="post()"> + <i v-if="posted" class="ti ti-check"></i> + <i v-else class="ti ti-send"></i> + </MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; +import MkTextarea from '../form/textarea.vue'; +import MkButton from '../MkButton.vue'; +import { apiUrl } from '@/config'; +import * as os from '@/os'; +import { PostBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + components: { + MkTextarea, + MkButton, + }, + props: { + block: { + type: Object as PropType<PostBlock>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + }, + data() { + return { + text: this.hpml.interpolate(this.block.text), + posted: false, + posting: false, + }; + }, + watch: { + 'hpml.vars': { + handler() { + this.text = this.hpml.interpolate(this.block.text); + }, + deep: true, + }, + }, + methods: { + upload() { + const promise = new Promise((ok) => { + const canvas = this.hpml.canvases[this.block.canvasId]; + canvas.toBlob(blob => { + const formData = new FormData(); + formData.append('file', blob); + formData.append('i', this.$i.token); + if (this.$store.state.uploadFolder) { + formData.append('folderId', this.$store.state.uploadFolder); + } + + window.fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: formData, + }) + .then(response => response.json()) + .then(f => { + ok(f); + }); + }); + }); + os.promiseDialog(promise); + return promise; + }, + async post() { + this.posting = true; + const file = this.block.attachCanvasImage ? await this.upload() : null; + os.apiWithDialog('notes/create', { + text: this.text === '' ? null : this.text, + fileIds: file ? [file.id] : undefined, + }).then(() => { + this.posted = true; + }); + }, + }, +}); +</script> + +<style lang="scss" scoped> +.ngbfujlo { + position: relative; + padding: 32px; + border-radius: 6px; + box-shadow: 0 2px 8px var(--shadow); + z-index: 1; + + > .button { + margin-top: 32px; + } + + @media (max-width: 600px) { + padding: 16px; + + > .button { + margin-top: 16px; + } + } +} +</style> diff --git a/packages/frontend/src/components/page/page.radio-button.vue b/packages/frontend/src/components/page/page.radio-button.vue new file mode 100644 index 0000000000..b4d9e01a54 --- /dev/null +++ b/packages/frontend/src/components/page/page.radio-button.vue @@ -0,0 +1,45 @@ +<template> +<div> + <div>{{ hpml.interpolate(block.title) }}</div> + <MkRadio v-for="item in block.values" :key="item" :modelValue="value" :value="item" @update:model-value="updateValue($event)">{{ item }}</MkRadio> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkRadio from '../form/radio.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { RadioButtonVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkRadio, + }, + props: { + block: { + type: Object as PropType<RadioButtonVarBlock>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue: string) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue, + }; + }, +}); +</script> diff --git a/packages/frontend/src/components/page/page.section.vue b/packages/frontend/src/components/page/page.section.vue new file mode 100644 index 0000000000..630c1f5179 --- /dev/null +++ b/packages/frontend/src/components/page/page.section.vue @@ -0,0 +1,60 @@ +<template> +<section class="sdgxphyu"> + <component :is="'h' + h">{{ block.title }}</component> + + <div class="children"> + <XBlock v-for="child in block.children" :key="child.id" :block="child" :hpml="hpml" :h="h + 1"/> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, PropType } from 'vue'; +import * as os from '@/os'; +import { SectionBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + components: { + XBlock: defineAsyncComponent(() => import('./page.block.vue')), + }, + props: { + block: { + type: Object as PropType<SectionBlock>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + h: { + required: true, + }, + }, +}); +</script> + +<style lang="scss" scoped> +.sdgxphyu { + margin: 1.5em 0; + + > h2 { + font-size: 1.35em; + margin: 0 0 0.5em 0; + } + + > h3 { + font-size: 1em; + margin: 0 0 0.5em 0; + } + + > h4 { + font-size: 1em; + margin: 0 0 0.5em 0; + } + + > .children { + //padding 16px + } +} +</style> diff --git a/packages/frontend/src/components/page/page.switch.vue b/packages/frontend/src/components/page/page.switch.vue new file mode 100644 index 0000000000..64dc4ff8aa --- /dev/null +++ b/packages/frontend/src/components/page/page.switch.vue @@ -0,0 +1,55 @@ +<template> +<div class="hkcxmtwj"> + <MkSwitch :model-value="value" @update:model-value="updateValue($event)">{{ hpml.interpolate(block.text) }}</MkSwitch> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkSwitch from '../form/switch.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { SwitchVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkSwitch, + }, + props: { + block: { + type: Object as PropType<SwitchVarBlock>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue: boolean) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.hkcxmtwj { + display: inline-block; + margin: 16px auto; + + & + .hkcxmtwj { + margin-left: 16px; + } +} +</style> diff --git a/packages/frontend/src/components/page/page.text-input.vue b/packages/frontend/src/components/page/page.text-input.vue new file mode 100644 index 0000000000..840649ece6 --- /dev/null +++ b/packages/frontend/src/components/page/page.text-input.vue @@ -0,0 +1,55 @@ +<template> +<div> + <MkInput class="kudkigyw" :model-value="value" type="text" @update:model-value="updateValue($event)"> + <template #label>{{ hpml.interpolate(block.text) }}</template> + </MkInput> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkInput from '../form/input.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { TextInputVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkInput, + }, + props: { + block: { + type: Object as PropType<TextInputVarBlock>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue new file mode 100644 index 0000000000..689c484521 --- /dev/null +++ b/packages/frontend/src/components/page/page.text.vue @@ -0,0 +1,68 @@ +<template> +<div class="mrdgzndn"> + <Mfm :key="text" :text="text" :is-note="false" :i="$i"/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" class="url"/> +</div> +</template> + +<script lang="ts"> +import { TextBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { defineAsyncComponent, defineComponent, PropType } from 'vue'; +import * as mfm from 'mfm-js'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; + +export default defineComponent({ + components: { + MkUrlPreview: defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')), + }, + props: { + block: { + type: Object as PropType<TextBlock>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + }, + data() { + return { + text: this.hpml.interpolate(this.block.text), + }; + }, + computed: { + urls(): string[] { + if (this.text) { + return extractUrlFromMfm(mfm.parse(this.text)); + } else { + return []; + } + }, + }, + watch: { + 'hpml.vars': { + handler() { + this.text = this.hpml.interpolate(this.block.text); + }, + deep: true, + }, + }, +}); +</script> + +<style lang="scss" scoped> +.mrdgzndn { + &:not(:first-child) { + margin-top: 0.5em; + } + + &:not(:last-child) { + margin-bottom: 0.5em; + } + + > .url { + margin: 0.5em 0; + } +} +</style> diff --git a/packages/frontend/src/components/page/page.textarea-input.vue b/packages/frontend/src/components/page/page.textarea-input.vue new file mode 100644 index 0000000000..507e1bd97b --- /dev/null +++ b/packages/frontend/src/components/page/page.textarea-input.vue @@ -0,0 +1,47 @@ +<template> +<div> + <MkTextarea :model-value="value" @update:model-value="updateValue($event)"> + <template #label>{{ hpml.interpolate(block.text) }}</template> + </MkTextarea> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkTextarea from '../form/textarea.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { HpmlTextInput } from '@/scripts/hpml'; +import { TextInputVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkTextarea, + }, + props: { + block: { + type: Object as PropType<TextInputVarBlock>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue, + }; + }, +}); +</script> diff --git a/packages/frontend/src/components/page/page.textarea.vue b/packages/frontend/src/components/page/page.textarea.vue new file mode 100644 index 0000000000..f809925081 --- /dev/null +++ b/packages/frontend/src/components/page/page.textarea.vue @@ -0,0 +1,39 @@ +<template> +<MkTextarea :model-value="text" readonly></MkTextarea> +</template> + +<script lang="ts"> +import { TextBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { defineComponent, PropType } from 'vue'; +import MkTextarea from '../form/textarea.vue'; + +export default defineComponent({ + components: { + MkTextarea, + }, + props: { + block: { + type: Object as PropType<TextBlock>, + required: true, + }, + hpml: { + type: Object as PropType<Hpml>, + required: true, + }, + }, + data() { + return { + text: this.hpml.interpolate(this.block.text), + }; + }, + watch: { + 'hpml.vars': { + handler() { + this.text = this.hpml.interpolate(this.block.text); + }, + deep: true, + }, + }, +}); +</script> diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue new file mode 100644 index 0000000000..b5cb73c009 --- /dev/null +++ b/packages/frontend/src/components/page/page.vue @@ -0,0 +1,85 @@ +<template> +<div v-if="hpml" class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }"> + <XBlock v-for="child in page.content" :key="child.id" :block="child" :hpml="hpml" :h="2"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, nextTick, onUnmounted, PropType } from 'vue'; +import { parse } from '@syuilo/aiscript'; +import XBlock from './page.block.vue'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { url } from '@/config'; +import { $i } from '@/account'; +import { defaultStore } from '@/store'; + +export default defineComponent({ + components: { + XBlock, + }, + props: { + page: { + type: Object as PropType<Record<string, any>>, + required: true, + }, + }, + setup(props, ctx) { + const hpml = new Hpml(props.page, { + randomSeed: Math.random(), + visitor: $i, + url: url, + enableAiScript: !defaultStore.state.disablePagesScript, + }); + + onMounted(() => { + nextTick(() => { + if (props.page.script && hpml.aiscript) { + let ast; + try { + ast = parse(props.page.script); + } catch (err) { + console.error(err); + /*os.alert({ + type: 'error', + text: 'Syntax error :(' + });*/ + return; + } + hpml.aiscript.exec(ast).then(() => { + hpml.eval(); + }).catch(err => { + console.error(err); + /*os.alert({ + type: 'error', + text: err + });*/ + }); + } else { + hpml.eval(); + } + }); + onUnmounted(() => { + if (hpml.aiscript) hpml.aiscript.abort(); + }); + }); + + return { + hpml, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.iroscrza { + &.serif { + > div { + font-family: serif; + } + } + + &.center { + text-align: center; + } +} +</style> |