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 | |
| 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')
534 files changed, 68428 insertions, 0 deletions
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts new file mode 100644 index 0000000000..0e991cdfb5 --- /dev/null +++ b/packages/frontend/src/account.ts @@ -0,0 +1,238 @@ +import { defineAsyncComponent, reactive } from 'vue'; +import * as misskey from 'misskey-js'; +import { showSuspendedDialog } from './scripts/show-suspended-dialog'; +import { i18n } from './i18n'; +import { del, get, set } from '@/scripts/idb-proxy'; +import { apiUrl } from '@/config'; +import { waiting, api, popup, popupMenu, success, alert } from '@/os'; +import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; + +// TODO: 他のタブと永続化されたstateを同期 + +type Account = misskey.entities.MeDetailed; + +const accountData = localStorage.getItem('account'); + +// TODO: 外部からはreadonlyに +export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null; + +export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator); +export const iAmAdmin = $i != null && $i.isAdmin; + +export async function signout() { + waiting(); + localStorage.removeItem('account'); + + await removeAccount($i.id); + + const accounts = await getAccounts(); + + //#region Remove service worker registration + try { + if (navigator.serviceWorker.controller) { + const registration = await navigator.serviceWorker.ready; + const push = await registration.pushManager.getSubscription(); + if (push) { + await window.fetch(`${apiUrl}/sw/unregister`, { + method: 'POST', + body: JSON.stringify({ + i: $i.token, + endpoint: push.endpoint, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + } + } + + if (accounts.length === 0) { + await navigator.serviceWorker.getRegistrations() + .then(registrations => { + return Promise.all(registrations.map(registration => registration.unregister())); + }); + } + } catch (err) {} + //#endregion + + document.cookie = 'igi=; path=/'; + + if (accounts.length > 0) login(accounts[0].token); + else unisonReload('/'); +} + +export async function getAccounts(): Promise<{ id: Account['id'], token: Account['token'] }[]> { + return (await get('accounts')) || []; +} + +export async function addAccount(id: Account['id'], token: Account['token']) { + const accounts = await getAccounts(); + if (!accounts.some(x => x.id === id)) { + await set('accounts', accounts.concat([{ id, token }])); + } +} + +export async function removeAccount(id: Account['id']) { + const accounts = await getAccounts(); + accounts.splice(accounts.findIndex(x => x.id === id), 1); + + if (accounts.length > 0) await set('accounts', accounts); + else await del('accounts'); +} + +function fetchAccount(token: string): Promise<Account> { + return new Promise((done, fail) => { + // Fetch user + window.fetch(`${apiUrl}/i`, { + method: 'POST', + body: JSON.stringify({ + i: token, + }), + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(res => res.json()) + .then(res => { + if (res.error) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + showSuspendedDialog().then(() => { + signout(); + }); + } else { + alert({ + type: 'error', + title: i18n.ts.failedToFetchAccountInformation, + text: JSON.stringify(res.error), + }); + } + } else { + res.token = token; + done(res); + } + }) + .catch(fail); + }); +} + +export function updateAccount(accountData) { + for (const [key, value] of Object.entries(accountData)) { + $i[key] = value; + } + localStorage.setItem('account', JSON.stringify($i)); +} + +export function refreshAccount() { + return fetchAccount($i.token).then(updateAccount); +} + +export async function login(token: Account['token'], redirect?: string) { + waiting(); + if (_DEV_) console.log('logging as token ', token); + const me = await fetchAccount(token); + localStorage.setItem('account', JSON.stringify(me)); + document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う + await addAccount(me.id, token); + + if (redirect) { + // 他のタブは再読み込みするだけ + reloadChannel.postMessage(null); + // このページはredirectで指定された先に移動 + location.href = redirect; + return; + } + + unisonReload(); +} + +export async function openAccountMenu(opts: { + includeCurrentAccount?: boolean; + withExtraOperation: boolean; + active?: misskey.entities.UserDetailed['id']; + onChoose?: (account: misskey.entities.UserDetailed) => void; +}, ev: MouseEvent) { + function showSigninDialog() { + popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: res => { + addAccount(res.id, res.i); + success(); + }, + }, 'closed'); + } + + function createAccount() { + popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { + done: res => { + addAccount(res.id, res.i); + switchAccountWithToken(res.i); + }, + }, 'closed'); + } + + async function switchAccount(account: misskey.entities.UserDetailed) { + const storedAccounts = await getAccounts(); + const token = storedAccounts.find(x => x.id === account.id).token; + switchAccountWithToken(token); + } + + function switchAccountWithToken(token: string) { + login(token); + } + + const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id)); + const accountsPromise = api('users/show', { userIds: storedAccounts.map(x => x.id) }); + + function createItem(account: misskey.entities.UserDetailed) { + return { + type: 'user', + user: account, + active: opts.active != null ? opts.active === account.id : false, + action: () => { + if (opts.onChoose) { + opts.onChoose(account); + } else { + switchAccount(account); + } + }, + }; + } + + const accountItemPromises = storedAccounts.map(a => new Promise(res => { + accountsPromise.then(accounts => { + const account = accounts.find(x => x.id === a.id); + if (account == null) return res(null); + res(createItem(account)); + }); + })); + + if (opts.withExtraOperation) { + popupMenu([...[{ + type: 'link', + text: i18n.ts.profile, + to: `/@${ $i.username }`, + avatar: $i, + }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { + type: 'parent', + icon: 'ti ti-plus', + text: i18n.ts.addAccount, + children: [{ + text: i18n.ts.existingAccount, + action: () => { showSigninDialog(); }, + }, { + text: i18n.ts.createAccount, + action: () => { createAccount(); }, + }], + }, { + type: 'link', + icon: 'ti ti-users', + text: i18n.ts.manageAccounts, + to: '/settings/accounts', + }]], ev.currentTarget ?? ev.target, { + align: 'left', + }); + } else { + popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, { + align: 'left', + }); + } +} 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> diff --git a/packages/frontend/src/config.ts b/packages/frontend/src/config.ts new file mode 100644 index 0000000000..f2022b0f02 --- /dev/null +++ b/packages/frontend/src/config.ts @@ -0,0 +1,15 @@ +const address = new URL(location.href); +const siteName = (document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement)?.content; + +export const host = address.host; +export const hostname = address.hostname; +export const url = address.origin; +export const apiUrl = url + '/api'; +export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming'; +export const lang = localStorage.getItem('lang'); +export const langs = _LANGS_; +export const locale = JSON.parse(localStorage.getItem('locale')); +export const version = _VERSION_; +export const instanceName = siteName === 'Misskey' ? host : siteName; +export const ui = localStorage.getItem('ui'); +export const debug = localStorage.getItem('debug') === 'true'; diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts new file mode 100644 index 0000000000..77366cf07b --- /dev/null +++ b/packages/frontend/src/const.ts @@ -0,0 +1,45 @@ +// ブラウザで直接表示することを許可するファイルの種類のリスト +// ここに含まれないものは application/octet-stream としてレスポンスされる +// SVGはXSSを生むので許可しない +export const FILE_TYPE_BROWSERSAFE = [ + // Images + 'image/png', + 'image/gif', + 'image/jpeg', + 'image/webp', + 'image/avif', + 'image/apng', + 'image/bmp', + 'image/tiff', + 'image/x-icon', + + // OggS + 'audio/opus', + 'video/ogg', + 'audio/ogg', + 'application/ogg', + + // ISO/IEC base media file format + 'video/quicktime', + 'video/mp4', + 'audio/mp4', + 'video/x-m4v', + 'audio/x-m4a', + 'video/3gpp', + 'video/3gpp2', + + 'video/mpeg', + 'audio/mpeg', + + 'video/webm', + 'audio/webm', + + 'audio/aac', + 'audio/x-flac', + 'audio/vnd.wave', +]; +/* +https://github.com/sindresorhus/file-type/blob/main/supported.js +https://github.com/sindresorhus/file-type/blob/main/core.js +https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers +*/ diff --git a/packages/frontend/src/directives/adaptive-border.ts b/packages/frontend/src/directives/adaptive-border.ts new file mode 100644 index 0000000000..619c9f0b6d --- /dev/null +++ b/packages/frontend/src/directives/adaptive-border.ts @@ -0,0 +1,24 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + const getBgColor = (el: HTMLElement) => { + const style = window.getComputedStyle(el); + if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) { + return style.backgroundColor; + } else { + return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; + } + }; + + const parentBg = getBgColor(src.parentElement); + + const myBg = window.getComputedStyle(src).backgroundColor; + + if (parentBg === myBg) { + src.style.borderColor = 'var(--divider)'; + } else { + src.style.borderColor = myBg; + } + }, +} as Directive; diff --git a/packages/frontend/src/directives/anim.ts b/packages/frontend/src/directives/anim.ts new file mode 100644 index 0000000000..04e1c6a404 --- /dev/null +++ b/packages/frontend/src/directives/anim.ts @@ -0,0 +1,18 @@ +import { Directive } from 'vue'; + +export default { + beforeMount(src, binding, vn) { + src.style.opacity = '0'; + src.style.transform = 'scale(0.9)'; + // ページネーションと相性が悪いので + //if (typeof binding.value === 'number') src.style.transitionDelay = `${binding.value * 30}ms`; + src.classList.add('_zoom'); + }, + + mounted(src, binding, vn) { + window.setTimeout(() => { + src.style.opacity = '1'; + src.style.transform = 'none'; + }, 1); + }, +} as Directive; diff --git a/packages/frontend/src/directives/appear.ts b/packages/frontend/src/directives/appear.ts new file mode 100644 index 0000000000..7fa43fc34a --- /dev/null +++ b/packages/frontend/src/directives/appear.ts @@ -0,0 +1,22 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + const fn = binding.value; + if (fn == null) return; + + const observer = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) { + fn(); + } + }); + + observer.observe(src); + + src._observer_ = observer; + }, + + unmounted(src, binding, vn) { + if (src._observer_) src._observer_.disconnect(); + }, +} as Directive; diff --git a/packages/frontend/src/directives/click-anime.ts b/packages/frontend/src/directives/click-anime.ts new file mode 100644 index 0000000000..e2f514b7ca --- /dev/null +++ b/packages/frontend/src/directives/click-anime.ts @@ -0,0 +1,31 @@ +import { Directive } from 'vue'; +import { defaultStore } from '@/store'; + +export default { + mounted(el, binding, vn) { + /* + if (!defaultStore.state.animation) return; + + el.classList.add('_anime_bounce_standBy'); + + el.addEventListener('mousedown', () => { + el.classList.add('_anime_bounce_standBy'); + el.classList.add('_anime_bounce_ready'); + + el.addEventListener('mouseleave', () => { + el.classList.remove('_anime_bounce_ready'); + }); + }); + + el.addEventListener('click', () => { + el.classList.add('_anime_bounce'); + }); + + el.addEventListener('animationend', () => { + el.classList.remove('_anime_bounce_ready'); + el.classList.remove('_anime_bounce'); + el.classList.add('_anime_bounce_standBy'); + }); + */ + }, +} as Directive; diff --git a/packages/frontend/src/directives/follow-append.ts b/packages/frontend/src/directives/follow-append.ts new file mode 100644 index 0000000000..62e0ac3b94 --- /dev/null +++ b/packages/frontend/src/directives/follow-append.ts @@ -0,0 +1,35 @@ +import { Directive } from 'vue'; +import { getScrollContainer, getScrollPosition } from '@/scripts/scroll'; + +export default { + mounted(src, binding, vn) { + if (binding.value === false) return; + + let isBottom = true; + + const container = getScrollContainer(src)!; + container.addEventListener('scroll', () => { + const pos = getScrollPosition(container); + const viewHeight = container.clientHeight; + const height = container.scrollHeight; + isBottom = (pos + viewHeight > height - 32); + }, { passive: true }); + container.scrollTop = container.scrollHeight; + + const ro = new ResizeObserver((entries, observer) => { + if (isBottom) { + const height = container.scrollHeight; + container.scrollTop = height; + } + }); + + ro.observe(src); + + // TODO: 新たにプロパティを作るのをやめMapを使う + src._ro_ = ro; + }, + + unmounted(src, binding, vn) { + if (src._ro_) src._ro_.unobserve(src); + }, +} as Directive; diff --git a/packages/frontend/src/directives/get-size.ts b/packages/frontend/src/directives/get-size.ts new file mode 100644 index 0000000000..ff3bdd78ac --- /dev/null +++ b/packages/frontend/src/directives/get-size.ts @@ -0,0 +1,54 @@ +import { Directive } from 'vue'; + +const mountings = new Map<Element, { + resize: ResizeObserver; + intersection?: IntersectionObserver; + fn: (w: number, h: number) => void; +}>(); + +function calc(src: Element) { + const info = mountings.get(src); + const height = src.clientHeight; + const width = src.clientWidth; + + if (!info) return; + + // アクティベート前などでsrcが描画されていない場合 + if (!height) { + // IntersectionObserverで表示検出する + if (!info.intersection) { + info.intersection = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) calc(src); + }); + } + info.intersection.observe(src); + return; + } + if (info.intersection) { + info.intersection.disconnect(); + delete info.intersection; + } + + info.fn(width, height); +} + +export default { + mounted(src, binding, vn) { + const resize = new ResizeObserver((entries, observer) => { + calc(src); + }); + resize.observe(src); + + mountings.set(src, { resize, fn: binding.value }); + calc(src); + }, + + unmounted(src, binding, vn) { + binding.value(0, 0); + const info = mountings.get(src); + if (!info) return; + info.resize.disconnect(); + if (info.intersection) info.intersection.disconnect(); + mountings.delete(src); + }, +} as Directive<Element, (w: number, h: number) => void>; diff --git a/packages/frontend/src/directives/hotkey.ts b/packages/frontend/src/directives/hotkey.ts new file mode 100644 index 0000000000..dfc5f646a4 --- /dev/null +++ b/packages/frontend/src/directives/hotkey.ts @@ -0,0 +1,24 @@ +import { Directive } from 'vue'; +import { makeHotkey } from '../scripts/hotkey'; + +export default { + mounted(el, binding) { + el._hotkey_global = binding.modifiers.global === true; + + el._keyHandler = makeHotkey(binding.value); + + if (el._hotkey_global) { + document.addEventListener('keydown', el._keyHandler); + } else { + el.addEventListener('keydown', el._keyHandler); + } + }, + + unmounted(el) { + if (el._hotkey_global) { + document.removeEventListener('keydown', el._keyHandler); + } else { + el.removeEventListener('keydown', el._keyHandler); + } + }, +} as Directive; diff --git a/packages/frontend/src/directives/index.ts b/packages/frontend/src/directives/index.ts new file mode 100644 index 0000000000..401a917cba --- /dev/null +++ b/packages/frontend/src/directives/index.ts @@ -0,0 +1,28 @@ +import { App } from 'vue'; + +import userPreview from './user-preview'; +import size from './size'; +import getSize from './get-size'; +import ripple from './ripple'; +import tooltip from './tooltip'; +import hotkey from './hotkey'; +import appear from './appear'; +import anim from './anim'; +import clickAnime from './click-anime'; +import panel from './panel'; +import adaptiveBorder from './adaptive-border'; + +export default function(app: App) { + app.directive('userPreview', userPreview); + app.directive('user-preview', userPreview); + app.directive('size', size); + app.directive('get-size', getSize); + app.directive('ripple', ripple); + app.directive('tooltip', tooltip); + app.directive('hotkey', hotkey); + app.directive('appear', appear); + app.directive('anim', anim); + app.directive('click-anime', clickAnime); + app.directive('panel', panel); + app.directive('adaptive-border', adaptiveBorder); +} diff --git a/packages/frontend/src/directives/panel.ts b/packages/frontend/src/directives/panel.ts new file mode 100644 index 0000000000..d31dc41ed4 --- /dev/null +++ b/packages/frontend/src/directives/panel.ts @@ -0,0 +1,24 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + const getBgColor = (el: HTMLElement) => { + const style = window.getComputedStyle(el); + if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) { + return style.backgroundColor; + } else { + return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; + } + }; + + const parentBg = getBgColor(src.parentElement); + + const myBg = getComputedStyle(document.documentElement).getPropertyValue('--panel'); + + if (parentBg === myBg) { + src.style.backgroundColor = 'var(--bg)'; + } else { + src.style.backgroundColor = 'var(--panel)'; + } + }, +} as Directive; diff --git a/packages/frontend/src/directives/ripple.ts b/packages/frontend/src/directives/ripple.ts new file mode 100644 index 0000000000..d32f7ab441 --- /dev/null +++ b/packages/frontend/src/directives/ripple.ts @@ -0,0 +1,18 @@ +import Ripple from '@/components/MkRipple.vue'; +import { popup } from '@/os'; + +export default { + mounted(el, binding, vn) { + // 明示的に false であればバインドしない + if (binding.value === false) return; + + el.addEventListener('click', () => { + const rect = el.getBoundingClientRect(); + + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + + popup(Ripple, { x, y }, {}, 'end'); + }); + }, +}; diff --git a/packages/frontend/src/directives/size.ts b/packages/frontend/src/directives/size.ts new file mode 100644 index 0000000000..da8bd78ea1 --- /dev/null +++ b/packages/frontend/src/directives/size.ts @@ -0,0 +1,123 @@ +import { Directive } from 'vue'; + +type Value = { max?: number[]; min?: number[]; }; + +//const observers = new Map<Element, ResizeObserver>(); +const mountings = new Map<Element, { + value: Value; + resize: ResizeObserver; + intersection?: IntersectionObserver; + previousWidth: number; + twoPreviousWidth: number; +}>(); + +type ClassOrder = { + add: string[]; + remove: string[]; +}; + +const isContainerQueriesSupported = ('container' in document.documentElement.style); + +const cache = new Map<string, ClassOrder>(); + +function getClassOrder(width: number, queue: Value): ClassOrder { + const getMaxClass = (v: number) => `max-width_${v}px`; + const getMinClass = (v: number) => `min-width_${v}px`; + + return { + add: [ + ...(queue.max ? queue.max.filter(v => width <= v).map(getMaxClass) : []), + ...(queue.min ? queue.min.filter(v => width >= v).map(getMinClass) : []), + ], + remove: [ + ...(queue.max ? queue.max.filter(v => width > v).map(getMaxClass) : []), + ...(queue.min ? queue.min.filter(v => width < v).map(getMinClass) : []), + ], + }; +} + +function applyClassOrder(el: Element, order: ClassOrder) { + el.classList.add(...order.add); + el.classList.remove(...order.remove); +} + +function getOrderName(width: number, queue: Value): string { + return `${width}|${queue.max ? queue.max.join(',') : ''}|${queue.min ? queue.min.join(',') : ''}`; +} + +function calc(el: Element) { + const info = mountings.get(el); + const width = el.clientWidth; + + if (!info || info.previousWidth === width) return; + + // アクティベート前などでsrcが描画されていない場合 + if (!width) { + // IntersectionObserverで表示検出する + if (!info.intersection) { + info.intersection = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) calc(el); + }); + } + info.intersection.observe(el); + return; + } + if (info.intersection) { + info.intersection.disconnect(); + delete info.intersection; + } + + mountings.set(el, { ...info, ...{ previousWidth: width, twoPreviousWidth: info.previousWidth }}); + + // Prevent infinite resizing + // https://github.com/misskey-dev/misskey/issues/9076 + if (info.twoPreviousWidth === width) { + return; + } + + const cached = cache.get(getOrderName(width, info.value)); + if (cached) { + applyClassOrder(el, cached); + } else { + const order = getClassOrder(width, info.value); + cache.set(getOrderName(width, info.value), order); + applyClassOrder(el, order); + } +} + +export default { + mounted(src, binding, vn) { + if (isContainerQueriesSupported) return; + + const resize = new ResizeObserver((entries, observer) => { + calc(src); + }); + + mountings.set(src, { + value: binding.value, + resize, + previousWidth: 0, + twoPreviousWidth: 0, + }); + + calc(src); + resize.observe(src); + }, + + updated(src, binding, vn) { + if (isContainerQueriesSupported) return; + + mountings.set(src, Object.assign({}, mountings.get(src), { value: binding.value })); + calc(src); + }, + + unmounted(src, binding, vn) { + if (isContainerQueriesSupported) return; + + const info = mountings.get(src); + if (!info) return; + info.resize.disconnect(); + if (info.intersection) info.intersection.disconnect(); + mountings.delete(src); + }, +} as Directive<Element, Value>; diff --git a/packages/frontend/src/directives/tooltip.ts b/packages/frontend/src/directives/tooltip.ts new file mode 100644 index 0000000000..5d13497b5f --- /dev/null +++ b/packages/frontend/src/directives/tooltip.ts @@ -0,0 +1,93 @@ +// TODO: useTooltip関数使うようにしたい +// ただディレクティブ内でonUnmountedなどのcomposition api使えるのか不明 + +import { defineAsyncComponent, Directive, ref } from 'vue'; +import { isTouchUsing } from '@/scripts/touch'; +import { popup, alert } from '@/os'; + +const start = isTouchUsing ? 'touchstart' : 'mouseover'; +const end = isTouchUsing ? 'touchend' : 'mouseleave'; + +export default { + mounted(el: HTMLElement, binding, vn) { + const delay = binding.modifiers.noDelay ? 0 : 100; + + const self = (el as any)._tooltipDirective_ = {} as any; + + self.text = binding.value as string; + self._close = null; + self.showTimer = null; + self.hideTimer = null; + self.checkTimer = null; + + self.close = () => { + if (self._close) { + window.clearInterval(self.checkTimer); + self._close(); + self._close = null; + } + }; + + if (binding.arg === 'dialog') { + el.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + alert({ + type: 'info', + text: binding.value, + }); + return false; + }); + } + + self.show = () => { + if (!document.body.contains(el)) return; + if (self._close) return; + if (self.text == null) return; + + const showing = ref(true); + popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), { + showing, + text: self.text, + asMfm: binding.modifiers.mfm, + direction: binding.modifiers.left ? 'left' : binding.modifiers.right ? 'right' : binding.modifiers.top ? 'top' : binding.modifiers.bottom ? 'bottom' : 'top', + targetElement: el, + }, {}, 'closed'); + + self._close = () => { + showing.value = false; + }; + }; + + el.addEventListener('selectstart', ev => { + ev.preventDefault(); + }); + + el.addEventListener(start, () => { + window.clearTimeout(self.showTimer); + window.clearTimeout(self.hideTimer); + self.showTimer = window.setTimeout(self.show, delay); + }, { passive: true }); + + el.addEventListener(end, () => { + window.clearTimeout(self.showTimer); + window.clearTimeout(self.hideTimer); + self.hideTimer = window.setTimeout(self.close, delay); + }, { passive: true }); + + el.addEventListener('click', () => { + window.clearTimeout(self.showTimer); + self.close(); + }); + }, + + updated(el, binding) { + const self = el._tooltipDirective_; + self.text = binding.value as string; + }, + + unmounted(el, binding, vn) { + const self = el._tooltipDirective_; + window.clearInterval(self.checkTimer); + }, +} as Directive; diff --git a/packages/frontend/src/directives/user-preview.ts b/packages/frontend/src/directives/user-preview.ts new file mode 100644 index 0000000000..ed5f00ca65 --- /dev/null +++ b/packages/frontend/src/directives/user-preview.ts @@ -0,0 +1,118 @@ +import { defineAsyncComponent, Directive, ref } from 'vue'; +import autobind from 'autobind-decorator'; +import { popup } from '@/os'; + +export class UserPreview { + private el; + private user; + private showTimer; + private hideTimer; + private checkTimer; + private promise; + + constructor(el, user) { + this.el = el; + this.user = user; + + this.attach(); + } + + @autobind + private show() { + if (!document.body.contains(this.el)) return; + if (this.promise) return; + + const showing = ref(true); + + popup(defineAsyncComponent(() => import('@/components/MkUserPreview.vue')), { + showing, + q: this.user, + source: this.el, + }, { + mouseover: () => { + window.clearTimeout(this.hideTimer); + }, + mouseleave: () => { + window.clearTimeout(this.showTimer); + this.hideTimer = window.setTimeout(this.close, 500); + }, + }, 'closed'); + + this.promise = { + cancel: () => { + showing.value = false; + }, + }; + + this.checkTimer = window.setInterval(() => { + if (!document.body.contains(this.el)) { + window.clearTimeout(this.showTimer); + window.clearTimeout(this.hideTimer); + this.close(); + } + }, 1000); + } + + @autobind + private close() { + if (this.promise) { + window.clearInterval(this.checkTimer); + this.promise.cancel(); + this.promise = null; + } + } + + @autobind + private onMouseover() { + window.clearTimeout(this.showTimer); + window.clearTimeout(this.hideTimer); + this.showTimer = window.setTimeout(this.show, 500); + } + + @autobind + private onMouseleave() { + window.clearTimeout(this.showTimer); + window.clearTimeout(this.hideTimer); + this.hideTimer = window.setTimeout(this.close, 500); + } + + @autobind + private onClick() { + window.clearTimeout(this.showTimer); + this.close(); + } + + @autobind + public attach() { + this.el.addEventListener('mouseover', this.onMouseover); + this.el.addEventListener('mouseleave', this.onMouseleave); + this.el.addEventListener('click', this.onClick); + } + + @autobind + public detach() { + this.el.removeEventListener('mouseover', this.onMouseover); + this.el.removeEventListener('mouseleave', this.onMouseleave); + this.el.removeEventListener('click', this.onClick); + window.clearInterval(this.checkTimer); + } +} + +export default { + mounted(el: HTMLElement, binding, vn) { + if (binding.value == null) return; + + // TODO: 新たにプロパティを作るのをやめMapを使う + // ただメモリ的には↓の方が省メモリかもしれないので検討中 + const self = (el as any)._userPreviewDirective_ = {} as any; + + self.preview = new UserPreview(el, binding.value); + }, + + unmounted(el, binding, vn) { + if (binding.value == null) return; + + const self = el._userPreviewDirective_; + self.preview.detach(); + }, +} as Directive; diff --git a/packages/frontend/src/emojilist.json b/packages/frontend/src/emojilist.json new file mode 100644 index 0000000000..402e82e33b --- /dev/null +++ b/packages/frontend/src/emojilist.json @@ -0,0 +1,1785 @@ +[ + { "category": "face", "char": "😀", "name": "grinning", "keywords": ["face", "smile", "happy", "joy", ": D", "grin"] }, + { "category": "face", "char": "😬", "name": "grimacing", "keywords": ["face", "grimace", "teeth"] }, + { "category": "face", "char": "😁", "name": "grin", "keywords": ["face", "happy", "smile", "joy", "kawaii"] }, + { "category": "face", "char": "😂", "name": "joy", "keywords": ["face", "cry", "tears", "weep", "happy", "happytears", "haha"] }, + { "category": "face", "char": "🤣", "name": "rofl", "keywords": ["face", "rolling", "floor", "laughing", "lol", "haha"] }, + { "category": "face", "char": "🥳", "name": "partying", "keywords": ["face", "celebration", "woohoo"] }, + { "category": "face", "char": "😃", "name": "smiley", "keywords": ["face", "happy", "joy", "haha", ": D", ": )", "smile", "funny"] }, + { "category": "face", "char": "😄", "name": "smile", "keywords": ["face", "happy", "joy", "funny", "haha", "laugh", "like", ": D", ": )"] }, + { "category": "face", "char": "😅", "name": "sweat_smile", "keywords": ["face", "hot", "happy", "laugh", "sweat", "smile", "relief"] }, + { "category": "face", "char": "🥲", "name": "smiling_face_with_tear", "keywords": ["face"] }, + { "category": "face", "char": "😆", "name": "laughing", "keywords": ["happy", "joy", "lol", "satisfied", "haha", "face", "glad", "XD", "laugh"] }, + { "category": "face", "char": "😇", "name": "innocent", "keywords": ["face", "angel", "heaven", "halo"] }, + { "category": "face", "char": "😉", "name": "wink", "keywords": ["face", "happy", "mischievous", "secret", ";)", "smile", "eye"] }, + { "category": "face", "char": "😊", "name": "blush", "keywords": ["face", "smile", "happy", "flushed", "crush", "embarrassed", "shy", "joy"] }, + { "category": "face", "char": "🙂", "name": "slightly_smiling_face", "keywords": ["face", "smile"] }, + { "category": "face", "char": "🙃", "name": "upside_down_face", "keywords": ["face", "flipped", "silly", "smile"] }, + { "category": "face", "char": "☺️", "name": "relaxed", "keywords": ["face", "blush", "massage", "happiness"] }, + { "category": "face", "char": "😋", "name": "yum", "keywords": ["happy", "joy", "tongue", "smile", "face", "silly", "yummy", "nom", "delicious", "savouring"] }, + { "category": "face", "char": "😌", "name": "relieved", "keywords": ["face", "relaxed", "phew", "massage", "happiness"] }, + { "category": "face", "char": "😍", "name": "heart_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "heart"] }, + { "category": "face", "char": "🥰", "name": "smiling_face_with_three_hearts", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "hearts", "adore"] }, + { "category": "face", "char": "😘", "name": "kissing_heart", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] }, + { "category": "face", "char": "😗", "name": "kissing", "keywords": ["love", "like", "face", "3", "valentines", "infatuation", "kiss"] }, + { "category": "face", "char": "😙", "name": "kissing_smiling_eyes", "keywords": ["face", "affection", "valentines", "infatuation", "kiss"] }, + { "category": "face", "char": "😚", "name": "kissing_closed_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] }, + { "category": "face", "char": "😜", "name": "stuck_out_tongue_winking_eye", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "wink", "tongue"] }, + { "category": "face", "char": "🤪", "name": "zany", "keywords": ["face", "goofy", "crazy"] }, + { "category": "face", "char": "🤨", "name": "raised_eyebrow", "keywords": ["face", "distrust", "scepticism", "disapproval", "disbelief", "surprise"] }, + { "category": "face", "char": "🧐", "name": "monocle", "keywords": ["face", "stuffy", "wealthy"] }, + { "category": "face", "char": "😝", "name": "stuck_out_tongue_closed_eyes", "keywords": ["face", "prank", "playful", "mischievous", "smile", "tongue"] }, + { "category": "face", "char": "😛", "name": "stuck_out_tongue", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "tongue"] }, + { "category": "face", "char": "🤑", "name": "money_mouth_face", "keywords": ["face", "rich", "dollar", "money"] }, + { "category": "face", "char": "🤓", "name": "nerd_face", "keywords": ["face", "nerdy", "geek", "dork"] }, + { "category": "face", "char": "🥸", "name": "disguised_face", "keywords": ["face", "nose", "glasses", "incognito"] }, + { "category": "face", "char": "😎", "name": "sunglasses", "keywords": ["face", "cool", "smile", "summer", "beach", "sunglass"] }, + { "category": "face", "char": "🤩", "name": "star_struck", "keywords": ["face", "smile", "starry", "eyes", "grinning"] }, + { "category": "face", "char": "🤡", "name": "clown_face", "keywords": ["face"] }, + { "category": "face", "char": "🤠", "name": "cowboy_hat_face", "keywords": ["face", "cowgirl", "hat"] }, + { "category": "face", "char": "🤗", "name": "hugs", "keywords": ["face", "smile", "hug"] }, + { "category": "face", "char": "😏", "name": "smirk", "keywords": ["face", "smile", "mean", "prank", "smug", "sarcasm"] }, + { "category": "face", "char": "😶", "name": "no_mouth", "keywords": ["face", "hellokitty"] }, + { "category": "face", "char": "😐", "name": "neutral_face", "keywords": ["indifference", "meh", ": |", "neutral"] }, + { "category": "face", "char": "😑", "name": "expressionless", "keywords": ["face", "indifferent", "-_-", "meh", "deadpan"] }, + { "category": "face", "char": "😒", "name": "unamused", "keywords": ["indifference", "bored", "straight face", "serious", "sarcasm", "unimpressed", "skeptical", "dubious", "side_eye"] }, + { "category": "face", "char": "🙄", "name": "roll_eyes", "keywords": ["face", "eyeroll", "frustrated"] }, + { "category": "face", "char": "🤔", "name": "thinking", "keywords": ["face", "hmmm", "think", "consider"] }, + { "category": "face", "char": "🤥", "name": "lying_face", "keywords": ["face", "lie", "pinocchio"] }, + { "category": "face", "char": "🤭", "name": "hand_over_mouth", "keywords": ["face", "whoops", "shock", "surprise"] }, + { "category": "face", "char": "🤫", "name": "shushing", "keywords": ["face", "quiet", "shhh"] }, + { "category": "face", "char": "🤬", "name": "symbols_over_mouth", "keywords": ["face", "swearing", "cursing", "cussing", "profanity", "expletive"] }, + { "category": "face", "char": "🤯", "name": "exploding_head", "keywords": ["face", "shocked", "mind", "blown"] }, + { "category": "face", "char": "😳", "name": "flushed", "keywords": ["face", "blush", "shy", "flattered"] }, + { "category": "face", "char": "😞", "name": "disappointed", "keywords": ["face", "sad", "upset", "depressed", ": ("] }, + { "category": "face", "char": "😟", "name": "worried", "keywords": ["face", "concern", "nervous", ": ("] }, + { "category": "face", "char": "😠", "name": "angry", "keywords": ["mad", "face", "annoyed", "frustrated"] }, + { "category": "face", "char": "😡", "name": "rage", "keywords": ["angry", "mad", "hate", "despise"] }, + { "category": "face", "char": "😔", "name": "pensive", "keywords": ["face", "sad", "depressed", "upset"] }, + { "category": "face", "char": "😕", "name": "confused", "keywords": ["face", "indifference", "huh", "weird", "hmmm", ": /"] }, + { "category": "face", "char": "🙁", "name": "slightly_frowning_face", "keywords": ["face", "frowning", "disappointed", "sad", "upset"] }, + { "category": "face", "char": "☹", "name": "frowning_face", "keywords": ["face", "sad", "upset", "frown"] }, + { "category": "face", "char": "😣", "name": "persevere", "keywords": ["face", "sick", "no", "upset", "oops"] }, + { "category": "face", "char": "😖", "name": "confounded", "keywords": ["face", "confused", "sick", "unwell", "oops", ": S"] }, + { "category": "face", "char": "😫", "name": "tired_face", "keywords": ["sick", "whine", "upset", "frustrated"] }, + { "category": "face", "char": "😩", "name": "weary", "keywords": ["face", "tired", "sleepy", "sad", "frustrated", "upset"] }, + { "category": "face", "char": "🥺", "name": "pleading", "keywords": ["face", "begging", "mercy"] }, + { "category": "face", "char": "😤", "name": "triumph", "keywords": ["face", "gas", "phew", "proud", "pride"] }, + { "category": "face", "char": "😮", "name": "open_mouth", "keywords": ["face", "surprise", "impressed", "wow", "whoa", ": O"] }, + { "category": "face", "char": "😱", "name": "scream", "keywords": ["face", "munch", "scared", "omg"] }, + { "category": "face", "char": "😨", "name": "fearful", "keywords": ["face", "scared", "terrified", "nervous", "oops", "huh"] }, + { "category": "face", "char": "😰", "name": "cold_sweat", "keywords": ["face", "nervous", "sweat"] }, + { "category": "face", "char": "😯", "name": "hushed", "keywords": ["face", "woo", "shh"] }, + { "category": "face", "char": "😦", "name": "frowning", "keywords": ["face", "aw", "what"] }, + { "category": "face", "char": "😧", "name": "anguished", "keywords": ["face", "stunned", "nervous"] }, + { "category": "face", "char": "😢", "name": "cry", "keywords": ["face", "tears", "sad", "depressed", "upset", ": '("] }, + { "category": "face", "char": "😥", "name": "disappointed_relieved", "keywords": ["face", "phew", "sweat", "nervous"] }, + { "category": "face", "char": "🤤", "name": "drooling_face", "keywords": ["face"] }, + { "category": "face", "char": "😪", "name": "sleepy", "keywords": ["face", "tired", "rest", "nap"] }, + { "category": "face", "char": "😓", "name": "sweat", "keywords": ["face", "hot", "sad", "tired", "exercise"] }, + { "category": "face", "char": "🥵", "name": "hot", "keywords": ["face", "feverish", "heat", "red", "sweating"] }, + { "category": "face", "char": "🥶", "name": "cold", "keywords": ["face", "blue", "freezing", "frozen", "frostbite", "icicles"] }, + { "category": "face", "char": "😭", "name": "sob", "keywords": ["face", "cry", "tears", "sad", "upset", "depressed"] }, + { "category": "face", "char": "😵", "name": "dizzy_face", "keywords": ["spent", "unconscious", "xox", "dizzy"] }, + { "category": "face", "char": "😲", "name": "astonished", "keywords": ["face", "xox", "surprised", "poisoned"] }, + { "category": "face", "char": "🤐", "name": "zipper_mouth_face", "keywords": ["face", "sealed", "zipper", "secret"] }, + { "category": "face", "char": "🤢", "name": "nauseated_face", "keywords": ["face", "vomit", "gross", "green", "sick", "throw up", "ill"] }, + { "category": "face", "char": "🤧", "name": "sneezing_face", "keywords": ["face", "gesundheit", "sneeze", "sick", "allergy"] }, + { "category": "face", "char": "🤮", "name": "vomiting", "keywords": ["face", "sick"] }, + { "category": "face", "char": "😷", "name": "mask", "keywords": ["face", "sick", "ill", "disease"] }, + { "category": "face", "char": "🤒", "name": "face_with_thermometer", "keywords": ["sick", "temperature", "thermometer", "cold", "fever"] }, + { "category": "face", "char": "🤕", "name": "face_with_head_bandage", "keywords": ["injured", "clumsy", "bandage", "hurt"] }, + { "category": "face", "char": "🥴", "name": "woozy", "keywords": ["face", "dizzy", "intoxicated", "tipsy", "wavy"] }, + { "category": "face", "char": "🥱", "name": "yawning", "keywords": ["face", "tired", "yawning"] }, + { "category": "face", "char": "😴", "name": "sleeping", "keywords": ["face", "tired", "sleepy", "night", "zzz"] }, + { "category": "face", "char": "💤", "name": "zzz", "keywords": ["sleepy", "tired", "dream"] }, + { "category": "face", "char": "\uD83D\uDE36\u200D\uD83C\uDF2B\uFE0F", "name": "face_in_clouds", "keywords": [] }, + { "category": "face", "char": "\uD83D\uDE2E\u200D\uD83D\uDCA8", "name": "face_exhaling", "keywords": [] }, + { "category": "face", "char": "\uD83D\uDE35\u200D\uD83D\uDCAB", "name": "face_with_spiral_eyes", "keywords": [] }, + { "category": "face", "char": "\uD83E\uDEE0", "name": "melting_face", "keywords": ["disappear", "dissolve", "liquid", "melt", "toketa"] }, + { "category": "face", "char": "\uD83E\uDEE2", "name": "face_with_open_eyes_and_hand_over_mouth", "keywords": ["amazement", "awe", "disbelief", "embarrass", "scared", "surprise", "ohoho"] }, + { "category": "face", "char": "\uD83E\uDEE3", "name": "face_with_peeking_eye", "keywords": ["captivated", "peep", "stare", "chunibyo"] }, + { "category": "face", "char": "\uD83E\uDEE1", "name": "saluting_face", "keywords": ["ok", "salute", "sunny", "troops", "yes", "raja"] }, + { "category": "face", "char": "\uD83E\uDEE5", "name": "dotted_line_face", "keywords": ["depressed", "disappear", "hide", "introvert", "invisible", "tensen"] }, + { "category": "face", "char": "\uD83E\uDEE4", "name": "face_with_diagonal_mouth", "keywords": ["disappointed", "meh", "skeptical", "unsure"] }, + { "category": "face", "char": "\uD83E\uDD79", "name": "face_holding_back_tears", "keywords": ["angry", "cry", "proud", "resist", "sad"] }, + { "category": "face", "char": "💩", "name": "poop", "keywords": ["hankey", "shitface", "fail", "turd", "shit"] }, + { "category": "face", "char": "😈", "name": "smiling_imp", "keywords": ["devil", "horns"] }, + { "category": "face", "char": "👿", "name": "imp", "keywords": ["devil", "angry", "horns"] }, + { "category": "face", "char": "👹", "name": "japanese_ogre", "keywords": ["monster", "red", "mask", "halloween", "scary", "creepy", "devil", "demon", "japanese", "ogre"] }, + { "category": "face", "char": "👺", "name": "japanese_goblin", "keywords": ["red", "evil", "mask", "monster", "scary", "creepy", "japanese", "goblin"] }, + { "category": "face", "char": "💀", "name": "skull", "keywords": ["dead", "skeleton", "creepy", "death"] }, + { "category": "face", "char": "👻", "name": "ghost", "keywords": ["halloween", "spooky", "scary"] }, + { "category": "face", "char": "👽", "name": "alien", "keywords": ["UFO", "paul", "weird", "outer_space"] }, + { "category": "face", "char": "🤖", "name": "robot", "keywords": ["computer", "machine", "bot"] }, + { "category": "face", "char": "😺", "name": "smiley_cat", "keywords": ["animal", "cats", "happy", "smile"] }, + { "category": "face", "char": "😸", "name": "smile_cat", "keywords": ["animal", "cats", "smile"] }, + { "category": "face", "char": "😹", "name": "joy_cat", "keywords": ["animal", "cats", "haha", "happy", "tears"] }, + { "category": "face", "char": "😻", "name": "heart_eyes_cat", "keywords": ["animal", "love", "like", "affection", "cats", "valentines", "heart"] }, + { "category": "face", "char": "😼", "name": "smirk_cat", "keywords": ["animal", "cats", "smirk"] }, + { "category": "face", "char": "😽", "name": "kissing_cat", "keywords": ["animal", "cats", "kiss"] }, + { "category": "face", "char": "🙀", "name": "scream_cat", "keywords": ["animal", "cats", "munch", "scared", "scream"] }, + { "category": "face", "char": "😿", "name": "crying_cat_face", "keywords": ["animal", "tears", "weep", "sad", "cats", "upset", "cry"] }, + { "category": "face", "char": "😾", "name": "pouting_cat", "keywords": ["animal", "cats"] }, + { "category": "people", "char": "🤲", "name": "palms_up", "keywords": ["hands", "gesture", "cupped", "prayer"] }, + { "category": "people", "char": "🙌", "name": "raised_hands", "keywords": ["gesture", "hooray", "yea", "celebration", "hands"] }, + { "category": "people", "char": "👏", "name": "clap", "keywords": ["hands", "praise", "applause", "congrats", "yay"] }, + { "category": "people", "char": "👋", "name": "wave", "keywords": ["hands", "gesture", "goodbye", "solong", "farewell", "hello", "hi", "palm"] }, + { "category": "people", "char": "🤙", "name": "call_me_hand", "keywords": ["hands", "gesture"] }, + { "category": "people", "char": "👍", "name": "+1", "keywords": ["thumbsup", "yes", "awesome", "good", "agree", "accept", "cool", "hand", "like"] }, + { "category": "people", "char": "👎", "name": "-1", "keywords": ["thumbsdown", "no", "dislike", "hand"] }, + { "category": "people", "char": "👊", "name": "facepunch", "keywords": ["angry", "violence", "fist", "hit", "attack", "hand"] }, + { "category": "people", "char": "✊", "name": "fist", "keywords": ["fingers", "hand", "grasp"] }, + { "category": "people", "char": "🤛", "name": "fist_left", "keywords": ["hand", "fistbump"] }, + { "category": "people", "char": "🤜", "name": "fist_right", "keywords": ["hand", "fistbump"] }, + { "category": "people", "char": "✌", "name": "v", "keywords": ["fingers", "ohyeah", "hand", "peace", "victory", "two"] }, + { "category": "people", "char": "👌", "name": "ok_hand", "keywords": ["fingers", "limbs", "perfect", "ok", "okay"] }, + { "category": "people", "char": "✋", "name": "raised_hand", "keywords": ["fingers", "stop", "highfive", "palm", "ban"] }, + { "category": "people", "char": "🤚", "name": "raised_back_of_hand", "keywords": ["fingers", "raised", "backhand"] }, + { "category": "people", "char": "👐", "name": "open_hands", "keywords": ["fingers", "butterfly", "hands", "open"] }, + { "category": "people", "char": "💪", "name": "muscle", "keywords": ["arm", "flex", "hand", "summer", "strong", "biceps"] }, + { "category": "people", "char": "🦾", "name": "mechanical_arm", "keywords": ["flex", "hand", "strong", "biceps"] }, + { "category": "people", "char": "🙏", "name": "pray", "keywords": ["please", "hope", "wish", "namaste", "highfive"] }, + { "category": "people", "char": "🦶", "name": "foot", "keywords": ["kick", "stomp"] }, + { "category": "people", "char": "🦵", "name": "leg", "keywords": ["kick", "limb"] }, + { "category": "people", "char": "🦿", "name": "mechanical_leg", "keywords": ["kick", "limb"] }, + { "category": "people", "char": "🤝", "name": "handshake", "keywords": ["agreement", "shake"] }, + { "category": "people", "char": "☝", "name": "point_up", "keywords": ["hand", "fingers", "direction", "up"] }, + { "category": "people", "char": "👆", "name": "point_up_2", "keywords": ["fingers", "hand", "direction", "up"] }, + { "category": "people", "char": "👇", "name": "point_down", "keywords": ["fingers", "hand", "direction", "down"] }, + { "category": "people", "char": "👈", "name": "point_left", "keywords": ["direction", "fingers", "hand", "left"] }, + { "category": "people", "char": "👉", "name": "point_right", "keywords": ["fingers", "hand", "direction", "right"] }, + { "category": "people", "char": "🖕", "name": "fu", "keywords": ["hand", "fingers", "rude", "middle", "flipping"] }, + { "category": "people", "char": "🖐", "name": "raised_hand_with_fingers_splayed", "keywords": ["hand", "fingers", "palm"] }, + { "category": "people", "char": "🤟", "name": "love_you", "keywords": ["hand", "fingers", "gesture"] }, + { "category": "people", "char": "🤘", "name": "metal", "keywords": ["hand", "fingers", "evil_eye", "sign_of_horns", "rock_on"] }, + { "category": "people", "char": "🤞", "name": "crossed_fingers", "keywords": ["good", "lucky"] }, + { "category": "people", "char": "🖖", "name": "vulcan_salute", "keywords": ["hand", "fingers", "spock", "star trek"] }, + { "category": "people", "char": "✍", "name": "writing_hand", "keywords": ["lower_left_ballpoint_pen", "stationery", "write", "compose"] }, + { "category": "people", "char": "\uD83E\uDEF0", "name": "hand_with_index_finger_and_thumb_crossed", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEF1", "name": "rightwards_hand", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEF2", "name": "leftwards_hand", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEF3", "name": "palm_down_hand", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEF4", "name": "palm_up_hand", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEF5", "name": "index_pointing_at_the_viewer", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEF6", "name": "heart_hands", "keywords": ["moemoekyun"] }, + { "category": "people", "char": "🤏", "name": "pinching_hand", "keywords": ["hand", "fingers"] }, + { "category": "people", "char": "🤌", "name": "pinched_fingers", "keywords": ["hand", "fingers"] }, + { "category": "people", "char": "🤳", "name": "selfie", "keywords": ["camera", "phone"] }, + { "category": "people", "char": "💅", "name": "nail_care", "keywords": ["beauty", "manicure", "finger", "fashion", "nail"] }, + { "category": "people", "char": "👄", "name": "lips", "keywords": ["mouth", "kiss"] }, + { "category": "people", "char": "\uD83E\uDEE6", "name": "biting_lip", "keywords": [] }, + { "category": "people", "char": "🦷", "name": "tooth", "keywords": ["teeth", "dentist"] }, + { "category": "people", "char": "👅", "name": "tongue", "keywords": ["mouth", "playful"] }, + { "category": "people", "char": "👂", "name": "ear", "keywords": ["face", "hear", "sound", "listen"] }, + { "category": "people", "char": "🦻", "name": "ear_with_hearing_aid", "keywords": ["face", "hear", "sound", "listen"] }, + { "category": "people", "char": "👃", "name": "nose", "keywords": ["smell", "sniff"] }, + { "category": "people", "char": "👁", "name": "eye", "keywords": ["face", "look", "see", "watch", "stare"] }, + { "category": "people", "char": "👀", "name": "eyes", "keywords": ["look", "watch", "stalk", "peek", "see"] }, + { "category": "people", "char": "🧠", "name": "brain", "keywords": ["smart", "intelligent"] }, + { "category": "people", "char": "🫀", "name": "anatomical_heart", "keywords": [] }, + { "category": "people", "char": "🫁", "name": "lungs", "keywords": [] }, + { "category": "people", "char": "👤", "name": "bust_in_silhouette", "keywords": ["user", "person", "human"] }, + { "category": "people", "char": "👥", "name": "busts_in_silhouette", "keywords": ["user", "person", "human", "group", "team"] }, + { "category": "people", "char": "🗣", "name": "speaking_head", "keywords": ["user", "person", "human", "sing", "say", "talk"] }, + { "category": "people", "char": "👶", "name": "baby", "keywords": ["child", "boy", "girl", "toddler"] }, + { "category": "people", "char": "🧒", "name": "child", "keywords": ["gender-neutral", "young"] }, + { "category": "people", "char": "👦", "name": "boy", "keywords": ["man", "male", "guy", "teenager"] }, + { "category": "people", "char": "👧", "name": "girl", "keywords": ["female", "woman", "teenager"] }, + { "category": "people", "char": "🧑", "name": "adult", "keywords": ["gender-neutral", "person"] }, + { "category": "people", "char": "👨", "name": "man", "keywords": ["mustache", "father", "dad", "guy", "classy", "sir", "moustache"] }, + { "category": "people", "char": "👩", "name": "woman", "keywords": ["female", "girls", "lady"] }, + { "category": "people", "char": "🧑🦱", "name": "curly_hair", "keywords": ["curly", "afro", "braids", "ringlets"] }, + { "category": "people", "char": "👩🦱", "name": "curly_hair_woman", "keywords": ["woman", "female", "girl", "curly", "afro", "braids", "ringlets"] }, + { "category": "people", "char": "👨🦱", "name": "curly_hair_man", "keywords": ["man", "male", "boy", "guy", "curly", "afro", "braids", "ringlets"] }, + { "category": "people", "char": "🧑🦰", "name": "red_hair", "keywords": ["redhead"] }, + { "category": "people", "char": "👩🦰", "name": "red_hair_woman", "keywords": ["woman", "female", "girl", "ginger", "redhead"] }, + { "category": "people", "char": "👨🦰", "name": "red_hair_man", "keywords": ["man", "male", "boy", "guy", "ginger", "redhead"] }, + { "category": "people", "char": "👱♀️", "name": "blonde_woman", "keywords": ["woman", "female", "girl", "blonde", "person"] }, + { "category": "people", "char": "👱", "name": "blonde_man", "keywords": ["man", "male", "boy", "blonde", "guy", "person"] }, + { "category": "people", "char": "🧑🦳", "name": "white_hair", "keywords": ["gray", "old", "white"] }, + { "category": "people", "char": "👩🦳", "name": "white_hair_woman", "keywords": ["woman", "female", "girl", "gray", "old", "white"] }, + { "category": "people", "char": "👨🦳", "name": "white_hair_man", "keywords": ["man", "male", "boy", "guy", "gray", "old", "white"] }, + { "category": "people", "char": "🧑🦲", "name": "bald", "keywords": ["bald", "chemotherapy", "hairless", "shaven"] }, + { "category": "people", "char": "👩🦲", "name": "bald_woman", "keywords": ["woman", "female", "girl", "bald", "chemotherapy", "hairless", "shaven"] }, + { "category": "people", "char": "👨🦲", "name": "bald_man", "keywords": ["man", "male", "boy", "guy", "bald", "chemotherapy", "hairless", "shaven"] }, + { "category": "people", "char": "🧔", "name": "bearded_person", "keywords": ["person", "bewhiskered"] }, + { "category": "people", "char": "🧓", "name": "older_adult", "keywords": ["human", "elder", "senior", "gender-neutral"] }, + { "category": "people", "char": "👴", "name": "older_man", "keywords": ["human", "male", "men", "old", "elder", "senior"] }, + { "category": "people", "char": "👵", "name": "older_woman", "keywords": ["human", "female", "women", "lady", "old", "elder", "senior"] }, + { "category": "people", "char": "👲", "name": "man_with_gua_pi_mao", "keywords": ["male", "boy", "chinese"] }, + { "category": "people", "char": "🧕", "name": "woman_with_headscarf", "keywords": ["female", "hijab", "mantilla", "tichel"] }, + { "category": "people", "char": "👳♀️", "name": "woman_with_turban", "keywords": ["female", "indian", "hinduism", "arabs", "woman"] }, + { "category": "people", "char": "👳", "name": "man_with_turban", "keywords": ["male", "indian", "hinduism", "arabs"] }, + { "category": "people", "char": "👮♀️", "name": "policewoman", "keywords": ["woman", "police", "law", "legal", "enforcement", "arrest", "911", "female"] }, + { "category": "people", "char": "👮", "name": "policeman", "keywords": ["man", "police", "law", "legal", "enforcement", "arrest", "911"] }, + { "category": "people", "char": "👷♀️", "name": "construction_worker_woman", "keywords": ["female", "human", "wip", "build", "construction", "worker", "labor", "woman"] }, + { "category": "people", "char": "👷", "name": "construction_worker_man", "keywords": ["male", "human", "wip", "guy", "build", "construction", "worker", "labor"] }, + { "category": "people", "char": "💂♀️", "name": "guardswoman", "keywords": ["uk", "gb", "british", "female", "royal", "woman"] }, + { "category": "people", "char": "💂", "name": "guardsman", "keywords": ["uk", "gb", "british", "male", "guy", "royal"] }, + { "category": "people", "char": "🕵️♀️", "name": "female_detective", "keywords": ["human", "spy", "detective", "female", "woman"] }, + { "category": "people", "char": "🕵", "name": "male_detective", "keywords": ["human", "spy", "detective"] }, + { "category": "people", "char": "🧑⚕️", "name": "health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "human"] }, + { "category": "people", "char": "👩⚕️", "name": "woman_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "woman", "human"] }, + { "category": "people", "char": "👨⚕️", "name": "man_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "man", "human"] }, + { "category": "people", "char": "🧑🌾", "name": "farmer", "keywords": ["rancher", "gardener", "human"] }, + { "category": "people", "char": "👩🌾", "name": "woman_farmer", "keywords": ["rancher", "gardener", "woman", "human"] }, + { "category": "people", "char": "👨🌾", "name": "man_farmer", "keywords": ["rancher", "gardener", "man", "human"] }, + { "category": "people", "char": "🧑🍳", "name": "cook", "keywords": ["chef", "human"] }, + { "category": "people", "char": "👩🍳", "name": "woman_cook", "keywords": ["chef", "woman", "human"] }, + { "category": "people", "char": "👨🍳", "name": "man_cook", "keywords": ["chef", "man", "human"] }, + { "category": "people", "char": "🧑🎓", "name": "student", "keywords": ["graduate", "human"] }, + { "category": "people", "char": "👩🎓", "name": "woman_student", "keywords": ["graduate", "woman", "human"] }, + { "category": "people", "char": "👨🎓", "name": "man_student", "keywords": ["graduate", "man", "human"] }, + { "category": "people", "char": "🧑🎤", "name": "singer", "keywords": ["rockstar", "entertainer", "human"] }, + { "category": "people", "char": "👩🎤", "name": "woman_singer", "keywords": ["rockstar", "entertainer", "woman", "human"] }, + { "category": "people", "char": "👨🎤", "name": "man_singer", "keywords": ["rockstar", "entertainer", "man", "human"] }, + { "category": "people", "char": "🧑🏫", "name": "teacher", "keywords": ["instructor", "professor", "human"] }, + { "category": "people", "char": "👩🏫", "name": "woman_teacher", "keywords": ["instructor", "professor", "woman", "human"] }, + { "category": "people", "char": "👨🏫", "name": "man_teacher", "keywords": ["instructor", "professor", "man", "human"] }, + { "category": "people", "char": "🧑🏭", "name": "factory_worker", "keywords": ["assembly", "industrial", "human"] }, + { "category": "people", "char": "👩🏭", "name": "woman_factory_worker", "keywords": ["assembly", "industrial", "woman", "human"] }, + { "category": "people", "char": "👨🏭", "name": "man_factory_worker", "keywords": ["assembly", "industrial", "man", "human"] }, + { "category": "people", "char": "🧑💻", "name": "technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "human", "laptop", "computer"] }, + { "category": "people", "char": "👩💻", "name": "woman_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "woman", "human", "laptop", "computer"] }, + { "category": "people", "char": "👨💻", "name": "man_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "man", "human", "laptop", "computer"] }, + { "category": "people", "char": "🧑💼", "name": "office_worker", "keywords": ["business", "manager", "human"] }, + { "category": "people", "char": "👩💼", "name": "woman_office_worker", "keywords": ["business", "manager", "woman", "human"] }, + { "category": "people", "char": "👨💼", "name": "man_office_worker", "keywords": ["business", "manager", "man", "human"] }, + { "category": "people", "char": "🧑🔧", "name": "mechanic", "keywords": ["plumber", "human", "wrench"] }, + { "category": "people", "char": "👩🔧", "name": "woman_mechanic", "keywords": ["plumber", "woman", "human", "wrench"] }, + { "category": "people", "char": "👨🔧", "name": "man_mechanic", "keywords": ["plumber", "man", "human", "wrench"] }, + { "category": "people", "char": "🧑🔬", "name": "scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "human"] }, + { "category": "people", "char": "👩🔬", "name": "woman_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "woman", "human"] }, + { "category": "people", "char": "👨🔬", "name": "man_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "man", "human"] }, + { "category": "people", "char": "🧑🎨", "name": "artist", "keywords": ["painter", "human"] }, + { "category": "people", "char": "👩🎨", "name": "woman_artist", "keywords": ["painter", "woman", "human"] }, + { "category": "people", "char": "👨🎨", "name": "man_artist", "keywords": ["painter", "man", "human"] }, + { "category": "people", "char": "🧑🚒", "name": "firefighter", "keywords": ["fireman", "human"] }, + { "category": "people", "char": "👩🚒", "name": "woman_firefighter", "keywords": ["fireman", "woman", "human"] }, + { "category": "people", "char": "👨🚒", "name": "man_firefighter", "keywords": ["fireman", "man", "human"] }, + { "category": "people", "char": "🧑✈️", "name": "pilot", "keywords": ["aviator", "plane", "human"] }, + { "category": "people", "char": "👩✈️", "name": "woman_pilot", "keywords": ["aviator", "plane", "woman", "human"] }, + { "category": "people", "char": "👨✈️", "name": "man_pilot", "keywords": ["aviator", "plane", "man", "human"] }, + { "category": "people", "char": "🧑🚀", "name": "astronaut", "keywords": ["space", "rocket", "human"] }, + { "category": "people", "char": "👩🚀", "name": "woman_astronaut", "keywords": ["space", "rocket", "woman", "human"] }, + { "category": "people", "char": "👨🚀", "name": "man_astronaut", "keywords": ["space", "rocket", "man", "human"] }, + { "category": "people", "char": "🧑⚖️", "name": "judge", "keywords": ["justice", "court", "human"] }, + { "category": "people", "char": "👩⚖️", "name": "woman_judge", "keywords": ["justice", "court", "woman", "human"] }, + { "category": "people", "char": "👨⚖️", "name": "man_judge", "keywords": ["justice", "court", "man", "human"] }, + { "category": "people", "char": "🦸♀️", "name": "woman_superhero", "keywords": ["woman", "female", "good", "heroine", "superpowers"] }, + { "category": "people", "char": "🦸♂️", "name": "man_superhero", "keywords": ["man", "male", "good", "hero", "superpowers"] }, + { "category": "people", "char": "🦹♀️", "name": "woman_supervillain", "keywords": ["woman", "female", "evil", "bad", "criminal", "heroine", "superpowers"] }, + { "category": "people", "char": "🦹♂️", "name": "man_supervillain", "keywords": ["man", "male", "evil", "bad", "criminal", "hero", "superpowers"] }, + { "category": "people", "char": "🤶", "name": "mrs_claus", "keywords": ["woman", "female", "xmas", "mother christmas"] }, + { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF84", "name": "mx_claus", "keywords": ["xmas", "christmas"] }, + { "category": "people", "char": "🎅", "name": "santa", "keywords": ["festival", "man", "male", "xmas", "father christmas"] }, + { "category": "people", "char": "🥷", "name": "ninja", "keywords": [] }, + { "category": "people", "char": "🧙♀️", "name": "sorceress", "keywords": ["woman", "female", "mage", "witch"] }, + { "category": "people", "char": "🧙♂️", "name": "wizard", "keywords": ["man", "male", "mage", "sorcerer"] }, + { "category": "people", "char": "🧝♀️", "name": "woman_elf", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧝♂️", "name": "man_elf", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧛♀️", "name": "woman_vampire", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧛♂️", "name": "man_vampire", "keywords": ["man", "male", "dracula"] }, + { "category": "people", "char": "🧟♀️", "name": "woman_zombie", "keywords": ["woman", "female", "undead", "walking dead"] }, + { "category": "people", "char": "🧟♂️", "name": "man_zombie", "keywords": ["man", "male", "dracula", "undead", "walking dead"] }, + { "category": "people", "char": "🧞♀️", "name": "woman_genie", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧞♂️", "name": "man_genie", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧜♀️", "name": "mermaid", "keywords": ["woman", "female", "merwoman", "ariel"] }, + { "category": "people", "char": "🧜♂️", "name": "merman", "keywords": ["man", "male", "triton"] }, + { "category": "people", "char": "🧚♀️", "name": "woman_fairy", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧚♂️", "name": "man_fairy", "keywords": ["man", "male"] }, + { "category": "people", "char": "👼", "name": "angel", "keywords": ["heaven", "wings", "halo"] }, + { "category": "people", "char": "\uD83E\uDDCC", "name": "troll", "keywords": [] }, + { "category": "people", "char": "🤰", "name": "pregnant_woman", "keywords": ["baby"] }, + { "category": "people", "char": "\uD83E\uDEC3", "name": "pregnant_man", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEC4", "name": "pregnant_person", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEC5", "name": "person_with_crown", "keywords": [] }, + { "category": "people", "char": "🤱", "name": "breastfeeding", "keywords": ["nursing", "baby"] }, + { "category": "people", "char": "\uD83D\uDC69\u200D\uD83C\uDF7C", "name": "woman_feeding_baby", "keywords": [] }, + { "category": "people", "char": "\uD83D\uDC68\u200D\uD83C\uDF7C", "name": "man_feeding_baby", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF7C", "name": "person_feeding_baby", "keywords": [] }, + { "category": "people", "char": "👸", "name": "princess", "keywords": ["girl", "woman", "female", "blond", "crown", "royal", "queen"] }, + { "category": "people", "char": "🤴", "name": "prince", "keywords": ["boy", "man", "male", "crown", "royal", "king"] }, + { "category": "people", "char": "👰", "name": "person_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] }, + { "category": "people", "char": "👰", "name": "bride_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] }, + { "category": "people", "char": "🤵", "name": "person_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] }, + { "category": "people", "char": "🤵", "name": "man_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] }, + { "category": "people", "char": "🏃♀️", "name": "running_woman", "keywords": ["woman", "walking", "exercise", "race", "running", "female"] }, + { "category": "people", "char": "🏃", "name": "running_man", "keywords": ["man", "walking", "exercise", "race", "running"] }, + { "category": "people", "char": "🚶♀️", "name": "walking_woman", "keywords": ["human", "feet", "steps", "woman", "female"] }, + { "category": "people", "char": "🚶", "name": "walking_man", "keywords": ["human", "feet", "steps"] }, + { "category": "people", "char": "💃", "name": "dancer", "keywords": ["female", "girl", "woman", "fun"] }, + { "category": "people", "char": "🕺", "name": "man_dancing", "keywords": ["male", "boy", "fun", "dancer"] }, + { "category": "people", "char": "👯", "name": "dancing_women", "keywords": ["female", "bunny", "women", "girls"] }, + { "category": "people", "char": "👯♂️", "name": "dancing_men", "keywords": ["male", "bunny", "men", "boys"] }, + { "category": "people", "char": "👫", "name": "couple", "keywords": ["pair", "people", "human", "love", "date", "dating", "like", "affection", "valentines", "marriage"] }, + { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1", "name": "people_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "human"] }, + { "category": "people", "char": "👬", "name": "two_men_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "man", "human"] }, + { "category": "people", "char": "👭", "name": "two_women_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "female", "human"] }, + { "category": "people", "char": "🫂", "name": "people_hugging", "keywords": [] }, + { "category": "people", "char": "🙇♀️", "name": "bowing_woman", "keywords": ["woman", "female", "girl"] }, + { "category": "people", "char": "🙇", "name": "bowing_man", "keywords": ["man", "male", "boy"] }, + { "category": "people", "char": "🤦♂️", "name": "man_facepalming", "keywords": ["man", "male", "boy", "disbelief"] }, + { "category": "people", "char": "🤦♀️", "name": "woman_facepalming", "keywords": ["woman", "female", "girl", "disbelief"] }, + { "category": "people", "char": "🤷", "name": "woman_shrugging", "keywords": ["woman", "female", "girl", "confused", "indifferent", "doubt"] }, + { "category": "people", "char": "🤷♂️", "name": "man_shrugging", "keywords": ["man", "male", "boy", "confused", "indifferent", "doubt"] }, + { "category": "people", "char": "💁", "name": "tipping_hand_woman", "keywords": ["female", "girl", "woman", "human", "information"] }, + { "category": "people", "char": "💁♂️", "name": "tipping_hand_man", "keywords": ["male", "boy", "man", "human", "information"] }, + { "category": "people", "char": "🙅", "name": "no_good_woman", "keywords": ["female", "girl", "woman", "nope"] }, + { "category": "people", "char": "🙅♂️", "name": "no_good_man", "keywords": ["male", "boy", "man", "nope"] }, + { "category": "people", "char": "🙆", "name": "ok_woman", "keywords": ["women", "girl", "female", "pink", "human", "woman"] }, + { "category": "people", "char": "🙆♂️", "name": "ok_man", "keywords": ["men", "boy", "male", "blue", "human", "man"] }, + { "category": "people", "char": "🙋", "name": "raising_hand_woman", "keywords": ["female", "girl", "woman"] }, + { "category": "people", "char": "🙋♂️", "name": "raising_hand_man", "keywords": ["male", "boy", "man"] }, + { "category": "people", "char": "🙎", "name": "pouting_woman", "keywords": ["female", "girl", "woman"] }, + { "category": "people", "char": "🙎♂️", "name": "pouting_man", "keywords": ["male", "boy", "man"] }, + { "category": "people", "char": "🙍", "name": "frowning_woman", "keywords": ["female", "girl", "woman", "sad", "depressed", "discouraged", "unhappy"] }, + { "category": "people", "char": "🙍♂️", "name": "frowning_man", "keywords": ["male", "boy", "man", "sad", "depressed", "discouraged", "unhappy"] }, + { "category": "people", "char": "💇", "name": "haircut_woman", "keywords": ["female", "girl", "woman"] }, + { "category": "people", "char": "💇♂️", "name": "haircut_man", "keywords": ["male", "boy", "man"] }, + { "category": "people", "char": "💆", "name": "massage_woman", "keywords": ["female", "girl", "woman", "head"] }, + { "category": "people", "char": "💆♂️", "name": "massage_man", "keywords": ["male", "boy", "man", "head"] }, + { "category": "people", "char": "🧖♀️", "name": "woman_in_steamy_room", "keywords": ["female", "woman", "spa", "steamroom", "sauna"] }, + { "category": "people", "char": "🧖♂️", "name": "man_in_steamy_room", "keywords": ["male", "man", "spa", "steamroom", "sauna"] }, + { "category": "people", "char": "🧏♀️", "name": "woman_deaf", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧏♂️", "name": "man_deaf", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧍♀️", "name": "woman_standing", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧍♂️", "name": "man_standing", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧎♀️", "name": "woman_kneeling", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧎♂️", "name": "man_kneeling", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧑🦯", "name": "person_with_probing_cane", "keywords": ["accessibility", "blind"] }, + { "category": "people", "char": "👩🦯", "name": "woman_with_probing_cane", "keywords": ["woman", "female", "accessibility", "blind"] }, + { "category": "people", "char": "👨🦯", "name": "man_with_probing_cane", "keywords": ["man", "male", "accessibility", "blind"] }, + { "category": "people", "char": "🧑🦼", "name": "person_in_motorized_wheelchair", "keywords": ["accessibility"] }, + { "category": "people", "char": "👩🦼", "name": "woman_in_motorized_wheelchair", "keywords": ["woman", "female", "accessibility"] }, + { "category": "people", "char": "👨🦼", "name": "man_in_motorized_wheelchair", "keywords": ["man", "male", "accessibility"] }, + { "category": "people", "char": "🧑🦽", "name": "person_in_manual_wheelchair", "keywords": ["accessibility"] }, + { "category": "people", "char": "👩🦽", "name": "woman_in_manual_wheelchair", "keywords": ["woman", "female", "accessibility"] }, + { "category": "people", "char": "👨🦽", "name": "man_in_manual_wheelchair", "keywords": ["man", "male", "accessibility"] }, + { "category": "people", "char": "💑", "name": "couple_with_heart_woman_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] }, + { "category": "people", "char": "👩❤️👩", "name": "couple_with_heart_woman_woman", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] }, + { "category": "people", "char": "👨❤️👨", "name": "couple_with_heart_man_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] }, + { "category": "people", "char": "💏", "name": "couplekiss_man_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] }, + { "category": "people", "char": "👩❤️💋👩", "name": "couplekiss_woman_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] }, + { "category": "people", "char": "👨❤️💋👨", "name": "couplekiss_man_man", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] }, + { "category": "people", "char": "👪", "name": "family_man_woman_boy", "keywords": ["home", "parents", "child", "mom", "dad", "father", "mother", "people", "human"] }, + { "category": "people", "char": "👨👩👧", "name": "family_man_woman_girl", "keywords": ["home", "parents", "people", "human", "child"] }, + { "category": "people", "char": "👨👩👧👦", "name": "family_man_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👩👦👦", "name": "family_man_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👩👧👧", "name": "family_man_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩👩👦", "name": "family_woman_woman_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩👩👧", "name": "family_woman_woman_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩👩👧👦", "name": "family_woman_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩👩👦👦", "name": "family_woman_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩👩👧👧", "name": "family_woman_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👨👦", "name": "family_man_man_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👨👧", "name": "family_man_man_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👨👧👦", "name": "family_man_man_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👨👦👦", "name": "family_man_man_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👨👧👧", "name": "family_man_man_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩👦", "name": "family_woman_boy", "keywords": ["home", "parent", "people", "human", "child"] }, + { "category": "people", "char": "👩👧", "name": "family_woman_girl", "keywords": ["home", "parent", "people", "human", "child"] }, + { "category": "people", "char": "👩👧👦", "name": "family_woman_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👩👦👦", "name": "family_woman_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👩👧👧", "name": "family_woman_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👨👦", "name": "family_man_boy", "keywords": ["home", "parent", "people", "human", "child"] }, + { "category": "people", "char": "👨👧", "name": "family_man_girl", "keywords": ["home", "parent", "people", "human", "child"] }, + { "category": "people", "char": "👨👧👦", "name": "family_man_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👨👦👦", "name": "family_man_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👨👧👧", "name": "family_man_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "🧶", "name": "yarn", "keywords": ["ball", "crochet", "knit"] }, + { "category": "people", "char": "🧵", "name": "thread", "keywords": ["needle", "sewing", "spool", "string"] }, + { "category": "people", "char": "🧥", "name": "coat", "keywords": ["jacket"] }, + { "category": "people", "char": "🥼", "name": "labcoat", "keywords": ["doctor", "experiment", "scientist", "chemist"] }, + { "category": "people", "char": "👚", "name": "womans_clothes", "keywords": ["fashion", "shopping_bags", "female"] }, + { "category": "people", "char": "👕", "name": "tshirt", "keywords": ["fashion", "cloth", "casual", "shirt", "tee"] }, + { "category": "people", "char": "👖", "name": "jeans", "keywords": ["fashion", "shopping"] }, + { "category": "people", "char": "👔", "name": "necktie", "keywords": ["shirt", "suitup", "formal", "fashion", "cloth", "business"] }, + { "category": "people", "char": "👗", "name": "dress", "keywords": ["clothes", "fashion", "shopping"] }, + { "category": "people", "char": "👙", "name": "bikini", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] }, + { "category": "people", "char": "🩱", "name": "one_piece_swimsuit", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] }, + { "category": "people", "char": "👘", "name": "kimono", "keywords": ["dress", "fashion", "women", "female", "japanese"] }, + { "category": "people", "char": "🥻", "name": "sari", "keywords": ["dress", "fashion", "women", "female"] }, + { "category": "people", "char": "🩲", "name": "briefs", "keywords": ["dress", "fashion"] }, + { "category": "people", "char": "🩳", "name": "shorts", "keywords": ["dress", "fashion"] }, + { "category": "people", "char": "💄", "name": "lipstick", "keywords": ["female", "girl", "fashion", "woman"] }, + { "category": "people", "char": "💋", "name": "kiss", "keywords": ["face", "lips", "love", "like", "affection", "valentines"] }, + { "category": "people", "char": "👣", "name": "footprints", "keywords": ["feet", "tracking", "walking", "beach"] }, + { "category": "people", "char": "🥿", "name": "flat_shoe", "keywords": ["ballet", "slip-on", "slipper"] }, + { "category": "people", "char": "👠", "name": "high_heel", "keywords": ["fashion", "shoes", "female", "pumps", "stiletto"] }, + { "category": "people", "char": "👡", "name": "sandal", "keywords": ["shoes", "fashion", "flip flops"] }, + { "category": "people", "char": "👢", "name": "boot", "keywords": ["shoes", "fashion"] }, + { "category": "people", "char": "👞", "name": "mans_shoe", "keywords": ["fashion", "male"] }, + { "category": "people", "char": "👟", "name": "athletic_shoe", "keywords": ["shoes", "sports", "sneakers"] }, + { "category": "people", "char": "🩴", "name": "thong_sandal", "keywords": [] }, + { "category": "people", "char": "🩰", "name": "ballet_shoes", "keywords": ["shoes", "sports"] }, + { "category": "people", "char": "🧦", "name": "socks", "keywords": ["stockings", "clothes"] }, + { "category": "people", "char": "🧤", "name": "gloves", "keywords": ["hands", "winter", "clothes"] }, + { "category": "people", "char": "🧣", "name": "scarf", "keywords": ["neck", "winter", "clothes"] }, + { "category": "people", "char": "👒", "name": "womans_hat", "keywords": ["fashion", "accessories", "female", "lady", "spring"] }, + { "category": "people", "char": "🎩", "name": "tophat", "keywords": ["magic", "gentleman", "classy", "circus"] }, + { "category": "people", "char": "🧢", "name": "billed_hat", "keywords": ["cap", "baseball"] }, + { "category": "people", "char": "⛑", "name": "rescue_worker_helmet", "keywords": ["construction", "build"] }, + { "category": "people", "char": "🪖", "name": "military_helmet", "keywords": [] }, + { "category": "people", "char": "🎓", "name": "mortar_board", "keywords": ["school", "college", "degree", "university", "graduation", "cap", "hat", "legal", "learn", "education"] }, + { "category": "people", "char": "👑", "name": "crown", "keywords": ["king", "kod", "leader", "royalty", "lord"] }, + { "category": "people", "char": "🎒", "name": "school_satchel", "keywords": ["student", "education", "bag", "backpack"] }, + { "category": "people", "char": "🧳", "name": "luggage", "keywords": ["packing", "travel"] }, + { "category": "people", "char": "👝", "name": "pouch", "keywords": ["bag", "accessories", "shopping"] }, + { "category": "people", "char": "👛", "name": "purse", "keywords": ["fashion", "accessories", "money", "sales", "shopping"] }, + { "category": "people", "char": "👜", "name": "handbag", "keywords": ["fashion", "accessory", "accessories", "shopping"] }, + { "category": "people", "char": "💼", "name": "briefcase", "keywords": ["business", "documents", "work", "law", "legal", "job", "career"] }, + { "category": "people", "char": "👓", "name": "eyeglasses", "keywords": ["fashion", "accessories", "eyesight", "nerdy", "dork", "geek"] }, + { "category": "people", "char": "🕶", "name": "dark_sunglasses", "keywords": ["face", "cool", "accessories"] }, + { "category": "people", "char": "🥽", "name": "goggles", "keywords": ["eyes", "protection", "safety"] }, + { "category": "people", "char": "💍", "name": "ring", "keywords": ["wedding", "propose", "marriage", "valentines", "diamond", "fashion", "jewelry", "gem", "engagement"] }, + { "category": "people", "char": "🌂", "name": "closed_umbrella", "keywords": ["weather", "rain", "drizzle"] }, + { "category": "animals_and_nature", "char": "🐶", "name": "dog", "keywords": ["animal", "friend", "nature", "woof", "puppy", "pet", "faithful"] }, + { "category": "animals_and_nature", "char": "🐱", "name": "cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] }, + { "category": "animals_and_nature", "char": "🐈⬛", "name": "black_cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] }, + { "category": "animals_and_nature", "char": "🐭", "name": "mouse", "keywords": ["animal", "nature", "cheese_wedge", "rodent"] }, + { "category": "animals_and_nature", "char": "🐹", "name": "hamster", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐰", "name": "rabbit", "keywords": ["animal", "nature", "pet", "spring", "magic", "bunny"] }, + { "category": "animals_and_nature", "char": "🦊", "name": "fox_face", "keywords": ["animal", "nature", "face"] }, + { "category": "animals_and_nature", "char": "🐻", "name": "bear", "keywords": ["animal", "nature", "wild"] }, + { "category": "animals_and_nature", "char": "🐼", "name": "panda_face", "keywords": ["animal", "nature", "panda"] }, + { "category": "animals_and_nature", "char": "🐨", "name": "koala", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐯", "name": "tiger", "keywords": ["animal", "cat", "danger", "wild", "nature", "roar"] }, + { "category": "animals_and_nature", "char": "🦁", "name": "lion", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐮", "name": "cow", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] }, + { "category": "animals_and_nature", "char": "🐷", "name": "pig", "keywords": ["animal", "oink", "nature"] }, + { "category": "animals_and_nature", "char": "🐽", "name": "pig_nose", "keywords": ["animal", "oink"] }, + { "category": "animals_and_nature", "char": "🐸", "name": "frog", "keywords": ["animal", "nature", "croak", "toad"] }, + { "category": "animals_and_nature", "char": "🦑", "name": "squid", "keywords": ["animal", "nature", "ocean", "sea"] }, + { "category": "animals_and_nature", "char": "🐙", "name": "octopus", "keywords": ["animal", "creature", "ocean", "sea", "nature", "beach"] }, + { "category": "animals_and_nature", "char": "🦐", "name": "shrimp", "keywords": ["animal", "ocean", "nature", "seafood"] }, + { "category": "animals_and_nature", "char": "🐵", "name": "monkey_face", "keywords": ["animal", "nature", "circus"] }, + { "category": "animals_and_nature", "char": "🦍", "name": "gorilla", "keywords": ["animal", "nature", "circus"] }, + { "category": "animals_and_nature", "char": "🙈", "name": "see_no_evil", "keywords": ["monkey", "animal", "nature", "haha"] }, + { "category": "animals_and_nature", "char": "🙉", "name": "hear_no_evil", "keywords": ["animal", "monkey", "nature"] }, + { "category": "animals_and_nature", "char": "🙊", "name": "speak_no_evil", "keywords": ["monkey", "animal", "nature", "omg"] }, + { "category": "animals_and_nature", "char": "🐒", "name": "monkey", "keywords": ["animal", "nature", "banana", "circus"] }, + { "category": "animals_and_nature", "char": "🐔", "name": "chicken", "keywords": ["animal", "cluck", "nature", "bird"] }, + { "category": "animals_and_nature", "char": "🐧", "name": "penguin", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐦", "name": "bird", "keywords": ["animal", "nature", "fly", "tweet", "spring"] }, + { "category": "animals_and_nature", "char": "🐤", "name": "baby_chick", "keywords": ["animal", "chicken", "bird"] }, + { "category": "animals_and_nature", "char": "🐣", "name": "hatching_chick", "keywords": ["animal", "chicken", "egg", "born", "baby", "bird"] }, + { "category": "animals_and_nature", "char": "🐥", "name": "hatched_chick", "keywords": ["animal", "chicken", "baby", "bird"] }, + { "category": "animals_and_nature", "char": "🦆", "name": "duck", "keywords": ["animal", "nature", "bird", "mallard"] }, + { "category": "animals_and_nature", "char": "🦅", "name": "eagle", "keywords": ["animal", "nature", "bird"] }, + { "category": "animals_and_nature", "char": "🦉", "name": "owl", "keywords": ["animal", "nature", "bird", "hoot"] }, + { "category": "animals_and_nature", "char": "🦇", "name": "bat", "keywords": ["animal", "nature", "blind", "vampire"] }, + { "category": "animals_and_nature", "char": "🐺", "name": "wolf", "keywords": ["animal", "nature", "wild"] }, + { "category": "animals_and_nature", "char": "🐗", "name": "boar", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐴", "name": "horse", "keywords": ["animal", "brown", "nature"] }, + { "category": "animals_and_nature", "char": "🦄", "name": "unicorn", "keywords": ["animal", "nature", "mystical"] }, + { "category": "animals_and_nature", "char": "🐝", "name": "honeybee", "keywords": ["animal", "insect", "nature", "bug", "spring", "honey"] }, + { "category": "animals_and_nature", "char": "🐛", "name": "bug", "keywords": ["animal", "insect", "nature", "worm"] }, + { "category": "animals_and_nature", "char": "🦋", "name": "butterfly", "keywords": ["animal", "insect", "nature", "caterpillar"] }, + { "category": "animals_and_nature", "char": "🐌", "name": "snail", "keywords": ["slow", "animal", "shell"] }, + { "category": "animals_and_nature", "char": "🐞", "name": "lady_beetle", "keywords": ["animal", "insect", "nature", "ladybug"] }, + { "category": "animals_and_nature", "char": "🐜", "name": "ant", "keywords": ["animal", "insect", "nature", "bug"] }, + { "category": "animals_and_nature", "char": "🦗", "name": "grasshopper", "keywords": ["animal", "cricket", "chirp"] }, + { "category": "animals_and_nature", "char": "🕷", "name": "spider", "keywords": ["animal", "arachnid"] }, + { "category": "animals_and_nature", "char": "🪲", "name": "beetle", "keywords": ["animal"] }, + { "category": "animals_and_nature", "char": "🪳", "name": "cockroach", "keywords": ["animal"] }, + { "category": "animals_and_nature", "char": "🪰", "name": "fly", "keywords": ["animal"] }, + { "category": "animals_and_nature", "char": "🪱", "name": "worm", "keywords": ["animal"] }, + { "category": "animals_and_nature", "char": "🦂", "name": "scorpion", "keywords": ["animal", "arachnid"] }, + { "category": "animals_and_nature", "char": "🦀", "name": "crab", "keywords": ["animal", "crustacean"] }, + { "category": "animals_and_nature", "char": "🐍", "name": "snake", "keywords": ["animal", "evil", "nature", "hiss", "python"] }, + { "category": "animals_and_nature", "char": "🦎", "name": "lizard", "keywords": ["animal", "nature", "reptile"] }, + { "category": "animals_and_nature", "char": "🦖", "name": "t-rex", "keywords": ["animal", "nature", "dinosaur", "tyrannosaurus", "extinct"] }, + { "category": "animals_and_nature", "char": "🦕", "name": "sauropod", "keywords": ["animal", "nature", "dinosaur", "brachiosaurus", "brontosaurus", "diplodocus", "extinct"] }, + { "category": "animals_and_nature", "char": "🐢", "name": "turtle", "keywords": ["animal", "slow", "nature", "tortoise"] }, + { "category": "animals_and_nature", "char": "🐠", "name": "tropical_fish", "keywords": ["animal", "swim", "ocean", "beach", "nemo"] }, + { "category": "animals_and_nature", "char": "🐟", "name": "fish", "keywords": ["animal", "food", "nature"] }, + { "category": "animals_and_nature", "char": "🐡", "name": "blowfish", "keywords": ["animal", "nature", "food", "sea", "ocean"] }, + { "category": "animals_and_nature", "char": "🐬", "name": "dolphin", "keywords": ["animal", "nature", "fish", "sea", "ocean", "flipper", "fins", "beach"] }, + { "category": "animals_and_nature", "char": "🦈", "name": "shark", "keywords": ["animal", "nature", "fish", "sea", "ocean", "jaws", "fins", "beach"] }, + { "category": "animals_and_nature", "char": "🐳", "name": "whale", "keywords": ["animal", "nature", "sea", "ocean"] }, + { "category": "animals_and_nature", "char": "🐋", "name": "whale2", "keywords": ["animal", "nature", "sea", "ocean"] }, + { "category": "animals_and_nature", "char": "🐊", "name": "crocodile", "keywords": ["animal", "nature", "reptile", "lizard", "alligator"] }, + { "category": "animals_and_nature", "char": "🐆", "name": "leopard", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦓", "name": "zebra", "keywords": ["animal", "nature", "stripes", "safari"] }, + { "category": "animals_and_nature", "char": "🐅", "name": "tiger2", "keywords": ["animal", "nature", "roar"] }, + { "category": "animals_and_nature", "char": "🐃", "name": "water_buffalo", "keywords": ["animal", "nature", "ox", "cow"] }, + { "category": "animals_and_nature", "char": "🐂", "name": "ox", "keywords": ["animal", "cow", "beef"] }, + { "category": "animals_and_nature", "char": "🐄", "name": "cow2", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] }, + { "category": "animals_and_nature", "char": "🦌", "name": "deer", "keywords": ["animal", "nature", "horns", "venison"] }, + { "category": "animals_and_nature", "char": "🐪", "name": "dromedary_camel", "keywords": ["animal", "hot", "desert", "hump"] }, + { "category": "animals_and_nature", "char": "🐫", "name": "camel", "keywords": ["animal", "nature", "hot", "desert", "hump"] }, + { "category": "animals_and_nature", "char": "🦒", "name": "giraffe", "keywords": ["animal", "nature", "spots", "safari"] }, + { "category": "animals_and_nature", "char": "🐘", "name": "elephant", "keywords": ["animal", "nature", "nose", "th", "circus"] }, + { "category": "animals_and_nature", "char": "🦏", "name": "rhinoceros", "keywords": ["animal", "nature", "horn"] }, + { "category": "animals_and_nature", "char": "🐐", "name": "goat", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐏", "name": "ram", "keywords": ["animal", "sheep", "nature"] }, + { "category": "animals_and_nature", "char": "🐑", "name": "sheep", "keywords": ["animal", "nature", "wool", "shipit"] }, + { "category": "animals_and_nature", "char": "🐎", "name": "racehorse", "keywords": ["animal", "gamble", "luck"] }, + { "category": "animals_and_nature", "char": "🐖", "name": "pig2", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐀", "name": "rat", "keywords": ["animal", "mouse", "rodent"] }, + { "category": "animals_and_nature", "char": "🐁", "name": "mouse2", "keywords": ["animal", "nature", "rodent"] }, + { "category": "animals_and_nature", "char": "🐓", "name": "rooster", "keywords": ["animal", "nature", "chicken"] }, + { "category": "animals_and_nature", "char": "🦃", "name": "turkey", "keywords": ["animal", "bird"] }, + { "category": "animals_and_nature", "char": "🕊", "name": "dove", "keywords": ["animal", "bird"] }, + { "category": "animals_and_nature", "char": "🐕", "name": "dog2", "keywords": ["animal", "nature", "friend", "doge", "pet", "faithful"] }, + { "category": "animals_and_nature", "char": "🐩", "name": "poodle", "keywords": ["dog", "animal", "101", "nature", "pet"] }, + { "category": "animals_and_nature", "char": "🐈", "name": "cat2", "keywords": ["animal", "meow", "pet", "cats"] }, + { "category": "animals_and_nature", "char": "🐇", "name": "rabbit2", "keywords": ["animal", "nature", "pet", "magic", "spring"] }, + { "category": "animals_and_nature", "char": "🐿", "name": "chipmunk", "keywords": ["animal", "nature", "rodent", "squirrel"] }, + { "category": "animals_and_nature", "char": "🦔", "name": "hedgehog", "keywords": ["animal", "nature", "spiny"] }, + { "category": "animals_and_nature", "char": "🦝", "name": "raccoon", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦙", "name": "llama", "keywords": ["animal", "nature", "alpaca"] }, + { "category": "animals_and_nature", "char": "🦛", "name": "hippopotamus", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦘", "name": "kangaroo", "keywords": ["animal", "nature", "australia", "joey", "hop", "marsupial"] }, + { "category": "animals_and_nature", "char": "🦡", "name": "badger", "keywords": ["animal", "nature", "honey"] }, + { "category": "animals_and_nature", "char": "🦢", "name": "swan", "keywords": ["animal", "nature", "bird"] }, + { "category": "animals_and_nature", "char": "🦚", "name": "peacock", "keywords": ["animal", "nature", "peahen", "bird"] }, + { "category": "animals_and_nature", "char": "🦜", "name": "parrot", "keywords": ["animal", "nature", "bird", "pirate", "talk"] }, + { "category": "animals_and_nature", "char": "🦞", "name": "lobster", "keywords": ["animal", "nature", "bisque", "claws", "seafood"] }, + { "category": "animals_and_nature", "char": "🦠", "name": "microbe", "keywords": ["amoeba", "bacteria", "germs"] }, + { "category": "animals_and_nature", "char": "🦟", "name": "mosquito", "keywords": ["animal", "nature", "insect", "malaria"] }, + { "category": "animals_and_nature", "char": "🦬", "name": "bison", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦣", "name": "mammoth", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦫", "name": "beaver", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐻❄️", "name": "polar_bear", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦤", "name": "dodo", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🪶", "name": "feather", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦭", "name": "seal", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐾", "name": "paw_prints", "keywords": ["animal", "tracking", "footprints", "dog", "cat", "pet", "feet"] }, + { "category": "animals_and_nature", "char": "🐉", "name": "dragon", "keywords": ["animal", "myth", "nature", "chinese", "green"] }, + { "category": "animals_and_nature", "char": "🐲", "name": "dragon_face", "keywords": ["animal", "myth", "nature", "chinese", "green"] }, + { "category": "animals_and_nature", "char": "🦧", "name": "orangutan", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦮", "name": "guide_dog", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐕🦺", "name": "service_dog", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦥", "name": "sloth", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦦", "name": "otter", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦨", "name": "skunk", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦩", "name": "flamingo", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🌵", "name": "cactus", "keywords": ["vegetable", "plant", "nature"] }, + { "category": "animals_and_nature", "char": "🎄", "name": "christmas_tree", "keywords": ["festival", "vacation", "december", "xmas", "celebration"] }, + { "category": "animals_and_nature", "char": "🌲", "name": "evergreen_tree", "keywords": ["plant", "nature"] }, + { "category": "animals_and_nature", "char": "🌳", "name": "deciduous_tree", "keywords": ["plant", "nature"] }, + { "category": "animals_and_nature", "char": "🌴", "name": "palm_tree", "keywords": ["plant", "vegetable", "nature", "summer", "beach", "mojito", "tropical"] }, + { "category": "animals_and_nature", "char": "🌱", "name": "seedling", "keywords": ["plant", "nature", "grass", "lawn", "spring"] }, + { "category": "animals_and_nature", "char": "🌿", "name": "herb", "keywords": ["vegetable", "plant", "medicine", "weed", "grass", "lawn"] }, + { "category": "animals_and_nature", "char": "☘", "name": "shamrock", "keywords": ["vegetable", "plant", "nature", "irish", "clover"] }, + { "category": "animals_and_nature", "char": "🍀", "name": "four_leaf_clover", "keywords": ["vegetable", "plant", "nature", "lucky", "irish"] }, + { "category": "animals_and_nature", "char": "🎍", "name": "bamboo", "keywords": ["plant", "nature", "vegetable", "panda", "pine_decoration"] }, + { "category": "animals_and_nature", "char": "🎋", "name": "tanabata_tree", "keywords": ["plant", "nature", "branch", "summer"] }, + { "category": "animals_and_nature", "char": "🍃", "name": "leaves", "keywords": ["nature", "plant", "tree", "vegetable", "grass", "lawn", "spring"] }, + { "category": "animals_and_nature", "char": "🍂", "name": "fallen_leaf", "keywords": ["nature", "plant", "vegetable", "leaves"] }, + { "category": "animals_and_nature", "char": "🍁", "name": "maple_leaf", "keywords": ["nature", "plant", "vegetable", "ca", "fall"] }, + { "category": "animals_and_nature", "char": "🌾", "name": "ear_of_rice", "keywords": ["nature", "plant"] }, + { "category": "animals_and_nature", "char": "🌺", "name": "hibiscus", "keywords": ["plant", "vegetable", "flowers", "beach"] }, + { "category": "animals_and_nature", "char": "🌻", "name": "sunflower", "keywords": ["nature", "plant", "fall"] }, + { "category": "animals_and_nature", "char": "🌹", "name": "rose", "keywords": ["flowers", "valentines", "love", "spring"] }, + { "category": "animals_and_nature", "char": "🥀", "name": "wilted_flower", "keywords": ["plant", "nature", "flower"] }, + { "category": "animals_and_nature", "char": "🌷", "name": "tulip", "keywords": ["flowers", "plant", "nature", "summer", "spring"] }, + { "category": "animals_and_nature", "char": "🌼", "name": "blossom", "keywords": ["nature", "flowers", "yellow"] }, + { "category": "animals_and_nature", "char": "🌸", "name": "cherry_blossom", "keywords": ["nature", "plant", "spring", "flower"] }, + { "category": "animals_and_nature", "char": "💐", "name": "bouquet", "keywords": ["flowers", "nature", "spring"] }, + { "category": "animals_and_nature", "char": "🍄", "name": "mushroom", "keywords": ["plant", "vegetable"] }, + { "category": "animals_and_nature", "char": "🪴", "name": "potted_plant", "keywords": ["plant"] }, + { "category": "animals_and_nature", "char": "🌰", "name": "chestnut", "keywords": ["food", "squirrel"] }, + { "category": "animals_and_nature", "char": "🎃", "name": "jack_o_lantern", "keywords": ["halloween", "light", "pumpkin", "creepy", "fall"] }, + { "category": "animals_and_nature", "char": "🐚", "name": "shell", "keywords": ["nature", "sea", "beach"] }, + { "category": "animals_and_nature", "char": "🕸", "name": "spider_web", "keywords": ["animal", "insect", "arachnid", "silk"] }, + { "category": "animals_and_nature", "char": "🌎", "name": "earth_americas", "keywords": ["globe", "world", "USA", "international"] }, + { "category": "animals_and_nature", "char": "🌍", "name": "earth_africa", "keywords": ["globe", "world", "international"] }, + { "category": "animals_and_nature", "char": "🌏", "name": "earth_asia", "keywords": ["globe", "world", "east", "international"] }, + { "category": "animals_and_nature", "char": "🪐", "name": "ringed_planet", "keywords": ["saturn"] }, + { "category": "animals_and_nature", "char": "🌕", "name": "full_moon", "keywords": ["nature", "yellow", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌖", "name": "waning_gibbous_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep", "waxing_gibbous_moon"] }, + { "category": "animals_and_nature", "char": "🌗", "name": "last_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌘", "name": "waning_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌑", "name": "new_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌒", "name": "waxing_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌓", "name": "first_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌔", "name": "waxing_gibbous_moon", "keywords": ["nature", "night", "sky", "gray", "twilight", "planet", "space", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌚", "name": "new_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌝", "name": "full_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌛", "name": "first_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌜", "name": "last_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌞", "name": "sun_with_face", "keywords": ["nature", "morning", "sky"] }, + { "category": "animals_and_nature", "char": "🌙", "name": "crescent_moon", "keywords": ["night", "sleep", "sky", "evening", "magic"] }, + { "category": "animals_and_nature", "char": "⭐", "name": "star", "keywords": ["night", "yellow"] }, + { "category": "animals_and_nature", "char": "🌟", "name": "star2", "keywords": ["night", "sparkle", "awesome", "good", "magic"] }, + { "category": "animals_and_nature", "char": "💫", "name": "dizzy", "keywords": ["star", "sparkle", "shoot", "magic"] }, + { "category": "animals_and_nature", "char": "✨", "name": "sparkles", "keywords": ["stars", "shine", "shiny", "cool", "awesome", "good", "magic"] }, + { "category": "animals_and_nature", "char": "☄", "name": "comet", "keywords": ["space"] }, + { "category": "animals_and_nature", "char": "☀️", "name": "sunny", "keywords": ["weather", "nature", "brightness", "summer", "beach", "spring"] }, + { "category": "animals_and_nature", "char": "🌤", "name": "sun_behind_small_cloud", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "⛅", "name": "partly_sunny", "keywords": ["weather", "nature", "cloudy", "morning", "fall", "spring"] }, + { "category": "animals_and_nature", "char": "🌥", "name": "sun_behind_large_cloud", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "🌦", "name": "sun_behind_rain_cloud", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "☁️", "name": "cloud", "keywords": ["weather", "sky"] }, + { "category": "animals_and_nature", "char": "🌧", "name": "cloud_with_rain", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "⛈", "name": "cloud_with_lightning_and_rain", "keywords": ["weather", "lightning"] }, + { "category": "animals_and_nature", "char": "🌩", "name": "cloud_with_lightning", "keywords": ["weather", "thunder"] }, + { "category": "animals_and_nature", "char": "⚡", "name": "zap", "keywords": ["thunder", "weather", "lightning bolt", "fast"] }, + { "category": "animals_and_nature", "char": "🔥", "name": "fire", "keywords": ["hot", "cook", "flame"] }, + { "category": "animals_and_nature", "char": "💥", "name": "boom", "keywords": ["bomb", "explode", "explosion", "collision", "blown"] }, + { "category": "animals_and_nature", "char": "❄️", "name": "snowflake", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas"] }, + { "category": "animals_and_nature", "char": "🌨", "name": "cloud_with_snow", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "⛄", "name": "snowman", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen", "without_snow"] }, + { "category": "animals_and_nature", "char": "☃", "name": "snowman_with_snow", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen"] }, + { "category": "animals_and_nature", "char": "🌬", "name": "wind_face", "keywords": ["gust", "air"] }, + { "category": "animals_and_nature", "char": "💨", "name": "dash", "keywords": ["wind", "air", "fast", "shoo", "fart", "smoke", "puff"] }, + { "category": "animals_and_nature", "char": "🌪", "name": "tornado", "keywords": ["weather", "cyclone", "twister"] }, + { "category": "animals_and_nature", "char": "🌫", "name": "fog", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "☂", "name": "open_umbrella", "keywords": ["weather", "spring"] }, + { "category": "animals_and_nature", "char": "☔", "name": "umbrella", "keywords": ["rainy", "weather", "spring"] }, + { "category": "animals_and_nature", "char": "💧", "name": "droplet", "keywords": ["water", "drip", "faucet", "spring"] }, + { "category": "animals_and_nature", "char": "💦", "name": "sweat_drops", "keywords": ["water", "drip", "oops"] }, + { "category": "animals_and_nature", "char": "🌊", "name": "ocean", "keywords": ["sea", "water", "wave", "nature", "tsunami", "disaster"] }, + { "category": "animals_and_nature", "char": "\uD83E\uDEB7", "name": "lotus", "keywords": [] }, + { "category": "animals_and_nature", "char": "\uD83E\uDEB8", "name": "coral", "keywords": [] }, + { "category": "animals_and_nature", "char": "\uD83E\uDEB9", "name": "empty_nest", "keywords": [] }, + { "category": "animals_and_nature", "char": "\uD83E\uDEBA", "name": "nest_with_eggs", "keywords": [] }, + { "category": "food_and_drink", "char": "🍏", "name": "green_apple", "keywords": ["fruit", "nature"] }, + { "category": "food_and_drink", "char": "🍎", "name": "apple", "keywords": ["fruit", "mac", "school"] }, + { "category": "food_and_drink", "char": "🍐", "name": "pear", "keywords": ["fruit", "nature", "food"] }, + { "category": "food_and_drink", "char": "🍊", "name": "tangerine", "keywords": ["food", "fruit", "nature", "orange"] }, + { "category": "food_and_drink", "char": "🍋", "name": "lemon", "keywords": ["fruit", "nature"] }, + { "category": "food_and_drink", "char": "🍌", "name": "banana", "keywords": ["fruit", "food", "monkey"] }, + { "category": "food_and_drink", "char": "🍉", "name": "watermelon", "keywords": ["fruit", "food", "picnic", "summer"] }, + { "category": "food_and_drink", "char": "🍇", "name": "grapes", "keywords": ["fruit", "food", "wine"] }, + { "category": "food_and_drink", "char": "🍓", "name": "strawberry", "keywords": ["fruit", "food", "nature"] }, + { "category": "food_and_drink", "char": "🍈", "name": "melon", "keywords": ["fruit", "nature", "food"] }, + { "category": "food_and_drink", "char": "🍒", "name": "cherries", "keywords": ["food", "fruit"] }, + { "category": "food_and_drink", "char": "🍑", "name": "peach", "keywords": ["fruit", "nature", "food"] }, + { "category": "food_and_drink", "char": "🍍", "name": "pineapple", "keywords": ["fruit", "nature", "food"] }, + { "category": "food_and_drink", "char": "🥥", "name": "coconut", "keywords": ["fruit", "nature", "food", "palm"] }, + { "category": "food_and_drink", "char": "🥝", "name": "kiwi_fruit", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🥭", "name": "mango", "keywords": ["fruit", "food", "tropical"] }, + { "category": "food_and_drink", "char": "🥑", "name": "avocado", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🥦", "name": "broccoli", "keywords": ["fruit", "food", "vegetable"] }, + { "category": "food_and_drink", "char": "🍅", "name": "tomato", "keywords": ["fruit", "vegetable", "nature", "food"] }, + { "category": "food_and_drink", "char": "🍆", "name": "eggplant", "keywords": ["vegetable", "nature", "food", "aubergine"] }, + { "category": "food_and_drink", "char": "🥒", "name": "cucumber", "keywords": ["fruit", "food", "pickle"] }, + { "category": "food_and_drink", "char": "🫐", "name": "blueberries", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🫒", "name": "olive", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🫑", "name": "bell_pepper", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🥕", "name": "carrot", "keywords": ["vegetable", "food", "orange"] }, + { "category": "food_and_drink", "char": "🌶", "name": "hot_pepper", "keywords": ["food", "spicy", "chilli", "chili"] }, + { "category": "food_and_drink", "char": "🥔", "name": "potato", "keywords": ["food", "tuber", "vegatable", "starch"] }, + { "category": "food_and_drink", "char": "🌽", "name": "corn", "keywords": ["food", "vegetable", "plant"] }, + { "category": "food_and_drink", "char": "🥬", "name": "leafy_greens", "keywords": ["food", "vegetable", "plant", "bok choy", "cabbage", "kale", "lettuce"] }, + { "category": "food_and_drink", "char": "🍠", "name": "sweet_potato", "keywords": ["food", "nature"] }, + { "category": "food_and_drink", "char": "🥜", "name": "peanuts", "keywords": ["food", "nut"] }, + { "category": "food_and_drink", "char": "🧄", "name": "garlic", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🧅", "name": "onion", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🍯", "name": "honey_pot", "keywords": ["bees", "sweet", "kitchen"] }, + { "category": "food_and_drink", "char": "🥐", "name": "croissant", "keywords": ["food", "bread", "french"] }, + { "category": "food_and_drink", "char": "🍞", "name": "bread", "keywords": ["food", "wheat", "breakfast", "toast"] }, + { "category": "food_and_drink", "char": "🥖", "name": "baguette_bread", "keywords": ["food", "bread", "french"] }, + { "category": "food_and_drink", "char": "🥯", "name": "bagel", "keywords": ["food", "bread", "bakery", "schmear"] }, + { "category": "food_and_drink", "char": "🥨", "name": "pretzel", "keywords": ["food", "bread", "twisted"] }, + { "category": "food_and_drink", "char": "🧀", "name": "cheese", "keywords": ["food", "chadder"] }, + { "category": "food_and_drink", "char": "🥚", "name": "egg", "keywords": ["food", "chicken", "breakfast"] }, + { "category": "food_and_drink", "char": "🥓", "name": "bacon", "keywords": ["food", "breakfast", "pork", "pig", "meat"] }, + { "category": "food_and_drink", "char": "🥩", "name": "steak", "keywords": ["food", "cow", "meat", "cut", "chop", "lambchop", "porkchop"] }, + { "category": "food_and_drink", "char": "🥞", "name": "pancakes", "keywords": ["food", "breakfast", "flapjacks", "hotcakes"] }, + { "category": "food_and_drink", "char": "🍗", "name": "poultry_leg", "keywords": ["food", "meat", "drumstick", "bird", "chicken", "turkey"] }, + { "category": "food_and_drink", "char": "🍖", "name": "meat_on_bone", "keywords": ["good", "food", "drumstick"] }, + { "category": "food_and_drink", "char": "🦴", "name": "bone", "keywords": ["skeleton"] }, + { "category": "food_and_drink", "char": "🍤", "name": "fried_shrimp", "keywords": ["food", "animal", "appetizer", "summer"] }, + { "category": "food_and_drink", "char": "🍳", "name": "fried_egg", "keywords": ["food", "breakfast", "kitchen", "egg"] }, + { "category": "food_and_drink", "char": "🍔", "name": "hamburger", "keywords": ["meat", "fast food", "beef", "cheeseburger", "mcdonalds", "burger king"] }, + { "category": "food_and_drink", "char": "🍟", "name": "fries", "keywords": ["chips", "snack", "fast food"] }, + { "category": "food_and_drink", "char": "🥙", "name": "stuffed_flatbread", "keywords": ["food", "flatbread", "stuffed", "gyro"] }, + { "category": "food_and_drink", "char": "🌭", "name": "hotdog", "keywords": ["food", "frankfurter"] }, + { "category": "food_and_drink", "char": "🍕", "name": "pizza", "keywords": ["food", "party"] }, + { "category": "food_and_drink", "char": "🥪", "name": "sandwich", "keywords": ["food", "lunch", "bread"] }, + { "category": "food_and_drink", "char": "🥫", "name": "canned_food", "keywords": ["food", "soup"] }, + { "category": "food_and_drink", "char": "🍝", "name": "spaghetti", "keywords": ["food", "italian", "noodle"] }, + { "category": "food_and_drink", "char": "🌮", "name": "taco", "keywords": ["food", "mexican"] }, + { "category": "food_and_drink", "char": "🌯", "name": "burrito", "keywords": ["food", "mexican"] }, + { "category": "food_and_drink", "char": "🥗", "name": "green_salad", "keywords": ["food", "healthy", "lettuce"] }, + { "category": "food_and_drink", "char": "🥘", "name": "shallow_pan_of_food", "keywords": ["food", "cooking", "casserole", "paella"] }, + { "category": "food_and_drink", "char": "🍜", "name": "ramen", "keywords": ["food", "japanese", "noodle", "chopsticks"] }, + { "category": "food_and_drink", "char": "🍲", "name": "stew", "keywords": ["food", "meat", "soup"] }, + { "category": "food_and_drink", "char": "🍥", "name": "fish_cake", "keywords": ["food", "japan", "sea", "beach", "narutomaki", "pink", "swirl", "kamaboko", "surimi", "ramen"] }, + { "category": "food_and_drink", "char": "🥠", "name": "fortune_cookie", "keywords": ["food", "prophecy"] }, + { "category": "food_and_drink", "char": "🍣", "name": "sushi", "keywords": ["food", "fish", "japanese", "rice"] }, + { "category": "food_and_drink", "char": "🍱", "name": "bento", "keywords": ["food", "japanese", "box"] }, + { "category": "food_and_drink", "char": "🍛", "name": "curry", "keywords": ["food", "spicy", "hot", "indian"] }, + { "category": "food_and_drink", "char": "🍙", "name": "rice_ball", "keywords": ["food", "japanese"] }, + { "category": "food_and_drink", "char": "🍚", "name": "rice", "keywords": ["food", "china", "asian"] }, + { "category": "food_and_drink", "char": "🍘", "name": "rice_cracker", "keywords": ["food", "japanese"] }, + { "category": "food_and_drink", "char": "🍢", "name": "oden", "keywords": ["food", "japanese"] }, + { "category": "food_and_drink", "char": "🍡", "name": "dango", "keywords": ["food", "dessert", "sweet", "japanese", "barbecue", "meat"] }, + { "category": "food_and_drink", "char": "🍧", "name": "shaved_ice", "keywords": ["hot", "dessert", "summer"] }, + { "category": "food_and_drink", "char": "🍨", "name": "ice_cream", "keywords": ["food", "hot", "dessert"] }, + { "category": "food_and_drink", "char": "🍦", "name": "icecream", "keywords": ["food", "hot", "dessert", "summer"] }, + { "category": "food_and_drink", "char": "🥧", "name": "pie", "keywords": ["food", "dessert", "pastry"] }, + { "category": "food_and_drink", "char": "🍰", "name": "cake", "keywords": ["food", "dessert"] }, + { "category": "food_and_drink", "char": "🧁", "name": "cupcake", "keywords": ["food", "dessert", "bakery", "sweet"] }, + { "category": "food_and_drink", "char": "🥮", "name": "moon_cake", "keywords": ["food", "autumn"] }, + { "category": "food_and_drink", "char": "🎂", "name": "birthday", "keywords": ["food", "dessert", "cake"] }, + { "category": "food_and_drink", "char": "🍮", "name": "custard", "keywords": ["dessert", "food"] }, + { "category": "food_and_drink", "char": "🍬", "name": "candy", "keywords": ["snack", "dessert", "sweet", "lolly"] }, + { "category": "food_and_drink", "char": "🍭", "name": "lollipop", "keywords": ["food", "snack", "candy", "sweet"] }, + { "category": "food_and_drink", "char": "🍫", "name": "chocolate_bar", "keywords": ["food", "snack", "dessert", "sweet"] }, + { "category": "food_and_drink", "char": "🍿", "name": "popcorn", "keywords": ["food", "movie theater", "films", "snack"] }, + { "category": "food_and_drink", "char": "🥟", "name": "dumpling", "keywords": ["food", "empanada", "pierogi", "potsticker"] }, + { "category": "food_and_drink", "char": "🍩", "name": "doughnut", "keywords": ["food", "dessert", "snack", "sweet", "donut"] }, + { "category": "food_and_drink", "char": "🍪", "name": "cookie", "keywords": ["food", "snack", "oreo", "chocolate", "sweet", "dessert"] }, + { "category": "food_and_drink", "char": "🧇", "name": "waffle", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🧆", "name": "falafel", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🧈", "name": "butter", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🦪", "name": "oyster", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🫓", "name": "flatbread", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🫔", "name": "tamale", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🫕", "name": "fondue", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🥛", "name": "milk_glass", "keywords": ["beverage", "drink", "cow"] }, + { "category": "food_and_drink", "char": "🍺", "name": "beer", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] }, + { "category": "food_and_drink", "char": "🍻", "name": "beers", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] }, + { "category": "food_and_drink", "char": "🥂", "name": "clinking_glasses", "keywords": ["beverage", "drink", "party", "alcohol", "celebrate", "cheers", "wine", "champagne", "toast"] }, + { "category": "food_and_drink", "char": "🍷", "name": "wine_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "booze"] }, + { "category": "food_and_drink", "char": "🥃", "name": "tumbler_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "liquor", "booze", "bourbon", "scotch", "whisky", "glass", "shot"] }, + { "category": "food_and_drink", "char": "🍸", "name": "cocktail", "keywords": ["drink", "drunk", "alcohol", "beverage", "booze", "mojito"] }, + { "category": "food_and_drink", "char": "🍹", "name": "tropical_drink", "keywords": ["beverage", "cocktail", "summer", "beach", "alcohol", "booze", "mojito"] }, + { "category": "food_and_drink", "char": "🍾", "name": "champagne", "keywords": ["drink", "wine", "bottle", "celebration"] }, + { "category": "food_and_drink", "char": "🍶", "name": "sake", "keywords": ["wine", "drink", "drunk", "beverage", "japanese", "alcohol", "booze"] }, + { "category": "food_and_drink", "char": "🍵", "name": "tea", "keywords": ["drink", "bowl", "breakfast", "green", "british"] }, + { "category": "food_and_drink", "char": "🥤", "name": "cup_with_straw", "keywords": ["drink", "soda"] }, + { "category": "food_and_drink", "char": "☕", "name": "coffee", "keywords": ["beverage", "caffeine", "latte", "espresso"] }, + { "category": "food_and_drink", "char": "🫖", "name": "teapot", "keywords": [] }, + { "category": "food_and_drink", "char": "🧋", "name": "bubble_tea", "keywords": ["tapioca"] }, + { "category": "food_and_drink", "char": "🍼", "name": "baby_bottle", "keywords": ["food", "container", "milk"] }, + { "category": "food_and_drink", "char": "🧃", "name": "beverage_box", "keywords": ["food", "drink"] }, + { "category": "food_and_drink", "char": "🧉", "name": "mate", "keywords": ["food", "drink"] }, + { "category": "food_and_drink", "char": "🧊", "name": "ice_cube", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🧂", "name": "salt", "keywords": ["condiment", "shaker"] }, + { "category": "food_and_drink", "char": "🥄", "name": "spoon", "keywords": ["cutlery", "kitchen", "tableware"] }, + { "category": "food_and_drink", "char": "🍴", "name": "fork_and_knife", "keywords": ["cutlery", "kitchen"] }, + { "category": "food_and_drink", "char": "🍽", "name": "plate_with_cutlery", "keywords": ["food", "eat", "meal", "lunch", "dinner", "restaurant"] }, + { "category": "food_and_drink", "char": "🥣", "name": "bowl_with_spoon", "keywords": ["food", "breakfast", "cereal", "oatmeal", "porridge"] }, + { "category": "food_and_drink", "char": "🥡", "name": "takeout_box", "keywords": ["food", "leftovers"] }, + { "category": "food_and_drink", "char": "🥢", "name": "chopsticks", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "\uD83E\uDED7", "name": "pouring_liquid", "keywords": [] }, + { "category": "food_and_drink", "char": "\uD83E\uDED8", "name": "beans", "keywords": [] }, + { "category": "food_and_drink", "char": "\uD83E\uDED9", "name": "jar", "keywords": [] }, + { "category": "activity", "char": "⚽", "name": "soccer", "keywords": ["sports", "football"] }, + { "category": "activity", "char": "🏀", "name": "basketball", "keywords": ["sports", "balls", "NBA"] }, + { "category": "activity", "char": "🏈", "name": "football", "keywords": ["sports", "balls", "NFL"] }, + { "category": "activity", "char": "⚾", "name": "baseball", "keywords": ["sports", "balls"] }, + { "category": "activity", "char": "🥎", "name": "softball", "keywords": ["sports", "balls"] }, + { "category": "activity", "char": "🎾", "name": "tennis", "keywords": ["sports", "balls", "green"] }, + { "category": "activity", "char": "🏐", "name": "volleyball", "keywords": ["sports", "balls"] }, + { "category": "activity", "char": "🏉", "name": "rugby_football", "keywords": ["sports", "team"] }, + { "category": "activity", "char": "🥏", "name": "flying_disc", "keywords": ["sports", "frisbee", "ultimate"] }, + { "category": "activity", "char": "🎱", "name": "8ball", "keywords": ["pool", "hobby", "game", "luck", "magic"] }, + { "category": "activity", "char": "⛳", "name": "golf", "keywords": ["sports", "business", "flag", "hole", "summer"] }, + { "category": "activity", "char": "🏌️♀️", "name": "golfing_woman", "keywords": ["sports", "business", "woman", "female"] }, + { "category": "activity", "char": "🏌", "name": "golfing_man", "keywords": ["sports", "business"] }, + { "category": "activity", "char": "🏓", "name": "ping_pong", "keywords": ["sports", "pingpong"] }, + { "category": "activity", "char": "🏸", "name": "badminton", "keywords": ["sports"] }, + { "category": "activity", "char": "🥅", "name": "goal_net", "keywords": ["sports"] }, + { "category": "activity", "char": "🏒", "name": "ice_hockey", "keywords": ["sports"] }, + { "category": "activity", "char": "🏑", "name": "field_hockey", "keywords": ["sports"] }, + { "category": "activity", "char": "🥍", "name": "lacrosse", "keywords": ["sports", "ball", "stick"] }, + { "category": "activity", "char": "🏏", "name": "cricket", "keywords": ["sports"] }, + { "category": "activity", "char": "🎿", "name": "ski", "keywords": ["sports", "winter", "cold", "snow"] }, + { "category": "activity", "char": "⛷", "name": "skier", "keywords": ["sports", "winter", "snow"] }, + { "category": "activity", "char": "🏂", "name": "snowboarder", "keywords": ["sports", "winter"] }, + { "category": "activity", "char": "🤺", "name": "person_fencing", "keywords": ["sports", "fencing", "sword"] }, + { "category": "activity", "char": "🤼♀️", "name": "women_wrestling", "keywords": ["sports", "wrestlers"] }, + { "category": "activity", "char": "🤼♂️", "name": "men_wrestling", "keywords": ["sports", "wrestlers"] }, + { "category": "activity", "char": "🤸♀️", "name": "woman_cartwheeling", "keywords": ["gymnastics"] }, + { "category": "activity", "char": "🤸♂️", "name": "man_cartwheeling", "keywords": ["gymnastics"] }, + { "category": "activity", "char": "🤾♀️", "name": "woman_playing_handball", "keywords": ["sports"] }, + { "category": "activity", "char": "🤾♂️", "name": "man_playing_handball", "keywords": ["sports"] }, + { "category": "activity", "char": "⛸", "name": "ice_skate", "keywords": ["sports"] }, + { "category": "activity", "char": "🥌", "name": "curling_stone", "keywords": ["sports"] }, + { "category": "activity", "char": "🛹", "name": "skateboard", "keywords": ["board"] }, + { "category": "activity", "char": "🛷", "name": "sled", "keywords": ["sleigh", "luge", "toboggan"] }, + { "category": "activity", "char": "🏹", "name": "bow_and_arrow", "keywords": ["sports"] }, + { "category": "activity", "char": "🎣", "name": "fishing_pole_and_fish", "keywords": ["food", "hobby", "summer"] }, + { "category": "activity", "char": "🥊", "name": "boxing_glove", "keywords": ["sports", "fighting"] }, + { "category": "activity", "char": "🥋", "name": "martial_arts_uniform", "keywords": ["judo", "karate", "taekwondo"] }, + { "category": "activity", "char": "🚣♀️", "name": "rowing_woman", "keywords": ["sports", "hobby", "water", "ship", "woman", "female"] }, + { "category": "activity", "char": "🚣", "name": "rowing_man", "keywords": ["sports", "hobby", "water", "ship"] }, + { "category": "activity", "char": "🧗♀️", "name": "climbing_woman", "keywords": ["sports", "hobby", "woman", "female", "rock"] }, + { "category": "activity", "char": "🧗♂️", "name": "climbing_man", "keywords": ["sports", "hobby", "man", "male", "rock"] }, + { "category": "activity", "char": "🏊♀️", "name": "swimming_woman", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer", "woman", "female"] }, + { "category": "activity", "char": "🏊", "name": "swimming_man", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer"] }, + { "category": "activity", "char": "🤽♀️", "name": "woman_playing_water_polo", "keywords": ["sports", "pool"] }, + { "category": "activity", "char": "🤽♂️", "name": "man_playing_water_polo", "keywords": ["sports", "pool"] }, + { "category": "activity", "char": "🧘♀️", "name": "woman_in_lotus_position", "keywords": ["woman", "female", "meditation", "yoga", "serenity", "zen", "mindfulness"] }, + { "category": "activity", "char": "🧘♂️", "name": "man_in_lotus_position", "keywords": ["man", "male", "meditation", "yoga", "serenity", "zen", "mindfulness"] }, + { "category": "activity", "char": "🏄♀️", "name": "surfing_woman", "keywords": ["sports", "ocean", "sea", "summer", "beach", "woman", "female"] }, + { "category": "activity", "char": "🏄", "name": "surfing_man", "keywords": ["sports", "ocean", "sea", "summer", "beach"] }, + { "category": "activity", "char": "🛀", "name": "bath", "keywords": ["clean", "shower", "bathroom"] }, + { "category": "activity", "char": "⛹️♀️", "name": "basketball_woman", "keywords": ["sports", "human", "woman", "female"] }, + { "category": "activity", "char": "⛹", "name": "basketball_man", "keywords": ["sports", "human"] }, + { "category": "activity", "char": "🏋️♀️", "name": "weight_lifting_woman", "keywords": ["sports", "training", "exercise", "woman", "female"] }, + { "category": "activity", "char": "🏋", "name": "weight_lifting_man", "keywords": ["sports", "training", "exercise"] }, + { "category": "activity", "char": "🚴♀️", "name": "biking_woman", "keywords": ["sports", "bike", "exercise", "hipster", "woman", "female"] }, + { "category": "activity", "char": "🚴", "name": "biking_man", "keywords": ["sports", "bike", "exercise", "hipster"] }, + { "category": "activity", "char": "🚵♀️", "name": "mountain_biking_woman", "keywords": ["transportation", "sports", "human", "race", "bike", "woman", "female"] }, + { "category": "activity", "char": "🚵", "name": "mountain_biking_man", "keywords": ["transportation", "sports", "human", "race", "bike"] }, + { "category": "activity", "char": "🏇", "name": "horse_racing", "keywords": ["animal", "betting", "competition", "gambling", "luck"] }, + { "category": "activity", "char": "🤿", "name": "diving_mask", "keywords": ["sports"] }, + { "category": "activity", "char": "🪀", "name": "yo_yo", "keywords": ["sports"] }, + { "category": "activity", "char": "🪁", "name": "kite", "keywords": ["sports"] }, + { "category": "activity", "char": "🦺", "name": "safety_vest", "keywords": ["sports"] }, + { "category": "activity", "char": "🪡", "name": "sewing_needle", "keywords": [] }, + { "category": "activity", "char": "🪢", "name": "knot", "keywords": [] }, + { "category": "activity", "char": "🕴", "name": "business_suit_levitating", "keywords": ["suit", "business", "levitate", "hover", "jump"] }, + { "category": "activity", "char": "🏆", "name": "trophy", "keywords": ["win", "award", "contest", "place", "ftw", "ceremony"] }, + { "category": "activity", "char": "🎽", "name": "running_shirt_with_sash", "keywords": ["play", "pageant"] }, + { "category": "activity", "char": "🏅", "name": "medal_sports", "keywords": ["award", "winning"] }, + { "category": "activity", "char": "🎖", "name": "medal_military", "keywords": ["award", "winning", "army"] }, + { "category": "activity", "char": "🥇", "name": "1st_place_medal", "keywords": ["award", "winning", "first"] }, + { "category": "activity", "char": "🥈", "name": "2nd_place_medal", "keywords": ["award", "second"] }, + { "category": "activity", "char": "🥉", "name": "3rd_place_medal", "keywords": ["award", "third"] }, + { "category": "activity", "char": "🎗", "name": "reminder_ribbon", "keywords": ["sports", "cause", "support", "awareness"] }, + { "category": "activity", "char": "🏵", "name": "rosette", "keywords": ["flower", "decoration", "military"] }, + { "category": "activity", "char": "🎫", "name": "ticket", "keywords": ["event", "concert", "pass"] }, + { "category": "activity", "char": "🎟", "name": "tickets", "keywords": ["sports", "concert", "entrance"] }, + { "category": "activity", "char": "🎭", "name": "performing_arts", "keywords": ["acting", "theater", "drama"] }, + { "category": "activity", "char": "🎨", "name": "art", "keywords": ["design", "paint", "draw", "colors"] }, + { "category": "activity", "char": "🎪", "name": "circus_tent", "keywords": ["festival", "carnival", "party"] }, + { "category": "activity", "char": "🤹♀️", "name": "woman_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] }, + { "category": "activity", "char": "🤹♂️", "name": "man_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] }, + { "category": "activity", "char": "🎤", "name": "microphone", "keywords": ["sound", "music", "PA", "sing", "talkshow"] }, + { "category": "activity", "char": "🎧", "name": "headphones", "keywords": ["music", "score", "gadgets"] }, + { "category": "activity", "char": "🎼", "name": "musical_score", "keywords": ["treble", "clef", "compose"] }, + { "category": "activity", "char": "🎹", "name": "musical_keyboard", "keywords": ["piano", "instrument", "compose"] }, + { "category": "activity", "char": "🥁", "name": "drum", "keywords": ["music", "instrument", "drumsticks", "snare"] }, + { "category": "activity", "char": "🎷", "name": "saxophone", "keywords": ["music", "instrument", "jazz", "blues"] }, + { "category": "activity", "char": "🎺", "name": "trumpet", "keywords": ["music", "brass"] }, + { "category": "activity", "char": "🎸", "name": "guitar", "keywords": ["music", "instrument"] }, + { "category": "activity", "char": "🎻", "name": "violin", "keywords": ["music", "instrument", "orchestra", "symphony"] }, + { "category": "activity", "char": "🪕", "name": "banjo", "keywords": ["music", "instrument"] }, + { "category": "activity", "char": "🪗", "name": "accordion", "keywords": ["music", "instrument"] }, + { "category": "activity", "char": "🪘", "name": "long_drum", "keywords": ["music", "instrument"] }, + { "category": "activity", "char": "🎬", "name": "clapper", "keywords": ["movie", "film", "record"] }, + { "category": "activity", "char": "🎮", "name": "video_game", "keywords": ["play", "console", "PS4", "controller"] }, + { "category": "activity", "char": "👾", "name": "space_invader", "keywords": ["game", "arcade", "play"] }, + { "category": "activity", "char": "🎯", "name": "dart", "keywords": ["game", "play", "bar", "target", "bullseye"] }, + { "category": "activity", "char": "🎲", "name": "game_die", "keywords": ["dice", "random", "tabletop", "play", "luck"] }, + { "category": "activity", "char": "♟️", "name": "chess_pawn", "keywords": ["expendable"] }, + { "category": "activity", "char": "🎰", "name": "slot_machine", "keywords": ["bet", "gamble", "vegas", "fruit machine", "luck", "casino"] }, + { "category": "activity", "char": "🧩", "name": "jigsaw", "keywords": ["interlocking", "puzzle", "piece"] }, + { "category": "activity", "char": "🎳", "name": "bowling", "keywords": ["sports", "fun", "play"] }, + { "category": "activity", "char": "🪄", "name": "magic_wand", "keywords": [] }, + { "category": "activity", "char": "🪅", "name": "pinata", "keywords": [] }, + { "category": "activity", "char": "🪆", "name": "nesting_dolls", "keywords": [] }, + { "category": "activity", "char": "\uD83E\uDEAC", "name": "hamsa", "keywords": [] }, + { "category": "activity", "char": "\uD83E\uDEA9", "name": "mirror_ball", "keywords": [] }, + { "category": "travel_and_places", "char": "🚗", "name": "red_car", "keywords": ["red", "transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚕", "name": "taxi", "keywords": ["uber", "vehicle", "cars", "transportation"] }, + { "category": "travel_and_places", "char": "🚙", "name": "blue_car", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚌", "name": "bus", "keywords": ["car", "vehicle", "transportation"] }, + { "category": "travel_and_places", "char": "🚎", "name": "trolleybus", "keywords": ["bart", "transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🏎", "name": "racing_car", "keywords": ["sports", "race", "fast", "formula", "f1"] }, + { "category": "travel_and_places", "char": "🚓", "name": "police_car", "keywords": ["vehicle", "cars", "transportation", "law", "legal", "enforcement"] }, + { "category": "travel_and_places", "char": "🚑", "name": "ambulance", "keywords": ["health", "911", "hospital"] }, + { "category": "travel_and_places", "char": "🚒", "name": "fire_engine", "keywords": ["transportation", "cars", "vehicle"] }, + { "category": "travel_and_places", "char": "🚐", "name": "minibus", "keywords": ["vehicle", "car", "transportation"] }, + { "category": "travel_and_places", "char": "🚚", "name": "truck", "keywords": ["cars", "transportation"] }, + { "category": "travel_and_places", "char": "🚛", "name": "articulated_lorry", "keywords": ["vehicle", "cars", "transportation", "express"] }, + { "category": "travel_and_places", "char": "🚜", "name": "tractor", "keywords": ["vehicle", "car", "farming", "agriculture"] }, + { "category": "travel_and_places", "char": "🛴", "name": "kick_scooter", "keywords": ["vehicle", "kick", "razor"] }, + { "category": "travel_and_places", "char": "🏍", "name": "motorcycle", "keywords": ["race", "sports", "fast"] }, + { "category": "travel_and_places", "char": "🚲", "name": "bike", "keywords": ["sports", "bicycle", "exercise", "hipster"] }, + { "category": "travel_and_places", "char": "🛵", "name": "motor_scooter", "keywords": ["vehicle", "vespa", "sasha"] }, + { "category": "travel_and_places", "char": "🦽", "name": "manual_wheelchair", "keywords": ["vehicle"] }, + { "category": "travel_and_places", "char": "🦼", "name": "motorized_wheelchair", "keywords": ["vehicle"] }, + { "category": "travel_and_places", "char": "🛺", "name": "auto_rickshaw", "keywords": ["vehicle"] }, + { "category": "travel_and_places", "char": "🪂", "name": "parachute", "keywords": ["vehicle"] }, + { "category": "travel_and_places", "char": "🚨", "name": "rotating_light", "keywords": ["police", "ambulance", "911", "emergency", "alert", "error", "pinged", "law", "legal"] }, + { "category": "travel_and_places", "char": "🚔", "name": "oncoming_police_car", "keywords": ["vehicle", "law", "legal", "enforcement", "911"] }, + { "category": "travel_and_places", "char": "🚍", "name": "oncoming_bus", "keywords": ["vehicle", "transportation"] }, + { "category": "travel_and_places", "char": "🚘", "name": "oncoming_automobile", "keywords": ["car", "vehicle", "transportation"] }, + { "category": "travel_and_places", "char": "🚖", "name": "oncoming_taxi", "keywords": ["vehicle", "cars", "uber"] }, + { "category": "travel_and_places", "char": "🚡", "name": "aerial_tramway", "keywords": ["transportation", "vehicle", "ski"] }, + { "category": "travel_and_places", "char": "🚠", "name": "mountain_cableway", "keywords": ["transportation", "vehicle", "ski"] }, + { "category": "travel_and_places", "char": "🚟", "name": "suspension_railway", "keywords": ["vehicle", "transportation"] }, + { "category": "travel_and_places", "char": "🚃", "name": "railway_car", "keywords": ["transportation", "vehicle", "train"] }, + { "category": "travel_and_places", "char": "🚋", "name": "train", "keywords": ["transportation", "vehicle", "carriage", "public", "travel"] }, + { "category": "travel_and_places", "char": "🚝", "name": "monorail", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚄", "name": "bullettrain_side", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚅", "name": "bullettrain_front", "keywords": ["transportation", "vehicle", "speed", "fast", "public", "travel"] }, + { "category": "travel_and_places", "char": "🚈", "name": "light_rail", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚞", "name": "mountain_railway", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚂", "name": "steam_locomotive", "keywords": ["transportation", "vehicle", "train"] }, + { "category": "travel_and_places", "char": "🚆", "name": "train2", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚇", "name": "metro", "keywords": ["transportation", "blue-square", "mrt", "underground", "tube"] }, + { "category": "travel_and_places", "char": "🚊", "name": "tram", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚉", "name": "station", "keywords": ["transportation", "vehicle", "public"] }, + { "category": "travel_and_places", "char": "🛸", "name": "flying_saucer", "keywords": ["transportation", "vehicle", "ufo"] }, + { "category": "travel_and_places", "char": "🚁", "name": "helicopter", "keywords": ["transportation", "vehicle", "fly"] }, + { "category": "travel_and_places", "char": "🛩", "name": "small_airplane", "keywords": ["flight", "transportation", "fly", "vehicle"] }, + { "category": "travel_and_places", "char": "✈️", "name": "airplane", "keywords": ["vehicle", "transportation", "flight", "fly"] }, + { "category": "travel_and_places", "char": "🛫", "name": "flight_departure", "keywords": ["airport", "flight", "landing"] }, + { "category": "travel_and_places", "char": "🛬", "name": "flight_arrival", "keywords": ["airport", "flight", "boarding"] }, + { "category": "travel_and_places", "char": "⛵", "name": "sailboat", "keywords": ["ship", "summer", "transportation", "water", "sailing"] }, + { "category": "travel_and_places", "char": "🛥", "name": "motor_boat", "keywords": ["ship"] }, + { "category": "travel_and_places", "char": "🚤", "name": "speedboat", "keywords": ["ship", "transportation", "vehicle", "summer"] }, + { "category": "travel_and_places", "char": "⛴", "name": "ferry", "keywords": ["boat", "ship", "yacht"] }, + { "category": "travel_and_places", "char": "🛳", "name": "passenger_ship", "keywords": ["yacht", "cruise", "ferry"] }, + { "category": "travel_and_places", "char": "🚀", "name": "rocket", "keywords": ["launch", "ship", "staffmode", "NASA", "outer space", "outer_space", "fly"] }, + { "category": "travel_and_places", "char": "🛰", "name": "artificial_satellite", "keywords": ["communication", "gps", "orbit", "spaceflight", "NASA", "ISS"] }, + { "category": "travel_and_places", "char": "🛻", "name": "pickup_truck", "keywords": ["car"] }, + { "category": "travel_and_places", "char": "🛼", "name": "roller_skate", "keywords": [] }, + { "category": "travel_and_places", "char": "💺", "name": "seat", "keywords": ["sit", "airplane", "transport", "bus", "flight", "fly"] }, + { "category": "travel_and_places", "char": "🛶", "name": "canoe", "keywords": ["boat", "paddle", "water", "ship"] }, + { "category": "travel_and_places", "char": "⚓", "name": "anchor", "keywords": ["ship", "ferry", "sea", "boat"] }, + { "category": "travel_and_places", "char": "🚧", "name": "construction", "keywords": ["wip", "progress", "caution", "warning"] }, + { "category": "travel_and_places", "char": "⛽", "name": "fuelpump", "keywords": ["gas station", "petroleum"] }, + { "category": "travel_and_places", "char": "🚏", "name": "busstop", "keywords": ["transportation", "wait"] }, + { "category": "travel_and_places", "char": "🚦", "name": "vertical_traffic_light", "keywords": ["transportation", "driving"] }, + { "category": "travel_and_places", "char": "🚥", "name": "traffic_light", "keywords": ["transportation", "signal"] }, + { "category": "travel_and_places", "char": "🏁", "name": "checkered_flag", "keywords": ["contest", "finishline", "race", "gokart"] }, + { "category": "travel_and_places", "char": "🚢", "name": "ship", "keywords": ["transportation", "titanic", "deploy"] }, + { "category": "travel_and_places", "char": "🎡", "name": "ferris_wheel", "keywords": ["photo", "carnival", "londoneye"] }, + { "category": "travel_and_places", "char": "🎢", "name": "roller_coaster", "keywords": ["carnival", "playground", "photo", "fun"] }, + { "category": "travel_and_places", "char": "🎠", "name": "carousel_horse", "keywords": ["photo", "carnival"] }, + { "category": "travel_and_places", "char": "🏗", "name": "building_construction", "keywords": ["wip", "working", "progress"] }, + { "category": "travel_and_places", "char": "🌁", "name": "foggy", "keywords": ["photo", "mountain"] }, + { "category": "travel_and_places", "char": "🏭", "name": "factory", "keywords": ["building", "industry", "pollution", "smoke"] }, + { "category": "travel_and_places", "char": "⛲", "name": "fountain", "keywords": ["photo", "summer", "water", "fresh"] }, + { "category": "travel_and_places", "char": "🎑", "name": "rice_scene", "keywords": ["photo", "japan", "asia", "tsukimi"] }, + { "category": "travel_and_places", "char": "⛰", "name": "mountain", "keywords": ["photo", "nature", "environment"] }, + { "category": "travel_and_places", "char": "🏔", "name": "mountain_snow", "keywords": ["photo", "nature", "environment", "winter", "cold"] }, + { "category": "travel_and_places", "char": "🗻", "name": "mount_fuji", "keywords": ["photo", "mountain", "nature", "japanese"] }, + { "category": "travel_and_places", "char": "🌋", "name": "volcano", "keywords": ["photo", "nature", "disaster"] }, + { "category": "travel_and_places", "char": "🗾", "name": "japan", "keywords": ["nation", "country", "japanese", "asia"] }, + { "category": "travel_and_places", "char": "🏕", "name": "camping", "keywords": ["photo", "outdoors", "tent"] }, + { "category": "travel_and_places", "char": "⛺", "name": "tent", "keywords": ["photo", "camping", "outdoors"] }, + { "category": "travel_and_places", "char": "🏞", "name": "national_park", "keywords": ["photo", "environment", "nature"] }, + { "category": "travel_and_places", "char": "🛣", "name": "motorway", "keywords": ["road", "cupertino", "interstate", "highway"] }, + { "category": "travel_and_places", "char": "🛤", "name": "railway_track", "keywords": ["train", "transportation"] }, + { "category": "travel_and_places", "char": "🌅", "name": "sunrise", "keywords": ["morning", "view", "vacation", "photo"] }, + { "category": "travel_and_places", "char": "🌄", "name": "sunrise_over_mountains", "keywords": ["view", "vacation", "photo"] }, + { "category": "travel_and_places", "char": "🏜", "name": "desert", "keywords": ["photo", "warm", "saharah"] }, + { "category": "travel_and_places", "char": "🏖", "name": "beach_umbrella", "keywords": ["weather", "summer", "sunny", "sand", "mojito"] }, + { "category": "travel_and_places", "char": "🏝", "name": "desert_island", "keywords": ["photo", "tropical", "mojito"] }, + { "category": "travel_and_places", "char": "🌇", "name": "city_sunrise", "keywords": ["photo", "good morning", "dawn"] }, + { "category": "travel_and_places", "char": "🌆", "name": "city_sunset", "keywords": ["photo", "evening", "sky", "buildings"] }, + { "category": "travel_and_places", "char": "🏙", "name": "cityscape", "keywords": ["photo", "night life", "urban"] }, + { "category": "travel_and_places", "char": "🌃", "name": "night_with_stars", "keywords": ["evening", "city", "downtown"] }, + { "category": "travel_and_places", "char": "🌉", "name": "bridge_at_night", "keywords": ["photo", "sanfrancisco"] }, + { "category": "travel_and_places", "char": "🌌", "name": "milky_way", "keywords": ["photo", "space", "stars"] }, + { "category": "travel_and_places", "char": "🌠", "name": "stars", "keywords": ["night", "photo"] }, + { "category": "travel_and_places", "char": "🎇", "name": "sparkler", "keywords": ["stars", "night", "shine"] }, + { "category": "travel_and_places", "char": "🎆", "name": "fireworks", "keywords": ["photo", "festival", "carnival", "congratulations"] }, + { "category": "travel_and_places", "char": "🌈", "name": "rainbow", "keywords": ["nature", "happy", "unicorn_face", "photo", "sky", "spring"] }, + { "category": "travel_and_places", "char": "🏘", "name": "houses", "keywords": ["buildings", "photo"] }, + { "category": "travel_and_places", "char": "🏰", "name": "european_castle", "keywords": ["building", "royalty", "history"] }, + { "category": "travel_and_places", "char": "🏯", "name": "japanese_castle", "keywords": ["photo", "building"] }, + { "category": "travel_and_places", "char": "🗼", "name": "tokyo_tower", "keywords": ["photo", "japanese"] }, + { "category": "travel_and_places", "char": "", "name": "shibuya_109", "keywords": ["photo", "japanese"] }, + { "category": "travel_and_places", "char": "🏟", "name": "stadium", "keywords": ["photo", "place", "sports", "concert", "venue"] }, + { "category": "travel_and_places", "char": "🗽", "name": "statue_of_liberty", "keywords": ["american", "newyork"] }, + { "category": "travel_and_places", "char": "🏠", "name": "house", "keywords": ["building", "home"] }, + { "category": "travel_and_places", "char": "🏡", "name": "house_with_garden", "keywords": ["home", "plant", "nature"] }, + { "category": "travel_and_places", "char": "🏚", "name": "derelict_house", "keywords": ["abandon", "evict", "broken", "building"] }, + { "category": "travel_and_places", "char": "🏢", "name": "office", "keywords": ["building", "bureau", "work"] }, + { "category": "travel_and_places", "char": "🏬", "name": "department_store", "keywords": ["building", "shopping", "mall"] }, + { "category": "travel_and_places", "char": "🏣", "name": "post_office", "keywords": ["building", "envelope", "communication"] }, + { "category": "travel_and_places", "char": "🏤", "name": "european_post_office", "keywords": ["building", "email"] }, + { "category": "travel_and_places", "char": "🏥", "name": "hospital", "keywords": ["building", "health", "surgery", "doctor"] }, + { "category": "travel_and_places", "char": "🏦", "name": "bank", "keywords": ["building", "money", "sales", "cash", "business", "enterprise"] }, + { "category": "travel_and_places", "char": "🏨", "name": "hotel", "keywords": ["building", "accomodation", "checkin"] }, + { "category": "travel_and_places", "char": "🏪", "name": "convenience_store", "keywords": ["building", "shopping", "groceries"] }, + { "category": "travel_and_places", "char": "🏫", "name": "school", "keywords": ["building", "student", "education", "learn", "teach"] }, + { "category": "travel_and_places", "char": "🏩", "name": "love_hotel", "keywords": ["like", "affection", "dating"] }, + { "category": "travel_and_places", "char": "💒", "name": "wedding", "keywords": ["love", "like", "affection", "couple", "marriage", "bride", "groom"] }, + { "category": "travel_and_places", "char": "🏛", "name": "classical_building", "keywords": ["art", "culture", "history"] }, + { "category": "travel_and_places", "char": "⛪", "name": "church", "keywords": ["building", "religion", "christ"] }, + { "category": "travel_and_places", "char": "🕌", "name": "mosque", "keywords": ["islam", "worship", "minaret"] }, + { "category": "travel_and_places", "char": "🕍", "name": "synagogue", "keywords": ["judaism", "worship", "temple", "jewish"] }, + { "category": "travel_and_places", "char": "🕋", "name": "kaaba", "keywords": ["mecca", "mosque", "islam"] }, + { "category": "travel_and_places", "char": "⛩", "name": "shinto_shrine", "keywords": ["temple", "japan", "kyoto"] }, + { "category": "travel_and_places", "char": "🛕", "name": "hindu_temple", "keywords": ["temple"] }, + { "category": "travel_and_places", "char": "🪨", "name": "rock", "keywords": [] }, + { "category": "travel_and_places", "char": "🪵", "name": "wood", "keywords": [] }, + { "category": "travel_and_places", "char": "🛖", "name": "hut", "keywords": [] }, + { "category": "travel_and_places", "char": "\uD83D\uDEDD", "name": "playground_slide", "keywords": [] }, + { "category": "travel_and_places", "char": "\uD83D\uDEDE", "name": "wheel", "keywords": [] }, + { "category": "travel_and_places", "char": "\uD83D\uDEDF", "name": "ring_buoy", "keywords": [] }, + { "category": "objects", "char": "⌚", "name": "watch", "keywords": ["time", "accessories"] }, + { "category": "objects", "char": "📱", "name": "iphone", "keywords": ["technology", "apple", "gadgets", "dial"] }, + { "category": "objects", "char": "📲", "name": "calling", "keywords": ["iphone", "incoming"] }, + { "category": "objects", "char": "💻", "name": "computer", "keywords": ["technology", "laptop", "screen", "display", "monitor"] }, + { "category": "objects", "char": "⌨", "name": "keyboard", "keywords": ["technology", "computer", "type", "input", "text"] }, + { "category": "objects", "char": "🖥", "name": "desktop_computer", "keywords": ["technology", "computing", "screen"] }, + { "category": "objects", "char": "🖨", "name": "printer", "keywords": ["paper", "ink"] }, + { "category": "objects", "char": "🖱", "name": "computer_mouse", "keywords": ["click"] }, + { "category": "objects", "char": "🖲", "name": "trackball", "keywords": ["technology", "trackpad"] }, + { "category": "objects", "char": "🕹", "name": "joystick", "keywords": ["game", "play"] }, + { "category": "objects", "char": "🗜", "name": "clamp", "keywords": ["tool"] }, + { "category": "objects", "char": "💽", "name": "minidisc", "keywords": ["technology", "record", "data", "disk", "90s"] }, + { "category": "objects", "char": "💾", "name": "floppy_disk", "keywords": ["oldschool", "technology", "save", "90s", "80s"] }, + { "category": "objects", "char": "💿", "name": "cd", "keywords": ["technology", "dvd", "disk", "disc", "90s"] }, + { "category": "objects", "char": "📀", "name": "dvd", "keywords": ["cd", "disk", "disc"] }, + { "category": "objects", "char": "📼", "name": "vhs", "keywords": ["record", "video", "oldschool", "90s", "80s"] }, + { "category": "objects", "char": "📷", "name": "camera", "keywords": ["gadgets", "photography"] }, + { "category": "objects", "char": "📸", "name": "camera_flash", "keywords": ["photography", "gadgets"] }, + { "category": "objects", "char": "📹", "name": "video_camera", "keywords": ["film", "record"] }, + { "category": "objects", "char": "🎥", "name": "movie_camera", "keywords": ["film", "record"] }, + { "category": "objects", "char": "📽", "name": "film_projector", "keywords": ["video", "tape", "record", "movie"] }, + { "category": "objects", "char": "🎞", "name": "film_strip", "keywords": ["movie"] }, + { "category": "objects", "char": "📞", "name": "telephone_receiver", "keywords": ["technology", "communication", "dial"] }, + { "category": "objects", "char": "☎️", "name": "phone", "keywords": ["technology", "communication", "dial", "telephone"] }, + { "category": "objects", "char": "📟", "name": "pager", "keywords": ["bbcall", "oldschool", "90s"] }, + { "category": "objects", "char": "📠", "name": "fax", "keywords": ["communication", "technology"] }, + { "category": "objects", "char": "📺", "name": "tv", "keywords": ["technology", "program", "oldschool", "show", "television"] }, + { "category": "objects", "char": "📻", "name": "radio", "keywords": ["communication", "music", "podcast", "program"] }, + { "category": "objects", "char": "🎙", "name": "studio_microphone", "keywords": ["sing", "recording", "artist", "talkshow"] }, + { "category": "objects", "char": "🎚", "name": "level_slider", "keywords": ["scale"] }, + { "category": "objects", "char": "🎛", "name": "control_knobs", "keywords": ["dial"] }, + { "category": "objects", "char": "🧭", "name": "compass", "keywords": ["magnetic", "navigation", "orienteering"] }, + { "category": "objects", "char": "⏱", "name": "stopwatch", "keywords": ["time", "deadline"] }, + { "category": "objects", "char": "⏲", "name": "timer_clock", "keywords": ["alarm"] }, + { "category": "objects", "char": "⏰", "name": "alarm_clock", "keywords": ["time", "wake"] }, + { "category": "objects", "char": "🕰", "name": "mantelpiece_clock", "keywords": ["time"] }, + { "category": "objects", "char": "⏳", "name": "hourglass_flowing_sand", "keywords": ["oldschool", "time", "countdown"] }, + { "category": "objects", "char": "⌛", "name": "hourglass", "keywords": ["time", "clock", "oldschool", "limit", "exam", "quiz", "test"] }, + { "category": "objects", "char": "📡", "name": "satellite", "keywords": ["communication", "future", "radio", "space"] }, + { "category": "objects", "char": "🔋", "name": "battery", "keywords": ["power", "energy", "sustain"] }, + { "category": "objects", "char": "\uD83E\uDEAB", "name": "battery", "keywords": [] }, + { "category": "objects", "char": "🔌", "name": "electric_plug", "keywords": ["charger", "power"] }, + { "category": "objects", "char": "💡", "name": "bulb", "keywords": ["light", "electricity", "idea"] }, + { "category": "objects", "char": "🔦", "name": "flashlight", "keywords": ["dark", "camping", "sight", "night"] }, + { "category": "objects", "char": "🕯", "name": "candle", "keywords": ["fire", "wax"] }, + { "category": "objects", "char": "🧯", "name": "fire_extinguisher", "keywords": ["quench"] }, + { "category": "objects", "char": "🗑", "name": "wastebasket", "keywords": ["bin", "trash", "rubbish", "garbage", "toss"] }, + { "category": "objects", "char": "🛢", "name": "oil_drum", "keywords": ["barrell"] }, + { "category": "objects", "char": "💸", "name": "money_with_wings", "keywords": ["dollar", "bills", "payment", "sale"] }, + { "category": "objects", "char": "💵", "name": "dollar", "keywords": ["money", "sales", "bill", "currency"] }, + { "category": "objects", "char": "💴", "name": "yen", "keywords": ["money", "sales", "japanese", "dollar", "currency"] }, + { "category": "objects", "char": "💶", "name": "euro", "keywords": ["money", "sales", "dollar", "currency"] }, + { "category": "objects", "char": "💷", "name": "pound", "keywords": ["british", "sterling", "money", "sales", "bills", "uk", "england", "currency"] }, + { "category": "objects", "char": "💰", "name": "moneybag", "keywords": ["dollar", "payment", "coins", "sale"] }, + { "category": "objects", "char": "🪙", "name": "coin", "keywords": ["dollar", "payment", "coins", "sale"] }, + { "category": "objects", "char": "💳", "name": "credit_card", "keywords": ["money", "sales", "dollar", "bill", "payment", "shopping"] }, + { "category": "objects", "char": "\uD83E\uDEAB", "name": "identification_card", "keywords": [] }, + { "category": "objects", "char": "💎", "name": "gem", "keywords": ["blue", "ruby", "diamond", "jewelry"] }, + { "category": "objects", "char": "⚖", "name": "balance_scale", "keywords": ["law", "fairness", "weight"] }, + { "category": "objects", "char": "🧰", "name": "toolbox", "keywords": ["tools", "diy", "fix", "maintainer", "mechanic"] }, + { "category": "objects", "char": "🔧", "name": "wrench", "keywords": ["tools", "diy", "ikea", "fix", "maintainer"] }, + { "category": "objects", "char": "🔨", "name": "hammer", "keywords": ["tools", "build", "create"] }, + { "category": "objects", "char": "⚒", "name": "hammer_and_pick", "keywords": ["tools", "build", "create"] }, + { "category": "objects", "char": "🛠", "name": "hammer_and_wrench", "keywords": ["tools", "build", "create"] }, + { "category": "objects", "char": "⛏", "name": "pick", "keywords": ["tools", "dig"] }, + { "category": "objects", "char": "🪓", "name": "axe", "keywords": ["tools"] }, + { "category": "objects", "char": "🦯", "name": "probing_cane", "keywords": ["tools"] }, + { "category": "objects", "char": "🔩", "name": "nut_and_bolt", "keywords": ["handy", "tools", "fix"] }, + { "category": "objects", "char": "⚙", "name": "gear", "keywords": ["cog"] }, + { "category": "objects", "char": "🪃", "name": "boomerang", "keywords": ["tool"] }, + { "category": "objects", "char": "🪚", "name": "carpentry_saw", "keywords": ["tool"] }, + { "category": "objects", "char": "🪛", "name": "screwdriver", "keywords": ["tool"] }, + { "category": "objects", "char": "🪝", "name": "hook", "keywords": ["tool"] }, + { "category": "objects", "char": "🪜", "name": "ladder", "keywords": ["tool"] }, + { "category": "objects", "char": "🧱", "name": "brick", "keywords": ["bricks"] }, + { "category": "objects", "char": "⛓", "name": "chains", "keywords": ["lock", "arrest"] }, + { "category": "objects", "char": "🧲", "name": "magnet", "keywords": ["attraction", "magnetic"] }, + { "category": "objects", "char": "🔫", "name": "gun", "keywords": ["violence", "weapon", "pistol", "revolver"] }, + { "category": "objects", "char": "💣", "name": "bomb", "keywords": ["boom", "explode", "explosion", "terrorism"] }, + { "category": "objects", "char": "🧨", "name": "firecracker", "keywords": ["dynamite", "boom", "explode", "explosion", "explosive"] }, + { "category": "objects", "char": "🔪", "name": "hocho", "keywords": ["knife", "blade", "cutlery", "kitchen", "weapon"] }, + { "category": "objects", "char": "🗡", "name": "dagger", "keywords": ["weapon"] }, + { "category": "objects", "char": "⚔", "name": "crossed_swords", "keywords": ["weapon"] }, + { "category": "objects", "char": "🛡", "name": "shield", "keywords": ["protection", "security"] }, + { "category": "objects", "char": "🚬", "name": "smoking", "keywords": ["kills", "tobacco", "cigarette", "joint", "smoke"] }, + { "category": "objects", "char": "☠", "name": "skull_and_crossbones", "keywords": ["poison", "danger", "deadly", "scary", "death", "pirate", "evil"] }, + { "category": "objects", "char": "⚰", "name": "coffin", "keywords": ["vampire", "dead", "die", "death", "rip", "graveyard", "cemetery", "casket", "funeral", "box"] }, + { "category": "objects", "char": "⚱", "name": "funeral_urn", "keywords": ["dead", "die", "death", "rip", "ashes"] }, + { "category": "objects", "char": "🏺", "name": "amphora", "keywords": ["vase", "jar"] }, + { "category": "objects", "char": "🔮", "name": "crystal_ball", "keywords": ["disco", "party", "magic", "circus", "fortune_teller"] }, + { "category": "objects", "char": "📿", "name": "prayer_beads", "keywords": ["dhikr", "religious"] }, + { "category": "objects", "char": "🧿", "name": "nazar_amulet", "keywords": ["bead", "charm"] }, + { "category": "objects", "char": "💈", "name": "barber", "keywords": ["hair", "salon", "style"] }, + { "category": "objects", "char": "⚗", "name": "alembic", "keywords": ["distilling", "science", "experiment", "chemistry"] }, + { "category": "objects", "char": "🔭", "name": "telescope", "keywords": ["stars", "space", "zoom", "science", "astronomy"] }, + { "category": "objects", "char": "🔬", "name": "microscope", "keywords": ["laboratory", "experiment", "zoomin", "science", "study"] }, + { "category": "objects", "char": "🕳", "name": "hole", "keywords": ["embarrassing"] }, + { "category": "objects", "char": "💊", "name": "pill", "keywords": ["health", "medicine", "doctor", "pharmacy", "drug"] }, + { "category": "objects", "char": "💉", "name": "syringe", "keywords": ["health", "hospital", "drugs", "blood", "medicine", "needle", "doctor", "nurse"] }, + { "category": "objects", "char": "🩸", "name": "drop_of_blood", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] }, + { "category": "objects", "char": "🩹", "name": "adhesive_bandage", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] }, + { "category": "objects", "char": "🩺", "name": "stethoscope", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] }, + { "category": "objects", "char": "🪒", "name": "razor", "keywords": ["health"] }, + { "category": "objects", "char": "\uD83E\uDE7B", "name": "xray", "keywords": [] }, + { "category": "objects", "char": "\uD83E\uDE7C", "name": "crutch", "keywords": [] }, + { "category": "objects", "char": "🧬", "name": "dna", "keywords": ["biologist", "genetics", "life"] }, + { "category": "objects", "char": "🧫", "name": "petri_dish", "keywords": ["bacteria", "biology", "culture", "lab"] }, + { "category": "objects", "char": "🧪", "name": "test_tube", "keywords": ["chemistry", "experiment", "lab", "science"] }, + { "category": "objects", "char": "🌡", "name": "thermometer", "keywords": ["weather", "temperature", "hot", "cold"] }, + { "category": "objects", "char": "🧹", "name": "broom", "keywords": ["cleaning", "sweeping", "witch"] }, + { "category": "objects", "char": "🧺", "name": "basket", "keywords": ["laundry"] }, + { "category": "objects", "char": "🧻", "name": "toilet_paper", "keywords": ["roll"] }, + { "category": "objects", "char": "🏷", "name": "label", "keywords": ["sale", "tag"] }, + { "category": "objects", "char": "🔖", "name": "bookmark", "keywords": ["favorite", "label", "save"] }, + { "category": "objects", "char": "🚽", "name": "toilet", "keywords": ["restroom", "wc", "washroom", "bathroom", "potty"] }, + { "category": "objects", "char": "🚿", "name": "shower", "keywords": ["clean", "water", "bathroom"] }, + { "category": "objects", "char": "🛁", "name": "bathtub", "keywords": ["clean", "shower", "bathroom"] }, + { "category": "objects", "char": "🧼", "name": "soap", "keywords": ["bar", "bathing", "cleaning", "lather"] }, + { "category": "objects", "char": "🧽", "name": "sponge", "keywords": ["absorbing", "cleaning", "porous"] }, + { "category": "objects", "char": "🧴", "name": "lotion_bottle", "keywords": ["moisturizer", "sunscreen"] }, + { "category": "objects", "char": "🔑", "name": "key", "keywords": ["lock", "door", "password"] }, + { "category": "objects", "char": "🗝", "name": "old_key", "keywords": ["lock", "door", "password"] }, + { "category": "objects", "char": "🛋", "name": "couch_and_lamp", "keywords": ["read", "chill"] }, + { "category": "objects", "char": "🪔", "name": "diya_Lamp", "keywords": ["light", "oil"] }, + { "category": "objects", "char": "🛌", "name": "sleeping_bed", "keywords": ["bed", "rest"] }, + { "category": "objects", "char": "🛏", "name": "bed", "keywords": ["sleep", "rest"] }, + { "category": "objects", "char": "🚪", "name": "door", "keywords": ["house", "entry", "exit"] }, + { "category": "objects", "char": "🪑", "name": "chair", "keywords": ["house", "desk"] }, + { "category": "objects", "char": "🛎", "name": "bellhop_bell", "keywords": ["service"] }, + { "category": "objects", "char": "🧸", "name": "teddy_bear", "keywords": ["plush", "stuffed"] }, + { "category": "objects", "char": "🖼", "name": "framed_picture", "keywords": ["photography"] }, + { "category": "objects", "char": "🗺", "name": "world_map", "keywords": ["location", "direction"] }, + { "category": "objects", "char": "🛗", "name": "elevator", "keywords": ["household"] }, + { "category": "objects", "char": "🪞", "name": "mirror", "keywords": ["household"] }, + { "category": "objects", "char": "🪟", "name": "window", "keywords": ["household"] }, + { "category": "objects", "char": "🪠", "name": "plunger", "keywords": ["household"] }, + { "category": "objects", "char": "🪤", "name": "mouse_trap", "keywords": ["household"] }, + { "category": "objects", "char": "🪣", "name": "bucket", "keywords": ["household"] }, + { "category": "objects", "char": "🪥", "name": "toothbrush", "keywords": ["household"] }, + { "category": "objects", "char": "\uD83E\uDEE7", "name": "bubbles", "keywords": [] }, + { "category": "objects", "char": "⛱", "name": "parasol_on_ground", "keywords": ["weather", "summer"] }, + { "category": "objects", "char": "🗿", "name": "moyai", "keywords": ["rock", "easter island", "moai"] }, + { "category": "objects", "char": "🛍", "name": "shopping", "keywords": ["mall", "buy", "purchase"] }, + { "category": "objects", "char": "🛒", "name": "shopping_cart", "keywords": ["trolley"] }, + { "category": "objects", "char": "🎈", "name": "balloon", "keywords": ["party", "celebration", "birthday", "circus"] }, + { "category": "objects", "char": "🎏", "name": "flags", "keywords": ["fish", "japanese", "koinobori", "carp", "banner"] }, + { "category": "objects", "char": "🎀", "name": "ribbon", "keywords": ["decoration", "pink", "girl", "bowtie"] }, + { "category": "objects", "char": "🎁", "name": "gift", "keywords": ["present", "birthday", "christmas", "xmas"] }, + { "category": "objects", "char": "🎊", "name": "confetti_ball", "keywords": ["festival", "party", "birthday", "circus"] }, + { "category": "objects", "char": "🎉", "name": "tada", "keywords": ["party", "congratulations", "birthday", "magic", "circus", "celebration"] }, + { "category": "objects", "char": "🎎", "name": "dolls", "keywords": ["japanese", "toy", "kimono"] }, + { "category": "objects", "char": "🎐", "name": "wind_chime", "keywords": ["nature", "ding", "spring", "bell"] }, + { "category": "objects", "char": "🎌", "name": "crossed_flags", "keywords": ["japanese", "nation", "country", "border"] }, + { "category": "objects", "char": "🏮", "name": "izakaya_lantern", "keywords": ["light", "paper", "halloween", "spooky"] }, + { "category": "objects", "char": "🧧", "name": "red_envelope", "keywords": ["gift"] }, + { "category": "objects", "char": "✉️", "name": "email", "keywords": ["letter", "postal", "inbox", "communication"] }, + { "category": "objects", "char": "📩", "name": "envelope_with_arrow", "keywords": ["email", "communication"] }, + { "category": "objects", "char": "📨", "name": "incoming_envelope", "keywords": ["email", "inbox"] }, + { "category": "objects", "char": "📧", "name": "e-mail", "keywords": ["communication", "inbox"] }, + { "category": "objects", "char": "💌", "name": "love_letter", "keywords": ["email", "like", "affection", "envelope", "valentines"] }, + { "category": "objects", "char": "📮", "name": "postbox", "keywords": ["email", "letter", "envelope"] }, + { "category": "objects", "char": "📪", "name": "mailbox_closed", "keywords": ["email", "communication", "inbox"] }, + { "category": "objects", "char": "📫", "name": "mailbox", "keywords": ["email", "inbox", "communication"] }, + { "category": "objects", "char": "📬", "name": "mailbox_with_mail", "keywords": ["email", "inbox", "communication"] }, + { "category": "objects", "char": "📭", "name": "mailbox_with_no_mail", "keywords": ["email", "inbox"] }, + { "category": "objects", "char": "📦", "name": "package", "keywords": ["mail", "gift", "cardboard", "box", "moving"] }, + { "category": "objects", "char": "📯", "name": "postal_horn", "keywords": ["instrument", "music"] }, + { "category": "objects", "char": "📥", "name": "inbox_tray", "keywords": ["email", "documents"] }, + { "category": "objects", "char": "📤", "name": "outbox_tray", "keywords": ["inbox", "email"] }, + { "category": "objects", "char": "📜", "name": "scroll", "keywords": ["documents", "ancient", "history", "paper"] }, + { "category": "objects", "char": "📃", "name": "page_with_curl", "keywords": ["documents", "office", "paper"] }, + { "category": "objects", "char": "📑", "name": "bookmark_tabs", "keywords": ["favorite", "save", "order", "tidy"] }, + { "category": "objects", "char": "🧾", "name": "receipt", "keywords": ["accounting", "expenses"] }, + { "category": "objects", "char": "📊", "name": "bar_chart", "keywords": ["graph", "presentation", "stats"] }, + { "category": "objects", "char": "📈", "name": "chart_with_upwards_trend", "keywords": ["graph", "presentation", "stats", "recovery", "business", "economics", "money", "sales", "good", "success"] }, + { "category": "objects", "char": "📉", "name": "chart_with_downwards_trend", "keywords": ["graph", "presentation", "stats", "recession", "business", "economics", "money", "sales", "bad", "failure"] }, + { "category": "objects", "char": "📄", "name": "page_facing_up", "keywords": ["documents", "office", "paper", "information"] }, + { "category": "objects", "char": "📅", "name": "date", "keywords": ["calendar", "schedule"] }, + { "category": "objects", "char": "📆", "name": "calendar", "keywords": ["schedule", "date", "planning"] }, + { "category": "objects", "char": "🗓", "name": "spiral_calendar", "keywords": ["date", "schedule", "planning"] }, + { "category": "objects", "char": "📇", "name": "card_index", "keywords": ["business", "stationery"] }, + { "category": "objects", "char": "🗃", "name": "card_file_box", "keywords": ["business", "stationery"] }, + { "category": "objects", "char": "🗳", "name": "ballot_box", "keywords": ["election", "vote"] }, + { "category": "objects", "char": "🗄", "name": "file_cabinet", "keywords": ["filing", "organizing"] }, + { "category": "objects", "char": "📋", "name": "clipboard", "keywords": ["stationery", "documents"] }, + { "category": "objects", "char": "🗒", "name": "spiral_notepad", "keywords": ["memo", "stationery"] }, + { "category": "objects", "char": "📁", "name": "file_folder", "keywords": ["documents", "business", "office"] }, + { "category": "objects", "char": "📂", "name": "open_file_folder", "keywords": ["documents", "load"] }, + { "category": "objects", "char": "🗂", "name": "card_index_dividers", "keywords": ["organizing", "business", "stationery"] }, + { "category": "objects", "char": "🗞", "name": "newspaper_roll", "keywords": ["press", "headline"] }, + { "category": "objects", "char": "📰", "name": "newspaper", "keywords": ["press", "headline"] }, + { "category": "objects", "char": "📓", "name": "notebook", "keywords": ["stationery", "record", "notes", "paper", "study"] }, + { "category": "objects", "char": "📕", "name": "closed_book", "keywords": ["read", "library", "knowledge", "textbook", "learn"] }, + { "category": "objects", "char": "📗", "name": "green_book", "keywords": ["read", "library", "knowledge", "study"] }, + { "category": "objects", "char": "📘", "name": "blue_book", "keywords": ["read", "library", "knowledge", "learn", "study"] }, + { "category": "objects", "char": "📙", "name": "orange_book", "keywords": ["read", "library", "knowledge", "textbook", "study"] }, + { "category": "objects", "char": "📔", "name": "notebook_with_decorative_cover", "keywords": ["classroom", "notes", "record", "paper", "study"] }, + { "category": "objects", "char": "📒", "name": "ledger", "keywords": ["notes", "paper"] }, + { "category": "objects", "char": "📚", "name": "books", "keywords": ["literature", "library", "study"] }, + { "category": "objects", "char": "📖", "name": "open_book", "keywords": ["book", "read", "library", "knowledge", "literature", "learn", "study"] }, + { "category": "objects", "char": "🧷", "name": "safety_pin", "keywords": ["diaper"] }, + { "category": "objects", "char": "🔗", "name": "link", "keywords": ["rings", "url"] }, + { "category": "objects", "char": "📎", "name": "paperclip", "keywords": ["documents", "stationery"] }, + { "category": "objects", "char": "🖇", "name": "paperclips", "keywords": ["documents", "stationery"] }, + { "category": "objects", "char": "✂️", "name": "scissors", "keywords": ["stationery", "cut"] }, + { "category": "objects", "char": "📐", "name": "triangular_ruler", "keywords": ["stationery", "math", "architect", "sketch"] }, + { "category": "objects", "char": "📏", "name": "straight_ruler", "keywords": ["stationery", "calculate", "length", "math", "school", "drawing", "architect", "sketch"] }, + { "category": "objects", "char": "🧮", "name": "abacus", "keywords": ["calculation"] }, + { "category": "objects", "char": "📌", "name": "pushpin", "keywords": ["stationery", "mark", "here"] }, + { "category": "objects", "char": "📍", "name": "round_pushpin", "keywords": ["stationery", "location", "map", "here"] }, + { "category": "objects", "char": "🚩", "name": "triangular_flag_on_post", "keywords": ["mark", "milestone", "place"] }, + { "category": "objects", "char": "🏳", "name": "white_flag", "keywords": ["losing", "loser", "lost", "surrender", "give up", "fail"] }, + { "category": "objects", "char": "🏴", "name": "black_flag", "keywords": ["pirate"] }, + { "category": "objects", "char": "🏳️🌈", "name": "rainbow_flag", "keywords": ["flag", "rainbow", "pride", "gay", "lgbt", "glbt", "queer", "homosexual", "lesbian", "bisexual", "transgender"] }, + { "category": "objects", "char": "🏳️⚧️", "name": "transgender_flag", "keywords": ["flag", "transgender"] }, + { "category": "objects", "char": "🔐", "name": "closed_lock_with_key", "keywords": ["security", "privacy"] }, + { "category": "objects", "char": "🔒", "name": "lock", "keywords": ["security", "password", "padlock"] }, + { "category": "objects", "char": "🔓", "name": "unlock", "keywords": ["privacy", "security"] }, + { "category": "objects", "char": "🔏", "name": "lock_with_ink_pen", "keywords": ["security", "secret"] }, + { "category": "objects", "char": "🖊", "name": "pen", "keywords": ["stationery", "writing", "write"] }, + { "category": "objects", "char": "🖋", "name": "fountain_pen", "keywords": ["stationery", "writing", "write"] }, + { "category": "objects", "char": "✒️", "name": "black_nib", "keywords": ["pen", "stationery", "writing", "write"] }, + { "category": "objects", "char": "📝", "name": "memo", "keywords": ["write", "documents", "stationery", "pencil", "paper", "writing", "legal", "exam", "quiz", "test", "study", "compose"] }, + { "category": "objects", "char": "✏️", "name": "pencil2", "keywords": ["stationery", "write", "paper", "writing", "school", "study"] }, + { "category": "objects", "char": "🖍", "name": "crayon", "keywords": ["drawing", "creativity"] }, + { "category": "objects", "char": "🖌", "name": "paintbrush", "keywords": ["drawing", "creativity", "art"] }, + { "category": "objects", "char": "🔍", "name": "mag", "keywords": ["search", "zoom", "find", "detective"] }, + { "category": "objects", "char": "🔎", "name": "mag_right", "keywords": ["search", "zoom", "find", "detective"] }, + { "category": "objects", "char": "🪦", "name": "headstone", "keywords": [] }, + { "category": "objects", "char": "🪧", "name": "placard", "keywords": [] }, + { "category": "symbols", "char": "💯", "name": "100", "keywords": ["score", "perfect", "numbers", "century", "exam", "quiz", "test", "pass", "hundred"] }, + { "category": "symbols", "char": "🔢", "name": "1234", "keywords": ["numbers", "blue-square"] }, + { "category": "symbols", "char": "❤️", "name": "heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "🧡", "name": "orange_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💛", "name": "yellow_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💚", "name": "green_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💙", "name": "blue_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💜", "name": "purple_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "🤎", "name": "brown_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "🖤", "name": "black_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "🤍", "name": "white_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💔", "name": "broken_heart", "keywords": ["sad", "sorry", "break", "heart", "heartbreak"] }, + { "category": "symbols", "char": "❣", "name": "heavy_heart_exclamation", "keywords": ["decoration", "love"] }, + { "category": "symbols", "char": "💕", "name": "two_hearts", "keywords": ["love", "like", "affection", "valentines", "heart"] }, + { "category": "symbols", "char": "💞", "name": "revolving_hearts", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💓", "name": "heartbeat", "keywords": ["love", "like", "affection", "valentines", "pink", "heart"] }, + { "category": "symbols", "char": "💗", "name": "heartpulse", "keywords": ["like", "love", "affection", "valentines", "pink"] }, + { "category": "symbols", "char": "💖", "name": "sparkling_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💘", "name": "cupid", "keywords": ["love", "like", "heart", "affection", "valentines"] }, + { "category": "symbols", "char": "💝", "name": "gift_heart", "keywords": ["love", "valentines"] }, + { "category": "symbols", "char": "💟", "name": "heart_decoration", "keywords": ["purple-square", "love", "like"] }, + { "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83D\uDD25", "name": "heart_on_fire", "keywords": [] }, + { "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83E\uDE79", "name": "mending_heart", "keywords": [] }, + { "category": "symbols", "char": "☮", "name": "peace_symbol", "keywords": ["hippie"] }, + { "category": "symbols", "char": "✝", "name": "latin_cross", "keywords": ["christianity"] }, + { "category": "symbols", "char": "☪", "name": "star_and_crescent", "keywords": ["islam"] }, + { "category": "symbols", "char": "🕉", "name": "om", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] }, + { "category": "symbols", "char": "☸", "name": "wheel_of_dharma", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] }, + { "category": "symbols", "char": "✡", "name": "star_of_david", "keywords": ["judaism"] }, + { "category": "symbols", "char": "🔯", "name": "six_pointed_star", "keywords": ["purple-square", "religion", "jewish", "hexagram"] }, + { "category": "symbols", "char": "🕎", "name": "menorah", "keywords": ["hanukkah", "candles", "jewish"] }, + { "category": "symbols", "char": "☯", "name": "yin_yang", "keywords": ["balance"] }, + { "category": "symbols", "char": "☦", "name": "orthodox_cross", "keywords": ["suppedaneum", "religion"] }, + { "category": "symbols", "char": "🛐", "name": "place_of_worship", "keywords": ["religion", "church", "temple", "prayer"] }, + { "category": "symbols", "char": "⛎", "name": "ophiuchus", "keywords": ["sign", "purple-square", "constellation", "astrology"] }, + { "category": "symbols", "char": "♈", "name": "aries", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♉", "name": "taurus", "keywords": ["purple-square", "sign", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♊", "name": "gemini", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♋", "name": "cancer", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♌", "name": "leo", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♍", "name": "virgo", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♎", "name": "libra", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♏", "name": "scorpius", "keywords": ["sign", "zodiac", "purple-square", "astrology", "scorpio"] }, + { "category": "symbols", "char": "♐", "name": "sagittarius", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♑", "name": "capricorn", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♒", "name": "aquarius", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♓", "name": "pisces", "keywords": ["purple-square", "sign", "zodiac", "astrology"] }, + { "category": "symbols", "char": "🆔", "name": "id", "keywords": ["purple-square", "words"] }, + { "category": "symbols", "char": "⚛", "name": "atom_symbol", "keywords": ["science", "physics", "chemistry"] }, + { "category": "symbols", "char": "⚧️", "name": "transgender_symbol", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] }, + { "category": "symbols", "char": "🈳", "name": "u7a7a", "keywords": ["kanji", "japanese", "chinese", "empty", "sky", "blue-square", "aki"] }, + { "category": "symbols", "char": "🈹", "name": "u5272", "keywords": ["cut", "divide", "chinese", "kanji", "pink-square", "waribiki"] }, + { "category": "symbols", "char": "☢", "name": "radioactive", "keywords": ["nuclear", "danger"] }, + { "category": "symbols", "char": "☣", "name": "biohazard", "keywords": ["danger"] }, + { "category": "symbols", "char": "📴", "name": "mobile_phone_off", "keywords": ["mute", "orange-square", "silence", "quiet"] }, + { "category": "symbols", "char": "📳", "name": "vibration_mode", "keywords": ["orange-square", "phone"] }, + { "category": "symbols", "char": "🈶", "name": "u6709", "keywords": ["orange-square", "chinese", "have", "kanji", "ari"] }, + { "category": "symbols", "char": "🈚", "name": "u7121", "keywords": ["nothing", "chinese", "kanji", "japanese", "orange-square", "nashi"] }, + { "category": "symbols", "char": "🈸", "name": "u7533", "keywords": ["chinese", "japanese", "kanji", "orange-square", "moushikomi"] }, + { "category": "symbols", "char": "🈺", "name": "u55b6", "keywords": ["japanese", "opening hours", "orange-square", "eigyo"] }, + { "category": "symbols", "char": "🈷️", "name": "u6708", "keywords": ["chinese", "month", "moon", "japanese", "orange-square", "kanji", "tsuki", "tsukigime", "getsugaku"] }, + { "category": "symbols", "char": "✴️", "name": "eight_pointed_black_star", "keywords": ["orange-square", "shape", "polygon"] }, + { "category": "symbols", "char": "🆚", "name": "vs", "keywords": ["words", "orange-square"] }, + { "category": "symbols", "char": "🉑", "name": "accept", "keywords": ["ok", "good", "chinese", "kanji", "agree", "yes", "orange-circle"] }, + { "category": "symbols", "char": "💮", "name": "white_flower", "keywords": ["japanese", "spring"] }, + { "category": "symbols", "char": "🉐", "name": "ideograph_advantage", "keywords": ["chinese", "kanji", "obtain", "get", "circle"] }, + { "category": "symbols", "char": "㊙️", "name": "secret", "keywords": ["privacy", "chinese", "sshh", "kanji", "red-circle"] }, + { "category": "symbols", "char": "㊗️", "name": "congratulations", "keywords": ["chinese", "kanji", "japanese", "red-circle"] }, + { "category": "symbols", "char": "🈴", "name": "u5408", "keywords": ["japanese", "chinese", "join", "kanji", "red-square", "goukaku", "pass"] }, + { "category": "symbols", "char": "🈵", "name": "u6e80", "keywords": ["full", "chinese", "japanese", "red-square", "kanji", "man"] }, + { "category": "symbols", "char": "🈲", "name": "u7981", "keywords": ["kanji", "japanese", "chinese", "forbidden", "limit", "restricted", "red-square", "kinshi"] }, + { "category": "symbols", "char": "🅰️", "name": "a", "keywords": ["red-square", "alphabet", "letter"] }, + { "category": "symbols", "char": "🅱️", "name": "b", "keywords": ["red-square", "alphabet", "letter"] }, + { "category": "symbols", "char": "🆎", "name": "ab", "keywords": ["red-square", "alphabet"] }, + { "category": "symbols", "char": "🆑", "name": "cl", "keywords": ["alphabet", "words", "red-square"] }, + { "category": "symbols", "char": "🅾️", "name": "o2", "keywords": ["alphabet", "red-square", "letter"] }, + { "category": "symbols", "char": "🆘", "name": "sos", "keywords": ["help", "red-square", "words", "emergency", "911"] }, + { "category": "symbols", "char": "⛔", "name": "no_entry", "keywords": ["limit", "security", "privacy", "bad", "denied", "stop", "circle"] }, + { "category": "symbols", "char": "📛", "name": "name_badge", "keywords": ["fire", "forbid"] }, + { "category": "symbols", "char": "🚫", "name": "no_entry_sign", "keywords": ["forbid", "stop", "limit", "denied", "disallow", "circle"] }, + { "category": "symbols", "char": "❌", "name": "x", "keywords": ["no", "delete", "remove", "cancel", "red"] }, + { "category": "symbols", "char": "⭕", "name": "o", "keywords": ["circle", "round"] }, + { "category": "symbols", "char": "🛑", "name": "stop_sign", "keywords": ["stop"] }, + { "category": "symbols", "char": "💢", "name": "anger", "keywords": ["angry", "mad"] }, + { "category": "symbols", "char": "♨️", "name": "hotsprings", "keywords": ["bath", "warm", "relax"] }, + { "category": "symbols", "char": "🚷", "name": "no_pedestrians", "keywords": ["rules", "crossing", "walking", "circle"] }, + { "category": "symbols", "char": "🚯", "name": "do_not_litter", "keywords": ["trash", "bin", "garbage", "circle"] }, + { "category": "symbols", "char": "🚳", "name": "no_bicycles", "keywords": ["cyclist", "prohibited", "circle"] }, + { "category": "symbols", "char": "🚱", "name": "non-potable_water", "keywords": ["drink", "faucet", "tap", "circle"] }, + { "category": "symbols", "char": "🔞", "name": "underage", "keywords": ["18", "drink", "pub", "night", "minor", "circle"] }, + { "category": "symbols", "char": "📵", "name": "no_mobile_phones", "keywords": ["iphone", "mute", "circle"] }, + { "category": "symbols", "char": "❗", "name": "exclamation", "keywords": ["heavy_exclamation_mark", "danger", "surprise", "punctuation", "wow", "warning"] }, + { "category": "symbols", "char": "❕", "name": "grey_exclamation", "keywords": ["surprise", "punctuation", "gray", "wow", "warning"] }, + { "category": "symbols", "char": "❓", "name": "question", "keywords": ["doubt", "confused"] }, + { "category": "symbols", "char": "❔", "name": "grey_question", "keywords": ["doubts", "gray", "huh", "confused"] }, + { "category": "symbols", "char": "‼️", "name": "bangbang", "keywords": ["exclamation", "surprise"] }, + { "category": "symbols", "char": "⁉️", "name": "interrobang", "keywords": ["wat", "punctuation", "surprise"] }, + { "category": "symbols", "char": "🔅", "name": "low_brightness", "keywords": ["sun", "afternoon", "warm", "summer"] }, + { "category": "symbols", "char": "🔆", "name": "high_brightness", "keywords": ["sun", "light"] }, + { "category": "symbols", "char": "🔱", "name": "trident", "keywords": ["weapon", "spear"] }, + { "category": "symbols", "char": "⚜", "name": "fleur_de_lis", "keywords": ["decorative", "scout"] }, + { "category": "symbols", "char": "〽️", "name": "part_alternation_mark", "keywords": ["graph", "presentation", "stats", "business", "economics", "bad"] }, + { "category": "symbols", "char": "⚠️", "name": "warning", "keywords": ["exclamation", "wip", "alert", "error", "problem", "issue"] }, + { "category": "symbols", "char": "🚸", "name": "children_crossing", "keywords": ["school", "warning", "danger", "sign", "driving", "yellow-diamond"] }, + { "category": "symbols", "char": "🔰", "name": "beginner", "keywords": ["badge", "shield"] }, + { "category": "symbols", "char": "♻️", "name": "recycle", "keywords": ["arrow", "environment", "garbage", "trash"] }, + { "category": "symbols", "char": "🈯", "name": "u6307", "keywords": ["chinese", "point", "green-square", "kanji", "reserved", "shiteiseki"] }, + { "category": "symbols", "char": "💹", "name": "chart", "keywords": ["green-square", "graph", "presentation", "stats"] }, + { "category": "symbols", "char": "❇️", "name": "sparkle", "keywords": ["stars", "green-square", "awesome", "good", "fireworks"] }, + { "category": "symbols", "char": "✳️", "name": "eight_spoked_asterisk", "keywords": ["star", "sparkle", "green-square"] }, + { "category": "symbols", "char": "❎", "name": "negative_squared_cross_mark", "keywords": ["x", "green-square", "no", "deny"] }, + { "category": "symbols", "char": "✅", "name": "white_check_mark", "keywords": ["green-square", "ok", "agree", "vote", "election", "answer", "tick"] }, + { "category": "symbols", "char": "💠", "name": "diamond_shape_with_a_dot_inside", "keywords": ["jewel", "blue", "gem", "crystal", "fancy"] }, + { "category": "symbols", "char": "🌀", "name": "cyclone", "keywords": ["weather", "swirl", "blue", "cloud", "vortex", "spiral", "whirlpool", "spin", "tornado", "hurricane", "typhoon"] }, + { "category": "symbols", "char": "➿", "name": "loop", "keywords": ["tape", "cassette"] }, + { "category": "symbols", "char": "🌐", "name": "globe_with_meridians", "keywords": ["earth", "international", "world", "internet", "interweb", "i18n"] }, + { "category": "symbols", "char": "Ⓜ️", "name": "m", "keywords": ["alphabet", "blue-circle", "letter"] }, + { "category": "symbols", "char": "🏧", "name": "atm", "keywords": ["money", "sales", "cash", "blue-square", "payment", "bank"] }, + { "category": "symbols", "char": "🈂️", "name": "sa", "keywords": ["japanese", "blue-square", "katakana"] }, + { "category": "symbols", "char": "🛂", "name": "passport_control", "keywords": ["custom", "blue-square"] }, + { "category": "symbols", "char": "🛃", "name": "customs", "keywords": ["passport", "border", "blue-square"] }, + { "category": "symbols", "char": "🛄", "name": "baggage_claim", "keywords": ["blue-square", "airport", "transport"] }, + { "category": "symbols", "char": "🛅", "name": "left_luggage", "keywords": ["blue-square", "travel"] }, + { "category": "symbols", "char": "♿", "name": "wheelchair", "keywords": ["blue-square", "disabled", "a11y", "accessibility"] }, + { "category": "symbols", "char": "🚭", "name": "no_smoking", "keywords": ["cigarette", "blue-square", "smell", "smoke"] }, + { "category": "symbols", "char": "🚾", "name": "wc", "keywords": ["toilet", "restroom", "blue-square"] }, + { "category": "symbols", "char": "🅿️", "name": "parking", "keywords": ["cars", "blue-square", "alphabet", "letter"] }, + { "category": "symbols", "char": "🚰", "name": "potable_water", "keywords": ["blue-square", "liquid", "restroom", "cleaning", "faucet"] }, + { "category": "symbols", "char": "🚹", "name": "mens", "keywords": ["toilet", "restroom", "wc", "blue-square", "gender", "male"] }, + { "category": "symbols", "char": "🚺", "name": "womens", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] }, + { "category": "symbols", "char": "🚼", "name": "baby_symbol", "keywords": ["orange-square", "child"] }, + { "category": "symbols", "char": "🚻", "name": "restroom", "keywords": ["blue-square", "toilet", "refresh", "wc", "gender"] }, + { "category": "symbols", "char": "🚮", "name": "put_litter_in_its_place", "keywords": ["blue-square", "sign", "human", "info"] }, + { "category": "symbols", "char": "🎦", "name": "cinema", "keywords": ["blue-square", "record", "film", "movie", "curtain", "stage", "theater"] }, + { "category": "symbols", "char": "📶", "name": "signal_strength", "keywords": ["blue-square", "reception", "phone", "internet", "connection", "wifi", "bluetooth", "bars"] }, + { "category": "symbols", "char": "🈁", "name": "koko", "keywords": ["blue-square", "here", "katakana", "japanese", "destination"] }, + { "category": "symbols", "char": "🆖", "name": "ng", "keywords": ["blue-square", "words", "shape", "icon"] }, + { "category": "symbols", "char": "🆗", "name": "ok", "keywords": ["good", "agree", "yes", "blue-square"] }, + { "category": "symbols", "char": "🆙", "name": "up", "keywords": ["blue-square", "above", "high"] }, + { "category": "symbols", "char": "🆒", "name": "cool", "keywords": ["words", "blue-square"] }, + { "category": "symbols", "char": "🆕", "name": "new", "keywords": ["blue-square", "words", "start"] }, + { "category": "symbols", "char": "🆓", "name": "free", "keywords": ["blue-square", "words"] }, + { "category": "symbols", "char": "0️⃣", "name": "zero", "keywords": ["0", "numbers", "blue-square", "null"] }, + { "category": "symbols", "char": "1️⃣", "name": "one", "keywords": ["blue-square", "numbers", "1"] }, + { "category": "symbols", "char": "2️⃣", "name": "two", "keywords": ["numbers", "2", "prime", "blue-square"] }, + { "category": "symbols", "char": "3️⃣", "name": "three", "keywords": ["3", "numbers", "prime", "blue-square"] }, + { "category": "symbols", "char": "4️⃣", "name": "four", "keywords": ["4", "numbers", "blue-square"] }, + { "category": "symbols", "char": "5️⃣", "name": "five", "keywords": ["5", "numbers", "blue-square", "prime"] }, + { "category": "symbols", "char": "6️⃣", "name": "six", "keywords": ["6", "numbers", "blue-square"] }, + { "category": "symbols", "char": "7️⃣", "name": "seven", "keywords": ["7", "numbers", "blue-square", "prime"] }, + { "category": "symbols", "char": "8️⃣", "name": "eight", "keywords": ["8", "blue-square", "numbers"] }, + { "category": "symbols", "char": "9️⃣", "name": "nine", "keywords": ["blue-square", "numbers", "9"] }, + { "category": "symbols", "char": "🔟", "name": "keycap_ten", "keywords": ["numbers", "10", "blue-square"] }, + { "category": "symbols", "char": "*⃣", "name": "asterisk", "keywords": ["star", "keycap"] }, + { "category": "symbols", "char": "⏏️", "name": "eject_button", "keywords": ["blue-square"] }, + { "category": "symbols", "char": "▶️", "name": "arrow_forward", "keywords": ["blue-square", "right", "direction", "play"] }, + { "category": "symbols", "char": "⏸", "name": "pause_button", "keywords": ["pause", "blue-square"] }, + { "category": "symbols", "char": "⏭", "name": "next_track_button", "keywords": ["forward", "next", "blue-square"] }, + { "category": "symbols", "char": "⏹", "name": "stop_button", "keywords": ["blue-square"] }, + { "category": "symbols", "char": "⏺", "name": "record_button", "keywords": ["blue-square"] }, + { "category": "symbols", "char": "⏯", "name": "play_or_pause_button", "keywords": ["blue-square", "play", "pause"] }, + { "category": "symbols", "char": "⏮", "name": "previous_track_button", "keywords": ["backward"] }, + { "category": "symbols", "char": "⏩", "name": "fast_forward", "keywords": ["blue-square", "play", "speed", "continue"] }, + { "category": "symbols", "char": "⏪", "name": "rewind", "keywords": ["play", "blue-square"] }, + { "category": "symbols", "char": "🔀", "name": "twisted_rightwards_arrows", "keywords": ["blue-square", "shuffle", "music", "random"] }, + { "category": "symbols", "char": "🔁", "name": "repeat", "keywords": ["loop", "record"] }, + { "category": "symbols", "char": "🔂", "name": "repeat_one", "keywords": ["blue-square", "loop"] }, + { "category": "symbols", "char": "◀️", "name": "arrow_backward", "keywords": ["blue-square", "left", "direction"] }, + { "category": "symbols", "char": "🔼", "name": "arrow_up_small", "keywords": ["blue-square", "triangle", "direction", "point", "forward", "top"] }, + { "category": "symbols", "char": "🔽", "name": "arrow_down_small", "keywords": ["blue-square", "direction", "bottom"] }, + { "category": "symbols", "char": "⏫", "name": "arrow_double_up", "keywords": ["blue-square", "direction", "top"] }, + { "category": "symbols", "char": "⏬", "name": "arrow_double_down", "keywords": ["blue-square", "direction", "bottom"] }, + { "category": "symbols", "char": "➡️", "name": "arrow_right", "keywords": ["blue-square", "next"] }, + { "category": "symbols", "char": "⬅️", "name": "arrow_left", "keywords": ["blue-square", "previous", "back"] }, + { "category": "symbols", "char": "⬆️", "name": "arrow_up", "keywords": ["blue-square", "continue", "top", "direction"] }, + { "category": "symbols", "char": "⬇️", "name": "arrow_down", "keywords": ["blue-square", "direction", "bottom"] }, + { "category": "symbols", "char": "↗️", "name": "arrow_upper_right", "keywords": ["blue-square", "point", "direction", "diagonal", "northeast"] }, + { "category": "symbols", "char": "↘️", "name": "arrow_lower_right", "keywords": ["blue-square", "direction", "diagonal", "southeast"] }, + { "category": "symbols", "char": "↙️", "name": "arrow_lower_left", "keywords": ["blue-square", "direction", "diagonal", "southwest"] }, + { "category": "symbols", "char": "↖️", "name": "arrow_upper_left", "keywords": ["blue-square", "point", "direction", "diagonal", "northwest"] }, + { "category": "symbols", "char": "↕️", "name": "arrow_up_down", "keywords": ["blue-square", "direction", "way", "vertical"] }, + { "category": "symbols", "char": "↔️", "name": "left_right_arrow", "keywords": ["shape", "direction", "horizontal", "sideways"] }, + { "category": "symbols", "char": "🔄", "name": "arrows_counterclockwise", "keywords": ["blue-square", "sync", "cycle"] }, + { "category": "symbols", "char": "↪️", "name": "arrow_right_hook", "keywords": ["blue-square", "return", "rotate", "direction"] }, + { "category": "symbols", "char": "↩️", "name": "leftwards_arrow_with_hook", "keywords": ["back", "return", "blue-square", "undo", "enter"] }, + { "category": "symbols", "char": "⤴️", "name": "arrow_heading_up", "keywords": ["blue-square", "direction", "top"] }, + { "category": "symbols", "char": "⤵️", "name": "arrow_heading_down", "keywords": ["blue-square", "direction", "bottom"] }, + { "category": "symbols", "char": "#️⃣", "name": "hash", "keywords": ["symbol", "blue-square", "twitter"] }, + { "category": "symbols", "char": "ℹ️", "name": "information_source", "keywords": ["blue-square", "alphabet", "letter"] }, + { "category": "symbols", "char": "🔤", "name": "abc", "keywords": ["blue-square", "alphabet"] }, + { "category": "symbols", "char": "🔡", "name": "abcd", "keywords": ["blue-square", "alphabet"] }, + { "category": "symbols", "char": "🔠", "name": "capital_abcd", "keywords": ["alphabet", "words", "blue-square"] }, + { "category": "symbols", "char": "🔣", "name": "symbols", "keywords": ["blue-square", "music", "note", "ampersand", "percent", "glyphs", "characters"] }, + { "category": "symbols", "char": "🎵", "name": "musical_note", "keywords": ["score", "tone", "sound"] }, + { "category": "symbols", "char": "🎶", "name": "notes", "keywords": ["music", "score"] }, + { "category": "symbols", "char": "〰️", "name": "wavy_dash", "keywords": ["draw", "line", "moustache", "mustache", "squiggle", "scribble"] }, + { "category": "symbols", "char": "➰", "name": "curly_loop", "keywords": ["scribble", "draw", "shape", "squiggle"] }, + { "category": "symbols", "char": "✔️", "name": "heavy_check_mark", "keywords": ["ok", "nike", "answer", "yes", "tick"] }, + { "category": "symbols", "char": "🔃", "name": "arrows_clockwise", "keywords": ["sync", "cycle", "round", "repeat"] }, + { "category": "symbols", "char": "➕", "name": "heavy_plus_sign", "keywords": ["math", "calculation", "addition", "more", "increase"] }, + { "category": "symbols", "char": "➖", "name": "heavy_minus_sign", "keywords": ["math", "calculation", "subtract", "less"] }, + { "category": "symbols", "char": "➗", "name": "heavy_division_sign", "keywords": ["divide", "math", "calculation"] }, + { "category": "symbols", "char": "✖️", "name": "heavy_multiplication_x", "keywords": ["math", "calculation"] }, + { "category": "symbols", "char": "\uD83D\uDFF0", "name": "heavy_equals_sign", "keywords": [] }, + { "category": "symbols", "char": "♾", "name": "infinity", "keywords": ["forever"] }, + { "category": "symbols", "char": "💲", "name": "heavy_dollar_sign", "keywords": ["money", "sales", "payment", "currency", "buck"] }, + { "category": "symbols", "char": "💱", "name": "currency_exchange", "keywords": ["money", "sales", "dollar", "travel"] }, + { "category": "symbols", "char": "©️", "name": "copyright", "keywords": ["ip", "license", "circle", "law", "legal"] }, + { "category": "symbols", "char": "®️", "name": "registered", "keywords": ["alphabet", "circle"] }, + { "category": "symbols", "char": "™️", "name": "tm", "keywords": ["trademark", "brand", "law", "legal"] }, + { "category": "symbols", "char": "🔚", "name": "end", "keywords": ["words", "arrow"] }, + { "category": "symbols", "char": "🔙", "name": "back", "keywords": ["arrow", "words", "return"] }, + { "category": "symbols", "char": "🔛", "name": "on", "keywords": ["arrow", "words"] }, + { "category": "symbols", "char": "🔝", "name": "top", "keywords": ["words", "blue-square"] }, + { "category": "symbols", "char": "🔜", "name": "soon", "keywords": ["arrow", "words"] }, + { "category": "symbols", "char": "☑️", "name": "ballot_box_with_check", "keywords": ["ok", "agree", "confirm", "black-square", "vote", "election", "yes", "tick"] }, + { "category": "symbols", "char": "🔘", "name": "radio_button", "keywords": ["input", "old", "music", "circle"] }, + { "category": "symbols", "char": "⚫", "name": "black_circle", "keywords": ["shape", "button", "round"] }, + { "category": "symbols", "char": "⚪", "name": "white_circle", "keywords": ["shape", "round"] }, + { "category": "symbols", "char": "🔴", "name": "red_circle", "keywords": ["shape", "error", "danger"] }, + { "category": "symbols", "char": "🟠", "name": "orange_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟡", "name": "yellow_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟢", "name": "green_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🔵", "name": "large_blue_circle", "keywords": ["shape", "icon", "button"] }, + { "category": "symbols", "char": "🟣", "name": "purple_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟤", "name": "brown_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🔸", "name": "small_orange_diamond", "keywords": ["shape", "jewel", "gem"] }, + { "category": "symbols", "char": "🔹", "name": "small_blue_diamond", "keywords": ["shape", "jewel", "gem"] }, + { "category": "symbols", "char": "🔶", "name": "large_orange_diamond", "keywords": ["shape", "jewel", "gem"] }, + { "category": "symbols", "char": "🔷", "name": "large_blue_diamond", "keywords": ["shape", "jewel", "gem"] }, + { "category": "symbols", "char": "🔺", "name": "small_red_triangle", "keywords": ["shape", "direction", "up", "top"] }, + { "category": "symbols", "char": "▪️", "name": "black_small_square", "keywords": ["shape", "icon"] }, + { "category": "symbols", "char": "▫️", "name": "white_small_square", "keywords": ["shape", "icon"] }, + { "category": "symbols", "char": "⬛", "name": "black_large_square", "keywords": ["shape", "icon", "button"] }, + { "category": "symbols", "char": "⬜", "name": "white_large_square", "keywords": ["shape", "icon", "stone", "button"] }, + { "category": "symbols", "char": "🟥", "name": "red_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟧", "name": "orange_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟨", "name": "yellow_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟩", "name": "green_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟦", "name": "blue_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟪", "name": "purple_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟫", "name": "brown_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🔻", "name": "small_red_triangle_down", "keywords": ["shape", "direction", "bottom"] }, + { "category": "symbols", "char": "◼️", "name": "black_medium_square", "keywords": ["shape", "button", "icon"] }, + { "category": "symbols", "char": "◻️", "name": "white_medium_square", "keywords": ["shape", "stone", "icon"] }, + { "category": "symbols", "char": "◾", "name": "black_medium_small_square", "keywords": ["icon", "shape", "button"] }, + { "category": "symbols", "char": "◽", "name": "white_medium_small_square", "keywords": ["shape", "stone", "icon", "button"] }, + { "category": "symbols", "char": "🔲", "name": "black_square_button", "keywords": ["shape", "input", "frame"] }, + { "category": "symbols", "char": "🔳", "name": "white_square_button", "keywords": ["shape", "input"] }, + { "category": "symbols", "char": "🔈", "name": "speaker", "keywords": ["sound", "volume", "silence", "broadcast"] }, + { "category": "symbols", "char": "🔉", "name": "sound", "keywords": ["volume", "speaker", "broadcast"] }, + { "category": "symbols", "char": "🔊", "name": "loud_sound", "keywords": ["volume", "noise", "noisy", "speaker", "broadcast"] }, + { "category": "symbols", "char": "🔇", "name": "mute", "keywords": ["sound", "volume", "silence", "quiet"] }, + { "category": "symbols", "char": "📣", "name": "mega", "keywords": ["sound", "speaker", "volume"] }, + { "category": "symbols", "char": "📢", "name": "loudspeaker", "keywords": ["volume", "sound"] }, + { "category": "symbols", "char": "🔔", "name": "bell", "keywords": ["sound", "notification", "christmas", "xmas", "chime"] }, + { "category": "symbols", "char": "🔕", "name": "no_bell", "keywords": ["sound", "volume", "mute", "quiet", "silent"] }, + { "category": "symbols", "char": "🃏", "name": "black_joker", "keywords": ["poker", "cards", "game", "play", "magic"] }, + { "category": "symbols", "char": "🀄", "name": "mahjong", "keywords": ["game", "play", "chinese", "kanji"] }, + { "category": "symbols", "char": "♠️", "name": "spades", "keywords": ["poker", "cards", "suits", "magic"] }, + { "category": "symbols", "char": "♣️", "name": "clubs", "keywords": ["poker", "cards", "magic", "suits"] }, + { "category": "symbols", "char": "♥️", "name": "hearts", "keywords": ["poker", "cards", "magic", "suits"] }, + { "category": "symbols", "char": "♦️", "name": "diamonds", "keywords": ["poker", "cards", "magic", "suits"] }, + { "category": "symbols", "char": "🎴", "name": "flower_playing_cards", "keywords": ["game", "sunset", "red"] }, + { "category": "symbols", "char": "💭", "name": "thought_balloon", "keywords": ["bubble", "cloud", "speech", "thinking", "dream"] }, + { "category": "symbols", "char": "🗯", "name": "right_anger_bubble", "keywords": ["caption", "speech", "thinking", "mad"] }, + { "category": "symbols", "char": "💬", "name": "speech_balloon", "keywords": ["bubble", "words", "message", "talk", "chatting"] }, + { "category": "symbols", "char": "🗨", "name": "left_speech_bubble", "keywords": ["words", "message", "talk", "chatting"] }, + { "category": "symbols", "char": "🕐", "name": "clock1", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕑", "name": "clock2", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕒", "name": "clock3", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕓", "name": "clock4", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕔", "name": "clock5", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕕", "name": "clock6", "keywords": ["time", "late", "early", "schedule", "dawn", "dusk"] }, + { "category": "symbols", "char": "🕖", "name": "clock7", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕗", "name": "clock8", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕘", "name": "clock9", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕙", "name": "clock10", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕚", "name": "clock11", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕛", "name": "clock12", "keywords": ["time", "noon", "midnight", "midday", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕜", "name": "clock130", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕝", "name": "clock230", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕞", "name": "clock330", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕟", "name": "clock430", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕠", "name": "clock530", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕡", "name": "clock630", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕢", "name": "clock730", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕣", "name": "clock830", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕤", "name": "clock930", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕥", "name": "clock1030", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕦", "name": "clock1130", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕧", "name": "clock1230", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "flags", "char": "🇦🇫", "name": "afghanistan", "keywords": ["af", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇽", "name": "aland_islands", "keywords": ["Åland", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇱", "name": "albania", "keywords": ["al", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇿", "name": "algeria", "keywords": ["dz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇸", "name": "american_samoa", "keywords": ["american", "ws", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇩", "name": "andorra", "keywords": ["ad", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇴", "name": "angola", "keywords": ["ao", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇮", "name": "anguilla", "keywords": ["ai", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇶", "name": "antarctica", "keywords": ["aq", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇬", "name": "antigua_barbuda", "keywords": ["antigua", "barbuda", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇷", "name": "argentina", "keywords": ["ar", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇲", "name": "armenia", "keywords": ["am", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇼", "name": "aruba", "keywords": ["aw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇨", "name": "ascension_island", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇺", "name": "australia", "keywords": ["au", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇹", "name": "austria", "keywords": ["at", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇿", "name": "azerbaijan", "keywords": ["az", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇸", "name": "bahamas", "keywords": ["bs", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇭", "name": "bahrain", "keywords": ["bh", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇩", "name": "bangladesh", "keywords": ["bd", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇧", "name": "barbados", "keywords": ["bb", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇾", "name": "belarus", "keywords": ["by", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇪", "name": "belgium", "keywords": ["be", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇿", "name": "belize", "keywords": ["bz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇯", "name": "benin", "keywords": ["bj", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇲", "name": "bermuda", "keywords": ["bm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇹", "name": "bhutan", "keywords": ["bt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇴", "name": "bolivia", "keywords": ["bo", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇶", "name": "caribbean_netherlands", "keywords": ["bonaire", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇦", "name": "bosnia_herzegovina", "keywords": ["bosnia", "herzegovina", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇼", "name": "botswana", "keywords": ["bw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇷", "name": "brazil", "keywords": ["br", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇴", "name": "british_indian_ocean_territory", "keywords": ["british", "indian", "ocean", "territory", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇬", "name": "british_virgin_islands", "keywords": ["british", "virgin", "islands", "bvi", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇳", "name": "brunei", "keywords": ["bn", "darussalam", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇬", "name": "bulgaria", "keywords": ["bg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇫", "name": "burkina_faso", "keywords": ["burkina", "faso", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇮", "name": "burundi", "keywords": ["bi", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇻", "name": "cape_verde", "keywords": ["cabo", "verde", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇭", "name": "cambodia", "keywords": ["kh", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇲", "name": "cameroon", "keywords": ["cm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇦", "name": "canada", "keywords": ["ca", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇨", "name": "canary_islands", "keywords": ["canary", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇾", "name": "cayman_islands", "keywords": ["cayman", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇫", "name": "central_african_republic", "keywords": ["central", "african", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇩", "name": "chad", "keywords": ["td", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇱", "name": "chile", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇳", "name": "cn", "keywords": ["china", "chinese", "prc", "flag", "country", "nation", "banner"] }, + { "category": "flags", "char": "🇨🇽", "name": "christmas_island", "keywords": ["christmas", "island", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇨", "name": "cocos_islands", "keywords": ["cocos", "keeling", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇴", "name": "colombia", "keywords": ["co", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇲", "name": "comoros", "keywords": ["km", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇬", "name": "congo_brazzaville", "keywords": ["congo", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇩", "name": "congo_kinshasa", "keywords": ["congo", "democratic", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇰", "name": "cook_islands", "keywords": ["cook", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇷", "name": "costa_rica", "keywords": ["costa", "rica", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇷", "name": "croatia", "keywords": ["hr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇺", "name": "cuba", "keywords": ["cu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇼", "name": "curacao", "keywords": ["curaçao", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇾", "name": "cyprus", "keywords": ["cy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇿", "name": "czech_republic", "keywords": ["cz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇰", "name": "denmark", "keywords": ["dk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇯", "name": "djibouti", "keywords": ["dj", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇲", "name": "dominica", "keywords": ["dm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇴", "name": "dominican_republic", "keywords": ["dominican", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇨", "name": "ecuador", "keywords": ["ec", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇬", "name": "egypt", "keywords": ["eg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇻", "name": "el_salvador", "keywords": ["el", "salvador", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇶", "name": "equatorial_guinea", "keywords": ["equatorial", "gn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇷", "name": "eritrea", "keywords": ["er", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇪", "name": "estonia", "keywords": ["ee", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇹", "name": "ethiopia", "keywords": ["et", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇺", "name": "eu", "keywords": ["european", "union", "flag", "banner"] }, + { "category": "flags", "char": "🇫🇰", "name": "falkland_islands", "keywords": ["falkland", "islands", "malvinas", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇴", "name": "faroe_islands", "keywords": ["faroe", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇯", "name": "fiji", "keywords": ["fj", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇮", "name": "finland", "keywords": ["fi", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇷", "name": "fr", "keywords": ["banner", "flag", "nation", "france", "french", "country"] }, + { "category": "flags", "char": "🇬🇫", "name": "french_guiana", "keywords": ["french", "guiana", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇫", "name": "french_polynesia", "keywords": ["french", "polynesia", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇫", "name": "french_southern_territories", "keywords": ["french", "southern", "territories", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇦", "name": "gabon", "keywords": ["ga", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇲", "name": "gambia", "keywords": ["gm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇪", "name": "georgia", "keywords": ["ge", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇪", "name": "de", "keywords": ["german", "nation", "flag", "country", "banner"] }, + { "category": "flags", "char": "🇬🇭", "name": "ghana", "keywords": ["gh", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇮", "name": "gibraltar", "keywords": ["gi", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇷", "name": "greece", "keywords": ["gr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇱", "name": "greenland", "keywords": ["gl", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇩", "name": "grenada", "keywords": ["gd", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇵", "name": "guadeloupe", "keywords": ["gp", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇺", "name": "guam", "keywords": ["gu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇹", "name": "guatemala", "keywords": ["gt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇬", "name": "guernsey", "keywords": ["gg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇳", "name": "guinea", "keywords": ["gn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇼", "name": "guinea_bissau", "keywords": ["gw", "bissau", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇾", "name": "guyana", "keywords": ["gy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇹", "name": "haiti", "keywords": ["ht", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇳", "name": "honduras", "keywords": ["hn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇰", "name": "hong_kong", "keywords": ["hong", "kong", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇺", "name": "hungary", "keywords": ["hu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇸", "name": "iceland", "keywords": ["is", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇳", "name": "india", "keywords": ["in", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇩", "name": "indonesia", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇷", "name": "iran", "keywords": ["iran, ", "islamic", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇶", "name": "iraq", "keywords": ["iq", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇪", "name": "ireland", "keywords": ["ie", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇲", "name": "isle_of_man", "keywords": ["isle", "man", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇱", "name": "israel", "keywords": ["il", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇹", "name": "it", "keywords": ["italy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇮", "name": "cote_divoire", "keywords": ["ivory", "coast", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇯🇲", "name": "jamaica", "keywords": ["jm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇯🇵", "name": "jp", "keywords": ["japanese", "nation", "flag", "country", "banner"] }, + { "category": "flags", "char": "🇯🇪", "name": "jersey", "keywords": ["je", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇯🇴", "name": "jordan", "keywords": ["jo", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇿", "name": "kazakhstan", "keywords": ["kz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇪", "name": "kenya", "keywords": ["ke", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇮", "name": "kiribati", "keywords": ["ki", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇽🇰", "name": "kosovo", "keywords": ["xk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇼", "name": "kuwait", "keywords": ["kw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇬", "name": "kyrgyzstan", "keywords": ["kg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇦", "name": "laos", "keywords": ["lao", "democratic", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇻", "name": "latvia", "keywords": ["lv", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇧", "name": "lebanon", "keywords": ["lb", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇸", "name": "lesotho", "keywords": ["ls", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇷", "name": "liberia", "keywords": ["lr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇾", "name": "libya", "keywords": ["ly", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇮", "name": "liechtenstein", "keywords": ["li", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇹", "name": "lithuania", "keywords": ["lt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇺", "name": "luxembourg", "keywords": ["lu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇴", "name": "macau", "keywords": ["macao", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇰", "name": "macedonia", "keywords": ["macedonia, ", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇬", "name": "madagascar", "keywords": ["mg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇼", "name": "malawi", "keywords": ["mw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇾", "name": "malaysia", "keywords": ["my", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇻", "name": "maldives", "keywords": ["mv", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇱", "name": "mali", "keywords": ["ml", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇹", "name": "malta", "keywords": ["mt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇭", "name": "marshall_islands", "keywords": ["marshall", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇶", "name": "martinique", "keywords": ["mq", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇷", "name": "mauritania", "keywords": ["mr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇺", "name": "mauritius", "keywords": ["mu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇾🇹", "name": "mayotte", "keywords": ["yt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇽", "name": "mexico", "keywords": ["mx", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇲", "name": "micronesia", "keywords": ["micronesia, ", "federated", "states", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇩", "name": "moldova", "keywords": ["moldova, ", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇨", "name": "monaco", "keywords": ["mc", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇳", "name": "mongolia", "keywords": ["mn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇪", "name": "montenegro", "keywords": ["me", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇸", "name": "montserrat", "keywords": ["ms", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇦", "name": "morocco", "keywords": ["ma", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇿", "name": "mozambique", "keywords": ["mz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇲", "name": "myanmar", "keywords": ["mm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇦", "name": "namibia", "keywords": ["na", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇷", "name": "nauru", "keywords": ["nr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇵", "name": "nepal", "keywords": ["np", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇱", "name": "netherlands", "keywords": ["nl", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇨", "name": "new_caledonia", "keywords": ["new", "caledonia", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇿", "name": "new_zealand", "keywords": ["new", "zealand", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇮", "name": "nicaragua", "keywords": ["ni", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇪", "name": "niger", "keywords": ["ne", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇬", "name": "nigeria", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇺", "name": "niue", "keywords": ["nu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇫", "name": "norfolk_island", "keywords": ["norfolk", "island", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇵", "name": "northern_mariana_islands", "keywords": ["northern", "mariana", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇵", "name": "north_korea", "keywords": ["north", "korea", "nation", "flag", "country", "banner"] }, + { "category": "flags", "char": "🇳🇴", "name": "norway", "keywords": ["no", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇴🇲", "name": "oman", "keywords": ["om_symbol", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇰", "name": "pakistan", "keywords": ["pk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇼", "name": "palau", "keywords": ["pw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇸", "name": "palestinian_territories", "keywords": ["palestine", "palestinian", "territories", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇦", "name": "panama", "keywords": ["pa", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇬", "name": "papua_new_guinea", "keywords": ["papua", "new", "guinea", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇾", "name": "paraguay", "keywords": ["py", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇪", "name": "peru", "keywords": ["pe", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇭", "name": "philippines", "keywords": ["ph", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇳", "name": "pitcairn_islands", "keywords": ["pitcairn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇱", "name": "poland", "keywords": ["pl", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇹", "name": "portugal", "keywords": ["pt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇷", "name": "puerto_rico", "keywords": ["puerto", "rico", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇶🇦", "name": "qatar", "keywords": ["qa", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇪", "name": "reunion", "keywords": ["réunion", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇴", "name": "romania", "keywords": ["ro", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇺", "name": "ru", "keywords": ["russian", "federation", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇼", "name": "rwanda", "keywords": ["rw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇱", "name": "st_barthelemy", "keywords": ["saint", "barthélemy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇭", "name": "st_helena", "keywords": ["saint", "helena", "ascension", "tristan", "cunha", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇳", "name": "st_kitts_nevis", "keywords": ["saint", "kitts", "nevis", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇨", "name": "st_lucia", "keywords": ["saint", "lucia", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇲", "name": "st_pierre_miquelon", "keywords": ["saint", "pierre", "miquelon", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇨", "name": "st_vincent_grenadines", "keywords": ["saint", "vincent", "grenadines", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇼🇸", "name": "samoa", "keywords": ["ws", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇲", "name": "san_marino", "keywords": ["san", "marino", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇹", "name": "sao_tome_principe", "keywords": ["sao", "tome", "principe", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇦", "name": "saudi_arabia", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇳", "name": "senegal", "keywords": ["sn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇸", "name": "serbia", "keywords": ["rs", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇨", "name": "seychelles", "keywords": ["sc", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇱", "name": "sierra_leone", "keywords": ["sierra", "leone", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇬", "name": "singapore", "keywords": ["sg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇽", "name": "sint_maarten", "keywords": ["sint", "maarten", "dutch", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇰", "name": "slovakia", "keywords": ["sk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇮", "name": "slovenia", "keywords": ["si", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇧", "name": "solomon_islands", "keywords": ["solomon", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇴", "name": "somalia", "keywords": ["so", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇿🇦", "name": "south_africa", "keywords": ["south", "africa", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇸", "name": "south_georgia_south_sandwich_islands", "keywords": ["south", "georgia", "sandwich", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇷", "name": "kr", "keywords": ["south", "korea", "nation", "flag", "country", "banner"] }, + { "category": "flags", "char": "🇸🇸", "name": "south_sudan", "keywords": ["south", "sd", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇸", "name": "es", "keywords": ["spain", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇰", "name": "sri_lanka", "keywords": ["sri", "lanka", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇩", "name": "sudan", "keywords": ["sd", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇷", "name": "suriname", "keywords": ["sr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇿", "name": "swaziland", "keywords": ["sz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇪", "name": "sweden", "keywords": ["se", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇭", "name": "switzerland", "keywords": ["ch", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇾", "name": "syria", "keywords": ["syrian", "arab", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇼", "name": "taiwan", "keywords": ["tw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇯", "name": "tajikistan", "keywords": ["tj", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇿", "name": "tanzania", "keywords": ["tanzania, ", "united", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇭", "name": "thailand", "keywords": ["th", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇱", "name": "timor_leste", "keywords": ["timor", "leste", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇬", "name": "togo", "keywords": ["tg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇰", "name": "tokelau", "keywords": ["tk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇴", "name": "tonga", "keywords": ["to", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇹", "name": "trinidad_tobago", "keywords": ["trinidad", "tobago", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇦", "name": "tristan_da_cunha", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇳", "name": "tunisia", "keywords": ["tn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇷", "name": "tr", "keywords": ["turkey", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇲", "name": "turkmenistan", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇨", "name": "turks_caicos_islands", "keywords": ["turks", "caicos", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇻", "name": "tuvalu", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇬", "name": "uganda", "keywords": ["ug", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇦", "name": "ukraine", "keywords": ["ua", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇪", "name": "united_arab_emirates", "keywords": ["united", "arab", "emirates", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇧", "name": "uk", "keywords": ["united", "kingdom", "great", "britain", "northern", "ireland", "flag", "nation", "country", "banner", "british", "UK", "english", "england", "union jack"] }, + { "category": "flags", "char": "🏴", "name": "england", "keywords": ["flag", "english"] }, + { "category": "flags", "char": "🏴", "name": "scotland", "keywords": ["flag", "scottish"] }, + { "category": "flags", "char": "🏴", "name": "wales", "keywords": ["flag", "welsh"] }, + { "category": "flags", "char": "🇺🇸", "name": "us", "keywords": ["united", "states", "america", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇮", "name": "us_virgin_islands", "keywords": ["virgin", "islands", "us", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇾", "name": "uruguay", "keywords": ["uy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇿", "name": "uzbekistan", "keywords": ["uz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇺", "name": "vanuatu", "keywords": ["vu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇦", "name": "vatican_city", "keywords": ["vatican", "city", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇪", "name": "venezuela", "keywords": ["ve", "bolivarian", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇳", "name": "vietnam", "keywords": ["viet", "nam", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇼🇫", "name": "wallis_futuna", "keywords": ["wallis", "futuna", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇭", "name": "western_sahara", "keywords": ["western", "sahara", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇾🇪", "name": "yemen", "keywords": ["ye", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇿🇲", "name": "zambia", "keywords": ["zm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇿🇼", "name": "zimbabwe", "keywords": ["zw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇳", "name": "united_nations", "keywords": ["un", "flag", "banner"] }, + { "category": "flags", "char": "🏴☠️", "name": "pirate_flag", "keywords": ["skull", "crossbones", "flag", "banner"] } +] + diff --git a/packages/frontend/src/events.ts b/packages/frontend/src/events.ts new file mode 100644 index 0000000000..dbbd908b8f --- /dev/null +++ b/packages/frontend/src/events.ts @@ -0,0 +1,4 @@ +import { EventEmitter } from 'eventemitter3'; + +// TODO: 型付け +export const globalEvents = new EventEmitter(); diff --git a/packages/frontend/src/filters/bytes.ts b/packages/frontend/src/filters/bytes.ts new file mode 100644 index 0000000000..c80f2f0ed2 --- /dev/null +++ b/packages/frontend/src/filters/bytes.ts @@ -0,0 +1,9 @@ +export default (v, digits = 0) => { + if (v == null) return '?'; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + if (v === 0) return '0'; + const isMinus = v < 0; + if (isMinus) v = -v; + const i = Math.floor(Math.log(v) / Math.log(1024)); + return (isMinus ? '-' : '') + (v / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i]; +}; diff --git a/packages/frontend/src/filters/note.ts b/packages/frontend/src/filters/note.ts new file mode 100644 index 0000000000..cd9b7d98d2 --- /dev/null +++ b/packages/frontend/src/filters/note.ts @@ -0,0 +1,3 @@ +export const notePage = note => { + return `/notes/${note.id}`; +}; diff --git a/packages/frontend/src/filters/number.ts b/packages/frontend/src/filters/number.ts new file mode 100644 index 0000000000..880a848ca4 --- /dev/null +++ b/packages/frontend/src/filters/number.ts @@ -0,0 +1 @@ +export default n => n == null ? 'N/A' : n.toLocaleString(); diff --git a/packages/frontend/src/filters/user.ts b/packages/frontend/src/filters/user.ts new file mode 100644 index 0000000000..ff2f7e2dae --- /dev/null +++ b/packages/frontend/src/filters/user.ts @@ -0,0 +1,15 @@ +import * as misskey from 'misskey-js'; +import * as Acct from 'misskey-js/built/acct'; +import { url } from '@/config'; + +export const acct = (user: misskey.Acct) => { + return Acct.toString(user); +}; + +export const userName = (user: misskey.entities.User) => { + return user.name || user.username; +}; + +export const userPage = (user: misskey.Acct, path?, absolute = false) => { + return `${absolute ? url : ''}/@${acct(user)}${(path ? `/${path}` : '')}`; +}; diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts new file mode 100644 index 0000000000..31e066960d --- /dev/null +++ b/packages/frontend/src/i18n.ts @@ -0,0 +1,5 @@ +import { markRaw } from 'vue'; +import { locale } from '@/config'; +import { I18n } from '@/scripts/i18n'; + +export const i18n = markRaw(new I18n(locale)); diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts new file mode 100644 index 0000000000..508d3262b3 --- /dev/null +++ b/packages/frontend/src/init.ts @@ -0,0 +1,433 @@ +/** + * Client entry point + */ +// https://vitejs.dev/config/build-options.html#build-modulepreload +import 'vite/modulepreload-polyfill'; + +import '@/style.scss'; + +//#region account indexedDB migration +import { set } from '@/scripts/idb-proxy'; + +if (localStorage.getItem('accounts') != null) { + set('accounts', JSON.parse(localStorage.getItem('accounts'))); + localStorage.removeItem('accounts'); +} +//#endregion + +import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue'; +import { compareVersions } from 'compare-versions'; +import JSON5 from 'json5'; + +import widgets from '@/widgets'; +import directives from '@/directives'; +import components from '@/components'; +import { version, ui, lang, host } from '@/config'; +import { applyTheme } from '@/scripts/theme'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; +import { i18n } from '@/i18n'; +import { confirm, alert, post, popup, toast } from '@/os'; +import { stream } from '@/stream'; +import * as sound from '@/scripts/sound'; +import { $i, refreshAccount, login, updateAccount, signout } from '@/account'; +import { defaultStore, ColdDeviceStorage } from '@/store'; +import { fetchInstance, instance } from '@/instance'; +import { makeHotkey } from '@/scripts/hotkey'; +import { search } from '@/scripts/search'; +import { deviceKind } from '@/scripts/device-kind'; +import { initializeSw } from '@/scripts/initialize-sw'; +import { reloadChannel } from '@/scripts/unison-reload'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { getUrlWithoutLoginId } from '@/scripts/login-id'; +import { getAccountFromId } from '@/scripts/get-account-from-id'; + +(async () => { + console.info(`Misskey v${version}`); + + if (_DEV_) { + console.warn('Development mode!!!'); + + console.info(`vue ${vueVersion}`); + + (window as any).$i = $i; + (window as any).$store = defaultStore; + + window.addEventListener('error', event => { + console.error(event); + /* + alert({ + type: 'error', + title: 'DEV: Unhandled error', + text: event.message + }); + */ + }); + + window.addEventListener('unhandledrejection', event => { + console.error(event); + /* + alert({ + type: 'error', + title: 'DEV: Unhandled promise rejection', + text: event.reason + }); + */ + }); + } + + // タッチデバイスでCSSの:hoverを機能させる + document.addEventListener('touchend', () => {}, { passive: true }); + + // 一斉リロード + reloadChannel.addEventListener('message', path => { + if (path !== null) location.href = path; + else location.reload(); + }); + + // If mobile, insert the viewport meta tag + if (['smartphone', 'tablet'].includes(deviceKind)) { + const viewport = document.getElementsByName('viewport').item(0); + viewport.setAttribute('content', + `${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`); + } + + //#region Set lang attr + const html = document.documentElement; + html.setAttribute('lang', lang); + //#endregion + + //#region loginId + const params = new URLSearchParams(location.search); + const loginId = params.get('loginId'); + + if (loginId) { + const target = getUrlWithoutLoginId(location.href); + + if (!$i || $i.id !== loginId) { + const account = await getAccountFromId(loginId); + if (account) { + await login(account.token, target); + } + } + + history.replaceState({ misskey: 'loginId' }, '', target); + } + + //#endregion + + //#region Fetch user + if ($i && $i.token) { + if (_DEV_) { + console.log('account cache found. refreshing...'); + } + + refreshAccount(); + } else { + if (_DEV_) { + console.log('no account cache found.'); + } + + // 連携ログインの場合用にCookieを参照する + const i = (document.cookie.match(/igi=(\w+)/) ?? [null, null])[1]; + + if (i != null && i !== 'null') { + if (_DEV_) { + console.log('signing...'); + } + + try { + document.body.innerHTML = '<div>Please wait...</div>'; + await login(i); + } catch (err) { + // Render the error screen + // TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな) + document.body.innerHTML = '<div id="err">Oops!</div>'; + } + } else { + if (_DEV_) { + console.log('not signed in'); + } + } + } + //#endregion + + const fetchInstanceMetaPromise = fetchInstance(); + + fetchInstanceMetaPromise.then(() => { + localStorage.setItem('v', instance.version); + + // Init service worker + initializeSw(); + }); + + const app = createApp( + window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) : + !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : + ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : + ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : + defineAsyncComponent(() => import('@/ui/universal.vue')), + ); + + if (_DEV_) { + app.config.performance = true; + } + + app.config.globalProperties = { + $i, + $store: defaultStore, + $instance: instance, + $t: i18n.t, + $ts: i18n.ts, + }; + + widgets(app); + directives(app); + components(app); + + const splash = document.getElementById('splash'); + // 念のためnullチェック(HTMLが古い場合があるため(そのうち消す)) + if (splash) splash.addEventListener('transitionend', () => { + splash.remove(); + }); + + // https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210 + // なぜかinit.tsの内容が2回実行されることがあるため、mountするdivを1つに制限する + const rootEl = (() => { + const MISSKEY_MOUNT_DIV_ID = 'misskey_app'; + + const currentEl = document.getElementById(MISSKEY_MOUNT_DIV_ID); + + if (currentEl) { + console.warn('multiple import detected'); + return currentEl; + } + + const rootEl = document.createElement('div'); + rootEl.id = MISSKEY_MOUNT_DIV_ID; + document.body.appendChild(rootEl); + return rootEl; + })(); + + app.mount(rootEl); + + // boot.jsのやつを解除 + window.onerror = null; + window.onunhandledrejection = null; + + reactionPicker.init(); + + if (splash) { + splash.style.opacity = '0'; + splash.style.pointerEvents = 'none'; + } + + // クライアントが更新されたか? + const lastVersion = localStorage.getItem('lastVersion'); + if (lastVersion !== version) { + localStorage.setItem('lastVersion', version); + + // テーマリビルドするため + localStorage.removeItem('theme'); + + try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため + if (lastVersion != null && compareVersions(version, lastVersion) === 1) { + // ログインしてる場合だけ + if ($i) { + popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed'); + } + } + } catch (err) { + } + } + + // NOTE: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため) + watch(defaultStore.reactiveState.darkMode, (darkMode) => { + applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme')); + }, { immediate: localStorage.theme == null }); + + const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme')); + const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme')); + + watch(darkTheme, (theme) => { + if (defaultStore.state.darkMode) { + applyTheme(theme); + } + }); + + watch(lightTheme, (theme) => { + if (!defaultStore.state.darkMode) { + applyTheme(theme); + } + }); + + //#region Sync dark mode + if (ColdDeviceStorage.get('syncDeviceDarkMode')) { + defaultStore.set('darkMode', isDeviceDarkmode()); + } + + window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => { + if (ColdDeviceStorage.get('syncDeviceDarkMode')) { + defaultStore.set('darkMode', mql.matches); + } + }); + //#endregion + + fetchInstanceMetaPromise.then(() => { + if (defaultStore.state.themeInitial) { + if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON5.parse(instance.defaultLightTheme)); + if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON5.parse(instance.defaultDarkTheme)); + defaultStore.set('themeInitial', false); + } + }); + + watch(defaultStore.reactiveState.useBlurEffectForModal, v => { + document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none'); + }, { immediate: true }); + + watch(defaultStore.reactiveState.useBlurEffect, v => { + if (v) { + document.documentElement.style.removeProperty('--blur'); + } else { + document.documentElement.style.setProperty('--blur', 'none'); + } + }, { immediate: true }); + + let reloadDialogShowing = false; + stream.on('_disconnected_', async () => { + if (defaultStore.state.serverDisconnectedBehavior === 'reload') { + location.reload(); + } else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { + if (reloadDialogShowing) return; + reloadDialogShowing = true; + const { canceled } = await confirm({ + type: 'warning', + title: i18n.ts.disconnectedFromServer, + text: i18n.ts.reloadConfirm, + }); + reloadDialogShowing = false; + if (!canceled) { + location.reload(); + } + } + }); + + stream.on('emojiAdded', emojiData => { + // TODO + //store.commit('instance/set', ); + }); + + for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { + import('./plugin').then(({ install }) => { + install(plugin); + }); + } + + const hotkeys = { + 'd': (): void => { + defaultStore.set('darkMode', !defaultStore.state.darkMode); + }, + 's': search, + }; + + if ($i) { + // only add post shortcuts if logged in + hotkeys['p|n'] = post; + + if ($i.isDeleted) { + alert({ + type: 'warning', + text: i18n.ts.accountDeletionInProgress, + }); + } + + const lastUsed = localStorage.getItem('lastUsed'); + if (lastUsed) { + const lastUsedDate = parseInt(lastUsed, 10); + // 二時間以上前なら + if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) { + toast(i18n.t('welcomeBackWithName', { + name: $i.name || $i.username, + })); + } + } + localStorage.setItem('lastUsed', Date.now().toString()); + + if ('Notification' in window) { + // 許可を得ていなかったらリクエスト + if (Notification.permission === 'default') { + Notification.requestPermission(); + } + } + + const main = markRaw(stream.useChannel('main', null, 'System')); + + // 自分の情報が更新されたとき + main.on('meUpdated', i => { + updateAccount(i); + }); + + main.on('readAllNotifications', () => { + updateAccount({ hasUnreadNotification: false }); + }); + + main.on('unreadNotification', () => { + updateAccount({ hasUnreadNotification: true }); + }); + + main.on('unreadMention', () => { + updateAccount({ hasUnreadMentions: true }); + }); + + main.on('readAllUnreadMentions', () => { + updateAccount({ hasUnreadMentions: false }); + }); + + main.on('unreadSpecifiedNote', () => { + updateAccount({ hasUnreadSpecifiedNotes: true }); + }); + + main.on('readAllUnreadSpecifiedNotes', () => { + updateAccount({ hasUnreadSpecifiedNotes: false }); + }); + + main.on('readAllMessagingMessages', () => { + updateAccount({ hasUnreadMessagingMessage: false }); + }); + + main.on('unreadMessagingMessage', () => { + updateAccount({ hasUnreadMessagingMessage: true }); + sound.play('chatBg'); + }); + + main.on('readAllAntennas', () => { + updateAccount({ hasUnreadAntenna: false }); + }); + + main.on('unreadAntenna', () => { + updateAccount({ hasUnreadAntenna: true }); + sound.play('antenna'); + }); + + main.on('readAllAnnouncements', () => { + updateAccount({ hasUnreadAnnouncement: false }); + }); + + main.on('readAllChannels', () => { + updateAccount({ hasUnreadChannel: false }); + }); + + main.on('unreadChannel', () => { + updateAccount({ hasUnreadChannel: true }); + sound.play('channel'); + }); + + // トークンが再生成されたとき + // このままではMisskeyが利用できないので強制的にサインアウトさせる + main.on('myTokenRegenerated', () => { + signout(); + }); + } + + // shortcut + document.addEventListener('keydown', makeHotkey(hotkeys)); +})(); diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts new file mode 100644 index 0000000000..51464f32fb --- /dev/null +++ b/packages/frontend/src/instance.ts @@ -0,0 +1,45 @@ +import { computed, reactive } from 'vue'; +import * as Misskey from 'misskey-js'; +import { api } from './os'; + +// TODO: 他のタブと永続化されたstateを同期 + +const instanceData = localStorage.getItem('instance'); + +// TODO: instanceをリアクティブにするかは再考の余地あり + +export const instance: Misskey.entities.InstanceMetadata = reactive(instanceData ? JSON.parse(instanceData) : { + // TODO: set default values +}); + +export async function fetchInstance() { + const meta = await api('meta', { + detail: false, + }); + + for (const [k, v] of Object.entries(meta)) { + instance[k] = v; + } + + localStorage.setItem('instance', JSON.stringify(instance)); +} + +export const emojiCategories = computed(() => { + if (instance.emojis == null) return []; + const categories = new Set(); + for (const emoji of instance.emojis) { + categories.add(emoji.category); + } + return Array.from(categories); +}); + +export const emojiTags = computed(() => { + if (instance.emojis == null) return []; + const tags = new Set(); + for (const emoji of instance.emojis) { + for (const tag of emoji.aliases) { + tags.add(tag); + } + } + return Array.from(tags); +}); diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts new file mode 100644 index 0000000000..31e6cd64a4 --- /dev/null +++ b/packages/frontend/src/navbar.ts @@ -0,0 +1,135 @@ +import { computed, ref, reactive } from 'vue'; +import { $i } from './account'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { ui } from '@/config'; +import { unisonReload } from '@/scripts/unison-reload'; + +export const navbarItemDef = reactive({ + notifications: { + title: 'notifications', + icon: 'ti ti-bell', + show: computed(() => $i != null), + indicated: computed(() => $i != null && $i.hasUnreadNotification), + to: '/my/notifications', + }, + messaging: { + title: 'messaging', + icon: 'ti ti-messages', + show: computed(() => $i != null), + indicated: computed(() => $i != null && $i.hasUnreadMessagingMessage), + to: '/my/messaging', + }, + drive: { + title: 'drive', + icon: 'ti ti-cloud', + show: computed(() => $i != null), + to: '/my/drive', + }, + followRequests: { + title: 'followRequests', + icon: 'ti ti-user-plus', + show: computed(() => $i != null && $i.isLocked), + indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest), + to: '/my/follow-requests', + }, + explore: { + title: 'explore', + icon: 'ti ti-hash', + to: '/explore', + }, + announcements: { + title: 'announcements', + icon: 'ti ti-speakerphone', + indicated: computed(() => $i != null && $i.hasUnreadAnnouncement), + to: '/announcements', + }, + search: { + title: 'search', + icon: 'ti ti-search', + action: () => search(), + }, + lists: { + title: 'lists', + icon: 'ti ti-list', + show: computed(() => $i != null), + to: '/my/lists', + }, + /* + groups: { + title: 'groups', + icon: 'ti ti-users', + show: computed(() => $i != null), + to: '/my/groups', + }, + */ + antennas: { + title: 'antennas', + icon: 'ti ti-antenna', + show: computed(() => $i != null), + to: '/my/antennas', + }, + favorites: { + title: 'favorites', + icon: 'ti ti-star', + show: computed(() => $i != null), + to: '/my/favorites', + }, + pages: { + title: 'pages', + icon: 'ti ti-news', + to: '/pages', + }, + gallery: { + title: 'gallery', + icon: 'ti ti-icons', + to: '/gallery', + }, + clips: { + title: 'clip', + icon: 'ti ti-paperclip', + show: computed(() => $i != null), + to: '/my/clips', + }, + channels: { + title: 'channel', + icon: 'ti ti-device-tv', + to: '/channels', + }, + ui: { + title: 'switchUi', + icon: 'ti ti-devices', + action: (ev) => { + os.popupMenu([{ + text: i18n.ts.default, + active: ui === 'default' || ui === null, + action: () => { + localStorage.setItem('ui', 'default'); + unisonReload(); + }, + }, { + text: i18n.ts.deck, + active: ui === 'deck', + action: () => { + localStorage.setItem('ui', 'deck'); + unisonReload(); + }, + }, { + text: i18n.ts.classic, + active: ui === 'classic', + action: () => { + localStorage.setItem('ui', 'classic'); + unisonReload(); + }, + }], ev.currentTarget ?? ev.target); + }, + }, + reload: { + title: 'reload', + icon: 'ti ti-refresh', + action: (ev) => { + location.reload(); + }, + }, +}); diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/nirax.ts new file mode 100644 index 0000000000..53e73a8d48 --- /dev/null +++ b/packages/frontend/src/nirax.ts @@ -0,0 +1,275 @@ +// NIRAX --- A lightweight router + +import { EventEmitter } from 'eventemitter3'; +import { Ref, Component, ref, shallowRef, ShallowRef } from 'vue'; +import { pleaseLogin } from '@/scripts/please-login'; +import { safeURIDecode } from '@/scripts/safe-uri-decode'; + +type RouteDef = { + path: string; + component: Component; + query?: Record<string, string>; + loginRequired?: boolean; + name?: string; + hash?: string; + globalCacheKey?: string; + children?: RouteDef[]; +}; + +type ParsedPath = (string | { + name: string; + startsWith?: string; + wildcard?: boolean; + optional?: boolean; +})[]; + +export type Resolved = { route: RouteDef; props: Map<string, string>; child?: Resolved; }; + +function parsePath(path: string): ParsedPath { + const res = [] as ParsedPath; + + path = path.substring(1); + + for (const part of path.split('/')) { + if (part.includes(':')) { + const prefix = part.substring(0, part.indexOf(':')); + const placeholder = part.substring(part.indexOf(':') + 1); + const wildcard = placeholder.includes('(*)'); + const optional = placeholder.endsWith('?'); + res.push({ + name: placeholder.replace('(*)', '').replace('?', ''), + startsWith: prefix !== '' ? prefix : undefined, + wildcard, + optional, + }); + } else if (part.length !== 0) { + res.push(part); + } + } + + return res; +} + +export class Router extends EventEmitter<{ + change: (ctx: { + beforePath: string; + path: string; + resolved: Resolved; + key: string; + }) => void; + replace: (ctx: { + path: string; + key: string; + }) => void; + push: (ctx: { + beforePath: string; + path: string; + route: RouteDef | null; + props: Map<string, string> | null; + key: string; + }) => void; + same: () => void; +}> { + private routes: RouteDef[]; + public current: Resolved; + public currentRef: ShallowRef<Resolved> = shallowRef(); + public currentRoute: ShallowRef<RouteDef> = shallowRef(); + private currentPath: string; + private currentKey = Date.now().toString(); + + public navHook: ((path: string, flag?: any) => boolean) | null = null; + + constructor(routes: Router['routes'], currentPath: Router['currentPath']) { + super(); + + this.routes = routes; + this.currentPath = currentPath; + this.navigate(currentPath, null, false); + } + + public resolve(path: string): Resolved | null { + let queryString: string | null = null; + let hash: string | null = null; + if (path[0] === '/') path = path.substring(1); + if (path.includes('#')) { + hash = path.substring(path.indexOf('#') + 1); + path = path.substring(0, path.indexOf('#')); + } + if (path.includes('?')) { + queryString = path.substring(path.indexOf('?') + 1); + path = path.substring(0, path.indexOf('?')); + } + + if (_DEV_) console.log('Routing: ', path, queryString); + + function check(routes: RouteDef[], _parts: string[]): Resolved | null { + forEachRouteLoop: + for (const route of routes) { + let parts = [..._parts]; + const props = new Map<string, string>(); + + pathMatchLoop: + for (const p of parsePath(route.path)) { + if (typeof p === 'string') { + if (p === parts[0]) { + parts.shift(); + } else { + continue forEachRouteLoop; + } + } else { + if (parts[0] == null && !p.optional) { + continue forEachRouteLoop; + } + if (p.wildcard) { + if (parts.length !== 0) { + props.set(p.name, safeURIDecode(parts.join('/'))); + parts = []; + } + break pathMatchLoop; + } else { + if (p.startsWith) { + if (parts[0] == null || !parts[0].startsWith(p.startsWith)) continue forEachRouteLoop; + + props.set(p.name, safeURIDecode(parts[0].substring(p.startsWith.length))); + parts.shift(); + } else { + if (parts[0]) { + props.set(p.name, safeURIDecode(parts[0])); + } + parts.shift(); + } + } + } + } + + if (parts.length === 0) { + if (route.children) { + const child = check(route.children, []); + if (child) { + return { + route, + props, + child, + }; + } else { + continue forEachRouteLoop; + } + } + + if (route.hash != null && hash != null) { + props.set(route.hash, safeURIDecode(hash)); + } + + if (route.query != null && queryString != null) { + const queryObject = [...new URLSearchParams(queryString).entries()] + .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {}); + + for (const q in route.query) { + const as = route.query[q]; + if (queryObject[q]) { + props.set(as, safeURIDecode(queryObject[q])); + } + } + } + + return { + route, + props, + }; + } else { + if (route.children) { + const child = check(route.children, parts); + if (child) { + return { + route, + props, + child, + }; + } else { + continue forEachRouteLoop; + } + } else { + continue forEachRouteLoop; + } + } + } + + return null; + } + + const _parts = path.split('/').filter(part => part.length !== 0); + + return check(this.routes, _parts); + } + + private navigate(path: string, key: string | null | undefined, emitChange = true) { + const beforePath = this.currentPath; + this.currentPath = path; + + const res = this.resolve(this.currentPath); + + if (res == null) { + throw new Error('no route found for: ' + path); + } + + if (res.route.loginRequired) { + pleaseLogin('/'); + } + + const isSamePath = beforePath === path; + if (isSamePath && key == null) key = this.currentKey; + this.current = res; + this.currentRef.value = res; + this.currentRoute.value = res.route; + this.currentKey = res.route.globalCacheKey ?? key ?? path; + + if (emitChange) { + this.emit('change', { + beforePath, + path, + resolved: res, + key: this.currentKey, + }); + } + + return res; + } + + public getCurrentPath() { + return this.currentPath; + } + + public getCurrentKey() { + return this.currentKey; + } + + public push(path: string, flag?: any) { + const beforePath = this.currentPath; + if (path === beforePath) { + this.emit('same'); + return; + } + if (this.navHook) { + const cancel = this.navHook(path, flag); + if (cancel) return; + } + const res = this.navigate(path, null); + this.emit('push', { + beforePath, + path, + route: res.route, + props: res.props, + key: this.currentKey, + }); + } + + public replace(path: string, key?: string | null, emitEvent = true) { + this.navigate(path, key); + if (emitEvent) { + this.emit('replace', { + path, + key: this.currentKey, + }); + } + } +} diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts new file mode 100644 index 0000000000..7e57dcb4af --- /dev/null +++ b/packages/frontend/src/os.ts @@ -0,0 +1,588 @@ +// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する + +import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue'; +import { EventEmitter } from 'eventemitter3'; +import insertTextAtCursor from 'insert-text-at-cursor'; +import * as Misskey from 'misskey-js'; +import { apiUrl, url } from '@/config'; +import MkPostFormDialog from '@/components/MkPostFormDialog.vue'; +import MkWaitingDialog from '@/components/MkWaitingDialog.vue'; +import { MenuItem } from '@/types/menu'; +import { $i } from '@/account'; + +export const pendingApiRequestsCount = ref(0); + +const apiClient = new Misskey.api.APIClient({ + origin: url, +}); + +export const api = ((endpoint: string, data: Record<string, any> = {}, token?: string | null | undefined) => { + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const promise = new Promise((resolve, reject) => { + // Append a credential + if ($i) (data as any).i = $i.token; + if (token !== undefined) (data as any).i = token; + + // Send request + window.fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'omit', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +}) as typeof apiClient.request; + +export const apiGet = ((endpoint: string, data: Record<string, any> = {}) => { + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const query = new URLSearchParams(data); + + const promise = new Promise((resolve, reject) => { + // Send request + window.fetch(`${apiUrl}/${endpoint}?${query}`, { + method: 'GET', + credentials: 'omit', + cache: 'default', + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +}) as typeof apiClient.request; + +export const apiWithDialog = (( + endpoint: string, + data: Record<string, any> = {}, + token?: string | null | undefined, +) => { + const promise = api(endpoint, data, token); + promiseDialog(promise, null, (err) => { + alert({ + type: 'error', + text: err.message + '\n' + (err as any).id, + }); + }); + + return promise; +}) as typeof api; + +export function promiseDialog<T extends Promise<any>>( + promise: T, + onSuccess?: ((res: any) => void) | null, + onFailure?: ((err: Error) => void) | null, + text?: string, +): T { + const showing = ref(true); + const success = ref(false); + + promise.then(res => { + if (onSuccess) { + showing.value = false; + onSuccess(res); + } else { + success.value = true; + window.setTimeout(() => { + showing.value = false; + }, 1000); + } + }).catch(err => { + showing.value = false; + if (onFailure) { + onFailure(err); + } else { + alert({ + type: 'error', + text: err, + }); + } + }); + + // NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない) + popup(MkWaitingDialog, { + success: success, + showing: showing, + text: text, + }, {}, 'closed'); + + return promise; +} + +let popupIdCount = 0; +export const popups = ref([]) as Ref<{ + id: any; + component: any; + props: Record<string, any>; +}[]>; + +const zIndexes = { + low: 1000000, + middle: 2000000, + high: 3000000, +}; +export function claimZIndex(priority: 'low' | 'middle' | 'high' = 'low'): number { + zIndexes[priority] += 100; + return zIndexes[priority]; +} + +export async function popup(component: Component, props: Record<string, any>, events = {}, disposeEvent?: string) { + markRaw(component); + + const id = ++popupIdCount; + const dispose = () => { + // このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ? + window.setTimeout(() => { + popups.value = popups.value.filter(popup => popup.id !== id); + }, 0); + }; + const state = { + component, + props, + events: disposeEvent ? { + ...events, + [disposeEvent]: dispose, + } : events, + id, + }; + + popups.value.push(state); + + return { + dispose, + }; +} + +export function pageWindow(path: string) { + popup(defineAsyncComponent(() => import('@/components/MkPageWindow.vue')), { + initialPath: path, + }, {}, 'closed'); +} + +export function modalPageWindow(path: string) { + popup(defineAsyncComponent(() => import('@/components/MkModalPageWindow.vue')), { + initialPath: path, + }, {}, 'closed'); +} + +export function toast(message: string) { + popup(defineAsyncComponent(() => import('@/components/MkToast.vue')), { + message, + }, {}, 'closed'); +} + +export function alert(props: { + type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; + title?: string | null; + text?: string | null; +}): Promise<void> { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), props, { + done: result => { + resolve(); + }, + }, 'closed'); + }); +} + +export function confirm(props: { + type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; + title?: string | null; + text?: string | null; +}): Promise<{ canceled: boolean }> { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + ...props, + showCancelButton: true, + }, { + done: result => { + resolve(result ? result : { canceled: true }); + }, + }, 'closed'); + }); +} + +export function inputText(props: { + type?: 'text' | 'email' | 'password' | 'url'; + title?: string | null; + text?: string | null; + placeholder?: string | null; + default?: string | null; +}): Promise<{ canceled: true; result: undefined; } | { + canceled: false; result: string; +}> { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + title: props.title, + text: props.text, + input: { + type: props.type, + placeholder: props.placeholder, + default: props.default, + }, + }, { + done: result => { + resolve(result ? result : { canceled: true }); + }, + }, 'closed'); + }); +} + +export function inputNumber(props: { + title?: string | null; + text?: string | null; + placeholder?: string | null; + default?: number | null; +}): Promise<{ canceled: true; result: undefined; } | { + canceled: false; result: number; +}> { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + title: props.title, + text: props.text, + input: { + type: 'number', + placeholder: props.placeholder, + default: props.default, + }, + }, { + done: result => { + resolve(result ? result : { canceled: true }); + }, + }, 'closed'); + }); +} + +export function inputDate(props: { + title?: string | null; + text?: string | null; + placeholder?: string | null; + default?: Date | null; +}): Promise<{ canceled: true; result: undefined; } | { + canceled: false; result: Date; +}> { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + title: props.title, + text: props.text, + input: { + type: 'date', + placeholder: props.placeholder, + default: props.default, + }, + }, { + done: result => { + resolve(result ? { result: new Date(result.result), canceled: false } : { canceled: true }); + }, + }, 'closed'); + }); +} + +export function select<C = any>(props: { + title?: string | null; + text?: string | null; + default?: string | null; +} & ({ + items: { + value: C; + text: string; + }[]; +} | { + groupedItems: { + label: string; + items: { + value: C; + text: string; + }[]; + }[]; +})): Promise<{ canceled: true; result: undefined; } | { + canceled: false; result: C; +}> { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + title: props.title, + text: props.text, + select: { + items: props.items, + groupedItems: props.groupedItems, + default: props.default, + }, + }, { + done: result => { + resolve(result ? result : { canceled: true }); + }, + }, 'closed'); + }); +} + +export function success() { + return new Promise((resolve, reject) => { + const showing = ref(true); + window.setTimeout(() => { + showing.value = false; + }, 1000); + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + success: true, + showing: showing, + }, { + done: () => resolve(), + }, 'closed'); + }); +} + +export function waiting() { + return new Promise((resolve, reject) => { + const showing = ref(true); + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + success: false, + showing: showing, + }, { + done: () => resolve(), + }, 'closed'); + }); +} + +export function form(title, form) { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, { + done: result => { + resolve(result); + }, + }, 'closed'); + }); +} + +export async function selectUser() { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {}, { + ok: user => { + resolve(user); + }, + }, 'closed'); + }); +} + +export async function selectDriveFile(multiple: boolean) { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { + type: 'file', + multiple, + }, { + done: files => { + if (files) { + resolve(multiple ? files : files[0]); + } + }, + }, 'closed'); + }); +} + +export async function selectDriveFolder(multiple: boolean) { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { + type: 'folder', + multiple, + }, { + done: folders => { + if (folders) { + resolve(multiple ? folders : folders[0]); + } + }, + }, 'closed'); + }); +} + +export async function pickEmoji(src: HTMLElement | null, opts) { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { + src, + ...opts, + }, { + done: emoji => { + resolve(emoji); + }, + }, 'closed'); + }); +} + +export async function cropImage(image: Misskey.entities.DriveFile, options: { + aspectRatio: number; +}): Promise<Misskey.entities.DriveFile> { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { + file: image, + aspectRatio: options.aspectRatio, + }, { + ok: x => { + resolve(x); + }, + }, 'closed'); + }); +} + +type AwaitType<T> = + T extends Promise<infer U> ? U : + T extends (...args: any[]) => Promise<infer V> ? V : + T; +let openingEmojiPicker: AwaitType<ReturnType<typeof popup>> | null = null; +let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null; +export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: typeof activeTextarea) { + if (openingEmojiPicker) return; + + activeTextarea = initialTextarea; + + const textareas = document.querySelectorAll('textarea, input'); + for (const textarea of Array.from(textareas)) { + textarea.addEventListener('focus', () => { + activeTextarea = textarea; + }); + } + + const observer = new MutationObserver(records => { + for (const record of records) { + for (const node of Array.from(record.addedNodes).filter(node => node instanceof HTMLElement) as HTMLElement[]) { + const textareas = node.querySelectorAll('textarea, input') as NodeListOf<NonNullable<typeof activeTextarea>>; + for (const textarea of Array.from(textareas).filter(textarea => textarea.dataset.preventEmojiInsert == null)) { + if (document.activeElement === textarea) activeTextarea = textarea; + textarea.addEventListener('focus', () => { + activeTextarea = textarea; + }); + } + } + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: false, + characterData: false, + }); + + openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerWindow.vue')), { + src, + ...opts, + }, { + chosen: emoji => { + insertTextAtCursor(activeTextarea, emoji); + }, + closed: () => { + openingEmojiPicker!.dispose(); + openingEmojiPicker = null; + observer.disconnect(); + }, + }); +} + +export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement, options?: { + align?: string; + width?: number; + viaKeyboard?: boolean; +}) { + return new Promise((resolve, reject) => { + let dispose; + popup(defineAsyncComponent(() => import('@/components/MkPopupMenu.vue')), { + items, + src, + width: options?.width, + align: options?.align, + viaKeyboard: options?.viaKeyboard, + }, { + closed: () => { + resolve(); + dispose(); + }, + }).then(res => { + dispose = res.dispose; + }); + }); +} + +export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) { + ev.preventDefault(); + return new Promise((resolve, reject) => { + let dispose; + popup(defineAsyncComponent(() => import('@/components/MkContextMenu.vue')), { + items, + ev, + }, { + closed: () => { + resolve(); + dispose(); + }, + }).then(res => { + dispose = res.dispose; + }); + }); +} + +export function post(props: Record<string, any> = {}) { + return new Promise((resolve, reject) => { + // NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない + // NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、 + // Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、 + // 複数のpost formを開いたときに場合によってはエラーになる + // もちろん複数のpost formを開けること自体Misskeyサイドのバグなのだが + let dispose; + popup(MkPostFormDialog, props, { + closed: () => { + resolve(); + dispose(); + }, + }).then(res => { + dispose = res.dispose; + }); + }); +} + +export const deckGlobalEvents = new EventEmitter(); + +/* +export function checkExistence(fileData: ArrayBuffer): Promise<any> { + return new Promise((resolve, reject) => { + const data = new FormData(); + data.append('md5', getMD5(fileData)); + + os.api('drive/files/find-by-hash', { + md5: getMD5(fileData) + }).then(resp => { + resolve(resp.length > 0 ? resp[0] : null); + }); + }); +}*/ diff --git a/packages/frontend/src/pages/_empty_.vue b/packages/frontend/src/pages/_empty_.vue new file mode 100644 index 0000000000..000b6decc9 --- /dev/null +++ b/packages/frontend/src/pages/_empty_.vue @@ -0,0 +1,7 @@ +<template> +<div></div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +</script> diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue new file mode 100644 index 0000000000..232d525347 --- /dev/null +++ b/packages/frontend/src/pages/_error_.vue @@ -0,0 +1,89 @@ +<template> +<MkLoading v-if="!loaded"/> +<transition :name="$store.state.animation ? 'zoom' : ''" appear> + <div v-show="loaded" class="mjndxjch"> + <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> + <p><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></p> + <p v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</p> + <p v-else-if="serverIsDead">{{ i18n.ts.serverIsDead }}</p> + <template v-else> + <p>{{ i18n.ts.newVersionOfClientAvailable }}</p> + <p>{{ i18n.ts.youShouldUpgradeClient }}</p> + <MkButton class="button primary" @click="reload">{{ i18n.ts.reload }}</MkButton> + </template> + <p><MkA to="/docs/general/troubleshooting" class="_link">{{ i18n.ts.troubleshooting }}</MkA></p> + <p v-if="error" class="error">ERROR: {{ error }}</p> + </div> +</transition> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import { version } from '@/config'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = withDefaults(defineProps<{ + error?: Error; +}>(), { +}); + +let loaded = $ref(false); +let serverIsDead = $ref(false); +let meta = $ref<misskey.entities.LiteInstanceMetadata | null>(null); + +os.api('meta', { + detail: false, +}).then(res => { + loaded = true; + serverIsDead = false; + meta = res; + localStorage.setItem('v', res.version); +}, () => { + loaded = true; + serverIsDead = true; +}); + +function reload() { + unisonReload(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.error, + icon: 'ti ti-alert-triangle', +}); +</script> + +<style lang="scss" scoped> +.mjndxjch { + padding: 32px; + text-align: center; + + > p { + margin: 0 0 12px 0; + } + + > .button { + margin: 8px auto; + } + + > img { + vertical-align: bottom; + height: 128px; + margin-bottom: 24px; + border-radius: 16px; + } + + > .error { + opacity: 0.7; + } +} +</style> diff --git a/packages/frontend/src/pages/_loading_.vue b/packages/frontend/src/pages/_loading_.vue new file mode 100644 index 0000000000..1dd2e46e10 --- /dev/null +++ b/packages/frontend/src/pages/_loading_.vue @@ -0,0 +1,6 @@ +<template> +<MkLoading/> +</template> + +<script lang="ts" setup> +</script> diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue new file mode 100644 index 0000000000..3ec972bcda --- /dev/null +++ b/packages/frontend/src/pages/about-misskey.vue @@ -0,0 +1,264 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <div style="overflow: clip;"> + <MkSpacer :content-max="600" :margin-min="20"> + <div class="_formRoot znqjceqz"> + <div id="debug"></div> + <div ref="containerEl" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }"> + <img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/> + <div class="misskey">Misskey</div> + <div class="version">v{{ version }}</div> + <span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span> + </div> + <div class="_formBlock" style="text-align: center;"> + {{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a> + </div> + <div class="_formBlock" style="text-align: center;"> + <MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton> + </div> + <FormSection> + <div class="_formLinks"> + <FormLink to="https://github.com/misskey-dev/misskey" external> + <template #icon><i class="ti ti-code"></i></template> + {{ i18n.ts._aboutMisskey.source }} + <template #suffix>GitHub</template> + </FormLink> + <FormLink to="https://crowdin.com/project/misskey" external> + <template #icon><i class="ti ti-language-hiragana"></i></template> + {{ i18n.ts._aboutMisskey.translation }} + <template #suffix>Crowdin</template> + </FormLink> + <FormLink to="https://www.patreon.com/syuilo" external> + <template #icon><i class="ti ti-pig-money"></i></template> + {{ i18n.ts._aboutMisskey.donate }} + <template #suffix>Patreon</template> + </FormLink> + </div> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._aboutMisskey.contributors }}</template> + <div class="_formLinks"> + <FormLink to="https://github.com/syuilo" external>@syuilo</FormLink> + <FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink> + <FormLink to="https://github.com/mei23" external>@mei23</FormLink> + <FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink> + <FormLink to="https://github.com/tamaina" external>@tamaina</FormLink> + <FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink> + <FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink> + <FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink> + <FormLink to="https://github.com/marihachi" external>@marihachi</FormLink> + </div> + <template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template> + </FormSection> + <FormSection> + <template #label><Mfm text="$[jelly ❤]"/> {{ i18n.ts._aboutMisskey.patrons }}</template> + <div v-for="patron in patrons" :key="patron">{{ patron }}</div> + <template #caption>{{ i18n.ts._aboutMisskey.morePatrons }}</template> + </FormSection> + </div> + </MkSpacer> + </div> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { nextTick, onBeforeUnmount } from 'vue'; +import { version } from '@/config'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkLink from '@/components/MkLink.vue'; +import { physics } from '@/scripts/physics'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const patrons = [ + 'まっちゃとーにゅ', + 'mametsuko', + 'noellabo', + 'AureoleArk', + 'Gargron', + 'Nokotaro Takeda', + 'Suji Yan', + 'oi_yekssim', + 'regtan', + 'Hekovic', + 'nenohi', + 'Gitmo Life Services', + 'naga_rus', + 'Efertone', + 'Melilot', + 'motcha', + 'nanami kan', + 'sevvie Rose', + 'Hayato Ishikawa', + 'Puniko', + 'skehmatics', + 'Quinton Macejkovic', + 'YUKIMOCHI', + 'dansup', + 'mewl hayabusa', + 'Emilis', + 'Fristi', + 'makokunsan', + 'chidori ninokura', + 'Peter G.', + '見当かなみ', + 'natalie', + 'Maronu', + 'Steffen K9', + 'takimura', + 'sikyosyounin', + 'Nesakko', + 'YuzuRyo61', + 'blackskye', + 'sheeta.s', + 'osapon', + 'public_yusuke', + 'CG', + '吴浥', + 't_w', + 'Jerry', + 'nafuchoco', + 'Takumi Sugita', + 'GLaTAN', + 'mkatze', + 'kabo2468y', + 'mydarkstar', + 'Roujo', + 'DignifiedSilence', + 'uroco @99', + 'totokoro', + 'うし', + 'kiritan', + 'weepjp', + 'Liaizon Wakest', + 'Duponin', + 'Blue', + 'Naoki Hirayama', + 'wara', + 'Wataru Manji (manji0)', + 'みなしま', + 'kanoy', + 'xianon', + 'Denshi', + 'Osushimaru', + 'にょんへら', + 'おのだい', + 'Leni', + 'oss', + 'Weeble', + '蝉暮せせせ', + 'ThatOneCalculator', + 'pixeldesu', +]; + +let easterEggReady = false; +let easterEggEmojis = $ref([]); +let easterEggEngine = $ref(null); +const containerEl = $ref<HTMLElement>(); + +function iconLoaded() { + const emojis = defaultStore.state.reactions; + const containerWidth = containerEl.offsetWidth; + for (let i = 0; i < 32; i++) { + easterEggEmojis.push({ + id: i.toString(), + top: -(128 + (Math.random() * 256)), + left: (Math.random() * containerWidth), + emoji: emojis[Math.floor(Math.random() * emojis.length)], + }); + } + + nextTick(() => { + easterEggReady = true; + }); +} + +function gravity() { + if (!easterEggReady) return; + easterEggReady = false; + easterEggEngine = physics(containerEl); +} + +function iLoveMisskey() { + os.post({ + initialText: 'I $[jelly ❤] #Misskey', + instant: true, + }); +} + +onBeforeUnmount(() => { + if (easterEggEngine) { + easterEggEngine.stop(); + } +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.aboutMisskey, + icon: null, +}); +</script> + +<style lang="scss" scoped> +.znqjceqz { + > .about { + position: relative; + text-align: center; + padding: 16px; + border-radius: var(--radius); + + &.playing { + &, * { + user-select: none; + } + + * { + will-change: transform; + } + + > .emoji { + visibility: visible; + } + } + + > .icon { + display: block; + width: 100px; + margin: 0 auto; + border-radius: 16px; + } + + > .misskey { + margin: 0.75em auto 0 auto; + width: max-content; + } + + > .version { + margin: 0 auto; + width: max-content; + opacity: 0.5; + } + + > .emoji { + position: absolute; + top: 0; + left: 0; + visibility: hidden; + + > .emoji { + pointer-events: none; + font-size: 24px; + width: 24px; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue new file mode 100644 index 0000000000..53ce1e4b75 --- /dev/null +++ b/packages/frontend/src/pages/about.emojis.vue @@ -0,0 +1,134 @@ +<template> +<div class="driuhtrh"> + <div class="query"> + <MkInput v-model="q" class="" :placeholder="$ts.search"> + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + + <!-- たくさんあると邪魔 + <div class="tags"> + <span class="tag _button" v-for="tag in tags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span> + </div> + --> + </div> + + <MkFolder v-if="searchEmojis" class="emojis"> + <template #header>{{ $ts.searchResult }}</template> + <div class="zuvgdzyt"> + <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/> + </div> + </MkFolder> + + <MkFolder v-for="category in customEmojiCategories" :key="category" class="emojis"> + <template #header>{{ category || $ts.other }}</template> + <div class="zuvgdzyt"> + <XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" class="emoji" :emoji="emoji"/> + </div> + </MkFolder> +</div> +</template> + +<script lang="ts"> +import { defineComponent, computed } from 'vue'; +import XEmoji from './emojis.emoji.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkTab from '@/components/MkTab.vue'; +import * as os from '@/os'; +import { emojiCategories, emojiTags } from '@/instance'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkFolder, + MkTab, + XEmoji, + }, + + data() { + return { + q: '', + customEmojiCategories: emojiCategories, + customEmojis: this.$instance.emojis, + tags: emojiTags, + selectedTags: new Set(), + searchEmojis: null, + }; + }, + + watch: { + q() { this.search(); }, + selectedTags: { + handler() { + this.search(); + }, + deep: true, + }, + }, + + methods: { + search() { + if ((this.q === '' || this.q == null) && this.selectedTags.size === 0) { + this.searchEmojis = null; + return; + } + + if (this.selectedTags.size === 0) { + this.searchEmojis = this.customEmojis.filter(emoji => emoji.name.includes(this.q) || emoji.aliases.includes(this.q)); + } else { + this.searchEmojis = this.customEmojis.filter(emoji => (emoji.name.includes(this.q) || emoji.aliases.includes(this.q)) && [...this.selectedTags].every(t => emoji.aliases.includes(t))); + } + }, + + toggleTag(tag) { + if (this.selectedTags.has(tag)) { + this.selectedTags.delete(tag); + } else { + this.selectedTags.add(tag); + } + }, + }, +}); +</script> + +<style lang="scss" scoped> +.driuhtrh { + background: var(--bg); + + > .query { + background: var(--bg); + padding: 16px; + + > .tags { + > .tag { + display: inline-block; + margin: 8px 8px 0 0; + padding: 4px 8px; + font-size: 0.9em; + background: var(--accentedBg); + border-radius: 5px; + + &.active { + background: var(--accent); + color: var(--fgOnAccent); + } + } + } + } + + > .emojis { + --x-padding: 0 16px; + + .zuvgdzyt { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: 0 var(--margin) var(--margin) var(--margin); + } + } +} +</style> diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue new file mode 100644 index 0000000000..6c92ab1264 --- /dev/null +++ b/packages/frontend/src/pages/about.federation.vue @@ -0,0 +1,106 @@ +<template> +<div class="taeiyria"> + <div class="query"> + <MkInput v-model="host" :debounce="true" class=""> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + <FormSplit style="margin-top: var(--margin);"> + <MkSelect v-model="state"> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="federating">{{ i18n.ts.federating }}</option> + <option value="subscribing">{{ i18n.ts.subscribing }}</option> + <option value="publishing">{{ i18n.ts.publishing }}</option> + <option value="suspended">{{ i18n.ts.suspended }}</option> + <option value="blocked">{{ i18n.ts.blocked }}</option> + <option value="notResponding">{{ i18n.ts.notResponding }}</option> + </MkSelect> + <MkSelect v-model="sort"> + <template #label>{{ i18n.ts.sort }}</template> + <option value="+pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+notes">{{ i18n.ts.notes }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-notes">{{ i18n.ts.notes }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+users">{{ i18n.ts.users }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-users">{{ i18n.ts.users }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+following">{{ i18n.ts.following }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+lastCommunicatedAt">{{ i18n.ts.lastCommunication }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-lastCommunicatedAt">{{ i18n.ts.lastCommunication }} ({{ i18n.ts.ascendingOrder }})</option> + </MkSelect> + </FormSplit> + </div> + + <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> + <div class="dqokceoi"> + <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Last communicated: ${new Date(instance.lastCommunicatedAt).toLocaleString()}\nStatus: ${getStatus(instance)}`" class="instance" :to="`/instance-info/${instance.host}`"> + <MkInstanceCardMini :instance="instance"/> + </MkA> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; +import FormSplit from '@/components/form/split.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +let host = $ref(''); +let state = $ref('federating'); +let sort = $ref('+pubSub'); +const pagination = { + endpoint: 'federation/instances' as const, + limit: 10, + offsetMode: true, + params: computed(() => ({ + sort: sort, + host: host !== '' ? host : null, + ...( + state === 'federating' ? { federating: true } : + state === 'subscribing' ? { subscribing: true } : + state === 'publishing' ? { publishing: true } : + state === 'suspended' ? { suspended: true } : + state === 'blocked' ? { blocked: true } : + state === 'notResponding' ? { notResponding: true } : + {}), + })), +}; + +function getStatus(instance) { + if (instance.isSuspended) return 'Suspended'; + if (instance.isBlocked) return 'Blocked'; + if (instance.isNotResponding) return 'Error'; + return 'Alive'; +} +</script> + +<style lang="scss" scoped> +.taeiyria { + > .query { + background: var(--bg); + margin-bottom: 16px; + } +} + +.dqokceoi { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + grid-gap: 12px; + + > .instance:hover { + text-decoration: none; + } +} +</style> diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue new file mode 100644 index 0000000000..0ed692c5c5 --- /dev/null +++ b/packages/frontend/src/pages/about.vue @@ -0,0 +1,166 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20"> + <div class="_formRoot"> + <div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> + <div class="content"> + <img :src="$instance.iconUrl ?? $instance.faviconUrl ?? '/favicon.ico'" alt="" class="icon"/> + <div class="name"> + <b>{{ $instance.name ?? host }}</b> + </div> + </div> + </div> + + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.description }}</template> + <template #value><div v-html="$instance.description"></div></template> + </MkKeyValue> + + <FormSection> + <MkKeyValue class="_formBlock" :copy="version"> + <template #key>Misskey</template> + <template #value>{{ version }}</template> + </MkKeyValue> + <div class="_formBlock" v-html="i18n.t('poweredByMisskeyDescription', { name: $instance.name ?? host })"> + </div> + <FormLink to="/about-misskey">{{ i18n.ts.aboutMisskey }}</FormLink> + </FormSection> + + <FormSection> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.administrator }}</template> + <template #value>{{ $instance.maintainerName }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.contact }}</template> + <template #value>{{ $instance.maintainerEmail }}</template> + </MkKeyValue> + </FormSplit> + <FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ i18n.ts.tos }}</FormLink> + </FormSection> + + <FormSuspense :p="initStats"> + <FormSection> + <template #label>{{ i18n.ts.statistics }}</template> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.users }}</template> + <template #value>{{ number(stats.originalUsersCount) }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.notes }}</template> + <template #value>{{ number(stats.originalNotesCount) }}</template> + </MkKeyValue> + </FormSplit> + </FormSection> + </FormSuspense> + + <FormSection> + <template #label>Well-known resources</template> + <div class="_formLinks"> + <FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink> + <FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink> + <FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink> + <FormLink :to="`/robots.txt`" external>robots.txt</FormLink> + <FormLink :to="`/manifest.json`" external>manifest.json</FormLink> + </div> + </FormSection> + </div> + </MkSpacer> + <MkSpacer v-else-if="tab === 'emojis'" :content-max="1000" :margin-min="20"> + <XEmojis/> + </MkSpacer> + <MkSpacer v-else-if="tab === 'federation'" :content-max="1000" :margin-min="20"> + <XFederation/> + </MkSpacer> + <MkSpacer v-else-if="tab === 'charts'" :content-max="1000" :margin-min="20"> + <MkInstanceStats :chart-limit="500" :detailed="true"/> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import XEmojis from './about.emojis.vue'; +import XFederation from './about.federation.vue'; +import { version, instanceName, host } from '@/config'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormSplit from '@/components/form/split.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkInstanceStats from '@/components/MkInstanceStats.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = withDefaults(defineProps<{ + initialTab?: string; +}>(), { + initialTab: 'overview', +}); + +let stats = $ref(null); +let tab = $ref(props.initialTab); + +const initStats = () => os.api('stats', { +}).then((res) => { + stats = res; +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, +}, { + key: 'emojis', + title: i18n.ts.customEmojis, + icon: 'ti ti-mood-happy', +}, { + key: 'federation', + title: i18n.ts.federation, + icon: 'ti ti-whirl', +}, { + key: 'charts', + title: i18n.ts.charts, + icon: 'ti ti-chart-line', +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.instanceInfo, + icon: 'ti ti-info-circle', +}))); +</script> + +<style lang="scss" scoped> +.fwhjspax { + text-align: center; + border-radius: 10px; + overflow: clip; + background-size: cover; + background-position: center center; + + > .content { + overflow: hidden; + + > .icon { + display: block; + margin: 16px auto 0 auto; + height: 64px; + border-radius: 8px; + } + + > .name { + display: block; + padding: 16px; + color: #fff; + text-shadow: 0 0 8px #000; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue new file mode 100644 index 0000000000..a11249e75d --- /dev/null +++ b/packages/frontend/src/pages/admin-file.vue @@ -0,0 +1,160 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="file" :content-max="600" :margin-min="16" :margin-max="32"> + <div v-if="tab === 'overview'" class="cxqhhsmd _formRoot"> + <a class="_formBlock thumbnail" :href="file.url" target="_blank"> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + </a> + <div class="_formBlock"> + <MkKeyValue :copy="file.type" oneline style="margin: 1em 0;"> + <template #key>MIME Type</template> + <template #value><span class="_monospace">{{ file.type }}</span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>Size</template> + <template #value><span class="_monospace">{{ bytes(file.size) }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="file.id" oneline style="margin: 1em 0;"> + <template #key>ID</template> + <template #value><span class="_monospace">{{ file.id }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="file.md5" oneline style="margin: 1em 0;"> + <template #key>MD5</template> + <template #value><span class="_monospace">{{ file.md5 }}</span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.createdAt }}</template> + <template #value><span class="_monospace"><MkTime :time="file.createdAt" mode="detail" style="display: block;"/></span></template> + </MkKeyValue> + </div> + <MkA v-if="file.user" class="user" :to="`/user-info/${file.user.id}`"> + <MkUserCardMini :user="file.user"/> + </MkA> + <div class="_formBlock"> + <MkSwitch v-model="isSensitive" @update:model-value="toggleIsSensitive">NSFW</MkSwitch> + </div> + + <div class="_formBlock"> + <MkButton danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> + </div> + <div v-else-if="tab === 'ip' && info" class="_formRoot"> + <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo> + <MkKeyValue v-if="info.requestIp" class="_formBlock _monospace" :copy="info.requestIp" oneline> + <template #key>IP</template> + <template #value>{{ info.requestIp }}</template> + </MkKeyValue> + <FormSection v-if="info.requestHeaders"> + <template #label>Headers</template> + <MkKeyValue v-for="(v, k) in info.requestHeaders" :key="k" class="_formBlock _monospace"> + <template #key>{{ k }}</template> + <template #value>{{ v }}</template> + </MkKeyValue> + </FormSection> + </div> + <div v-else-if="tab === 'raw'" class="_formRoot"> + <MkObjectView v-if="info" tall :value="info"> + </MkObjectView> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkObjectView from '@/components/MkObjectView.vue'; +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormSection from '@/components/form/section.vue'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { acct } from '@/filters/user'; +import { iAmAdmin, iAmModerator } from '@/account'; + +let tab = $ref('overview'); +let file: any = $ref(null); +let info: any = $ref(null); +let isSensitive: boolean = $ref(false); + +const props = defineProps<{ + fileId: string, +}>(); + +async function fetch() { + file = await os.api('drive/files/show', { fileId: props.fileId }); + info = await os.api('admin/drive/show-file', { fileId: props.fileId }); + isSensitive = file.isSensitive; +} + +fetch(); + +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: file.name }), + }); + if (canceled) return; + + os.apiWithDialog('drive/files/delete', { + fileId: file.id, + }); +} + +async function toggleIsSensitive(v) { + await os.api('drive/files/update', { fileId: props.fileId, isSensitive: v }); + isSensitive = v; +} + +const headerActions = $computed(() => [{ + text: i18n.ts.openInNewTab, + icon: 'ti ti-external-link', + handler: () => { + window.open(file.url, '_blank'); + }, +}]); + +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, + icon: 'ti ti-info-circle', +}, iAmModerator ? { + key: 'ip', + title: 'IP', + icon: 'ti ti-password', +} : null, { + key: 'raw', + title: 'Raw data', + icon: 'ti ti-code', +}]); + +definePageMetadata(computed(() => ({ + title: file ? i18n.ts.file + ': ' + file.name : i18n.ts.file, + icon: 'ti ti-file', +}))); +</script> + +<style lang="scss" scoped> +.cxqhhsmd { + > .thumbnail { + display: block; + + > .thumbnail { + height: 300px; + max-width: 100%; + } + } + + > .user { + &:hover { + text-decoration: none; + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue new file mode 100644 index 0000000000..bdb41b2d2c --- /dev/null +++ b/packages/frontend/src/pages/admin/_header_.vue @@ -0,0 +1,292 @@ +<template> +<div ref="el" class="fdidabkc" :style="{ background: bg }" @click="onClick"> + <template v-if="metadata"> + <div class="titleContainer" @click="showTabsPopup"> + <i v-if="metadata.icon" class="icon" :class="metadata.icon"></i> + + <div class="title"> + <div class="title">{{ metadata.title }}</div> + </div> + </div> + <div class="tabs"> + <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)"> + <i v-if="tab.icon" class="icon" :class="tab.icon"></i> + <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> + </button> + <div ref="tabHighlightEl" class="highlight"></div> + </div> + </template> + <div class="buttons right"> + <template v-if="actions"> + <template v-for="action in actions"> + <MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> + <button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> + </template> + </template> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue'; +import tinycolor from 'tinycolor2'; +import { popupMenu } from '@/os'; +import { url } from '@/config'; +import { scrollToTop } from '@/scripts/scroll'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; +import { globalEvents } from '@/events'; +import { injectPageMetadata } from '@/scripts/page-metadata'; + +type Tab = { + key?: string | null; + title: string; + icon?: string; + iconOnly?: boolean; + onClick?: (ev: MouseEvent) => void; +}; + +const props = defineProps<{ + tabs?: Tab[]; + tab?: string; + actions?: { + text: string; + icon: string; + asFullButton?: boolean; + handler: (ev: MouseEvent) => void; + }[]; + thin?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'update:tab', key: string); +}>(); + +const metadata = injectPageMetadata(); + +const el = ref<HTMLElement>(null); +const tabRefs = {}; +const tabHighlightEl = $ref<HTMLElement | null>(null); +const bg = ref(null); +const height = ref(0); +const hasTabs = computed(() => { + return props.tabs && props.tabs.length > 0; +}); + +const showTabsPopup = (ev: MouseEvent) => { + if (!hasTabs.value) 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); +}; + +const preventDrag = (ev: TouchEvent) => { + ev.stopPropagation(); +}; + +const onClick = () => { + scrollToTop(el.value, { behavior: 'smooth' }); +}; + +function onTabMousedown(tab: Tab, ev: MouseEvent): void { + // ユーザビリティの観点からmousedown時にはonClickは呼ばない + if (tab.key) { + emit('update:tab', tab.key); + } +} + +function onTabClick(tab: Tab, ev: MouseEvent): void { + if (tab.onClick) { + ev.preventDefault(); + ev.stopPropagation(); + tab.onClick(ev); + } + if (tab.key) { + emit('update:tab', tab.key); + } +} + +const calcBg = () => { + const rawBg = metadata?.bg || 'var(--bg)'; + const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + tinyBg.setAlpha(0.85); + bg.value = tinyBg.toRgbString(); +}; + +onMounted(() => { + calcBg(); + globalEvents.on('themeChanged', calcBg); + + watch(() => [props.tab, props.tabs], () => { + nextTick(() => { + const tabEl = tabRefs[props.tab]; + if (tabEl && tabHighlightEl) { + // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabEl.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.style.width = rect.width + 'px'; + tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px'; + } + }); + }, { + immediate: true, + }); +}); + +onUnmounted(() => { + globalEvents.off('themeChanged', calcBg); +}); +</script> + +<style lang="scss" scoped> +.fdidabkc { + --height: 60px; + display: flex; + width: 100%; + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + + > .buttons { + --margin: 8px; + display: flex; + align-items: center; + height: var(--height); + margin: 0 var(--margin); + + &.right { + margin-left: auto; + } + + &:empty { + width: var(--height); + } + + > .button { + display: flex; + align-items: center; + justify-content: center; + height: calc(var(--height) - (var(--margin) * 2)); + width: calc(var(--height) - (var(--margin) * 2)); + box-sizing: border-box; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.highlighted { + color: var(--accent); + } + } + + > .fullButton { + & + .fullButton { + margin-left: 12px; + } + } + } + + > .titleContainer { + display: flex; + align-items: center; + 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/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue new file mode 100644 index 0000000000..973ec871ab --- /dev/null +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -0,0 +1,97 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="lcixvhis"> + <div class="_section reports"> + <div class="_content"> + <div class="inputs" style="display: flex;"> + <MkSelect v-model="state" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="unresolved">{{ i18n.ts.unresolved }}</option> + <option value="resolved">{{ i18n.ts.resolved }}</option> + </MkSelect> + <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.reporteeOrigin }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.reporterOrigin }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + </div> + <!-- TODO + <div class="inputs" style="display: flex; padding-top: 1.2em;"> + <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" :spellcheck="false"> + <span>{{ i18n.ts.username }}</span> + </MkInput> + <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params().origin === 'local'"> + <span>{{ i18n.ts.host }}</span> + </MkInput> + </div> + --> + + <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);"> + <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> + </MkPagination> + </div> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; + +import XHeader from './_header_.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import XAbuseReport from '@/components/MkAbuseReport.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let reports = $ref<InstanceType<typeof MkPagination>>(); + +let state = $ref('unresolved'); +let reporterOrigin = $ref('combined'); +let targetUserOrigin = $ref('combined'); +let searchUsername = $ref(''); +let searchHost = $ref(''); + +const pagination = { + endpoint: 'admin/abuse-user-reports' as const, + limit: 10, + params: computed(() => ({ + state, + reporterOrigin, + targetUserOrigin, + })), +}; + +function resolved(reportId) { + reports.removeItem(item => item.id === reportId); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.abuseReports, + icon: 'ti ti-exclamation-circle', +}); +</script> + +<style lang="scss" scoped> +.lcixvhis { + margin: var(--margin); +} +</style> diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue new file mode 100644 index 0000000000..2ec926c65c --- /dev/null +++ b/packages/frontend/src/pages/admin/ads.vue @@ -0,0 +1,132 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="uqshojas"> + <div v-for="ad in ads" class="_panel _formRoot ad"> + <MkAd v-if="ad.url" :specify="ad"/> + <MkInput v-model="ad.url" type="url" class="_formBlock"> + <template #label>URL</template> + </MkInput> + <MkInput v-model="ad.imageUrl" class="_formBlock"> + <template #label>{{ i18n.ts.imageUrl }}</template> + </MkInput> + <FormRadios v-model="ad.place" class="_formBlock"> + <template #label>Form</template> + <option value="square">square</option> + <option value="horizontal">horizontal</option> + <option value="horizontal-big">horizontal-big</option> + </FormRadios> + <!-- + <div style="margin: 32px 0;"> + {{ i18n.ts.priority }} + <MkRadio v-model="ad.priority" value="high">{{ i18n.ts.high }}</MkRadio> + <MkRadio v-model="ad.priority" value="middle">{{ i18n.ts.middle }}</MkRadio> + <MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio> + </div> + --> + <FormSplit> + <MkInput v-model="ad.ratio" type="number"> + <template #label>{{ i18n.ts.ratio }}</template> + </MkInput> + <MkInput v-model="ad.expiresAt" type="date"> + <template #label>{{ i18n.ts.expiration }}</template> + </MkInput> + </FormSplit> + <MkTextarea v-model="ad.memo" class="_formBlock"> + <template #label>{{ i18n.ts.memo }}</template> + </MkTextarea> + <div class="buttons _formBlock"> + <MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton class="button" inline danger @click="remove(ad)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> + </div> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormSplit from '@/components/form/split.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let ads: any[] = $ref([]); + +os.api('admin/ad/list').then(adsResponse => { + ads = adsResponse; +}); + +function add() { + ads.unshift({ + id: null, + memo: '', + place: 'square', + priority: 'middle', + ratio: 1, + url: '', + imageUrl: null, + expiresAt: null, + }); +} + +function remove(ad) { + os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: ad.url }), + }).then(({ canceled }) => { + if (canceled) return; + ads = ads.filter(x => x !== ad); + os.apiWithDialog('admin/ad/delete', { + id: ad.id, + }); + }); +} + +function save(ad) { + if (ad.id == null) { + os.apiWithDialog('admin/ad/create', { + ...ad, + expiresAt: new Date(ad.expiresAt).getTime(), + }); + } else { + os.apiWithDialog('admin/ad/update', { + ...ad, + expiresAt: new Date(ad.expiresAt).getTime(), + }); + } +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.add, + handler: add, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.ads, + icon: 'ti ti-ad', +}); +</script> + +<style lang="scss" scoped> +.uqshojas { + > .ad { + padding: 32px; + + &:not(:last-child) { + margin-bottom: var(--margin); + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue new file mode 100644 index 0000000000..607ad8aa02 --- /dev/null +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -0,0 +1,112 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="ztgjmzrw"> + <section v-for="announcement in announcements" class="_card _gap announcements"> + <div class="_content announcement"> + <MkInput v-model="announcement.title"> + <template #label>{{ i18n.ts.title }}</template> + </MkInput> + <MkTextarea v-model="announcement.text"> + <template #label>{{ i18n.ts.text }}</template> + </MkTextarea> + <MkInput v-model="announcement.imageUrl"> + <template #label>{{ i18n.ts.imageUrl }}</template> + </MkInput> + <p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p> + <div class="buttons"> + <MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton class="button" inline @click="remove(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> + </div> + </div> + </section> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let announcements: any[] = $ref([]); + +os.api('admin/announcements/list').then(announcementResponse => { + announcements = announcementResponse; +}); + +function add() { + announcements.unshift({ + id: null, + title: '', + text: '', + imageUrl: null, + }); +} + +function remove(announcement) { + os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: announcement.title }), + }).then(({ canceled }) => { + if (canceled) return; + announcements = announcements.filter(x => x !== announcement); + os.api('admin/announcements/delete', announcement); + }); +} + +function save(announcement) { + if (announcement.id == null) { + os.api('admin/announcements/create', announcement).then(() => { + os.alert({ + type: 'success', + text: i18n.ts.saved, + }); + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); + }); + } else { + os.api('admin/announcements/update', announcement).then(() => { + os.alert({ + type: 'success', + text: i18n.ts.saved, + }); + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); + }); + } +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.add, + handler: add, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.announcements, + icon: 'ti ti-speakerphone', +}); +</script> + +<style lang="scss" scoped> +.ztgjmzrw { + margin: var(--margin); +} +</style> diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue new file mode 100644 index 0000000000..d03961cf95 --- /dev/null +++ b/packages/frontend/src/pages/admin/bot-protection.vue @@ -0,0 +1,109 @@ +<template> +<div> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormRadios v-model="provider" class="_formBlock"> + <option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option> + <option value="hcaptcha">hCaptcha</option> + <option value="recaptcha">reCAPTCHA</option> + <option value="turnstile">Turnstile</option> + </FormRadios> + + <template v-if="provider === 'hcaptcha'"> + <FormInput v-model="hcaptchaSiteKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.hcaptchaSiteKey }}</template> + </FormInput> + <FormInput v-model="hcaptchaSecretKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.hcaptchaSecretKey }}</template> + </FormInput> + <FormSlot class="_formBlock"> + <template #label>{{ i18n.ts.preview }}</template> + <MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> + </FormSlot> + </template> + <template v-else-if="provider === 'recaptcha'"> + <FormInput v-model="recaptchaSiteKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.recaptchaSiteKey }}</template> + </FormInput> + <FormInput v-model="recaptchaSecretKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.recaptchaSecretKey }}</template> + </FormInput> + <FormSlot v-if="recaptchaSiteKey" class="_formBlock"> + <template #label>{{ i18n.ts.preview }}</template> + <MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/> + </FormSlot> + </template> + <template v-else-if="provider === 'turnstile'"> + <FormInput v-model="turnstileSiteKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.turnstileSiteKey }}</template> + </FormInput> + <FormInput v-model="turnstileSecretKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.turnstileSecretKey }}</template> + </FormInput> + <FormSlot class="_formBlock"> + <template #label>{{ i18n.ts.preview }}</template> + <MkCaptcha provider="turnstile" :sitekey="turnstileSiteKey || '1x00000000000000000000AA'"/> + </FormSlot> + </template> + + <FormButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> + </FormSuspense> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormSlot from '@/components/form/slot.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; + +const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue')); + +let provider = $ref(null); +let hcaptchaSiteKey: string | null = $ref(null); +let hcaptchaSecretKey: string | null = $ref(null); +let recaptchaSiteKey: string | null = $ref(null); +let recaptchaSecretKey: string | null = $ref(null); +let turnstileSiteKey: string | null = $ref(null); +let turnstileSecretKey: string | null = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + hcaptchaSiteKey = meta.hcaptchaSiteKey; + hcaptchaSecretKey = meta.hcaptchaSecretKey; + recaptchaSiteKey = meta.recaptchaSiteKey; + recaptchaSecretKey = meta.recaptchaSecretKey; + turnstileSiteKey = meta.turnstileSiteKey; + turnstileSecretKey = meta.turnstileSecretKey; + + provider = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : meta.enableTurnstile ? 'turnstile' : null; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableHcaptcha: provider === 'hcaptcha', + hcaptchaSiteKey, + hcaptchaSecretKey, + enableRecaptcha: provider === 'recaptcha', + recaptchaSiteKey, + recaptchaSecretKey, + enableTurnstile: provider === 'turnstile', + turnstileSiteKey, + turnstileSecretKey, + }).then(() => { + fetchInstance(); + }); +} +</script> diff --git a/packages/frontend/src/pages/admin/database.vue b/packages/frontend/src/pages/admin/database.vue new file mode 100644 index 0000000000..5a0d3d5e51 --- /dev/null +++ b/packages/frontend/src/pages/admin/database.vue @@ -0,0 +1,35 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory"> + <MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;"> + <template #key>{{ table[0] }}</template> + <template #value>{{ bytes(table[1].size) }} ({{ number(table[1].count) }} recs)</template> + </MkKeyValue> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import * as os from '@/os'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const databasePromiseFactory = () => os.api('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.database, + icon: 'ti ti-database', +}); +</script> diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue new file mode 100644 index 0000000000..6c9dee1704 --- /dev/null +++ b/packages/frontend/src/pages/admin/email-settings.vue @@ -0,0 +1,126 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="enableEmail" class="_formBlock"> + <template #label>{{ i18n.ts.enableEmail }} ({{ i18n.ts.recommended }})</template> + <template #caption>{{ i18n.ts.emailConfigInfo }}</template> + </FormSwitch> + + <template v-if="enableEmail"> + <FormInput v-model="email" type="email" class="_formBlock"> + <template #label>{{ i18n.ts.emailAddress }}</template> + </FormInput> + + <FormSection> + <template #label>{{ i18n.ts.smtpConfig }}</template> + <FormSplit :min-width="280"> + <FormInput v-model="smtpHost" class="_formBlock"> + <template #label>{{ i18n.ts.smtpHost }}</template> + </FormInput> + <FormInput v-model="smtpPort" type="number" class="_formBlock"> + <template #label>{{ i18n.ts.smtpPort }}</template> + </FormInput> + </FormSplit> + <FormSplit :min-width="280"> + <FormInput v-model="smtpUser" class="_formBlock"> + <template #label>{{ i18n.ts.smtpUser }}</template> + </FormInput> + <FormInput v-model="smtpPass" type="password" class="_formBlock"> + <template #label>{{ i18n.ts.smtpPass }}</template> + </FormInput> + </FormSplit> + <FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo> + <FormSwitch v-model="smtpSecure" class="_formBlock"> + <template #label>{{ i18n.ts.smtpSecure }}</template> + <template #caption>{{ i18n.ts.smtpSecureInfo }}</template> + </FormSwitch> + </FormSection> + </template> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormSection from '@/components/form/section.vue'; +import * as os from '@/os'; +import { fetchInstance, instance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let enableEmail: boolean = $ref(false); +let email: any = $ref(null); +let smtpSecure: boolean = $ref(false); +let smtpHost: string = $ref(''); +let smtpPort: number = $ref(0); +let smtpUser: string = $ref(''); +let smtpPass: string = $ref(''); + +async function init() { + const meta = await os.api('admin/meta'); + enableEmail = meta.enableEmail; + email = meta.email; + smtpSecure = meta.smtpSecure; + smtpHost = meta.smtpHost; + smtpPort = meta.smtpPort; + smtpUser = meta.smtpUser; + smtpPass = meta.smtpPass; +} + +async function testEmail() { + const { canceled, result: destination } = await os.inputText({ + title: i18n.ts.destination, + type: 'email', + placeholder: instance.maintainerEmail, + }); + if (canceled) return; + os.apiWithDialog('admin/send-email', { + to: destination, + subject: 'Test email', + text: 'Yo', + }); +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableEmail, + email, + smtpSecure, + smtpHost, + smtpPort, + smtpUser, + smtpPass, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + text: i18n.ts.testEmail, + handler: testEmail, +}, { + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.emailServer, + icon: 'ti ti-mail', +}); +</script> diff --git a/packages/frontend/src/pages/admin/emoji-edit-dialog.vue b/packages/frontend/src/pages/admin/emoji-edit-dialog.vue new file mode 100644 index 0000000000..bd601cb1de --- /dev/null +++ b/packages/frontend/src/pages/admin/emoji-edit-dialog.vue @@ -0,0 +1,106 @@ +<template> +<XModalWindow + ref="dialog" + :width="370" + :with-ok-button="true" + @close="$refs.dialog.close()" + @closed="$emit('closed')" + @ok="ok()" +> + <template #header>:{{ emoji.name }}:</template> + + <div class="_monolithic_"> + <div class="yigymqpb _section"> + <img :src="emoji.url" class="img"/> + <MkInput v-model="name" class="_formBlock"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + <MkInput v-model="category" class="_formBlock" :datalist="categories"> + <template #label>{{ i18n.ts.category }}</template> + </MkInput> + <MkInput v-model="aliases" class="_formBlock"> + <template #label>{{ i18n.ts.tags }}</template> + <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template> + </MkInput> + <MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> + </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 { unique } from '@/scripts/array'; +import { i18n } from '@/i18n'; +import { emojiCategories } from '@/instance'; + +const props = defineProps<{ + emoji: any, +}>(); + +let dialog = $ref(null); +let name: string = $ref(props.emoji.name); +let category: string = $ref(props.emoji.category); +let aliases: string = $ref(props.emoji.aliases.join(' ')); +let categories: string[] = $ref(emojiCategories); + +const emit = defineEmits<{ + (ev: 'done', v: { deleted?: boolean, updated?: any }): void, + (ev: 'closed'): void +}>(); + +function ok() { + update(); +} + +async function update() { + await os.apiWithDialog('admin/emoji/update', { + id: props.emoji.id, + name, + category, + aliases: aliases.split(' '), + }); + + emit('done', { + updated: { + id: props.emoji.id, + name, + category, + aliases: aliases.split(' '), + }, + }); + + dialog.close(); +} + +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: name }), + }); + if (canceled) return; + + os.api('admin/emoji/delete', { + id: props.emoji.id, + }).then(() => { + emit('done', { + deleted: true, + }); + dialog.close(); + }); +} +</script> + +<style lang="scss" scoped> +.yigymqpb { + > .img { + display: block; + height: 64px; + margin: 0 auto; + } +} +</style> diff --git a/packages/frontend/src/pages/admin/emojis.vue b/packages/frontend/src/pages/admin/emojis.vue new file mode 100644 index 0000000000..14c8466d73 --- /dev/null +++ b/packages/frontend/src/pages/admin/emojis.vue @@ -0,0 +1,398 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="ogwlenmc"> + <div v-if="tab === 'local'" class="local"> + <MkInput v-model="query" :debounce="true" type="search"> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.search }}</template> + </MkInput> + <MkSwitch v-model="selectMode" style="margin: 8px 0;"> + <template #label>Select mode</template> + </MkSwitch> + <div v-if="selectMode" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <MkButton inline @click="selectAll">Select all</MkButton> + <MkButton inline @click="setCategoryBulk">Set category</MkButton> + <MkButton inline @click="addTagBulk">Add tag</MkButton> + <MkButton inline @click="removeTagBulk">Remove tag</MkButton> + <MkButton inline @click="setTagBulk">Set tag</MkButton> + <MkButton inline danger @click="delBulk">Delete</MkButton> + </div> + <MkPagination ref="emojisPaginationComponent" :pagination="pagination"> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.category }}</div> + </div> + </button> + </div> + </template> + </MkPagination> + </div> + + <div v-else-if="tab === 'remote'" class="remote"> + <FormSplit> + <MkInput v-model="queryRemote" :debounce="true" type="search"> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.search }}</template> + </MkInput> + <MkInput v-model="host" :debounce="true"> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + </FormSplit> + <MkPagination :pagination="remotePagination"> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.host }}</div> + </div> + </div> + </div> + </template> + </MkPagination> + </div> + </div> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, defineComponent, ref, toRef } from 'vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import FormSplit from '@/components/form/split.vue'; +import { selectFile, selectFiles } from '@/scripts/select-file'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const emojisPaginationComponent = ref<InstanceType<typeof MkPagination>>(); + +const tab = ref('local'); +const query = ref(null); +const queryRemote = ref(null); +const host = ref(null); +const selectMode = ref(false); +const selectedEmojis = ref<string[]>([]); + +const pagination = { + endpoint: 'admin/emoji/list' as const, + limit: 30, + params: computed(() => ({ + query: (query.value && query.value !== '') ? query.value : null, + })), +}; + +const remotePagination = { + endpoint: 'admin/emoji/list-remote' as const, + limit: 30, + params: computed(() => ({ + query: (queryRemote.value && queryRemote.value !== '') ? queryRemote.value : null, + host: (host.value && host.value !== '') ? host.value : null, + })), +}; + +const selectAll = () => { + if (selectedEmojis.value.length > 0) { + selectedEmojis.value = []; + } else { + selectedEmojis.value = emojisPaginationComponent.value.items.map(item => item.id); + } +}; + +const toggleSelect = (emoji) => { + if (selectedEmojis.value.includes(emoji.id)) { + selectedEmojis.value = selectedEmojis.value.filter(x => x !== emoji.id); + } else { + selectedEmojis.value.push(emoji.id); + } +}; + +const add = async (ev: MouseEvent) => { + const files = await selectFiles(ev.currentTarget ?? ev.target, null); + + const promise = Promise.all(files.map(file => os.api('admin/emoji/add', { + fileId: file.id, + }))); + promise.then(() => { + emojisPaginationComponent.value.reload(); + }); + os.promiseDialog(promise); +}; + +const edit = (emoji) => { + os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { + emoji: emoji, + }, { + done: result => { + if (result.updated) { + emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({ + ...oldEmoji, + ...result.updated, + })); + } else if (result.deleted) { + emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id); + } + }, + }, 'closed'); +}; + +const im = (emoji) => { + os.apiWithDialog('admin/emoji/copy', { + emojiId: emoji.id, + }); +}; + +const remoteMenu = (emoji, ev: MouseEvent) => { + os.popupMenu([{ + type: 'label', + text: ':' + emoji.name + ':', + }, { + text: i18n.ts.import, + icon: 'ti ti-plus', + action: () => { im(emoji); }, + }], ev.currentTarget ?? ev.target); +}; + +const menu = (ev: MouseEvent) => { + os.popupMenu([{ + icon: 'ti ti-download', + text: i18n.ts.export, + action: async () => { + os.api('export-custom-emojis', { + }) + .then(() => { + os.alert({ + type: 'info', + text: i18n.ts.exportRequested, + }); + }).catch((err) => { + os.alert({ + type: 'error', + text: err.message, + }); + }); + }, + }, { + icon: 'ti ti-upload', + text: i18n.ts.import, + action: async () => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('admin/emoji/import-zip', { + fileId: file.id, + }) + .then(() => { + os.alert({ + type: 'info', + text: i18n.ts.importRequested, + }); + }).catch((err) => { + os.alert({ + type: 'error', + text: err.message, + }); + }); + }, + }], ev.currentTarget ?? ev.target); +}; + +const setCategoryBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Category', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/set-category-bulk', { + ids: selectedEmojis.value, + category: result, + }); + emojisPaginationComponent.value.reload(); +}; + +const addTagBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Tag', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/add-aliases-bulk', { + ids: selectedEmojis.value, + aliases: result.split(' '), + }); + emojisPaginationComponent.value.reload(); +}; + +const removeTagBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Tag', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/remove-aliases-bulk', { + ids: selectedEmojis.value, + aliases: result.split(' '), + }); + emojisPaginationComponent.value.reload(); +}; + +const setTagBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Tag', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/set-aliases-bulk', { + ids: selectedEmojis.value, + aliases: result.split(' '), + }); + emojisPaginationComponent.value.reload(); +}; + +const delBulk = async () => { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/delete-bulk', { + ids: selectedEmojis.value, + }); + emojisPaginationComponent.value.reload(); +}; + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.addEmoji, + handler: add, +}, { + icon: 'ti ti-dots', + handler: menu, +}]); + +const headerTabs = $computed(() => [{ + key: 'local', + title: i18n.ts.local, +}, { + key: 'remote', + title: i18n.ts.remote, +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.customEmojis, + icon: 'ti ti-mood-happy', +}))); +</script> + +<style lang="scss" scoped> +.ogwlenmc { + > .local { + .empty { + margin: var(--margin); + } + + .ldhfsamy { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: var(--margin) 0; + + > .emoji { + display: flex; + align-items: center; + padding: 11px; + text-align: left; + border: solid 1px var(--panel); + + &:hover { + border-color: var(--inputBorderHover); + } + + &.selected { + border-color: var(--accent); + } + + > .img { + width: 42px; + height: 42px; + } + + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + + > .name { + text-overflow: ellipsis; + overflow: hidden; + } + + > .info { + opacity: 0.5; + text-overflow: ellipsis; + overflow: hidden; + } + } + } + } + } + + > .remote { + .empty { + margin: var(--margin); + } + + .ldhfsamy { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: var(--margin) 0; + + > .emoji { + display: flex; + align-items: center; + padding: 12px; + text-align: left; + + &:hover { + color: var(--accent); + } + + > .img { + width: 32px; + height: 32px; + } + + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + + > .name { + text-overflow: ellipsis; + overflow: hidden; + } + + > .info { + opacity: 0.5; + font-size: 90%; + text-overflow: ellipsis; + overflow: hidden; + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue new file mode 100644 index 0000000000..8ad6bd4fc0 --- /dev/null +++ b/packages/frontend/src/pages/admin/files.vue @@ -0,0 +1,120 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions"/></template> + <MkSpacer :content-max="900"> + <div class="xrmjdkdw"> + <div> + <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <MkSelect v-model="origin" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.instance }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'"> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + </div> + <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap; padding-top: 1.2em;"> + <MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>User ID</template> + </MkInput> + <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>MIME type</template> + </MkInput> + </div> + <MkFileListForAdmin :pagination="pagination" :view-mode="viewMode"/> + </div> + </div> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let origin = $ref('local'); +let type = $ref(null); +let searchHost = $ref(''); +let userId = $ref(''); +let viewMode = $ref('grid'); +const pagination = { + endpoint: 'admin/drive/files' as const, + limit: 10, + params: computed(() => ({ + type: (type && type !== '') ? type : null, + userId: (userId && userId !== '') ? userId : null, + origin: origin, + hostname: (searchHost && searchHost !== '') ? searchHost : null, + })), +}; + +function clear() { + os.confirm({ + type: 'warning', + text: i18n.ts.clearCachedFilesConfirm, + }).then(({ canceled }) => { + if (canceled) return; + + os.apiWithDialog('admin/drive/clean-remote-files', {}); + }); +} + +function show(file) { + os.pageWindow(`/admin/file/${file.id}`); +} + +async function find() { + const { canceled, result: q } = await os.inputText({ + title: i18n.ts.fileIdOrUrl, + allowEmpty: false, + }); + if (canceled) return; + + os.api('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => { + show(file); + }).catch(err => { + if (err.code === 'NO_SUCH_FILE') { + os.alert({ + type: 'error', + text: i18n.ts.notFound, + }); + } + }); +} + +const headerActions = $computed(() => [{ + text: i18n.ts.lookup, + icon: 'ti ti-search', + handler: find, +}, { + text: i18n.ts.clearCachedFiles, + icon: 'ti ti-trash', + handler: clear, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.files, + icon: 'ti ti-cloud', +}))); +</script> + +<style lang="scss" scoped> +.xrmjdkdw { + margin: var(--margin); +} +</style> diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue new file mode 100644 index 0000000000..6c07a87eeb --- /dev/null +++ b/packages/frontend/src/pages/admin/index.vue @@ -0,0 +1,316 @@ +<template> +<div ref="el" class="hiyeyicy" :class="{ wide: !narrow }"> + <div v-if="!narrow || currentPage?.route.name == null" class="nav"> + <MkSpacer :content-max="700" :margin-min="16"> + <div class="lxpfedzu"> + <div class="banner"> + <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> + </div> + + <MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo> + <MkInfo v-if="noMaintainerInformation" warn class="info">{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + + <MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu> + </div> + </MkSpacer> + </div> + <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> + <RouterView/> + </div> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, watch } from 'vue'; +import { i18n } from '@/i18n'; +import MkSuperMenu from '@/components/MkSuperMenu.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import { scroll } from '@/scripts/scroll'; +import { instance } from '@/instance'; +import * as os from '@/os'; +import { lookupUser } from '@/scripts/lookup-user'; +import { useRouter } from '@/router'; +import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; + +const isEmpty = (x: string | null) => x == null || x === ''; + +const router = useRouter(); + +const indexInfo = { + title: i18n.ts.controlPanel, + icon: 'ti ti-settings', + hideHeader: true, +}; + +provide('shouldOmitHeaderTitle', false); + +let INFO = $ref(indexInfo); +let childInfo = $ref(null); +let narrow = $ref(false); +let view = $ref(null); +let el = $ref(null); +let pageProps = $ref({}); +let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail); +let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile; +let noEmailServer = !instance.enableEmail; +let thereIsUnresolvedAbuseReport = $ref(false); +let currentPage = $computed(() => router.currentRef.value.child); + +os.api('admin/abuse-user-reports', { + state: 'unresolved', + limit: 1, +}).then(reports => { + if (reports.length > 0) thereIsUnresolvedAbuseReport = true; +}); + +const NARROW_THRESHOLD = 600; +const ro = new ResizeObserver((entries, observer) => { + if (entries.length === 0) return; + narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD; +}); + +const menuDef = $computed(() => [{ + title: i18n.ts.quickAction, + items: [{ + type: 'button', + icon: 'ti ti-search', + text: i18n.ts.lookup, + action: lookup, + }, ...(instance.disableRegistration ? [{ + type: 'button', + icon: 'ti ti-user', + text: i18n.ts.invite, + action: invite, + }] : [])], +}, { + title: i18n.ts.administration, + items: [{ + icon: 'ti ti-dashboard', + text: i18n.ts.dashboard, + to: '/admin/overview', + active: currentPage?.route.name === 'overview', + }, { + icon: 'ti ti-users', + text: i18n.ts.users, + to: '/admin/users', + active: currentPage?.route.name === 'users', + }, { + icon: 'ti ti-mood-happy', + text: i18n.ts.customEmojis, + to: '/admin/emojis', + active: currentPage?.route.name === 'emojis', + }, { + icon: 'ti ti-whirl', + text: i18n.ts.federation, + to: '/about#federation', + active: currentPage?.route.name === 'federation', + }, { + icon: 'ti ti-clock-play', + text: i18n.ts.jobQueue, + to: '/admin/queue', + active: currentPage?.route.name === 'queue', + }, { + icon: 'ti ti-cloud', + text: i18n.ts.files, + to: '/admin/files', + active: currentPage?.route.name === 'files', + }, { + icon: 'ti ti-speakerphone', + text: i18n.ts.announcements, + to: '/admin/announcements', + active: currentPage?.route.name === 'announcements', + }, { + icon: 'ti ti-ad', + text: i18n.ts.ads, + to: '/admin/ads', + active: currentPage?.route.name === 'ads', + }, { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.abuseReports, + to: '/admin/abuses', + active: currentPage?.route.name === 'abuses', + }], +}, { + title: i18n.ts.settings, + items: [{ + icon: 'ti ti-settings', + text: i18n.ts.general, + to: '/admin/settings', + active: currentPage?.route.name === 'settings', + }, { + icon: 'ti ti-mail', + text: i18n.ts.emailServer, + to: '/admin/email-settings', + active: currentPage?.route.name === 'email-settings', + }, { + icon: 'ti ti-cloud', + text: i18n.ts.objectStorage, + to: '/admin/object-storage', + active: currentPage?.route.name === 'object-storage', + }, { + icon: 'ti ti-lock', + text: i18n.ts.security, + to: '/admin/security', + active: currentPage?.route.name === 'security', + }, { + icon: 'ti ti-planet', + text: i18n.ts.relays, + to: '/admin/relays', + active: currentPage?.route.name === 'relays', + }, { + icon: 'ti ti-share', + text: i18n.ts.integration, + to: '/admin/integrations', + active: currentPage?.route.name === 'integrations', + }, { + icon: 'ti ti-ban', + text: i18n.ts.instanceBlocking, + to: '/admin/instance-block', + active: currentPage?.route.name === 'instance-block', + }, { + icon: 'ti ti-ghost', + text: i18n.ts.proxyAccount, + to: '/admin/proxy-account', + active: currentPage?.route.name === 'proxy-account', + }, { + icon: 'ti ti-adjustments', + text: i18n.ts.other, + to: '/admin/other-settings', + active: currentPage?.route.name === 'other-settings', + }], +}, { + title: i18n.ts.info, + items: [{ + icon: 'ti ti-database', + text: i18n.ts.database, + to: '/admin/database', + active: currentPage?.route.name === 'database', + }], +}]); + +watch(narrow, () => { + if (currentPage?.route.name == null && !narrow) { + router.push('/admin/overview'); + } +}); + +onMounted(() => { + ro.observe(el); + + narrow = el.offsetWidth < NARROW_THRESHOLD; + if (currentPage?.route.name == null && !narrow) { + router.push('/admin/overview'); + } +}); + +onUnmounted(() => { + ro.disconnect(); +}); + +provideMetadataReceiver((info) => { + if (info == null) { + childInfo = null; + } else { + childInfo = info; + } +}); + +const invite = () => { + os.api('admin/invite').then(x => { + os.alert({ + type: 'info', + text: x.code, + }); + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); + }); +}; + +const lookup = (ev) => { + os.popupMenu([{ + text: i18n.ts.user, + icon: 'ti ti-user', + action: () => { + lookupUser(); + }, + }, { + text: i18n.ts.note, + icon: 'ti ti-pencil', + action: () => { + alert('TODO'); + }, + }, { + text: i18n.ts.file, + icon: 'ti ti-cloud', + action: () => { + alert('TODO'); + }, + }, { + text: i18n.ts.instance, + icon: 'ti ti-planet', + action: () => { + alert('TODO'); + }, + }], ev.currentTarget ?? ev.target); +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(INFO); + +defineExpose({ + header: { + title: i18n.ts.controlPanel, + }, +}); +</script> + +<style lang="scss" scoped> +.hiyeyicy { + &.wide { + display: flex; + margin: 0 auto; + height: 100%; + + > .nav { + width: 32%; + max-width: 280px; + box-sizing: border-box; + border-right: solid 0.5px var(--divider); + overflow: auto; + height: 100%; + } + + > .main { + flex: 1; + min-width: 0; + } + } + + > .nav { + .lxpfedzu { + > .info { + margin: 16px 0; + } + + > .banner { + margin: 16px; + + > .icon { + display: block; + margin: auto; + height: 42px; + border-radius: 8px; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue new file mode 100644 index 0000000000..1bdd174de4 --- /dev/null +++ b/packages/frontend/src/pages/admin/instance-block.vue @@ -0,0 +1,51 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <FormTextarea v-model="blockedHosts" class="_formBlock"> + <span>{{ i18n.ts.blockedInstances }}</span> + <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template> + </FormTextarea> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let blockedHosts: string = $ref(''); + +async function init() { + const meta = await os.api('admin/meta'); + blockedHosts = meta.blockedHosts.join('\n'); +} + +function save() { + os.apiWithDialog('admin/update-meta', { + blockedHosts: blockedHosts.split('\n') || [], + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.instanceBlocking, + icon: 'ti ti-ban', +}); +</script> diff --git a/packages/frontend/src/pages/admin/integrations.discord.vue b/packages/frontend/src/pages/admin/integrations.discord.vue new file mode 100644 index 0000000000..0a69c44c93 --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.discord.vue @@ -0,0 +1,60 @@ +<template> +<FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="enableDiscordIntegration" class="_formBlock"> + <template #label>{{ i18n.ts.enable }}</template> + </FormSwitch> + + <template v-if="enableDiscordIntegration"> + <FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/dc/cb` }}</FormInfo> + + <FormInput v-model="discordClientId" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Client ID</template> + </FormInput> + + <FormInput v-model="discordClientSecret" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Client Secret</template> + </FormInput> + </template> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> +</FormSuspense> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; + +let uri: string = $ref(''); +let enableDiscordIntegration: boolean = $ref(false); +let discordClientId: string | null = $ref(null); +let discordClientSecret: string | null = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + uri = meta.uri; + enableDiscordIntegration = meta.enableDiscordIntegration; + discordClientId = meta.discordClientId; + discordClientSecret = meta.discordClientSecret; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableDiscordIntegration, + discordClientId, + discordClientSecret, + }).then(() => { + fetchInstance(); + }); +} +</script> diff --git a/packages/frontend/src/pages/admin/integrations.github.vue b/packages/frontend/src/pages/admin/integrations.github.vue new file mode 100644 index 0000000000..66419d5891 --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.github.vue @@ -0,0 +1,60 @@ +<template> +<FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="enableGithubIntegration" class="_formBlock"> + <template #label>{{ i18n.ts.enable }}</template> + </FormSwitch> + + <template v-if="enableGithubIntegration"> + <FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/gh/cb` }}</FormInfo> + + <FormInput v-model="githubClientId" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Client ID</template> + </FormInput> + + <FormInput v-model="githubClientSecret" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Client Secret</template> + </FormInput> + </template> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> +</FormSuspense> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; + +let uri: string = $ref(''); +let enableGithubIntegration: boolean = $ref(false); +let githubClientId: string | null = $ref(null); +let githubClientSecret: string | null = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + uri = meta.uri; + enableGithubIntegration = meta.enableGithubIntegration; + githubClientId = meta.githubClientId; + githubClientSecret = meta.githubClientSecret; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableGithubIntegration, + githubClientId, + githubClientSecret, + }).then(() => { + fetchInstance(); + }); +} +</script> diff --git a/packages/frontend/src/pages/admin/integrations.twitter.vue b/packages/frontend/src/pages/admin/integrations.twitter.vue new file mode 100644 index 0000000000..1e8d882b9c --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.twitter.vue @@ -0,0 +1,60 @@ +<template> +<FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="enableTwitterIntegration" class="_formBlock"> + <template #label>{{ i18n.ts.enable }}</template> + </FormSwitch> + + <template v-if="enableTwitterIntegration"> + <FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/tw/cb` }}</FormInfo> + + <FormInput v-model="twitterConsumerKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Consumer Key</template> + </FormInput> + + <FormInput v-model="twitterConsumerSecret" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Consumer Secret</template> + </FormInput> + </template> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> +</FormSuspense> +</template> + +<script lang="ts" setup> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; + +let uri: string = $ref(''); +let enableTwitterIntegration: boolean = $ref(false); +let twitterConsumerKey: string | null = $ref(null); +let twitterConsumerSecret: string | null = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + uri = meta.uri; + enableTwitterIntegration = meta.enableTwitterIntegration; + twitterConsumerKey = meta.twitterConsumerKey; + twitterConsumerSecret = meta.twitterConsumerSecret; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableTwitterIntegration, + twitterConsumerKey, + twitterConsumerSecret, + }).then(() => { + fetchInstance(); + }); +} +</script> diff --git a/packages/frontend/src/pages/admin/integrations.vue b/packages/frontend/src/pages/admin/integrations.vue new file mode 100644 index 0000000000..9cc35baefd --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.vue @@ -0,0 +1,57 @@ +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-brand-twitter"></i></template> + <template #label>Twitter</template> + <template #suffix>{{ enableTwitterIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template> + <XTwitter/> + </FormFolder> + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-brand-github"></i></template> + <template #label>GitHub</template> + <template #suffix>{{ enableGithubIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template> + <XGithub/> + </FormFolder> + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-brand-discord"></i></template> + <template #label>Discord</template> + <template #suffix>{{ enableDiscordIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template> + <XDiscord/> + </FormFolder> + </FormSuspense> +</MkSpacer></MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XTwitter from './integrations.twitter.vue'; +import XGithub from './integrations.github.vue'; +import XDiscord from './integrations.discord.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormFolder from '@/components/form/folder.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let enableTwitterIntegration: boolean = $ref(false); +let enableGithubIntegration: boolean = $ref(false); +let enableDiscordIntegration: boolean = $ref(false); + +async function init() { + const meta = await os.api('admin/meta'); + enableTwitterIntegration = meta.enableTwitterIntegration; + enableGithubIntegration = meta.enableGithubIntegration; + enableDiscordIntegration = meta.enableDiscordIntegration; +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.integration, + icon: 'ti ti-share', +}); +</script> diff --git a/packages/frontend/src/pages/admin/metrics.vue b/packages/frontend/src/pages/admin/metrics.vue new file mode 100644 index 0000000000..db8e448639 --- /dev/null +++ b/packages/frontend/src/pages/admin/metrics.vue @@ -0,0 +1,472 @@ +<template> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div> + <div class="_debobigegoPanel xhexznfu"> + <div> + <canvas :ref="cpumem"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div> + <div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + <div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + </div> + </div> + </div> + </div> +</div> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div> + <div class="_debobigegoPanel xhexznfu"> + <div> + <canvas :ref="disk"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div> + <div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + <div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + </div> + </div> + </div> + </div> +</div> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</div> + <div class="_debobigegoPanel xhexznfu"> + <div> + <canvas :ref="net"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div> + </div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + Legend, + Title, + Tooltip, + SubTitle, +} from 'chart.js'; +import MkButton from '@/components/MkButton.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkInput from '@/components/form/input.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkwFederation from '../../widgets/federation.vue'; +import { version, url } from '@/config'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + Legend, + Title, + Tooltip, + SubTitle, +); + +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})`; +}; +import * as os from '@/os'; +import { stream } from '@/stream'; + +export default defineComponent({ + components: { + MkButton, + MkSelect, + MkInput, + MkContainer, + MkFolder, + MkwFederation, + }, + + data() { + return { + version, + url, + stats: null, + serverInfo: null, + connection: null, + queueConnection: markRaw(stream.useChannel('queueStats')), + memUsage: 0, + chartCpuMem: null, + chartNet: null, + jobs: [], + logs: [], + logLevel: 'all', + logDomain: '', + modLogs: [], + dbInfo: null, + overviewHeight: '1fr', + queueHeight: '1fr', + paused: false, + }; + }, + + computed: { + gridColor() { + // TODO: var(--panel)の色が暗いか明るいかで判定する + return this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + }, + }, + + mounted() { + this.fetchJobs(); + + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + os.api('admin/server-info', {}).then(res => { + this.serverInfo = res; + + this.connection = markRaw(stream.useChannel('serverStats')); + this.connection.on('stats', this.onStats); + this.connection.on('statsLog', this.onStatsLog); + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 150, + }); + + this.$nextTick(() => { + this.queueConnection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200, + }); + }); + }); + }, + + beforeUnmount() { + if (this.connection) { + this.connection.off('stats', this.onStats); + this.connection.off('statsLog', this.onStatsLog); + this.connection.dispose(); + } + this.queueConnection.dispose(); + }, + + methods: { + cpumem(el) { + if (this.chartCpuMem != null) return; + this.chartCpuMem = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'CPU', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#86b300', + backgroundColor: alpha('#86b300', 0.1), + data: [], + }, { + label: 'MEM (active)', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#935dbf', + backgroundColor: alpha('#935dbf', 0.02), + data: [], + }, { + label: 'MEM (used)', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#935dbf', + borderDash: [5, 5], + fill: false, + data: [], + }], + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 0, + }, + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + }, + }, + scales: { + x: { + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + }, + }, + y: { + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + max: 100, + }, + }, + }, + tooltips: { + intersect: false, + mode: 'index', + }, + }, + })); + }, + + net(el) { + if (this.chartNet != null) return; + this.chartNet = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'In', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#94a029', + backgroundColor: alpha('#94a029', 0.1), + data: [], + }, { + label: 'Out', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#ff9156', + backgroundColor: alpha('#ff9156', 0.1), + data: [], + }], + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 0, + }, + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + }, + }, + scales: { + x: { + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + }, + }, + y: { + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + }, + }, + }, + tooltips: { + intersect: false, + mode: 'index', + }, + }, + })); + }, + + disk(el) { + if (this.chartDisk != null) return; + this.chartDisk = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Read', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#94a029', + backgroundColor: alpha('#94a029', 0.1), + data: [], + }, { + label: 'Write', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#ff9156', + backgroundColor: alpha('#ff9156', 0.1), + data: [], + }], + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 0, + }, + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + }, + }, + scales: { + x: { + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + }, + }, + y: { + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + }, + }, + }, + tooltips: { + intersect: false, + mode: 'index', + }, + }, + })); + }, + + fetchJobs() { + os.api('admin/queue/deliver-delayed', {}).then(jobs => { + this.jobs = jobs; + }); + }, + + onStats(stats) { + if (this.paused) return; + + const cpu = (stats.cpu * 100).toFixed(0); + const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0); + const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0); + this.memUsage = stats.mem.active; + + this.chartCpuMem.data.labels.push(''); + this.chartCpuMem.data.datasets[0].data.push(cpu); + this.chartCpuMem.data.datasets[1].data.push(memActive); + this.chartCpuMem.data.datasets[2].data.push(memUsed); + this.chartNet.data.labels.push(''); + this.chartNet.data.datasets[0].data.push(stats.net.rx); + this.chartNet.data.datasets[1].data.push(stats.net.tx); + this.chartDisk.data.labels.push(''); + this.chartDisk.data.datasets[0].data.push(stats.fs.r); + this.chartDisk.data.datasets[1].data.push(stats.fs.w); + if (this.chartCpuMem.data.datasets[0].data.length > 150) { + this.chartCpuMem.data.labels.shift(); + this.chartCpuMem.data.datasets[0].data.shift(); + this.chartCpuMem.data.datasets[1].data.shift(); + this.chartCpuMem.data.datasets[2].data.shift(); + this.chartNet.data.labels.shift(); + this.chartNet.data.datasets[0].data.shift(); + this.chartNet.data.datasets[1].data.shift(); + this.chartDisk.data.labels.shift(); + this.chartDisk.data.datasets[0].data.shift(); + this.chartDisk.data.datasets[1].data.shift(); + } + this.chartCpuMem.update(); + this.chartNet.update(); + this.chartDisk.update(); + }, + + onStatsLog(statsLog) { + for (const stats of [...statsLog].reverse()) { + this.onStats(stats); + } + }, + + bytes, + + number, + + pause() { + this.paused = true; + }, + + resume() { + this.paused = false; + }, + }, +}); +</script> + +<style lang="scss" scoped> +.xhexznfu { + > div:nth-child(2) { + padding: 16px; + border-top: solid 0.5px var(--divider); + } +} +</style> diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue new file mode 100644 index 0000000000..f2ab30eaa5 --- /dev/null +++ b/packages/frontend/src/pages/admin/object-storage.vue @@ -0,0 +1,148 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch> + + <template v-if="useObjectStorage"> + <FormInput v-model="objectStorageBaseUrl" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageBaseUrl }}</template> + <template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageBucket" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageBucket }}</template> + <template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template> + </FormInput> + + <FormInput v-model="objectStoragePrefix" class="_formBlock"> + <template #label>{{ i18n.ts.objectStoragePrefix }}</template> + <template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageEndpoint" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageEndpoint }}</template> + <template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageRegion" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageRegion }}</template> + <template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template> + </FormInput> + + <FormSplit :min-width="280"> + <FormInput v-model="objectStorageAccessKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Access key</template> + </FormInput> + + <FormInput v-model="objectStorageSecretKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Secret key</template> + </FormInput> + </FormSplit> + + <FormSwitch v-model="objectStorageUseSSL" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageUseSSL }}</template> + <template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageUseProxy" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageUseProxy }}</template> + <template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock"> + <template #label>s3ForcePathStyle</template> + </FormSwitch> + </template> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormSection from '@/components/form/section.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let useObjectStorage: boolean = $ref(false); +let objectStorageBaseUrl: string | null = $ref(null); +let objectStorageBucket: string | null = $ref(null); +let objectStoragePrefix: string | null = $ref(null); +let objectStorageEndpoint: string | null = $ref(null); +let objectStorageRegion: string | null = $ref(null); +let objectStoragePort: number | null = $ref(null); +let objectStorageAccessKey: string | null = $ref(null); +let objectStorageSecretKey: string | null = $ref(null); +let objectStorageUseSSL: boolean = $ref(false); +let objectStorageUseProxy: boolean = $ref(false); +let objectStorageSetPublicRead: boolean = $ref(false); +let objectStorageS3ForcePathStyle: boolean = $ref(true); + +async function init() { + const meta = await os.api('admin/meta'); + useObjectStorage = meta.useObjectStorage; + objectStorageBaseUrl = meta.objectStorageBaseUrl; + objectStorageBucket = meta.objectStorageBucket; + objectStoragePrefix = meta.objectStoragePrefix; + objectStorageEndpoint = meta.objectStorageEndpoint; + objectStorageRegion = meta.objectStorageRegion; + objectStoragePort = meta.objectStoragePort; + objectStorageAccessKey = meta.objectStorageAccessKey; + objectStorageSecretKey = meta.objectStorageSecretKey; + objectStorageUseSSL = meta.objectStorageUseSSL; + objectStorageUseProxy = meta.objectStorageUseProxy; + objectStorageSetPublicRead = meta.objectStorageSetPublicRead; + objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + useObjectStorage, + objectStorageBaseUrl, + objectStorageBucket, + objectStoragePrefix, + objectStorageEndpoint, + objectStorageRegion, + objectStoragePort, + objectStorageAccessKey, + objectStorageSecretKey, + objectStorageUseSSL, + objectStorageUseProxy, + objectStorageSetPublicRead, + objectStorageS3ForcePathStyle, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.objectStorage, + icon: 'ti ti-cloud', +}); +</script> diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue new file mode 100644 index 0000000000..62dff6ce7f --- /dev/null +++ b/packages/frontend/src/pages/admin/other-settings.vue @@ -0,0 +1,44 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + none + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +async function init() { + await os.api('admin/meta'); +} + +function save() { + os.apiWithDialog('admin/update-meta').then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.other, + icon: 'ti ti-adjustments', +}); +</script> diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue new file mode 100644 index 0000000000..c3ce5ac901 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.active-users.vue @@ -0,0 +1,217 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-show="!fetching" :class="$style.root" class="_panel"> + <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 gradient from 'chartjs-plugin-gradient'; +import { chartVLine } from '@/scripts/chart-vline'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + gradient, +); + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +const chartEl = $ref<HTMLCanvasElement>(null); +const now = new Date(); +let chartInstance: Chart = null; +const chartLimit = 7; +let fetching = $ref(true); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +async function renderChart() { + if (chartInstance) { + chartInstance.destroy(); + } + + const getDate = (ago: number) => { + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + + return new Date(y, m, d - ago); + }; + + const format = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: v, + })); + }; + + const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); + + const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + // フォントカラー + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + const colorRead = '#3498db'; + const colorWrite = '#2ecc71'; + + const max = Math.max(...raw.read); + + chartInstance = new Chart(chartEl, { + type: 'bar', + data: { + datasets: [{ + parsing: false, + label: 'Read', + data: format(raw.read).slice().reverse(), + pointRadius: 0, + borderWidth: 0, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: colorRead, + barPercentage: 0.7, + categoryPercentage: 0.5, + fill: true, + }, { + parsing: false, + label: 'Write', + data: format(raw.write).slice().reverse(), + pointRadius: 0, + borderWidth: 0, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: colorWrite, + barPercentage: 0.7, + categoryPercentage: 0.5, + fill: true, + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 8, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + offset: true, + time: { + stepSize: 1, + unit: 'day', + }, + grid: { + display: false, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: true, + maxRotation: 0, + autoSkipPadding: 8, + }, + adapters: { + date: { + locale: enUS, + }, + }, + }, + y: { + position: 'left', + suggestedMax: 10, + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: true, + //mirror: true, + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + animation: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + gradient, + }, + }, + plugins: [chartVLine(vLineColor)], + }); + + fetching = false; +} + +onMounted(async () => { + renderChart(); +}); +</script> + +<style lang="scss" module> +.root { + padding: 20px; +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue new file mode 100644 index 0000000000..024ffdc245 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue @@ -0,0 +1,346 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-show="!fetching" :class="$style.root"> + <div class="charts _panel"> + <div class="chart"> + <canvas ref="chartEl2"></canvas> + </div> + <div class="chart"> + <canvas ref="chartEl"></canvas> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import gradient from 'chartjs-plugin-gradient'; +import { enUS } from 'date-fns/locale'; +import tinycolor from 'tinycolor2'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import MkNumberDiff from '@/components/MkNumberDiff.vue'; +import { i18n } from '@/i18n'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import { chartVLine } from '@/scripts/chart-vline'; +import { defaultStore } from '@/store'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + gradient, +); + +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 chartLimit = 50; +const chartEl = $ref<HTMLCanvasElement>(); +const chartEl2 = $ref<HTMLCanvasElement>(); +let fetching = $ref(true); + +const { handler: externalTooltipHandler } = useChartTooltip(); +const { handler: externalTooltipHandler2 } = useChartTooltip(); + +onMounted(async () => { + const now = new Date(); + + const getDate = (ago: number) => { + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + + return new Date(y, m, d - ago); + }; + + const format = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: v, + })); + }; + + const formatMinus = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: -v, + })); + }; + + const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); + + const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const succColor = '#87e000'; + const failColor = '#ff4400'; + + const succMax = Math.max(...raw.deliverSucceeded); + const failMax = Math.max(...raw.deliverFailed); + + // フォントカラー + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + new Chart(chartEl, { + type: 'line', + data: { + datasets: [{ + stack: 'a', + parsing: false, + label: 'Out: Succ', + data: format(raw.deliverSucceeded).slice().reverse(), + tension: 0.3, + pointRadius: 0, + borderWidth: 2, + borderColor: succColor, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: alpha(succColor, 0.35), + fill: true, + clip: 8, + }, { + stack: 'a', + parsing: false, + label: 'Out: Fail', + data: formatMinus(raw.deliverFailed).slice().reverse(), + tension: 0.3, + pointRadius: 0, + borderWidth: 2, + borderColor: failColor, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: alpha(failColor, 0.35), + fill: true, + clip: 8, + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 8, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + stacked: true, + offset: false, + time: { + stepSize: 1, + unit: 'day', + }, + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: true, + maxRotation: 0, + autoSkipPadding: 16, + }, + adapters: { + date: { + locale: enUS, + }, + }, + min: getDate(chartLimit).getTime(), + }, + y: { + stacked: true, + position: 'left', + suggestedMax: 10, + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: true, + //mirror: true, + callback: (value, index, values) => value < 0 ? -value : value, + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + elements: { + point: { + hoverRadius: 5, + hoverBorderWidth: 2, + }, + }, + animation: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + gradient, + }, + }, + plugins: [chartVLine(vLineColor)], + }); + + new Chart(chartEl2, { + type: 'bar', + data: { + datasets: [{ + parsing: false, + label: 'In', + data: format(raw.inboxReceived).slice().reverse(), + tension: 0.3, + pointRadius: 0, + borderWidth: 0, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: '#0cc2d6', + barPercentage: 0.8, + categoryPercentage: 0.9, + fill: true, + clip: 8, + }], + }, + options: { + aspectRatio: 5, + layout: { + padding: { + left: 0, + right: 8, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + offset: false, + time: { + stepSize: 1, + unit: 'day', + }, + grid: { + display: false, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + maxRotation: 0, + autoSkipPadding: 16, + }, + adapters: { + date: { + locale: enUS, + }, + }, + min: getDate(chartLimit).getTime(), + }, + y: { + position: 'left', + suggestedMax: 10, + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + elements: { + point: { + hoverRadius: 5, + hoverBorderWidth: 2, + }, + }, + animation: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler2, + }, + gradient, + }, + }, + plugins: [chartVLine(vLineColor)], + }); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + &:global { + > .charts { + > .chart { + padding: 16px; + + &:first-child { + border-bottom: solid 0.5px var(--divider); + } + } + } + } +} +</style> + diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue new file mode 100644 index 0000000000..71f5a054b4 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.federation.vue @@ -0,0 +1,185 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-show="!fetching" :class="$style.root"> + <div v-if="topSubInstancesForPie && topPubInstancesForPie" class="pies"> + <div class="pie deliver _panel"> + <div class="title">Sub</div> + <XPie :data="topSubInstancesForPie" class="chart"/> + <div class="subTitle">Top 10</div> + </div> + <div class="pie inbox _panel"> + <div class="title">Pub</div> + <XPie :data="topPubInstancesForPie" class="chart"/> + <div class="subTitle">Top 10</div> + </div> + </div> + <div v-if="!fetching" class="items"> + <div class="item _panel sub"> + <div class="icon"><i class="ti ti-world-download"></i></div> + <div class="body"> + <div class="value"> + {{ number(federationSubActive) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"></MkNumberDiff> + </div> + <div class="label">Sub</div> + </div> + </div> + <div class="item _panel pub"> + <div class="icon"><i class="ti ti-world-upload"></i></div> + <div class="body"> + <div class="value"> + {{ number(federationPubActive) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"></MkNumberDiff> + </div> + <div class="label">Pub</div> + </div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import XPie from './overview.pie.vue'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import MkNumberDiff from '@/components/MkNumberDiff.vue'; +import { i18n } from '@/i18n'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; + +let topSubInstancesForPie: any = $ref(null); +let topPubInstancesForPie: any = $ref(null); +let federationPubActive = $ref<number | null>(null); +let federationPubActiveDiff = $ref<number | null>(null); +let federationSubActive = $ref<number | null>(null); +let federationSubActiveDiff = $ref<number | null>(null); +let fetching = $ref(true); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +onMounted(async () => { + const chart = await os.apiGet('charts/federation', { limit: 2, span: 'day' }); + federationPubActive = chart.pubActive[0]; + federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1]; + federationSubActive = chart.subActive[0]; + federationSubActiveDiff = chart.subActive[0] - chart.subActive[1]; + + os.apiGet('federation/stats', { limit: 10 }).then(res => { + topSubInstancesForPie = res.topSubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followersCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]); + topPubInstancesForPie = res.topPubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followingCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]); + }); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + + &:global { + > .pies { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin-bottom: 12px; + + > .pie { + position: relative; + padding: 12px; + + > .title { + position: absolute; + top: 20px; + left: 20px; + font-size: 90%; + } + + > .chart { + max-height: 150px; + } + + > .subTitle { + position: absolute; + bottom: 20px; + right: 20px; + font-size: 85%; + } + } + } + + > .items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + + > .item { + display: flex; + box-sizing: border-box; + padding: 12px; + + > .icon { + display: grid; + place-items: center; + height: 100%; + aspect-ratio: 1; + margin-right: 12px; + background: var(--accentedBg); + color: var(--accent); + border-radius: 10px; + } + + &.sub { + > .icon { + background: #d5ba0026; + color: #dfc300; + } + } + + &.pub { + > .icon { + background: #00cf2326; + color: #00cd5b; + } + } + + > .body { + padding: 2px 0; + + > .value { + font-size: 1.25em; + font-weight: bold; + + > .diff { + font-size: 0.65em; + font-weight: normal; + } + } + + > .label { + font-size: 0.8em; + opacity: 0.5; + } + } + } + } + } +} +</style> + diff --git a/packages/frontend/src/pages/admin/overview.heatmap.vue b/packages/frontend/src/pages/admin/overview.heatmap.vue new file mode 100644 index 0000000000..16d1c83b9f --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.heatmap.vue @@ -0,0 +1,15 @@ +<template> +<div class="_panel" :class="$style.root"> + <MkActiveUsersHeatmap/> +</div> +</template> + +<script lang="ts" setup> +import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue'; +</script> + +<style lang="scss" module> +.root { + padding: 20px; +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue new file mode 100644 index 0000000000..29848bf03b --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.instances.vue @@ -0,0 +1,50 @@ +<template> +<div class="wbrkwale"> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <MkLoading v-if="fetching"/> + <div v-else class="instances"> + <MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" class="instance"> + <MkInstanceCardMini :instance="instance"/> + </MkA> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; + +const instances = ref([]); +const fetching = ref(true); + +const fetch = async () => { + const fetchedInstances = await os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 6, + }); + instances.value = fetchedInstances; + fetching.value = false; +}; + +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.wbrkwale { + > .instances { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + grid-gap: 12px; + + > .instance:hover { + text-decoration: none; + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.moderators.vue b/packages/frontend/src/pages/admin/overview.moderators.vue new file mode 100644 index 0000000000..a1f63c8711 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.moderators.vue @@ -0,0 +1,55 @@ +<template> +<div> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <MkLoading v-if="fetching"/> + <div v-else :class="$style.root" class="_panel"> + <MkA v-for="user in moderators" :key="user.id" class="user" :to="`/user-info/${user.id}`"> + <MkAvatar :user="user" class="avatar" :show-indicator="true" :disable-link="true"/> + </MkA> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; + +let moderators: any = $ref(null); +let fetching = $ref(true); + +onMounted(async () => { + moderators = await os.api('admin/show-users', { + sort: '+lastActiveDate', + state: 'adminOrModerator', + limit: 30, + }); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(30px, 40px)); + grid-gap: 12px; + place-content: center; + padding: 12px; + + &:global { + > .user { + width: 100%; + height: 100%; + aspect-ratio: 1; + + > .avatar { + width: 100%; + height: 100%; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue new file mode 100644 index 0000000000..94509cf006 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.pie.vue @@ -0,0 +1,110 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + DoughnutController, +} from 'chart.js'; +import number from '@/filters/number'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + DoughnutController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + data: { name: string; value: number; color: string; onClick?: () => void }[]; +}>(); + +const chartEl = ref<HTMLCanvasElement>(null); + +// フォントカラー +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip({ + position: 'middle', +}); + +let chartInstance: Chart; + +onMounted(() => { + chartInstance = new Chart(chartEl.value, { + type: 'doughnut', + data: { + labels: props.data.map(x => x.name), + datasets: [{ + backgroundColor: props.data.map(x => x.color), + borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'), + borderWidth: 2, + hoverOffset: 0, + data: props.data.map(x => x.value), + }], + }, + options: { + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 16, + }, + }, + onClick: (ev) => { + const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; + if (hit && props.data[hit.index].onClick) { + props.data[hit.index].onClick(); + } + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + }); +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue new file mode 100644 index 0000000000..1e095bddaa --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue @@ -0,0 +1,186 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { watch, onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import { chartVLine } from '@/scripts/chart-vline'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + type: string; +}>(); + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +const chartEl = ref<HTMLCanvasElement>(null); + +const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + +// フォントカラー +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +function setData(values) { + if (chartInstance == null) return; + for (const value of values) { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 100) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + } + chartInstance.update(); +} + +function pushData(value) { + if (chartInstance == null) return; + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 100) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + chartInstance.update(); +} + +const label = + props.type === 'process' ? 'Process' : + props.type === 'active' ? 'Active' : + props.type === 'delayed' ? 'Delayed' : + props.type === 'waiting' ? 'Waiting' : + '?' as never; + +const color = + props.type === 'process' ? '#00E396' : + props.type === 'active' ? '#00BCD4' : + props.type === 'delayed' ? '#E53935' : + props.type === 'waiting' ? '#FFB300' : + '?' as never; + +onMounted(() => { + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: label, + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: color, + backgroundColor: alpha(color, 0.2), + fill: true, + data: [], + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + grid: { + display: false, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + maxTicksLimit: 10, + }, + }, + y: { + min: 0, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + plugins: [chartVLine(vLineColor)], + }); +}); + +defineExpose({ + setData, + pushData, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue new file mode 100644 index 0000000000..72ebddc72f --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.queue.vue @@ -0,0 +1,127 @@ +<template> +<div :class="$style.root"> + <div class="_table status"> + <div class="_row"> + <div class="_cell" style="text-align: center;"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> + <div class="_cell" style="text-align: center;"><div class="_label">Active</div>{{ number(active) }}</div> + <div class="_cell" style="text-align: center;"><div class="_label">Waiting</div>{{ number(waiting) }}</div> + <div class="_cell" style="text-align: center;"><div class="_label">Delayed</div>{{ number(delayed) }}</div> + </div> + </div> + <div class="charts"> + <div class="chart"> + <div class="title">Process</div> + <XChart ref="chartProcess" type="process"/> + </div> + <div class="chart"> + <div class="title">Active</div> + <XChart ref="chartActive" type="active"/> + </div> + <div class="chart"> + <div class="title">Delayed</div> + <XChart ref="chartDelayed" type="delayed"/> + </div> + <div class="chart"> + <div class="title">Waiting</div> + <XChart ref="chartWaiting" type="waiting"/> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { markRaw, onMounted, onUnmounted, ref } from 'vue'; +import XChart from './overview.queue.chart.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; + +const connection = markRaw(stream.useChannel('queueStats')); + +const activeSincePrevTick = ref(0); +const active = ref(0); +const delayed = ref(0); +const waiting = ref(0); +let chartProcess = $ref<InstanceType<typeof XChart>>(); +let chartActive = $ref<InstanceType<typeof XChart>>(); +let chartDelayed = $ref<InstanceType<typeof XChart>>(); +let chartWaiting = $ref<InstanceType<typeof XChart>>(); + +const props = defineProps<{ + domain: string; +}>(); + +const onStats = (stats) => { + activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; + active.value = stats[props.domain].active; + delayed.value = stats[props.domain].delayed; + waiting.value = stats[props.domain].waiting; + + chartProcess.pushData(stats[props.domain].activeSincePrevTick); + chartActive.pushData(stats[props.domain].active); + chartDelayed.pushData(stats[props.domain].delayed); + chartWaiting.pushData(stats[props.domain].waiting); +}; + +const onStatsLog = (statsLog) => { + const dataProcess = []; + const dataActive = []; + const dataDelayed = []; + const dataWaiting = []; + + for (const stats of [...statsLog].reverse()) { + dataProcess.push(stats[props.domain].activeSincePrevTick); + dataActive.push(stats[props.domain].active); + dataDelayed.push(stats[props.domain].delayed); + dataWaiting.push(stats[props.domain].waiting); + } + + chartProcess.setData(dataProcess); + chartActive.setData(dataActive); + chartDelayed.setData(dataDelayed); + chartWaiting.setData(dataWaiting); +}; + +onMounted(() => { + connection.on('stats', onStats); + connection.on('statsLog', onStatsLog); + connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 100, + }); +}); + +onUnmounted(() => { + connection.off('stats', onStats); + connection.off('statsLog', onStatsLog); + connection.dispose(); +}); +</script> + +<style lang="scss" module> +.root { + &:global { + > .status { + padding: 0 0 16px 0; + } + + > .charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + + > .chart { + min-width: 0; + padding: 16px; + background: var(--panel); + border-radius: var(--radius); + + > .title { + font-size: 0.85em; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.retention.vue b/packages/frontend/src/pages/admin/overview.retention.vue new file mode 100644 index 0000000000..feac6f8118 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.retention.vue @@ -0,0 +1,49 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-else :class="$style.root"> + <div v-for="row in retention" class="row"> + <div v-for="value in getValues(row)" v-tooltip="value.percentage" class="cell"> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; + +let retention: any = $ref(null); +let fetching = $ref(true); + +function getValues(row) { + const data = []; + for (const key in row.data) { + data.push({ + date: new Date(key), + value: number(row.data[key]), + percentage: `${Math.ceil(row.data[key] / row.users) * 100}%`, + }); + } + data.sort((a, b) => a.date > b.date); + return data; +} + +onMounted(async () => { + retention = await os.apiGet('retention', {}); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + + &:global { + + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue new file mode 100644 index 0000000000..4dcf7e751a --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.stats.vue @@ -0,0 +1,155 @@ +<template> +<div> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <MkLoading v-if="fetching"/> + <div v-else :class="$style.root"> + <div class="item _panel users"> + <div class="icon"><i class="ti ti-users"></i></div> + <div class="body"> + <div class="value"> + {{ number(stats.originalUsersCount) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"></MkNumberDiff> + </div> + <div class="label">Users</div> + </div> + </div> + <div class="item _panel notes"> + <div class="icon"><i class="ti ti-pencil"></i></div> + <div class="body"> + <div class="value"> + {{ number(stats.originalNotesCount) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"></MkNumberDiff> + </div> + <div class="label">Notes</div> + </div> + </div> + <div class="item _panel instances"> + <div class="icon"><i class="ti ti-planet"></i></div> + <div class="body"> + <div class="value"> + {{ number(stats.instances) }} + </div> + <div class="label">Instances</div> + </div> + </div> + <div class="item _panel online"> + <div class="icon"><i class="ti ti-access-point"></i></div> + <div class="body"> + <div class="value"> + {{ number(onlineUsersCount) }} + </div> + <div class="label">Online</div> + </div> + </div> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import MkNumberDiff from '@/components/MkNumberDiff.vue'; +import { i18n } from '@/i18n'; + +let stats: any = $ref(null); +let usersComparedToThePrevDay = $ref<number>(); +let notesComparedToThePrevDay = $ref<number>(); +let onlineUsersCount = $ref(0); +let fetching = $ref(true); + +onMounted(async () => { + const [_stats, _onlineUsersCount] = await Promise.all([ + os.api('stats', {}), + os.api('get-online-users-count').then(res => res.count), + ]); + stats = _stats; + onlineUsersCount = _onlineUsersCount; + + os.apiGet('charts/users', { limit: 2, span: 'day' }).then(chart => { + usersComparedToThePrevDay = stats.originalUsersCount - chart.local.total[1]; + }); + + os.apiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => { + notesComparedToThePrevDay = stats.originalNotesCount - chart.local.total[1]; + }); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + + &:global { + > .item { + display: flex; + box-sizing: border-box; + padding: 12px; + + > .icon { + display: grid; + place-items: center; + height: 100%; + aspect-ratio: 1; + margin-right: 12px; + background: var(--accentedBg); + color: var(--accent); + border-radius: 10px; + } + + &.users { + > .icon { + background: #0088d726; + color: #3d96c1; + } + } + + &.notes { + > .icon { + background: #86b30026; + color: #86b300; + } + } + + &.instances { + > .icon { + background: #e96b0026; + color: #d76d00; + } + } + + &.online { + > .icon { + background: #8a00d126; + color: #c01ac3; + } + } + + > .body { + padding: 2px 0; + + > .value { + font-size: 1.25em; + font-weight: bold; + + > .diff { + font-size: 0.65em; + font-weight: normal; + } + } + + > .label { + font-size: 0.8em; + opacity: 0.5; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue new file mode 100644 index 0000000000..5d4be11742 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.users.vue @@ -0,0 +1,57 @@ +<template> +<div :class="$style.root"> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <MkLoading v-if="fetching"/> + <div v-else class="users"> + <MkA v-for="(user, i) in newUsers" :key="user.id" :to="`/user-info/${user.id}`" class="user"> + <MkUserCardMini :user="user"/> + </MkA> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; + +let newUsers = $ref(null); +let fetching = $ref(true); + +const fetch = async () => { + const _newUsers = await os.api('admin/show-users', { + limit: 5, + sort: '+createdAt', + origin: 'local', + }); + newUsers = _newUsers; + fetching = false; +}; + +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" module> +.root { + &:global { + > .users { + .chart-move { + transition: transform 1s ease; + } + + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-gap: 12px; + + > .user:hover { + text-decoration: none; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue new file mode 100644 index 0000000000..d656e55200 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.vue @@ -0,0 +1,190 @@ +<template> +<MkSpacer :content-max="1000"> + <div ref="rootEl" class="edbbcaef"> + <MkFolder class="item"> + <template #header>Stats</template> + <XStats/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Active users</template> + <XActiveUsers/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Heatmap</template> + <XHeatmap/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Retention rate</template> + <XRetention/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Moderators</template> + <XModerators/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Federation</template> + <XFederation/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Instances</template> + <XInstances/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Ap requests</template> + <XApRequests/> + </MkFolder> + + <MkFolder class="item"> + <template #header>New users</template> + <XUsers/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Deliver queue</template> + <XQueue domain="deliver"/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Inbox queue</template> + <XQueue domain="inbox"/> + </MkFolder> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue'; +import XFederation from './overview.federation.vue'; +import XInstances from './overview.instances.vue'; +import XQueue from './overview.queue.vue'; +import XApRequests from './overview.ap-requests.vue'; +import XUsers from './overview.users.vue'; +import XActiveUsers from './overview.active-users.vue'; +import XStats from './overview.stats.vue'; +import XRetention from './overview.retention.vue'; +import XModerators from './overview.moderators.vue'; +import XHeatmap from './overview.heatmap.vue'; +import MkTagCloud from '@/components/MkTagCloud.vue'; +import { version, url } from '@/config'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import 'chartjs-adapter-date-fns'; +import { defaultStore } from '@/store'; +import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; +import MkFolder from '@/components/MkFolder.vue'; + +const rootEl = $ref<HTMLElement>(); +let serverInfo: any = $ref(null); +let topSubInstancesForPie: any = $ref(null); +let topPubInstancesForPie: any = $ref(null); +let federationPubActive = $ref<number | null>(null); +let federationPubActiveDiff = $ref<number | null>(null); +let federationSubActive = $ref<number | null>(null); +let federationSubActiveDiff = $ref<number | null>(null); +let newUsers = $ref(null); +let activeInstances = $shallowRef(null); +const queueStatsConnection = markRaw(stream.useChannel('queueStats')); +const now = new Date(); +const filesPagination = { + endpoint: 'admin/drive/files' as const, + limit: 9, + noPaging: true, +}; + +function onInstanceClick(i) { + os.pageWindow(`/instance-info/${i.host}`); +} + +onMounted(async () => { + /* + const magicGrid = new MagicGrid({ + container: rootEl, + static: true, + animate: true, + }); + + magicGrid.listen(); + */ + + os.apiGet('charts/federation', { limit: 2, span: 'day' }).then(chart => { + federationPubActive = chart.pubActive[0]; + federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1]; + federationSubActive = chart.subActive[0]; + federationSubActiveDiff = chart.subActive[0] - chart.subActive[1]; + }); + + os.apiGet('federation/stats', { limit: 10 }).then(res => { + topSubInstancesForPie = res.topSubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followersCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]); + topPubInstancesForPie = res.topPubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followingCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]); + }); + + os.api('admin/server-info').then(serverInfoResponse => { + serverInfo = serverInfoResponse; + }); + + os.api('admin/show-users', { + limit: 5, + sort: '+createdAt', + }).then(res => { + newUsers = res; + }); + + os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 25, + }).then(res => { + activeInstances = res; + }); + + nextTick(() => { + queueStatsConnection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 100, + }); + }); +}); + +onBeforeUnmount(() => { + queueStatsConnection.dispose(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.dashboard, + icon: 'ti ti-dashboard', +}); +</script> + +<style lang="scss" scoped> +.edbbcaef { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); + grid-gap: 16px; +} +</style> diff --git a/packages/frontend/src/pages/admin/proxy-account.vue b/packages/frontend/src/pages/admin/proxy-account.vue new file mode 100644 index 0000000000..5d0d67980e --- /dev/null +++ b/packages/frontend/src/pages/admin/proxy-account.vue @@ -0,0 +1,62 @@ +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <MkInfo class="_formBlock">{{ i18n.ts.proxyAccountDescription }}</MkInfo> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.proxyAccount }}</template> + <template #value>{{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}</template> + </MkKeyValue> + + <FormButton primary class="_formBlock" @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</FormButton> + </FormSuspense> +</MkSpacer></MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let proxyAccount: any = $ref(null); +let proxyAccountId: any = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + proxyAccountId = meta.proxyAccountId; + if (proxyAccountId) { + proxyAccount = await os.api('users/show', { userId: proxyAccountId }); + } +} + +function chooseProxyAccount() { + os.selectUser().then(user => { + proxyAccount = user; + proxyAccountId = user.id; + save(); + }); +} + +function save() { + os.apiWithDialog('admin/update-meta', { + proxyAccountId: proxyAccountId, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.proxyAccount, + icon: 'ti ti-ghost', +}); +</script> diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue new file mode 100644 index 0000000000..5777674ae3 --- /dev/null +++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue @@ -0,0 +1,186 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { watch, onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import { chartVLine } from '@/scripts/chart-vline'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + type: string; +}>(); + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +const chartEl = ref<HTMLCanvasElement>(null); + +const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + +// フォントカラー +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +function setData(values) { + if (chartInstance == null) return; + for (const value of values) { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + } + chartInstance.update(); +} + +function pushData(value) { + if (chartInstance == null) return; + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + chartInstance.update(); +} + +const label = + props.type === 'process' ? 'Process' : + props.type === 'active' ? 'Active' : + props.type === 'delayed' ? 'Delayed' : + props.type === 'waiting' ? 'Waiting' : + '?' as never; + +const color = + props.type === 'process' ? '#00E396' : + props.type === 'active' ? '#00BCD4' : + props.type === 'delayed' ? '#E53935' : + props.type === 'waiting' ? '#FFB300' : + '?' as never; + +onMounted(() => { + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: label, + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: color, + backgroundColor: alpha(color, 0.2), + fill: true, + data: [], + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + maxTicksLimit: 10, + }, + }, + y: { + min: 0, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + plugins: [chartVLine(vLineColor)], + }); +}); + +defineExpose({ + setData, + pushData, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue new file mode 100644 index 0000000000..186a22c43e --- /dev/null +++ b/packages/frontend/src/pages/admin/queue.chart.vue @@ -0,0 +1,149 @@ +<template> +<div class="pumxzjhg"> + <div class="_table status"> + <div class="_row"> + <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> + <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> + <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> + <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> + </div> + </div> + <div class="charts"> + <div class="chart"> + <div class="title">Process</div> + <XChart ref="chartProcess" type="process"/> + </div> + <div class="chart"> + <div class="title">Active</div> + <XChart ref="chartActive" type="active"/> + </div> + <div class="chart"> + <div class="title">Delayed</div> + <XChart ref="chartDelayed" type="delayed"/> + </div> + <div class="chart"> + <div class="title">Waiting</div> + <XChart ref="chartWaiting" type="waiting"/> + </div> + </div> + <div class="jobs"> + <div v-if="jobs.length > 0"> + <div v-for="job in jobs" :key="job[0]"> + <span>{{ job[0] }}</span> + <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> + </div> + </div> + <span v-else style="opacity: 0.5;">{{ i18n.ts.noJobs }}</span> + </div> +</div> +</template> + +<script lang="ts" setup> +import { markRaw, onMounted, onUnmounted, ref } from 'vue'; +import XChart from './queue.chart.chart.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; + +const connection = markRaw(stream.useChannel('queueStats')); + +const activeSincePrevTick = ref(0); +const active = ref(0); +const delayed = ref(0); +const waiting = ref(0); +const jobs = ref([]); +let chartProcess = $ref<InstanceType<typeof XChart>>(); +let chartActive = $ref<InstanceType<typeof XChart>>(); +let chartDelayed = $ref<InstanceType<typeof XChart>>(); +let chartWaiting = $ref<InstanceType<typeof XChart>>(); + +const props = defineProps<{ + domain: string; +}>(); + +const onStats = (stats) => { + activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; + active.value = stats[props.domain].active; + delayed.value = stats[props.domain].delayed; + waiting.value = stats[props.domain].waiting; + + chartProcess.pushData(stats[props.domain].activeSincePrevTick); + chartActive.pushData(stats[props.domain].active); + chartDelayed.pushData(stats[props.domain].delayed); + chartWaiting.pushData(stats[props.domain].waiting); +}; + +const onStatsLog = (statsLog) => { + const dataProcess = []; + const dataActive = []; + const dataDelayed = []; + const dataWaiting = []; + + for (const stats of [...statsLog].reverse()) { + dataProcess.push(stats[props.domain].activeSincePrevTick); + dataActive.push(stats[props.domain].active); + dataDelayed.push(stats[props.domain].delayed); + dataWaiting.push(stats[props.domain].waiting); + } + + chartProcess.setData(dataProcess); + chartActive.setData(dataActive); + chartDelayed.setData(dataDelayed); + chartWaiting.setData(dataWaiting); +}; + +onMounted(() => { + os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => { + jobs.value = result; + }); + + connection.on('stats', onStats); + connection.on('statsLog', onStatsLog); + connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200, + }); +}); + +onUnmounted(() => { + connection.off('stats', onStats); + connection.off('statsLog', onStatsLog); + connection.dispose(); +}); +</script> + +<style lang="scss" scoped> +.pumxzjhg { + > .status { + padding: 16px; + } + + > .charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + + > .chart { + min-width: 0; + padding: 16px; + background: var(--panel); + border-radius: var(--radius); + + > .title { + margin-bottom: 8px; + } + } + } + + > .jobs { + margin-top: 16px; + padding: 16px; + max-height: 180px; + overflow: auto; + background: var(--panel); + border-radius: var(--radius); + } + +} +</style> diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/queue.vue new file mode 100644 index 0000000000..8d19b49fc5 --- /dev/null +++ b/packages/frontend/src/pages/admin/queue.vue @@ -0,0 +1,56 @@ +<template> +<MkStickyContainer> + <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <XQueue v-if="tab === 'deliver'" domain="deliver"/> + <XQueue v-else-if="tab === 'inbox'" domain="inbox"/> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { markRaw, onMounted, onBeforeUnmount, nextTick } from 'vue'; +import XQueue from './queue.chart.vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import * as config from '@/config'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let tab = $ref('deliver'); + +function clear() { + os.confirm({ + type: 'warning', + title: i18n.ts.clearQueueConfirmTitle, + text: i18n.ts.clearQueueConfirmText, + }).then(({ canceled }) => { + if (canceled) return; + + os.apiWithDialog('admin/queue/clear'); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-external-link', + text: i18n.ts.dashboard, + handler: () => { + window.open(config.url + '/queue', '_blank'); + }, +}]); + +const headerTabs = $computed(() => [{ + key: 'deliver', + title: 'Deliver', +}, { + key: 'inbox', + title: 'Inbox', +}]); + +definePageMetadata({ + title: i18n.ts.jobQueue, + icon: 'ti ti-clock-play', +}); +</script> diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue new file mode 100644 index 0000000000..4768ae67b1 --- /dev/null +++ b/packages/frontend/src/pages/admin/relays.vue @@ -0,0 +1,103 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;"> + <div>{{ relay.inbox }}</div> + <div class="status"> + <i v-if="relay.status === 'accepted'" class="ti ti-check icon accepted"></i> + <i v-else-if="relay.status === 'rejected'" class="ti ti-ban icon rejected"></i> + <i v-else class="ti ti-clock icon requesting"></i> + <span>{{ $t(`_relayStatus.${relay.status}`) }}</span> + </div> + <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let relays: any[] = $ref([]); + +async function addRelay() { + const { canceled, result: inbox } = await os.inputText({ + title: i18n.ts.addRelay, + type: 'url', + placeholder: i18n.ts.inboxUrl, + }); + if (canceled) return; + os.api('admin/relays/add', { + inbox, + }).then((relay: any) => { + refresh(); + }).catch((err: any) => { + os.alert({ + type: 'error', + text: err.message || err, + }); + }); +} + +function remove(inbox: string) { + os.api('admin/relays/remove', { + inbox, + }).then(() => { + refresh(); + }).catch((err: any) => { + os.alert({ + type: 'error', + text: err.message || err, + }); + }); +} + +function refresh() { + os.api('admin/relays/list').then((relayList: any) => { + relays = relayList; + }); +} + +refresh(); + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.addRelay, + handler: addRelay, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.relays, + icon: 'ti ti-planet', +}); +</script> + +<style lang="scss" scoped> +.relaycxt { + > .status { + margin: 8px 0; + + > .icon { + width: 1em; + margin-right: 0.75em; + + &.accepted { + color: var(--success); + } + + &.rejected { + color: var(--error); + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue new file mode 100644 index 0000000000..2682bda337 --- /dev/null +++ b/packages/frontend/src/pages/admin/security.vue @@ -0,0 +1,179 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-shield"></i></template> + <template #label>{{ i18n.ts.botProtection }}</template> + <template v-if="enableHcaptcha" #suffix>hCaptcha</template> + <template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template> + <template v-else-if="enableTurnstile" #suffix>Turnstile</template> + <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template> + + <XBotProtection/> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-eye-off"></i></template> + <template #label>{{ i18n.ts.sensitiveMediaDetection }}</template> + <template v-if="sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template> + <template v-else-if="sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template> + <template v-else-if="sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template> + <template v-else #suffix>{{ i18n.ts.none }}</template> + + <div class="_formRoot"> + <span class="_formBlock">{{ i18n.ts._sensitiveMediaDetection.description }}</span> + + <FormRadios v-model="sensitiveMediaDetection" class="_formBlock"> + <option value="none">{{ i18n.ts.none }}</option> + <option value="all">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.localOnly }}</option> + <option value="remote">{{ i18n.ts.remoteOnly }}</option> + </FormRadios> + + <FormRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :text-converter="(v) => `${v + 1}`" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template> + </FormRange> + + <FormSwitch v-model="enableSensitiveMediaDetectionForVideos" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template> + </FormSwitch> + + <FormSwitch v-model="setSensitiveFlagAutomatically" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template> + </FormSwitch> + + <!-- 現状 false positive が多すぎて実用に耐えない + <FormSwitch v-model="disallowUploadWhenPredictedAsPorn" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template> + </FormSwitch> + --> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #label>Active Email Validation</template> + <template v-if="enableActiveEmailValidation" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + + <div class="_formRoot"> + <span class="_formBlock">{{ i18n.ts.activeEmailValidationDescription }}</span> + <FormSwitch v-model="enableActiveEmailValidation" class="_formBlock" @update:model-value="save"> + <template #label>Enable</template> + </FormSwitch> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #label>Log IP address</template> + <template v-if="enableIpLogging" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + + <div class="_formRoot"> + <FormSwitch v-model="enableIpLogging" class="_formBlock" @update:model-value="save"> + <template #label>Enable</template> + </FormSwitch> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #label>Summaly Proxy</template> + + <div class="_formRoot"> + <FormInput v-model="summalyProxy" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>Summaly Proxy URL</template> + </FormInput> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> + </FormFolder> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XBotProtection from './bot-protection.vue'; +import XHeader from './_header_.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormRange from '@/components/form/range.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let summalyProxy: string = $ref(''); +let enableHcaptcha: boolean = $ref(false); +let enableRecaptcha: boolean = $ref(false); +let enableTurnstile: boolean = $ref(false); +let sensitiveMediaDetection: string = $ref('none'); +let sensitiveMediaDetectionSensitivity: number = $ref(0); +let setSensitiveFlagAutomatically: boolean = $ref(false); +let enableSensitiveMediaDetectionForVideos: boolean = $ref(false); +let enableIpLogging: boolean = $ref(false); +let enableActiveEmailValidation: boolean = $ref(false); + +async function init() { + const meta = await os.api('admin/meta'); + summalyProxy = meta.summalyProxy; + enableHcaptcha = meta.enableHcaptcha; + enableRecaptcha = meta.enableRecaptcha; + enableTurnstile = meta.enableTurnstile; + sensitiveMediaDetection = meta.sensitiveMediaDetection; + sensitiveMediaDetectionSensitivity = + meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 : + meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 : + meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 : + meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 : + meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0; + setSensitiveFlagAutomatically = meta.setSensitiveFlagAutomatically; + enableSensitiveMediaDetectionForVideos = meta.enableSensitiveMediaDetectionForVideos; + enableIpLogging = meta.enableIpLogging; + enableActiveEmailValidation = meta.enableActiveEmailValidation; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + summalyProxy, + sensitiveMediaDetection, + sensitiveMediaDetectionSensitivity: + sensitiveMediaDetectionSensitivity === 0 ? 'veryLow' : + sensitiveMediaDetectionSensitivity === 1 ? 'low' : + sensitiveMediaDetectionSensitivity === 2 ? 'medium' : + sensitiveMediaDetectionSensitivity === 3 ? 'high' : + sensitiveMediaDetectionSensitivity === 4 ? 'veryHigh' : + 0, + setSensitiveFlagAutomatically, + enableSensitiveMediaDetectionForVideos, + enableIpLogging, + enableActiveEmailValidation, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.security, + icon: 'ti ti-lock', +}); +</script> diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue new file mode 100644 index 0000000000..460eb92694 --- /dev/null +++ b/packages/frontend/src/pages/admin/settings.vue @@ -0,0 +1,262 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormInput v-model="name" class="_formBlock"> + <template #label>{{ i18n.ts.instanceName }}</template> + </FormInput> + + <FormTextarea v-model="description" class="_formBlock"> + <template #label>{{ i18n.ts.instanceDescription }}</template> + </FormTextarea> + + <FormInput v-model="tosUrl" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.tosUrl }}</template> + </FormInput> + + <FormSplit :min-width="300"> + <FormInput v-model="maintainerName" class="_formBlock"> + <template #label>{{ i18n.ts.maintainerName }}</template> + </FormInput> + + <FormInput v-model="maintainerEmail" type="email" class="_formBlock"> + <template #prefix><i class="ti ti-mail"></i></template> + <template #label>{{ i18n.ts.maintainerEmail }}</template> + </FormInput> + </FormSplit> + + <FormTextarea v-model="pinnedUsers" class="_formBlock"> + <template #label>{{ i18n.ts.pinnedUsers }}</template> + <template #caption>{{ i18n.ts.pinnedUsersDescription }}</template> + </FormTextarea> + + <FormSection> + <FormSwitch v-model="enableRegistration" class="_formBlock"> + <template #label>{{ i18n.ts.enableRegistration }}</template> + </FormSwitch> + + <FormSwitch v-model="emailRequiredForSignup" class="_formBlock"> + <template #label>{{ i18n.ts.emailRequiredForSignup }}</template> + </FormSwitch> + </FormSection> + + <FormSection> + <FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch> + <FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch> + <FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.theme }}</template> + + <FormInput v-model="iconUrl" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.iconUrl }}</template> + </FormInput> + + <FormInput v-model="bannerUrl" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.bannerUrl }}</template> + </FormInput> + + <FormInput v-model="backgroundImageUrl" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.backgroundImageUrl }}</template> + </FormInput> + + <FormInput v-model="themeColor" class="_formBlock"> + <template #prefix><i class="ti ti-palette"></i></template> + <template #label>{{ i18n.ts.themeColor }}</template> + <template #caption>#RRGGBB</template> + </FormInput> + + <FormTextarea v-model="defaultLightTheme" class="_formBlock"> + <template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template> + <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> + </FormTextarea> + + <FormTextarea v-model="defaultDarkTheme" class="_formBlock"> + <template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template> + <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> + </FormTextarea> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.files }}</template> + + <FormSwitch v-model="cacheRemoteFiles" class="_formBlock"> + <template #label>{{ i18n.ts.cacheRemoteFiles }}</template> + <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template> + </FormSwitch> + + <FormSplit :min-width="280"> + <FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock"> + <template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template> + <template #suffix>MB</template> + <template #caption>{{ i18n.ts.inMb }}</template> + </FormInput> + + <FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock"> + <template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template> + <template #suffix>MB</template> + <template #caption>{{ i18n.ts.inMb }}</template> + </FormInput> + </FormSplit> + </FormSection> + + <FormSection> + <template #label>ServiceWorker</template> + + <FormSwitch v-model="enableServiceWorker" class="_formBlock"> + <template #label>{{ i18n.ts.enableServiceworker }}</template> + <template #caption>{{ i18n.ts.serviceworkerInfo }}</template> + </FormSwitch> + + <template v-if="enableServiceWorker"> + <FormInput v-model="swPublicKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Public key</template> + </FormInput> + + <FormInput v-model="swPrivateKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Private key</template> + </FormInput> + </template> + </FormSection> + + <FormSection> + <template #label>DeepL Translation</template> + + <FormInput v-model="deeplAuthKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>DeepL Auth Key</template> + </FormInput> + <FormSwitch v-model="deeplIsPro" class="_formBlock"> + <template #label>Pro account</template> + </FormSwitch> + </FormSection> + </div> + </FormSuspense> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let name: string | null = $ref(null); +let description: string | null = $ref(null); +let tosUrl: string | null = $ref(null); +let maintainerName: string | null = $ref(null); +let maintainerEmail: string | null = $ref(null); +let iconUrl: string | null = $ref(null); +let bannerUrl: string | null = $ref(null); +let backgroundImageUrl: string | null = $ref(null); +let themeColor: any = $ref(null); +let defaultLightTheme: any = $ref(null); +let defaultDarkTheme: any = $ref(null); +let enableLocalTimeline: boolean = $ref(false); +let enableGlobalTimeline: boolean = $ref(false); +let pinnedUsers: string = $ref(''); +let cacheRemoteFiles: boolean = $ref(false); +let localDriveCapacityMb: any = $ref(0); +let remoteDriveCapacityMb: any = $ref(0); +let enableRegistration: boolean = $ref(false); +let emailRequiredForSignup: boolean = $ref(false); +let enableServiceWorker: boolean = $ref(false); +let swPublicKey: any = $ref(null); +let swPrivateKey: any = $ref(null); +let deeplAuthKey: string = $ref(''); +let deeplIsPro: boolean = $ref(false); + +async function init() { + const meta = await os.api('admin/meta'); + name = meta.name; + description = meta.description; + tosUrl = meta.tosUrl; + iconUrl = meta.iconUrl; + bannerUrl = meta.bannerUrl; + backgroundImageUrl = meta.backgroundImageUrl; + themeColor = meta.themeColor; + defaultLightTheme = meta.defaultLightTheme; + defaultDarkTheme = meta.defaultDarkTheme; + maintainerName = meta.maintainerName; + maintainerEmail = meta.maintainerEmail; + enableLocalTimeline = !meta.disableLocalTimeline; + enableGlobalTimeline = !meta.disableGlobalTimeline; + pinnedUsers = meta.pinnedUsers.join('\n'); + cacheRemoteFiles = meta.cacheRemoteFiles; + localDriveCapacityMb = meta.driveCapacityPerLocalUserMb; + remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb; + enableRegistration = !meta.disableRegistration; + emailRequiredForSignup = meta.emailRequiredForSignup; + enableServiceWorker = meta.enableServiceWorker; + swPublicKey = meta.swPublickey; + swPrivateKey = meta.swPrivateKey; + deeplAuthKey = meta.deeplAuthKey; + deeplIsPro = meta.deeplIsPro; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + name, + description, + tosUrl, + iconUrl, + bannerUrl, + backgroundImageUrl, + themeColor: themeColor === '' ? null : themeColor, + defaultLightTheme: defaultLightTheme === '' ? null : defaultLightTheme, + defaultDarkTheme: defaultDarkTheme === '' ? null : defaultDarkTheme, + maintainerName, + maintainerEmail, + disableLocalTimeline: !enableLocalTimeline, + disableGlobalTimeline: !enableGlobalTimeline, + pinnedUsers: pinnedUsers.split('\n'), + cacheRemoteFiles, + localDriveCapacityMb: parseInt(localDriveCapacityMb, 10), + remoteDriveCapacityMb: parseInt(remoteDriveCapacityMb, 10), + disableRegistration: !enableRegistration, + emailRequiredForSignup, + enableServiceWorker, + swPublicKey, + swPrivateKey, + deeplAuthKey, + deeplIsPro, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.general, + icon: 'ti ti-settings', +}); +</script> diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue new file mode 100644 index 0000000000..d466e21907 --- /dev/null +++ b/packages/frontend/src/pages/admin/users.vue @@ -0,0 +1,170 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="lknzcolw"> + <div class="users"> + <div class="inputs"> + <MkSelect v-model="sort" style="flex: 1;"> + <template #label>{{ i18n.ts.sort }}</template> + <option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.descendingOrder }})</option> + </MkSelect> + <MkSelect v-model="state" style="flex: 1;"> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="available">{{ i18n.ts.normal }}</option> + <option value="admin">{{ i18n.ts.administrator }}</option> + <option value="moderator">{{ i18n.ts.moderator }}</option> + <option value="silenced">{{ i18n.ts.silence }}</option> + <option value="suspended">{{ i18n.ts.suspend }}</option> + </MkSelect> + <MkSelect v-model="origin" style="flex: 1;"> + <template #label>{{ i18n.ts.instance }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + </div> + <div class="inputs"> + <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:model-value="$refs.users.reload()"> + <template #prefix>@</template> + <template #label>{{ i18n.ts.username }}</template> + </MkInput> + <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:model-value="$refs.users.reload()"> + <template #prefix>@</template> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + </div> + + <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users"> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`"> + <MkUserCardMini :user="user"/> + </MkA> + </MkPagination> + </div> + </div> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import XHeader from './_header_.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import * as os from '@/os'; +import { lookupUser } from '@/scripts/lookup-user'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; + +let paginationComponent = $ref<InstanceType<typeof MkPagination>>(); + +let sort = $ref('+createdAt'); +let state = $ref('all'); +let origin = $ref('local'); +let searchUsername = $ref(''); +let searchHost = $ref(''); +const pagination = { + endpoint: 'admin/show-users' as const, + limit: 10, + params: computed(() => ({ + sort: sort, + state: state, + origin: origin, + username: searchUsername, + hostname: searchHost, + })), + offsetMode: true, +}; + +function searchUser() { + os.selectUser().then(user => { + show(user); + }); +} + +async function addUser() { + const { canceled: canceled1, result: username } = await os.inputText({ + title: i18n.ts.username, + }); + if (canceled1) return; + + const { canceled: canceled2, result: password } = await os.inputText({ + title: i18n.ts.password, + type: 'password', + }); + if (canceled2) return; + + os.apiWithDialog('admin/accounts/create', { + username: username, + password: password, + }).then(res => { + paginationComponent.reload(); + }); +} + +function show(user) { + os.pageWindow(`/user-info/${user.id}`); +} + +const headerActions = $computed(() => [{ + icon: 'ti ti-search', + text: i18n.ts.search, + handler: searchUser, +}, { + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.addUser, + handler: addUser, +}, { + asFullButton: true, + icon: 'ti ti-search', + text: i18n.ts.lookup, + handler: lookupUser, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.users, + icon: 'ti ti-users', +}))); +</script> + +<style lang="scss" scoped> +.lknzcolw { + > .users { + + > .inputs { + display: flex; + margin-bottom: 16px; + + > * { + margin-right: 16px; + + &:last-child { + margin-right: 0; + } + } + } + + > .users { + margin-top: var(--margin); + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + grid-gap: 12px; + + > .user:hover { + text-decoration: none; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue new file mode 100644 index 0000000000..6a93b3b9fa --- /dev/null +++ b/packages/frontend/src/pages/announcements.vue @@ -0,0 +1,69 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _content"> + <section v-for="(announcement, i) in items" :key="announcement.id" class="_card announcement"> + <div class="_title"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div> + <div class="_content"> + <Mfm :text="announcement.text"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> + </div> + <div v-if="$i && !announcement.isRead" class="_footer"> + <MkButton primary @click="read(items, announcement, i)"><i class="ti ti-check"></i> {{ $ts.gotIt }}</MkButton> + </div> + </section> + </MkPagination> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'announcements' as const, + limit: 10, +}; + +// TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい +function read(items, announcement, i) { + items[i] = { + ...announcement, + isRead: true, + }; + os.api('i/read-announcement', { announcementId: announcement.id }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.announcements, + icon: 'ti ti-speakerphone', +}); +</script> + +<style lang="scss" scoped> +.ruryvtyk { + > .announcement { + &:not(:last-child) { + margin-bottom: var(--margin); + } + + > ._content { + > img { + display: block; + max-height: 300px; + max-width: 100%; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue new file mode 100644 index 0000000000..0b2c284c99 --- /dev/null +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -0,0 +1,128 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks"> + <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline + ref="tlEl" :key="antennaId" + class="tl" + src="antenna" + :antenna="antennaId" + :sound="true" + @queue="queueUpdated" + /> + </div> + </div> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, inject, watch } from 'vue'; +import XTimeline from '@/components/MkTimeline.vue'; +import { scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const router = useRouter(); + +const props = defineProps<{ + antennaId: string; +}>(); + +let antenna = $ref(null); +let queue = $ref(0); +let rootEl = $ref<HTMLElement>(); +let tlEl = $ref<InstanceType<typeof XTimeline>>(); +const keymap = $computed(() => ({ + 't': focus, +})); + +function queueUpdated(q) { + queue = q; +} + +function top() { + scroll(rootEl, { top: 0 }); +} + +async function timetravel() { + const { canceled, result: date } = await os.inputDate({ + title: i18n.ts.date, + }); + if (canceled) return; + + tlEl.timetravel(date); +} + +function settings() { + router.push(`/my/antennas/${props.antennaId}`); +} + +function focus() { + tlEl.focus(); +} + +watch(() => props.antennaId, async () => { + antenna = await os.api('antennas/show', { + antennaId: props.antennaId, + }); +}, { immediate: true }); + +const headerActions = $computed(() => antenna ? [{ + icon: 'fas fa-calendar-alt', + text: i18n.ts.jumpToSpecifiedDate, + handler: timetravel, +}, { + icon: 'ti ti-settings', + text: i18n.ts.settings, + handler: settings, +}] : []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => antenna ? { + title: antenna.name, + icon: 'ti ti-antenna', +} : null)); +</script> + +<style lang="scss" scoped> +.tqmomfks { + padding: var(--margin); + + > .new { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + z-index: 1000; + width: 100%; + + > button { + display: block; + margin: var(--margin) auto 0 auto; + padding: 8px 16px; + border-radius: 32px; + } + } + + > .tl { + background: var(--bg); + border-radius: var(--radius); + overflow: clip; + } + + &.min-width_800px { + max-width: 800px; + margin: 0 auto; + } +} + +@container (min-width: 800px) { + .tqmomfks { + max-width: 800px; + margin: 0 auto; + } +} +</style> diff --git a/packages/frontend/src/pages/api-console.vue b/packages/frontend/src/pages/api-console.vue new file mode 100644 index 0000000000..1d5339b44c --- /dev/null +++ b/packages/frontend/src/pages/api-console.vue @@ -0,0 +1,89 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="_formRoot"> + <div class="_formBlock"> + <MkInput v-model="endpoint" :datalist="endpoints" class="_formBlock" @update:model-value="onEndpointChange()"> + <template #label>Endpoint</template> + </MkInput> + <MkTextarea v-model="body" class="_formBlock" code> + <template #label>Params (JSON or JSON5)</template> + </MkTextarea> + <MkSwitch v-model="withCredential" class="_formBlock"> + With credential + </MkSwitch> + <MkButton class="_formBlock" primary :disabled="sending" @click="send"> + <template v-if="sending"><MkEllipsis/></template> + <template v-else><i class="ti ti-send"></i> Send</template> + </MkButton> + </div> + <div v-if="res" class="_formBlock"> + <MkTextarea v-model="res" code readonly tall> + <template #label>Response</template> + </MkTextarea> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import JSON5 from 'json5'; +import { Endpoints } from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const body = ref('{}'); +const endpoint = ref(''); +const endpoints = ref<any[]>([]); +const sending = ref(false); +const res = ref(''); +const withCredential = ref(true); + +os.api('endpoints').then(endpointResponse => { + endpoints.value = endpointResponse; +}); + +function send() { + sending.value = true; + const requestBody = JSON5.parse(body.value); + os.api(endpoint.value as keyof Endpoints, requestBody, requestBody.i || (withCredential.value ? undefined : null)).then(resp => { + sending.value = false; + res.value = JSON5.stringify(resp, null, 2); + }, err => { + sending.value = false; + res.value = JSON5.stringify(err, null, 2); + }); +} + +function onEndpointChange() { + os.api('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null).then(resp => { + const endpointBody = {}; + for (const p of resp.params) { + endpointBody[p.name] = + p.type === 'String' ? '' : + p.type === 'Number' ? 0 : + p.type === 'Boolean' ? false : + p.type === 'Array' ? [] : + p.type === 'Object' ? {} : + null; + } + body.value = JSON5.stringify(endpointBody, null, 2); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'API console', + icon: 'ti ti-terminal-2', +}); +</script> diff --git a/packages/frontend/src/pages/auth.form.vue b/packages/frontend/src/pages/auth.form.vue new file mode 100644 index 0000000000..1546735266 --- /dev/null +++ b/packages/frontend/src/pages/auth.form.vue @@ -0,0 +1,60 @@ +<template> +<section class="_section"> + <div class="_title">{{ $t('_auth.shareAccess', { name: app.name }) }}</div> + <div class="_content"> + <h2>{{ app.name }}</h2> + <p class="id">{{ app.id }}</p> + <p class="description">{{ app.description }}</p> + </div> + <div class="_content"> + <h2>{{ $ts._auth.permissionAsk }}</h2> + <ul> + <li v-for="p in app.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </div> + <div class="_footer"> + <MkButton inline @click="cancel">{{ $ts.cancel }}</MkButton> + <MkButton inline primary @click="accept">{{ $ts.accept }}</MkButton> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + }, + props: ['session'], + computed: { + name(): string { + const el = document.createElement('div'); + el.textContent = this.app.name; + return el.innerHTML; + }, + app(): any { + return this.session.app; + }, + }, + methods: { + cancel() { + os.api('auth/deny', { + token: this.session.token, + }).then(() => { + this.$emit('denied'); + }); + }, + + accept() { + os.api('auth/accept', { + token: this.session.token, + }).then(() => { + this.$emit('accepted'); + }); + }, + }, +}); +</script> diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue new file mode 100644 index 0000000000..bb55881a22 --- /dev/null +++ b/packages/frontend/src/pages/auth.vue @@ -0,0 +1,91 @@ +<template> +<div v-if="$i && fetching" class=""> + <MkLoading/> +</div> +<div v-else-if="$i"> + <XForm + v-if="state == 'waiting'" + ref="form" + class="form" + :session="session" + @denied="state = 'denied'" + @accepted="accepted" + /> + <div v-if="state == 'denied'" class="denied"> + <h1>{{ $ts._auth.denied }}</h1> + </div> + <div v-if="state == 'accepted'" class="accepted"> + <h1>{{ session.app.isAuthorized ? $t('already-authorized') : $ts.allowed }}</h1> + <p v-if="session.app.callbackUrl">{{ $ts._auth.callback }}<MkEllipsis/></p> + <p v-if="!session.app.callbackUrl">{{ $ts._auth.pleaseGoBack }}</p> + </div> + <div v-if="state == 'fetch-session-error'" class="error"> + <p>{{ $ts.somethingHappened }}</p> + </div> +</div> +<div v-else class="signin"> + <MkSignin @login="onLogin"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XForm from './auth.form.vue'; +import MkSignin from '@/components/MkSignin.vue'; +import * as os from '@/os'; +import { login } from '@/account'; + +export default defineComponent({ + components: { + XForm, + MkSignin, + }, + props: ['token'], + data() { + return { + state: null, + session: null, + fetching: true, + }; + }, + mounted() { + if (!this.$i) return; + + // Fetch session + os.api('auth/session/show', { + token: this.token, + }).then(session => { + this.session = session; + this.fetching = false; + + // 既に連携していた場合 + if (this.session.app.isAuthorized) { + os.api('auth/accept', { + token: this.session.token, + }).then(() => { + this.accepted(); + }); + } else { + this.state = 'waiting'; + } + }).catch(error => { + this.state = 'fetch-session-error'; + this.fetching = false; + }); + }, + methods: { + accepted() { + this.state = 'accepted'; + if (this.session.app.callbackUrl) { + location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`; + } + }, onLogin(res) { + login(res.i); + }, + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue new file mode 100644 index 0000000000..5ae7e63f99 --- /dev/null +++ b/packages/frontend/src/pages/channel-editor.vue @@ -0,0 +1,122 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="_formRoot"> + <MkInput v-model="name" class="_formBlock"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + + <MkTextarea v-model="description" class="_formBlock"> + <template #label>{{ i18n.ts.description }}</template> + </MkTextarea> + + <div class="banner"> + <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="ti ti-plus"></i> {{ i18n.ts._channel.setBanner }}</MkButton> + <div v-else-if="bannerUrl"> + <img :src="bannerUrl" style="width: 100%;"/> + <MkButton @click="removeBannerImage()"><i class="ti ti-trash"></i> {{ i18n.ts._channel.removeBanner }}</MkButton> + </div> + </div> + <div class="_formBlock"> + <MkButton primary @click="save()"><i class="ti ti-device-floppy"></i> {{ channelId ? i18n.ts.save : i18n.ts.create }}</MkButton> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, inject, watch } from 'vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const router = useRouter(); + +const props = defineProps<{ + channelId?: string; +}>(); + +let channel = $ref(null); +let name = $ref(null); +let description = $ref(null); +let bannerUrl = $ref<string | null>(null); +let bannerId = $ref<string | null>(null); + +watch(() => bannerId, async () => { + if (bannerId == null) { + bannerUrl = null; + } else { + bannerUrl = (await os.api('drive/files/show', { + fileId: bannerId, + })).url; + } +}); + +async function fetchChannel() { + if (props.channelId == null) return; + + channel = await os.api('channels/show', { + channelId: props.channelId, + }); + + name = channel.name; + description = channel.description; + bannerId = channel.bannerId; + bannerUrl = channel.bannerUrl; +} + +fetchChannel(); + +function save() { + const params = { + name: name, + description: description, + bannerId: bannerId, + }; + + if (props.channelId) { + params.channelId = props.channelId; + os.api('channels/update', params).then(() => { + os.success(); + }); + } else { + os.api('channels/create', params).then(created => { + os.success(); + router.push(`/channels/${created.id}`); + }); + } +} + +function setBannerImage(evt) { + selectFile(evt.currentTarget ?? evt.target, null).then(file => { + bannerId = file.id; + }); +} + +function removeBannerImage() { + bannerId = null; +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => props.channelId ? { + title: i18n.ts._channel.edit, + icon: 'ti ti-device-tv', +} : { + title: i18n.ts._channel.create, + icon: 'ti ti-device-tv', +})); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue new file mode 100644 index 0000000000..f271bb270f --- /dev/null +++ b/packages/frontend/src/pages/channel.vue @@ -0,0 +1,184 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div v-if="channel"> + <div class="wpgynlbz _panel _gap" :class="{ hide: !showBanner }"> + <XChannelFollowButton :channel="channel" :full="true" class="subscribe"/> + <button class="_button toggle" @click="() => showBanner = !showBanner"> + <template v-if="showBanner"><i class="ti ti-chevron-up"></i></template> + <template v-else><i class="ti ti-chevron-down"></i></template> + </button> + <div v-if="!showBanner" class="hideOverlay"> + </div> + <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner"> + <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 class="fade"></div> + </div> + <div v-if="channel.description" class="description"> + <Mfm :text="channel.description" :is-note="false" :i="$i"/> + </div> + </div> + + <XPostForm v-if="$i" :channel="channel" class="post-form _panel _gap" fixed/> + + <XTimeline :key="channelId" class="_gap" src="channel" :channel="channelId" @before="before" @after="after"/> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, inject, watch } from 'vue'; +import MkContainer from '@/components/MkContainer.vue'; +import XPostForm from '@/components/MkPostForm.vue'; +import XTimeline from '@/components/MkTimeline.vue'; +import XChannelFollowButton from '@/components/MkChannelFollowButton.vue'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const router = useRouter(); + +const props = defineProps<{ + channelId: string; +}>(); + +let channel = $ref(null); +let showBanner = $ref(true); +const pagination = { + endpoint: 'channels/timeline' as const, + limit: 10, + params: computed(() => ({ + channelId: props.channelId, + })), +}; + +watch(() => props.channelId, async () => { + channel = await os.api('channels/show', { + channelId: props.channelId, + }); +}, { immediate: true }); + +function edit() { + router.push(`/channels/${channel.id}/edit`); +} + +const headerActions = $computed(() => channel && channel.userId ? [{ + icon: 'ti ti-settings', + text: i18n.ts.edit, + handler: edit, +}] : null); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => channel ? { + title: channel.name, + icon: 'ti ti-device-tv', +} : null)); +</script> + +<style lang="scss" scoped> +.wpgynlbz { + position: relative; + + > .subscribe { + position: absolute; + z-index: 1; + top: 16px; + left: 16px; + } + + > .toggle { + position: absolute; + z-index: 2; + top: 8px; + right: 8px; + font-size: 1.2em; + width: 48px; + height: 48px; + color: #fff; + background: rgba(0, 0, 0, 0.5); + border-radius: 100%; + + > i { + vertical-align: middle; + } + } + + > .banner { + position: relative; + 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)); + } + + > .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; + } + } + + > .description { + padding: 16px; + } + + > .hideOverlay { + position: absolute; + z-index: 1; + top: 0; + left: 0; + width: 100%; + height: 100%; + -webkit-backdrop-filter: var(--blur, blur(16px)); + backdrop-filter: var(--blur, blur(16px)); + background: rgba(0, 0, 0, 0.3); + } + + &.hide { + > .subscribe { + display: none; + } + + > .toggle { + top: 0; + right: 0; + height: 100%; + background: transparent; + } + + > .banner { + height: 42px; + filter: blur(8px); + + > * { + display: none; + } + } + + > .description { + display: none; + } + } +} +</style> diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue new file mode 100644 index 0000000000..34e9dac196 --- /dev/null +++ b/packages/frontend/src/pages/channels.vue @@ -0,0 +1,79 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div v-if="tab === 'featured'" class="_content grwlizim featured"> + <MkPagination v-slot="{items}" :pagination="featuredPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/> + </MkPagination> + </div> + <div v-else-if="tab === 'following'" class="_content grwlizim following"> + <MkPagination v-slot="{items}" :pagination="followingPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/> + </MkPagination> + </div> + <div v-else-if="tab === 'owned'" class="_content grwlizim owned"> + <MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> + <MkPagination v-slot="{items}" :pagination="ownedPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, defineComponent, inject } from 'vue'; +import MkChannelPreview from '@/components/MkChannelPreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const router = useRouter(); + +let tab = $ref('featured'); + +const featuredPagination = { + endpoint: 'channels/featured' as const, + noPaging: true, +}; +const followingPagination = { + endpoint: 'channels/followed' as const, + limit: 5, +}; +const ownedPagination = { + endpoint: 'channels/owned' as const, + limit: 5, +}; + +function create() { + router.push('/channels/new'); +} + +const headerActions = $computed(() => [{ + icon: 'ti ti-plus', + text: i18n.ts.create, + handler: create, +}]); + +const headerTabs = $computed(() => [{ + key: 'featured', + title: i18n.ts._channel.featured, + icon: 'ti ti-comet', +}, { + key: 'following', + title: i18n.ts._channel.following, + icon: 'ti ti-heart', +}, { + key: 'owned', + title: i18n.ts._channel.owned, + icon: 'ti ti-edit', +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.channel, + icon: 'ti ti-device-tv', +}))); +</script> diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue new file mode 100644 index 0000000000..e0fbcb6bed --- /dev/null +++ b/packages/frontend/src/pages/clip.vue @@ -0,0 +1,129 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions"/></template> + <MkSpacer :content-max="800"> + <div v-if="clip"> + <div class="okzinsic _panel"> + <div v-if="clip.description" class="description"> + <Mfm :text="clip.description" :is-note="false" :i="$i"/> + </div> + <div class="user"> + <MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/> + </div> + </div> + + <XNotes :pagination="pagination" :detail="true"/> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch, provide } from 'vue'; +import * as misskey from 'misskey-js'; +import XNotes from '@/components/MkNotes.vue'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + clipId: string, +}>(); + +let clip: misskey.entities.Clip = $ref<misskey.entities.Clip>(); +const pagination = { + endpoint: 'clips/notes' as const, + limit: 10, + params: computed(() => ({ + clipId: props.clipId, + })), +}; + +const isOwned: boolean | null = $computed<boolean | null>(() => $i && clip && ($i.id === clip.userId)); + +watch(() => props.clipId, async () => { + clip = await os.api('clips/show', { + clipId: props.clipId, + }); +}, { + immediate: true, +}); + +provide('currentClipPage', $$(clip)); + +const headerActions = $computed(() => clip && isOwned ? [{ + icon: 'ti ti-pencil', + text: i18n.ts.edit, + handler: async (): Promise<void> => { + const { canceled, result } = await os.form(clip.name, { + name: { + type: 'string', + label: i18n.ts.name, + default: clip.name, + }, + description: { + type: 'string', + required: false, + multiline: true, + label: i18n.ts.description, + default: clip.description, + }, + isPublic: { + type: 'boolean', + label: i18n.ts.public, + default: clip.isPublic, + }, + }); + if (canceled) return; + + os.apiWithDialog('clips/update', { + clipId: clip.id, + ...result, + }); + }, +}, { + icon: 'ti ti-trash', + text: i18n.ts.delete, + danger: true, + handler: async (): Promise<void> => { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('deleteAreYouSure', { x: clip.name }), + }); + if (canceled) return; + + await os.apiWithDialog('clips/delete', { + clipId: clip.id, + }); + }, +}] : null); + +definePageMetadata(computed(() => clip ? { + title: clip.name, + icon: 'ti ti-paperclip', +} : null)); +</script> + +<style lang="scss" scoped> +.okzinsic { + position: relative; + margin-bottom: var(--margin); + + > .description { + padding: 16px; + } + + > .user { + $height: 32px; + padding: 16px; + border-top: solid 0.5px var(--divider); + line-height: $height; + + > .avatar { + width: $height; + height: $height; + } + } +} +</style> diff --git a/packages/frontend/src/pages/drive.vue b/packages/frontend/src/pages/drive.vue new file mode 100644 index 0000000000..04ade5c207 --- /dev/null +++ b/packages/frontend/src/pages/drive.vue @@ -0,0 +1,25 @@ +<template> +<div> + <XDrive ref="drive" @cd="x => folder = x"/> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import XDrive from '@/components/MkDrive.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let folder = $ref(null); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: folder ? folder.name : i18n.ts.drive, + icon: 'ti ti-cloud', + hideHeader: true, +}))); +</script> diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue new file mode 100644 index 0000000000..40fe496520 --- /dev/null +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -0,0 +1,72 @@ +<template> +<button class="zuvgdzyu _button" @click="menu"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.aliases.join(' ') }}</div> + </div> +</button> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + emoji: Record<string, unknown>; // TODO +}>(); + +function menu(ev) { + os.popupMenu([{ + type: 'label', + text: ':' + props.emoji.name + ':', + }, { + text: i18n.ts.copy, + icon: 'ti ti-copy', + action: () => { + copyToClipboard(`:${props.emoji.name}:`); + os.success(); + }, + }], ev.currentTarget ?? ev.target); +} +</script> + +<style lang="scss" scoped> +.zuvgdzyu { + display: flex; + align-items: center; + padding: 12px; + text-align: left; + background: var(--panel); + border-radius: 8px; + + &:hover { + border-color: var(--accent); + } + + > .img { + width: 42px; + height: 42px; + } + + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + + > .name { + text-overflow: ellipsis; + overflow: hidden; + } + + > .info { + opacity: 0.5; + font-size: 0.9em; + text-overflow: ellipsis; + overflow: hidden; + } + } +} +</style> diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue new file mode 100644 index 0000000000..18a371a086 --- /dev/null +++ b/packages/frontend/src/pages/explore.featured.vue @@ -0,0 +1,30 @@ +<template> +<MkSpacer :content-max="800"> + <MkTab v-model="tab" style="margin-bottom: var(--margin);"> + <option value="notes">{{ i18n.ts.notes }}</option> + <option value="polls">{{ i18n.ts.poll }}</option> + </MkTab> + <XNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/> + <XNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> +</MkSpacer> +</template> + +<script lang="ts" setup> +import XNotes from '@/components/MkNotes.vue'; +import MkTab from '@/components/MkTab.vue'; +import { i18n } from '@/i18n'; + +const paginationForNotes = { + endpoint: 'notes/featured' as const, + limit: 10, + offsetMode: true, +}; + +const paginationForPolls = { + endpoint: 'notes/polls/recommendation' as const, + limit: 10, + offsetMode: true, +}; + +let tab = $ref('notes'); +</script> diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue new file mode 100644 index 0000000000..bfee0a6c07 --- /dev/null +++ b/packages/frontend/src/pages/explore.users.vue @@ -0,0 +1,148 @@ +<template> +<MkSpacer :content-max="1200"> + <MkTab v-model="origin" style="margin-bottom: var(--margin);"> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkTab> + <div v-if="origin === 'local'"> + <template v-if="tag == null"> + <MkFolder class="_gap" persist-key="explore-pinned-users"> + <template #header><i class="fas fa-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template> + <XUserList :pagination="pinnedUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-popular-users"> + <template #header><i class="fas fa-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template> + <XUserList :pagination="popularUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-recently-updated-users"> + <template #header><i class="fas fa-comment-alt ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template> + <XUserList :pagination="recentlyUpdatedUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-recently-registered-users"> + <template #header><i class="ti ti-plus ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template> + <XUserList :pagination="recentlyRegisteredUsers"/> + </MkFolder> + </template> + </div> + <div v-else> + <MkFolder ref="tagsEl" :foldable="true" :expanded="false" class="_gap"> + <template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template> + + <div class="vxjfqztj"> + <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA> + <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA> + </div> + </MkFolder> + + <MkFolder v-if="tag != null" :key="`${tag}`" class="_gap"> + <template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template> + <XUserList :pagination="tagUsers"/> + </MkFolder> + + <template v-if="tag == null"> + <MkFolder class="_gap"> + <template #header><i class="fas fa-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template> + <XUserList :pagination="popularUsersF"/> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-comment-alt ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template> + <XUserList :pagination="recentlyUpdatedUsersF"/> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-rocket ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template> + <XUserList :pagination="recentlyRegisteredUsersF"/> + </MkFolder> + </template> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import XUserList from '@/components/MkUserList.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkTab from '@/components/MkTab.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; + +const props = defineProps<{ + tag?: string; +}>(); + +let origin = $ref('local'); +let tagsEl = $ref<InstanceType<typeof MkFolder>>(); +let tagsLocal = $ref([]); +let tagsRemote = $ref([]); + +watch(() => props.tag, () => { + if (tagsEl) tagsEl.toggleContent(props.tag == null); +}); + +const tagUsers = $computed(() => ({ + endpoint: 'hashtags/users' as const, + limit: 30, + params: { + tag: props.tag, + origin: 'combined', + sort: '+follower', + }, +})); + +const pinnedUsers = { endpoint: 'pinned-users' }; +const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'local', + sort: '+follower', +} }; +const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + sort: '+updatedAt', +} }; +const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + state: 'alive', + sort: '+createdAt', +} }; +const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'remote', + sort: '+follower', +} }; +const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+updatedAt', +} }; +const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+createdAt', +} }; + +os.api('hashtags/list', { + sort: '+attachedLocalUsers', + attachedToLocalUserOnly: true, + limit: 30, +}).then(tags => { + tagsLocal = tags; +}); +os.api('hashtags/list', { + sort: '+attachedRemoteUsers', + attachedToRemoteUserOnly: true, + limit: 30, +}).then(tags => { + tagsRemote = tags; +}); +</script> + +<style lang="scss" scoped> +.vxjfqztj { + > * { + margin-right: 16px; + + &.local { + font-weight: bold; + } + } +} +</style> diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue new file mode 100644 index 0000000000..6b0bcdaf62 --- /dev/null +++ b/packages/frontend/src/pages/explore.vue @@ -0,0 +1,87 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <div class="lznhrdub"> + <div v-if="tab === 'featured'"> + <XFeatured/> + </div> + <div v-else-if="tab === 'users'"> + <XUsers/> + </div> + <div v-else-if="tab === 'search'"> + <MkSpacer :content-max="1200"> + <div> + <MkInput v-model="searchQuery" :debounce="true" type="search" class="_formBlock"> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.searchUser }}</template> + </MkInput> + <MkRadios v-model="searchOrigin" class="_formBlock"> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkRadios> + </div> + + <XUserList v-if="searchQuery" ref="searchEl" class="_gap" :pagination="searchPagination"/> + </MkSpacer> + </div> + </div> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import XFeatured from './explore.featured.vue'; +import XUsers from './explore.users.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkInput from '@/components/form/input.vue'; +import MkRadios from '@/components/form/radios.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import XUserList from '@/components/MkUserList.vue'; + +const props = defineProps<{ + tag?: string; +}>(); + +let tab = $ref('featured'); +let tagsEl = $ref<InstanceType<typeof MkFolder>>(); +let searchQuery = $ref(null); +let searchOrigin = $ref('combined'); + +watch(() => props.tag, () => { + if (tagsEl) tagsEl.toggleContent(props.tag == null); +}); + +const searchPagination = { + endpoint: 'users/search' as const, + limit: 10, + params: computed(() => (searchQuery && searchQuery !== '') ? { + query: searchQuery, + origin: searchOrigin, + } : null), +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'featured', + icon: 'ti ti-bolt', + title: i18n.ts.featured, +}, { + key: 'users', + icon: 'ti ti-users', + title: i18n.ts.users, +}, { + key: 'search', + title: i18n.ts.search, +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.explore, + icon: 'ti ti-hash', +}))); +</script> diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue new file mode 100644 index 0000000000..ab47efec71 --- /dev/null +++ b/packages/frontend/src/pages/favorites.vue @@ -0,0 +1,49 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :content-max="800"> + <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 }"> + <XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false"> + <XNote :key="item.id" :note="item.note" :class="$style.note"/> + </XList> + </template> + </MkPagination> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import XNote from '@/components/MkNote.vue'; +import XList from '@/components/MkDateSeparatedList.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'i/favorites' as const, + limit: 10, +}; + +const pagingComponent = ref<InstanceType<typeof MkPagination>>(); + +definePageMetadata({ + title: i18n.ts.favorites, + icon: 'ti ti-star', +}); +</script> + +<style lang="scss" module> +.note { + background: var(--panel); + border-radius: var(--radius); +} +</style> diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue new file mode 100644 index 0000000000..b20679ccc1 --- /dev/null +++ b/packages/frontend/src/pages/follow-requests.vue @@ -0,0 +1,153 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :content-max="800"> + <MkPagination ref="paginationComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noFollowRequests }}</div> + </div> + </template> + <template #default="{items}"> + <div class="mk-follow-requests"> + <div v-for="req in items" :key="req.id" class="user _panel"> + <MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/> + <div class="body"> + <div class="name"> + <MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA> + <p class="acct">@{{ acct(req.follower) }}</p> + </div> + <div v-if="req.follower.description" class="description" :title="req.follower.description"> + <Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/> + </div> + <div class="actions"> + <button class="_button" @click="accept(req.follower)"><i class="ti ti-check"></i></button> + <button class="_button" @click="reject(req.follower)"><i class="ti ti-x"></i></button> + </div> + </div> + </div> + </div> + </template> + </MkPagination> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import { userPage, acct } from '@/filters/user'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const paginationComponent = ref<InstanceType<typeof MkPagination>>(); + +const pagination = { + endpoint: 'following/requests/list' as const, + limit: 10, +}; + +function accept(user) { + os.api('following/requests/accept', { userId: user.id }).then(() => { + paginationComponent.value.reload(); + }); +} + +function reject(user) { + os.api('following/requests/reject', { userId: user.id }).then(() => { + paginationComponent.value.reload(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.followRequests, + icon: 'ti ti-user-plus', +}))); +</script> + +<style lang="scss" scoped> +.mk-follow-requests { + > .user { + display: flex; + padding: 16px; + + > .avatar { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + width: 42px; + height: 42px; + border-radius: 8px; + } + + > .body { + display: flex; + width: calc(100% - 54px); + position: relative; + + > .name { + width: 45%; + + @media (max-width: 500px) { + width: 100%; + } + + > .name, + > .acct { + display: block; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + margin: 0; + } + + > .name { + font-size: 16px; + line-height: 24px; + } + + > .acct { + font-size: 15px; + line-height: 16px; + opacity: 0.7; + } + } + + > .description { + width: 55%; + line-height: 42px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.7; + font-size: 14px; + padding-right: 40px; + padding-left: 8px; + box-sizing: border-box; + + @media (max-width: 500px) { + display: none; + } + } + + > .actions { + position: absolute; + top: 0; + bottom: 0; + right: 0; + margin: auto 0; + + > button { + padding: 12px; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/follow.vue b/packages/frontend/src/pages/follow.vue new file mode 100644 index 0000000000..828246d678 --- /dev/null +++ b/packages/frontend/src/pages/follow.vue @@ -0,0 +1,62 @@ +<template> +<div class="mk-follow-page"> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as os from '@/os'; +import { mainRouter } from '@/router'; +import { i18n } from '@/i18n'; + +async function follow(user): Promise<void> { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('followConfirm', { name: user.name || user.username }), + }); + + if (canceled) { + window.close(); + return; + } + + os.apiWithDialog('following/create', { + userId: user.id, + }); +} + +const acct = new URL(location.href).searchParams.get('acct'); +if (acct == null) { + throw new Error('acct required'); +} + +let promise; + +if (acct.startsWith('https://')) { + promise = os.api('ap/show', { + uri: acct, + }); + promise.then(res => { + if (res.type === 'User') { + follow(res.object); + } else if (res.type === 'Note') { + mainRouter.push(`/notes/${res.object.id}`); + } else { + os.alert({ + type: 'error', + text: 'Not a user', + }).then(() => { + window.close(); + }); + } + }); +} else { + promise = os.api('users/show', Acct.parse(acct)); + promise.then(user => { + follow(user); + }); +} + +os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); +</script> diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue new file mode 100644 index 0000000000..c8111d7890 --- /dev/null +++ b/packages/frontend/src/pages/gallery/edit.vue @@ -0,0 +1,149 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <FormInput v-model="title"> + <template #label>{{ i18n.ts.title }}</template> + </FormInput> + + <FormTextarea v-model="description" :max="500"> + <template #label>{{ i18n.ts.description }}</template> + </FormTextarea> + + <div class=""> + <div v-for="file in files" :key="file.id" class="wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }"> + <div class="name">{{ file.name }}</div> + <button v-tooltip="i18n.ts.remove" class="remove _button" @click="remove(file)"><i class="ti ti-x"></i></button> + </div> + <FormButton primary @click="selectFile"><i class="ti ti-plus"></i> {{ i18n.ts.attachFile }}</FormButton> + </div> + + <FormSwitch v-model="isSensitive">{{ i18n.ts.markAsSensitive }}</FormSwitch> + + <FormButton v-if="postId" primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + <FormButton v-else primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.publish }}</FormButton> + + <FormButton v-if="postId" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</FormButton> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, inject, watch } from 'vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInput from '@/components/form/input.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import { selectFiles } from '@/scripts/select-file'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const router = useRouter(); + +const props = defineProps<{ + postId?: string; +}>(); + +let init = $ref(null); +let files = $ref([]); +let description = $ref(null); +let title = $ref(null); +let isSensitive = $ref(false); + +function selectFile(evt) { + selectFiles(evt.currentTarget ?? evt.target, null).then(selected => { + files = files.concat(selected); + }); +} + +function remove(file) { + files = files.filter(f => f.id !== file.id); +} + +async function save() { + if (props.postId) { + await os.apiWithDialog('gallery/posts/update', { + postId: props.postId, + title: title, + description: description, + fileIds: files.map(file => file.id), + isSensitive: isSensitive, + }); + router.push(`/gallery/${props.postId}`); + } else { + const created = await os.apiWithDialog('gallery/posts/create', { + title: title, + description: description, + fileIds: files.map(file => file.id), + isSensitive: isSensitive, + }); + router.push(`/gallery/${created.id}`); + } +} + +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }); + if (canceled) return; + await os.apiWithDialog('gallery/posts/delete', { + postId: props.postId, + }); + router.push('/gallery'); +} + +watch(() => props.postId, () => { + init = () => props.postId ? os.api('gallery/posts/show', { + postId: props.postId, + }).then(post => { + files = post.files; + title = post.title; + description = post.description; + isSensitive = post.isSensitive; + }) : Promise.resolve(null); +}, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => props.postId ? { + title: i18n.ts.edit, + icon: 'ti ti-pencil', +} : { + title: i18n.ts.postToGallery, + icon: 'ti ti-pencil', +})); +</script> + +<style lang="scss" scoped> +.wqugxsfx { + height: 200px; + background-size: contain; + background-position: center; + background-repeat: no-repeat; + position: relative; + + > .name { + position: absolute; + top: 8px; + left: 9px; + padding: 8px; + background: var(--panel); + } + + > .remove { + position: absolute; + top: 8px; + right: 9px; + padding: 8px; + background: var(--panel); + } +} +</style> diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue new file mode 100644 index 0000000000..24a634bab5 --- /dev/null +++ b/packages/frontend/src/pages/gallery/index.vue @@ -0,0 +1,139 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1400"> + <div class="_root"> + <div v-if="tab === 'explore'"> + <MkFolder class="_gap"> + <template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template> + <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="ti ti-comet"></i>{{ i18n.ts.popularPosts }}</template> + <MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> + </MkFolder> + </div> + <div v-else-if="tab === 'liked'"> + <MkPagination v-slot="{items}" :pagination="likedPostsPagination"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/> + </div> + </MkPagination> + </div> + <div v-else-if="tab === 'my'"> + <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="ti ti-plus"></i> {{ i18n.ts.postToGallery }}</MkA> + <MkPagination v-slot="{items}" :pagination="myPostsPagination"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, defineComponent, watch } from 'vue'; +import XUserList from '@/components/MkUserList.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkInput from '@/components/form/input.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { useRouter } from '@/router'; + +const router = useRouter(); + +const props = defineProps<{ + tag?: string; +}>(); + +let tab = $ref('explore'); +let tags = $ref([]); +let tagsRef = $ref(); + +const recentPostsPagination = { + endpoint: 'gallery/posts' as const, + limit: 6, +}; +const popularPostsPagination = { + endpoint: 'gallery/featured' as const, + limit: 5, +}; +const myPostsPagination = { + endpoint: 'i/gallery/posts' as const, + limit: 5, +}; +const likedPostsPagination = { + endpoint: 'i/gallery/likes' as const, + limit: 5, +}; + +const tagUsersPagination = $computed(() => ({ + endpoint: 'hashtags/users' as const, + limit: 30, + params: { + tag: this.tag, + origin: 'combined', + sort: '+follower', + }, +})); + +watch(() => props.tag, () => { + if (tagsRef) tagsRef.tags.toggleContent(props.tag == null); +}); + +const headerActions = $computed(() => [{ + icon: 'ti ti-plus', + text: i18n.ts.create, + handler: () => { + router.push('/gallery/new'); + }, +}]); + +const headerTabs = $computed(() => [{ + key: 'explore', + title: i18n.ts.gallery, + icon: 'ti ti-icons', +}, { + key: 'liked', + title: i18n.ts._gallery.liked, + icon: 'ti ti-heart', +}, { + key: 'my', + title: i18n.ts._gallery.my, + icon: 'ti ti-edit', +}]); + +definePageMetadata({ + title: i18n.ts.gallery, + icon: 'ti ti-icons', +}); +</script> + +<style lang="scss" scoped> +.vfpdbgtk { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: 12px; + margin: 0 var(--margin); + + > .post { + + } +} +</style> diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue new file mode 100644 index 0000000000..85ab1048be --- /dev/null +++ b/packages/frontend/src/pages/gallery/post.vue @@ -0,0 +1,265 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000" :margin-min="16" :margin-max="32"> + <div class="_root"> + <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <div v-if="post" class="rkxwuolj"> + <div class="files"> + <div v-for="file in post.files" :key="file.id" class="file"> + <img :src="file.url"/> + </div> + </div> + <div class="body _block"> + <div class="title">{{ post.title }}</div> + <div class="description"><Mfm :text="post.description"/></div> + <div class="info"> + <i class="ti ti-clock"></i> <MkTime :time="post.createdAt" mode="detail"/> + </div> + <div class="actions"> + <div class="like"> + <MkButton v-if="post.isLiked" v-tooltip="i18n.ts._gallery.unlike" class="button" primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts._gallery.like" class="button" @click="like()"><i class="ti ti-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton> + </div> + <div class="other"> + <button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="ti ti-pencil ti-fw"></i></button> + <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button> + <button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button> + </div> + </div> + <div class="user"> + <MkAvatar :user="post.user" class="avatar"/> + <div class="name"> + <MkUserName :user="post.user" style="display: block;"/> + <MkAcct :user="post.user"/> + </div> + <MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + </div> + </div> + <MkAd :prefer="['horizontal', 'horizontal-big']"/> + <MkContainer :max-height="300" :foldable="true" class="other"> + <template #header><i class="ti ti-clock"></i> {{ i18n.ts.recentPosts }}</template> + <MkPagination v-slot="{items}" :pagination="otherPostsPagination"> + <div class="sdrarzaf"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> + </MkContainer> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, defineComponent, inject, watch } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; +import MkFollowButton from '@/components/MkFollowButton.vue'; +import { url } from '@/config'; +import { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const router = useRouter(); + +const props = defineProps<{ + postId: string; +}>(); + +let post = $ref(null); +let error = $ref(null); +const otherPostsPagination = { + endpoint: 'users/gallery/posts' as const, + limit: 6, + params: computed(() => ({ + userId: post.user.id, + })), +}; + +function fetchPost() { + post = null; + os.api('gallery/posts/show', { + postId: props.postId, + }).then(_post => { + post = _post; + }).catch(_error => { + error = _error; + }); +} + +function share() { + navigator.share({ + title: post.title, + text: post.description, + url: `${url}/gallery/${post.id}`, + }); +} + +function shareWithNote() { + os.post({ + initialText: `${post.title} ${url}/gallery/${post.id}`, + }); +} + +function like() { + os.apiWithDialog('gallery/posts/like', { + postId: props.postId, + }).then(() => { + post.isLiked = true; + post.likedCount++; + }); +} + +async function unlike() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.unlikeConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('gallery/posts/unlike', { + postId: props.postId, + }).then(() => { + post.isLiked = false; + post.likedCount--; + }); +} + +function edit() { + router.push(`/gallery/${post.id}/edit`); +} + +watch(() => props.postId, fetchPost, { immediate: true }); + +const headerActions = $computed(() => [{ + icon: 'ti ti-pencil', + text: i18n.ts.edit, + handler: edit, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => post ? { + title: post.title, + avatar: post.user, +} : null)); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.rkxwuolj { + > .files { + > .file { + > img { + display: block; + max-width: 100%; + max-height: 500px; + margin: 0 auto; + } + + & + .file { + margin-top: 16px; + } + } + } + + > .body { + padding: 32px; + + > .title { + font-weight: bold; + font-size: 1.2em; + margin-bottom: 16px; + } + + > .info { + margin-top: 16px; + font-size: 90%; + opacity: 0.7; + } + + > .actions { + display: flex; + align-items: center; + margin-top: 16px; + padding: 16px 0 0 0; + border-top: solid 0.5px var(--divider); + + > .like { + > .button { + --accent: rgb(241 97 132); + --X8: rgb(241 92 128); + --buttonBg: rgb(216 71 106 / 5%); + --buttonHoverBg: rgb(216 71 106 / 10%); + color: #ff002f; + + ::v-deep(.count) { + margin-left: 0.5em; + } + } + } + + > .other { + margin-left: auto; + + > button { + padding: 8px; + margin: 0 8px; + + &:hover { + color: var(--fgHighlighted); + } + } + } + } + + > .user { + margin-top: 16px; + padding: 16px 0 0 0; + border-top: solid 0.5px var(--divider); + display: flex; + align-items: center; + + > .avatar { + width: 52px; + height: 52px; + } + + > .name { + margin: 0 0 0 12px; + font-size: 90%; + } + + > .koudoku { + margin-left: auto; + } + } + } +} + +.sdrarzaf { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: 12px; + margin: var(--margin); + + > .post { + + } +} +</style> diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue new file mode 100644 index 0000000000..a2a1254360 --- /dev/null +++ b/packages/frontend/src/pages/instance-info.vue @@ -0,0 +1,258 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="instance" :content-max="600" :margin-min="16" :margin-max="32"> + <div v-if="tab === 'overview'" class="_formRoot"> + <div class="fnfelxur"> + <img :src="faviconUrl" alt="" class="icon"/> + <span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span> + </div> + <MkKeyValue :copy="host" oneline style="margin: 1em 0;"> + <template #key>Host</template> + <template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.software }}</template> + <template #value><span class="_monospace">{{ instance.softwareName || `(${i18n.ts.unknown})` }} / {{ instance.softwareVersion || `(${i18n.ts.unknown})` }}</span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.administrator }}</template> + <template #value>{{ instance.maintainerName || `(${i18n.ts.unknown})` }} ({{ instance.maintainerEmail || `(${i18n.ts.unknown})` }})</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ instance.description }}</template> + </MkKeyValue> + + <FormSection v-if="iAmModerator"> + <template #label>Moderation</template> + <FormSwitch v-model="suspended" class="_formBlock" @update:model-value="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</FormSwitch> + <FormSwitch v-model="isBlocked" class="_formBlock" @update:model-value="toggleBlock">{{ i18n.ts.blockThisInstance }}</FormSwitch> + <MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton> + </FormSection> + + <FormSection> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.registeredAt }}</template> + <template #value><MkTime mode="detail" :time="instance.caughtAt"/></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.updatedAt }}</template> + <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.latestRequestSentAt }}</template> + <template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.latestStatus }}</template> + <template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.latestRequestReceivedAt }}</template> + <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template> + </MkKeyValue> + </FormSection> + + <FormSection> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>Following (Pub)</template> + <template #value>{{ number(instance.followingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>Followers (Sub)</template> + <template #value>{{ number(instance.followersCount) }}</template> + </MkKeyValue> + </FormSection> + + <FormSection> + <template #label>Well-known resources</template> + <FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink> + <FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink> + <FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink> + <FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink> + <FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink> + </FormSection> + </div> + <div v-else-if="tab === 'chart'" class="_formRoot"> + <div class="cmhjzshl"> + <div class="selects"> + <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> + <option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option> + <option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option> + <option value="instance-users-total">{{ i18n.ts._instanceCharts.usersTotal }}</option> + <option value="instance-notes">{{ i18n.ts._instanceCharts.notes }}</option> + <option value="instance-notes-total">{{ i18n.ts._instanceCharts.notesTotal }}</option> + <option value="instance-ff">{{ i18n.ts._instanceCharts.ff }}</option> + <option value="instance-ff-total">{{ i18n.ts._instanceCharts.ffTotal }}</option> + <option value="instance-drive-usage">{{ i18n.ts._instanceCharts.cacheSize }}</option> + <option value="instance-drive-usage-total">{{ i18n.ts._instanceCharts.cacheSizeTotal }}</option> + <option value="instance-drive-files">{{ i18n.ts._instanceCharts.files }}</option> + <option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option> + </MkSelect> + </div> + <div class="charts"> + <div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ host: host }" :detailed="true"></MkChart> + <div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ host: host }" :detailed="true"></MkChart> + </div> + </div> + </div> + <div v-else-if="tab === 'users'" class="_formRoot"> + <MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;"> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`"> + <MkUserCardMini :user="user"/> + </MkA> + </MkPagination> + </div> + <div v-else-if="tab === 'raw'" class="_formRoot"> + <MkObjectView tall :value="instance"> + </MkObjectView> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as misskey from 'misskey-js'; +import MkChart from '@/components/MkChart.vue'; +import MkObjectView from '@/components/MkObjectView.vue'; +import FormLink from '@/components/form/link.vue'; +import MkLink from '@/components/MkLink.vue'; +import MkButton from '@/components/MkButton.vue'; +import FormSection from '@/components/form/section.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkSelect from '@/components/form/select.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import { iAmModerator } from '@/account'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import { getProxiedImageUrlNullable } from '@/scripts/media-proxy'; + +const props = defineProps<{ + host: string; +}>(); + +let tab = $ref('overview'); +let chartSrc = $ref('instance-requests'); +let meta = $ref<misskey.entities.DetailedInstanceMetadata | null>(null); +let instance = $ref<misskey.entities.Instance | null>(null); +let suspended = $ref(false); +let isBlocked = $ref(false); +let faviconUrl = $ref(null); + +const usersPagination = { + endpoint: iAmModerator ? 'admin/show-users' : 'users' as const, + limit: 10, + params: { + sort: '+updatedAt', + state: 'all', + hostname: props.host, + }, + offsetMode: true, +}; + +async function fetch() { + instance = await os.api('federation/show-instance', { + host: props.host, + }); + suspended = instance.isSuspended; + isBlocked = instance.isBlocked; + faviconUrl = getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.iconUrl, 'preview'); +} + +async function toggleBlock(ev) { + if (meta == null) return; + await os.api('admin/update-meta', { + blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host), + }); +} + +async function toggleSuspend(v) { + await os.api('admin/federation/update-instance', { + host: instance.host, + isSuspended: suspended, + }); +} + +function refreshMetadata() { + os.api('admin/federation/refresh-remote-instance-metadata', { + host: instance.host, + }); + os.alert({ + text: 'Refresh requested', + }); +} + +fetch(); + +const headerActions = $computed(() => [{ + text: `https://${props.host}`, + icon: 'ti ti-external-link', + handler: () => { + window.open(`https://${props.host}`, '_blank'); + }, +}]); + +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, + icon: 'ti ti-info-circle', +}, { + key: 'chart', + title: i18n.ts.charts, + icon: 'ti ti-chart-line', +}, { + key: 'users', + title: i18n.ts.users, + icon: 'ti ti-users', +}, { + key: 'raw', + title: 'Raw', + icon: 'ti ti-code', +}]); + +definePageMetadata({ + title: props.host, + icon: 'ti ti-server', +}); +</script> + +<style lang="scss" scoped> +.fnfelxur { + display: flex; + align-items: center; + + > .icon { + display: block; + margin: 0 16px 0 0; + height: 64px; + border-radius: 8px; + } + + > .name { + word-break: break-all; + } +} + +.cmhjzshl { + > .selects { + display: flex; + margin: 0 0 16px 0; + } + + > .charts { + > .label { + margin-bottom: 12px; + font-weight: bold; + } + } +} +</style> diff --git a/packages/frontend/src/pages/messaging/index.vue b/packages/frontend/src/pages/messaging/index.vue new file mode 100644 index 0000000000..0d30998330 --- /dev/null +++ b/packages/frontend/src/pages/messaging/index.vue @@ -0,0 +1,327 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div v-size="{ max: [400] }" class="yweeujhr"> + <MkButton primary class="start" @click="start"><i class="ti ti-plus"></i> {{ $ts.startMessaging }}</MkButton> + + <div v-if="messages.length > 0" class="history"> + <MkA + v-for="(message, i) in messages" + :key="message.id" + v-anim="i" + class="message _block" + :class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }" + :to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" + :data-index="i" + > + <div> + <MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/> + <header v-if="message.groupId"> + <span class="name">{{ message.group.name }}</span> + <MkTime :time="message.createdAt" class="time"/> + </header> + <header v-else> + <span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span> + <span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span> + <MkTime :time="message.createdAt" class="time"/> + </header> + <div class="body"> + <p class="text"><span v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p> + </div> + </div> + </MkA> + </div> + <div v-if="!fetching && messages.length == 0" class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noHistory }}</div> + </div> + <MkLoading v-if="fetching"/> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, defineComponent, inject, markRaw, onMounted, onUnmounted } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import MkButton from '@/components/MkButton.vue'; +import { acct } from '@/filters/user'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; + +const router = useRouter(); + +let fetching = $ref(true); +let moreFetching = $ref(false); +let messages = $ref([]); +let connection = $ref(null); + +const getAcct = Acct.toString; + +function isMe(message) { + return message.userId === $i.id; +} + +function onMessage(message) { + if (message.recipientId) { + messages = messages.filter(m => !( + (m.recipientId === message.recipientId && m.userId === message.userId) || + (m.recipientId === message.userId && m.userId === message.recipientId))); + + messages.unshift(message); + } else if (message.groupId) { + messages = messages.filter(m => m.groupId !== message.groupId); + messages.unshift(message); + } +} + +function onRead(ids) { + for (const id of ids) { + const found = messages.find(m => m.id === id); + if (found) { + if (found.recipientId) { + found.isRead = true; + } else if (found.groupId) { + found.reads.push($i.id); + } + } + } +} + +function start(ev) { + os.popupMenu([{ + text: i18n.ts.messagingWithUser, + icon: 'ti ti-user', + action: () => { startUser(); }, + }, { + text: i18n.ts.messagingWithGroup, + icon: 'ti ti-users', + action: () => { startGroup(); }, + }], ev.currentTarget ?? ev.target); +} + +async function startUser() { + os.selectUser().then(user => { + router.push(`/my/messaging/${Acct.toString(user)}`); + }); +} + +async function startGroup() { + const groups1 = await os.api('users/groups/owned'); + const groups2 = await os.api('users/groups/joined'); + if (groups1.length === 0 && groups2.length === 0) { + os.alert({ + type: 'warning', + title: i18n.ts.youHaveNoGroups, + text: i18n.ts.joinOrCreateGroup, + }); + return; + } + const { canceled, result: group } = await os.select({ + title: i18n.ts.group, + items: groups1.concat(groups2).map(group => ({ + value: group, text: group.name, + })), + }); + if (canceled) return; + router.push(`/my/messaging/group/${group.id}`); +} + +onMounted(() => { + connection = markRaw(stream.useChannel('messagingIndex')); + + connection.on('message', onMessage); + connection.on('read', onRead); + + os.api('messaging/history', { group: false }).then(userMessages => { + os.api('messaging/history', { group: true }).then(groupMessages => { + const _messages = userMessages.concat(groupMessages); + _messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + messages = _messages; + fetching = false; + }); + }); +}); + +onUnmounted(() => { + if (connection) connection.dispose(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.messaging, + icon: 'ti ti-messages', +}); +</script> + +<style lang="scss" scoped> +.yweeujhr { + + > .start { + margin: 0 auto var(--margin) auto; + } + + > .history { + > .message { + display: block; + text-decoration: none; + margin-bottom: var(--margin); + + * { + pointer-events: none; + user-select: none; + } + + &:hover { + .avatar { + filter: saturate(200%); + } + } + + &:active { + } + + &.isRead, + &.isMe { + opacity: 0.8; + } + + &:not(.isMe):not(.isRead) { + > div { + background-image: url("/client-assets/unread.svg"); + background-repeat: no-repeat; + background-position: 0 center; + } + } + + &:after { + content: ""; + display: block; + clear: both; + } + + > div { + padding: 20px 30px; + + &:after { + content: ""; + display: block; + clear: both; + } + + > header { + display: flex; + align-items: center; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + + > .name { + margin: 0; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 1em; + font-weight: bold; + transition: all 0.1s ease; + } + + > .username { + margin: 0 8px; + } + + > .time { + margin: 0 0 0 auto; + } + } + + > .avatar { + float: left; + width: 54px; + height: 54px; + margin: 0 16px 0 0; + border-radius: 8px; + transition: all 0.1s ease; + } + + > .body { + + > .text { + display: block; + margin: 0 0 0 0; + padding: 0; + overflow: hidden; + overflow-wrap: break-word; + font-size: 1.1em; + color: var(--faceText); + + .me { + opacity: 0.7; + } + } + + > .image { + display: block; + max-width: 100%; + max-height: 512px; + } + } + } + } + } + + &.max-width_400px { + > .history { + > .message { + &:not(.isMe):not(.isRead) { + > div { + background-image: none; + border-left: solid 4px #3aa2dc; + } + } + + > div { + padding: 16px; + font-size: 0.9em; + + > .avatar { + margin: 0 12px 0 0; + } + } + } + } + } +} + +@container (max-width: 400px) { + .yweeujhr { + > .history { + > .message { + &:not(.isMe):not(.isRead) { + > div { + background-image: none; + border-left: solid 4px #3aa2dc; + } + } + + > div { + padding: 16px; + font-size: 0.9em; + + > .avatar { + margin: 0 12px 0 0; + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/messaging/messaging-room.form.vue b/packages/frontend/src/pages/messaging/messaging-room.form.vue new file mode 100644 index 0000000000..84572815c0 --- /dev/null +++ b/packages/frontend/src/pages/messaging/messaging-room.form.vue @@ -0,0 +1,364 @@ +<template> +<div + class="pemppnzi _block" + @dragover.stop="onDragover" + @drop.stop="onDrop" +> + <textarea + ref="textEl" + v-model="text" + :placeholder="i18n.ts.inputMessageHere" + @keydown="onKeydown" + @compositionupdate="onCompositionUpdate" + @paste="onPaste" + ></textarea> + <footer> + <div v-if="file" class="file" @click="file = null">{{ file.name }}</div> + <div class="buttons"> + <button class="_button" @click="chooseFile"><i class="ti ti-photo-plus"></i></button> + <button class="_button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> + <button class="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send"> + <template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template> + </button> + </div> + </footer> + <input ref="fileEl" type="file" @change="onChangeFile"/> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import autosize from 'autosize'; +//import insertTextAtCursor from 'insert-text-at-cursor'; +import { throttle } from 'throttle-debounce'; +import { formatTimeString } from '@/scripts/format-time-string'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +//import { Autocomplete } from '@/scripts/autocomplete'; +import { uploadFile } from '@/scripts/upload'; + +const props = defineProps<{ + user?: Misskey.entities.UserDetailed | null; + group?: Misskey.entities.UserGroup | null; +}>(); + +let textEl = $ref<HTMLTextAreaElement>(); +let fileEl = $ref<HTMLInputElement>(); + +let text = $ref<string>(''); +let file = $ref<Misskey.entities.DriveFile | null>(null); +let sending = $ref(false); +const typing = throttle(3000, () => { + stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id }); +}); + +let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id); +let canSend = $computed(() => (text != null && text !== '') || file != null); + +watch([$$(text), $$(file)], saveDraft); + +async function onPaste(ev: ClipboardEvent) { + if (!ev.clipboardData) return; + + const clipboardData = ev.clipboardData; + const items = clipboardData.items; + + if (items.length === 1) { + if (items[0].kind === 'file') { + const pastedFile = items[0].getAsFile(); + if (!pastedFile) return; + const lio = pastedFile.name.lastIndexOf('.'); + const ext = lio >= 0 ? pastedFile.name.slice(lio) : ''; + const formatted = formatTimeString(new Date(pastedFile.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, '1') + ext; + if (formatted) upload(pastedFile, formatted); + } + } else { + if (items[0].kind === 'file') { + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + } + } +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + ev.preventDefault(); + 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 onDrop(ev: DragEvent): void { + if (!ev.dataTransfer) return; + + // ファイルだったら + if (ev.dataTransfer.files.length === 1) { + ev.preventDefault(); + upload(ev.dataTransfer.files[0]); + return; + } else if (ev.dataTransfer.files.length > 1) { + ev.preventDefault(); + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + return; + } + + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + file = JSON.parse(driveFile); + ev.preventDefault(); + } + //#endregion +} + +function onKeydown(ev: KeyboardEvent) { + typing(); + if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey) && canSend) { + send(); + } +} + +function onCompositionUpdate() { + typing(); +} + +function chooseFile(ev: MouseEvent) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => { + file = selectedFile; + }); +} + +function onChangeFile() { + if (fileEl.files![0]) upload(fileEl.files[0]); +} + +function upload(fileToUpload: File, name?: string) { + uploadFile(fileToUpload, defaultStore.state.uploadFolder, name).then(res => { + file = res; + }); +} + +function send() { + sending = true; + os.api('messaging/messages/create', { + userId: props.user ? props.user.id : undefined, + groupId: props.group ? props.group.id : undefined, + text: text ? text : undefined, + fileId: file ? file.id : undefined, + }).then(message => { + clear(); + }).catch(err => { + console.error(err); + }).then(() => { + sending = false; + }); +} + +function clear() { + text = ''; + file = null; + deleteDraft(); +} + +function saveDraft() { + const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + drafts[draftKey] = { + updatedAt: new Date(), + // eslint-disable-next-line id-denylist + data: { + text: text, + file: file, + }, + }; + + localStorage.setItem('message_drafts', JSON.stringify(drafts)); +} + +function deleteDraft() { + const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + delete drafts[draftKey]; + + localStorage.setItem('message_drafts', JSON.stringify(drafts)); +} + +async function insertEmoji(ev: MouseEvent) { + os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textEl); +} + +onMounted(() => { + autosize(textEl); + + // TODO: detach when unmount + // TODO + //new Autocomplete(textEl, this, { model: 'text' }); + + // 書きかけの投稿を復元 + const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[draftKey]; + if (draft) { + text = draft.data.text; + file = draft.data.file; + } +}); + +defineExpose({ + file, + upload, +}); +</script> + +<style lang="scss" scoped> +.pemppnzi { + position: relative; + + > textarea { + cursor: auto; + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 80px; + margin: 0; + padding: 16px 16px 0 16px; + resize: none; + font-size: 1em; + font-family: inherit; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + background: transparent; + box-sizing: border-box; + color: var(--fg); + } + + footer { + position: sticky; + bottom: 0; + background: var(--panel); + + > .file { + padding: 8px; + color: var(--fg); + background: transparent; + cursor: pointer; + } + } + + .files { + display: block; + margin: 0; + padding: 0 8px; + list-style: none; + + &:after { + content: ''; + display: block; + clear: both; + } + + > li { + display: block; + float: left; + margin: 4px; + padding: 0; + width: 64px; + height: 64px; + background-color: #eee; + background-repeat: no-repeat; + background-position: center center; + background-size: cover; + cursor: move; + + &:hover { + > .remove { + display: block; + } + } + + > .remove { + display: none; + position: absolute; + right: -6px; + top: -6px; + margin: 0; + padding: 0; + background: transparent; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + cursor: pointer; + } + } + } + + .buttons { + display: flex; + + ._button { + margin: 0; + padding: 16px; + font-size: 1em; + font-weight: normal; + text-decoration: none; + transition: color 0.1s ease; + + &:hover { + color: var(--accent); + } + + &:active { + color: var(--accentDarken); + transition: color 0s ease; + } + } + + > .send { + margin-left: auto; + color: var(--accent); + + &:hover { + color: var(--accentLighten); + } + + &:active { + color: var(--accentDarken); + transition: color 0s ease; + } + } + } + + input[type=file] { + display: none; + } +} +</style> diff --git a/packages/frontend/src/pages/messaging/messaging-room.message.vue b/packages/frontend/src/pages/messaging/messaging-room.message.vue new file mode 100644 index 0000000000..dbf0e37b73 --- /dev/null +++ b/packages/frontend/src/pages/messaging/messaging-room.message.vue @@ -0,0 +1,367 @@ +<template> +<div v-size="{ max: [400, 500] }" class="thvuemwp" :class="{ isMe }"> + <MkAvatar class="avatar" :user="message.user" :show-indicator="true"/> + <div class="content"> + <div class="balloon" :class="{ noText: message.text == null }"> + <button v-if="isMe" class="delete-button" :title="$ts.delete" @click="del"> + <img src="/client-assets/remove.png" alt="Delete"/> + </button> + <div v-if="!message.isDeleted" class="content"> + <Mfm v-if="message.text" ref="text" class="text" :text="message.text" :i="$i"/> + <div v-if="message.file" class="file"> + <a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name"> + <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/> + <p v-else>{{ message.file.name }}</p> + </a> + </div> + </div> + <div v-else class="content"> + <p class="is-deleted">{{ $ts.deleted }}</p> + </div> + </div> + <div></div> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/> + <footer> + <template v-if="isGroup"> + <span v-if="message.reads.length > 0" class="read">{{ $ts.messageRead }} {{ message.reads.length }}</span> + </template> + <template v-else> + <span v-if="isMe && message.isRead" class="read">{{ $ts.messageRead }}</span> + </template> + <MkTime :time="message.createdAt"/> + <template v-if="message.is_edited"><i class="ti ti-pencil"></i></template> + </footer> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; + +const props = defineProps<{ + message: Misskey.entities.MessagingMessage; + isGroup?: boolean; +}>(); + +const isMe = $computed(() => props.message.userId === $i?.id); +const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []); + +function del(): void { + os.api('messaging/messages/delete', { + messageId: props.message.id, + }); +} +</script> + +<style lang="scss" scoped> +.thvuemwp { + $me-balloon-color: var(--accent); + + position: relative; + background-color: transparent; + display: flex; + + > .avatar { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + display: block; + width: 54px; + height: 54px; + transition: all 0.1s ease; + } + + > .content { + min-width: 0; + + > .balloon { + position: relative; + display: inline-flex; + align-items: center; + padding: 0; + min-height: 38px; + border-radius: 16px; + max-width: 100%; + + &:before { + content: ""; + pointer-events: none; + display: block; + position: absolute; + top: 12px; + } + + & + * { + clear: both; + } + + &:hover { + > .delete-button { + display: block; + } + } + + > .delete-button { + display: none; + position: absolute; + z-index: 1; + top: -4px; + right: -4px; + margin: 0; + padding: 0; + cursor: pointer; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + background: transparent; + + > img { + vertical-align: bottom; + width: 16px; + height: 16px; + cursor: pointer; + } + } + + > .content { + max-width: 100%; + + > .is-deleted { + display: block; + margin: 0; + padding: 0; + overflow: hidden; + overflow-wrap: break-word; + font-size: 1em; + color: rgba(#000, 0.5); + } + + > .text { + display: block; + margin: 0; + padding: 12px 18px; + overflow: hidden; + overflow-wrap: break-word; + word-break: break-word; + font-size: 1em; + color: rgba(#000, 0.8); + + & + .file { + > a { + border-radius: 0 0 16px 16px; + } + } + } + + > .file { + > a { + display: block; + max-width: 100%; + border-radius: 16px; + overflow: hidden; + text-decoration: none; + + &:hover { + text-decoration: none; + + > p { + background: #ccc; + } + } + + > * { + display: block; + margin: 0; + width: 100%; + max-height: 512px; + object-fit: contain; + box-sizing: border-box; + } + + > p { + padding: 30px; + text-align: center; + color: #555; + background: #ddd; + } + } + } + } + } + + > footer { + display: block; + margin: 2px 0 0 0; + font-size: 0.65em; + + > .read { + margin: 0 8px; + } + + > i { + margin-left: 4px; + } + } + } + + &:not(.isMe) { + padding-left: var(--margin); + + > .content { + padding-left: 16px; + padding-right: 32px; + + > .balloon { + $color: var(--messageBg); + background: $color; + + &.noText { + background: transparent; + } + + &:not(.noText):before { + left: -14px; + border-top: solid 8px transparent; + border-right: solid 8px $color; + border-bottom: solid 8px transparent; + border-left: solid 8px transparent; + } + + > .content { + > .text { + color: var(--fg); + } + } + } + + > footer { + text-align: left; + } + } + } + + &.isMe { + flex-direction: row-reverse; + padding-right: var(--margin); + right: var(--margin); // 削除時にposition: absoluteになったときに使う + + > .content { + padding-right: 16px; + padding-left: 32px; + text-align: right; + + > .balloon { + background: $me-balloon-color; + text-align: left; + + ::selection { + color: var(--accent); + background-color: #fff; + } + + &.noText { + background: transparent; + } + + &:not(.noText):before { + right: -14px; + left: auto; + border-top: solid 8px transparent; + border-right: solid 8px transparent; + border-bottom: solid 8px transparent; + border-left: solid 8px $me-balloon-color; + } + + > .content { + + > p.is-deleted { + color: rgba(#fff, 0.5); + } + + > .text { + &, ::v-deep(*) { + color: var(--fgOnAccent) !important; + } + } + } + } + + > footer { + text-align: right; + + > .read { + user-select: none; + } + } + } + } + + &.max-width_400px { + > .avatar { + width: 48px; + height: 48px; + } + + > .content { + > .balloon { + > .content { + > .text { + font-size: 0.9em; + } + } + } + } + } + + &.max-width_500px { + > .content { + > .balloon { + > .content { + > .text { + padding: 8px 16px; + } + } + } + } + } +} + +@container (max-width: 400px) { + .thvuemwp { + > .avatar { + width: 48px; + height: 48px; + } + + > .content { + > .balloon { + > .content { + > .text { + font-size: 0.9em; + } + } + } + } + } +} + +@container (max-width: 500px) { + .thvuemwp { + > .content { + > .balloon { + > .content { + > .text { + padding: 8px 16px; + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/messaging/messaging-room.vue b/packages/frontend/src/pages/messaging/messaging-room.vue new file mode 100644 index 0000000000..b6eeb9260e --- /dev/null +++ b/packages/frontend/src/pages/messaging/messaging-room.vue @@ -0,0 +1,411 @@ +<template> +<div + ref="rootEl" + class="_section" + @dragover.prevent.stop="onDragover" + @drop.prevent.stop="onDrop" +> + <div class="_content mk-messaging-room"> + <div class="body"> + <MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noMessagesYet }}</div> + </div> + </template> + + <template #default="{ items: messages, fetching: pFetching }"> + <XList + v-if="messages.length > 0" + v-slot="{ item: message }" + :class="{ messages: true, 'deny-move-transition': pFetching }" + :items="messages" + direction="up" + reversed + > + <XMessage :key="message.id" :message="message" :is-group="group != null"/> + </XList> + </template> + </MkPagination> + </div> + <footer> + <div v-if="typers.length > 0" class="typers"> + <I18n :src="i18n.ts.typingUsers" text-tag="span" class="users"> + <template #users> + <b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b> + </template> + </I18n> + <MkEllipsis/> + </div> + <transition :name="animation ? 'fade' : ''"> + <div v-show="showIndicator" class="new-message"> + <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas ti-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button> + </div> + </transition> + <XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/> + </footer> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'; +import * as Misskey from 'misskey-js'; +import * as Acct from 'misskey-js/built/acct'; +import XMessage from './messaging-room.message.vue'; +import XForm from './messaging-room.form.vue'; +import XList from '@/components/MkDateSeparatedList.vue'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import * as sound from '@/scripts/sound'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { defaultStore } from '@/store'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + userAcct?: string; + groupId?: string; +}>(); + +let rootEl = $ref<HTMLDivElement>(); +let formEl = $ref<InstanceType<typeof XForm>>(); +let pagingComponent = $ref<InstanceType<typeof MkPagination>>(); + +let fetching = $ref(true); +let user: Misskey.entities.UserDetailed | null = $ref(null); +let group: Misskey.entities.UserGroup | null = $ref(null); +let typers: Misskey.entities.User[] = $ref([]); +let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null); +let showIndicator = $ref(false); +const { + animation, +} = defaultStore.reactiveState; + +let pagination: Paging | null = $ref(null); + +watch([() => props.userAcct, () => props.groupId], () => { + if (connection) connection.dispose(); + fetch(); +}); + +async function fetch() { + fetching = true; + + if (props.userAcct) { + const acct = Acct.parse(props.userAcct); + user = await os.api('users/show', { username: acct.username, host: acct.host || undefined }); + group = null; + + pagination = { + endpoint: 'messaging/messages', + limit: 20, + params: { + userId: user.id, + }, + reversed: true, + pageEl: $$(rootEl).value, + }; + connection = stream.useChannel('messaging', { + otherparty: user.id, + }); + } else { + user = null; + group = await os.api('users/groups/show', { groupId: props.groupId }); + + pagination = { + endpoint: 'messaging/messages', + limit: 20, + params: { + groupId: group?.id, + }, + reversed: true, + pageEl: $$(rootEl).value, + }; + connection = stream.useChannel('messaging', { + group: group?.id, + }); + } + + connection.on('message', onMessage); + connection.on('read', onRead); + connection.on('deleted', onDeleted); + connection.on('typers', _typers => { + typers = _typers.filter(u => u.id !== $i?.id); + }); + + document.addEventListener('visibilitychange', onVisibilitychange); + + nextTick(() => { + thisScrollToBottom(); + window.setTimeout(() => { + fetching = false; + }, 300); + }); +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + + if (isFile || isDriveFile) { + 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 onDrop(ev: DragEvent): void { + if (!ev.dataTransfer) return; + + // ファイルだったら + if (ev.dataTransfer.files.length === 1) { + formEl.upload(ev.dataTransfer.files[0]); + return; + } else if (ev.dataTransfer.files.length > 1) { + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + return; + } + + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + const file = JSON.parse(driveFile); + formEl.file = file; + } + //#endregion +} + +function onMessage(message) { + sound.play('chat'); + + const _isBottom = isBottomVisible(rootEl, 64); + + pagingComponent.prepend(message); + if (message.userId !== $i?.id && !document.hidden) { + connection?.send('read', { + id: message.id, + }); + } + + if (_isBottom) { + // Scroll to bottom + nextTick(() => { + thisScrollToBottom(); + }); + } else if (message.userId !== $i?.id) { + // Notify + notifyNewMessage(); + } +} + +function onRead(x) { + if (user) { + if (!Array.isArray(x)) x = [x]; + for (const id of x) { + if (pagingComponent.items.some(y => y.id === id)) { + const exist = pagingComponent.items.map(y => y.id).indexOf(id); + pagingComponent.items[exist] = { + ...pagingComponent.items[exist], + isRead: true, + }; + } + } + } else if (group) { + for (const id of x.ids) { + if (pagingComponent.items.some(y => y.id === id)) { + const exist = pagingComponent.items.map(y => y.id).indexOf(id); + pagingComponent.items[exist] = { + ...pagingComponent.items[exist], + reads: [...pagingComponent.items[exist].reads, x.userId], + }; + } + } + } +} + +function onDeleted(id) { + const msg = pagingComponent.items.find(m => m.id === id); + if (msg) { + pagingComponent.items = pagingComponent.items.filter(m => m.id !== msg.id); + } +} + +function thisScrollToBottom() { + scrollToBottom($$(rootEl).value, { behavior: 'smooth' }); +} + +function onIndicatorClick() { + showIndicator = false; + thisScrollToBottom(); +} + +let scrollRemove: (() => void) | null = $ref(null); + +function notifyNewMessage() { + showIndicator = true; + + scrollRemove = onScrollBottom(rootEl, () => { + showIndicator = false; + scrollRemove = null; + }); +} + +function onVisibilitychange() { + if (document.hidden) return; + for (const message of pagingComponent.items) { + if (message.userId !== $i?.id && !message.isRead) { + connection?.send('read', { + id: message.id, + }); + } + } +} + +onMounted(() => { + fetch(); +}); + +onBeforeUnmount(() => { + connection?.dispose(); + document.removeEventListener('visibilitychange', onVisibilitychange); + if (scrollRemove) scrollRemove(); +}); + +definePageMetadata(computed(() => !fetching ? user ? { + userName: user, + avatar: user, +} : { + title: group?.name, + icon: 'ti ti-users', +} : null)); +</script> + +<style lang="scss" scoped> +.mk-messaging-room { + position: relative; + overflow: auto; + + > .body { + .more { + display: block; + margin: 16px auto; + padding: 0 12px; + line-height: 24px; + color: #fff; + background: rgba(#000, 0.3); + border-radius: 12px; + + &:hover { + background: rgba(#000, 0.4); + } + + &:active { + background: rgba(#000, 0.5); + } + + &.fetching { + cursor: wait; + } + + > i { + margin-right: 4px; + } + } + + .messages { + padding: 8px 0; + + > ::v-deep(*) { + margin-bottom: 16px; + } + } + } + + > footer { + width: 100%; + position: sticky; + z-index: 2; + bottom: 0; + padding-top: 8px; + bottom: calc(env(safe-area-inset-bottom, 0px) + 8px); + + > .new-message { + width: 100%; + padding-bottom: 8px; + text-align: center; + + > button { + display: inline-block; + margin: 0; + padding: 0 12px; + line-height: 32px; + font-size: 12px; + border-radius: 16px; + + > i { + display: inline-block; + margin-right: 8px; + } + } + } + + > .typers { + position: absolute; + bottom: 100%; + padding: 0 8px 0 8px; + font-size: 0.9em; + color: var(--fgTransparentWeak); + + > .users { + > .user + .user:before { + content: ", "; + font-weight: normal; + } + + > .user:last-of-type:after { + content: " "; + } + } + } + + > .form { + max-height: 12em; + overflow-y: scroll; + border-top: solid 0.5px var(--divider); + } + } +} + +.fade-enter-active, .fade-leave-active { + transition: opacity 0.1s; +} + +.fade-enter-from, .fade-leave-to { + transition: opacity 0.5s; + opacity: 0; +} +</style> diff --git a/packages/frontend/src/pages/mfm-cheat-sheet.vue b/packages/frontend/src/pages/mfm-cheat-sheet.vue new file mode 100644 index 0000000000..7c85dfb7ad --- /dev/null +++ b/packages/frontend/src/pages/mfm-cheat-sheet.vue @@ -0,0 +1,387 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :content-max="800"> + <div class="mwysmxbg"> + <div>{{ i18n.ts._mfm.intro }}</div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.mention }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.mentionDescription }}</p> + <div class="preview"> + <Mfm :text="preview_mention"/> + <MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.hashtag }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.hashtagDescription }}</p> + <div class="preview"> + <Mfm :text="preview_hashtag"/> + <MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.url }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.urlDescription }}</p> + <div class="preview"> + <Mfm :text="preview_url"/> + <MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.link }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.linkDescription }}</p> + <div class="preview"> + <Mfm :text="preview_link"/> + <MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.emoji }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.emojiDescription }}</p> + <div class="preview"> + <Mfm :text="preview_emoji"/> + <MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.bold }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.boldDescription }}</p> + <div class="preview"> + <Mfm :text="preview_bold"/> + <MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.small }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.smallDescription }}</p> + <div class="preview"> + <Mfm :text="preview_small"/> + <MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.quote }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.quoteDescription }}</p> + <div class="preview"> + <Mfm :text="preview_quote"/> + <MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.center }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.centerDescription }}</p> + <div class="preview"> + <Mfm :text="preview_center"/> + <MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.inlineCode }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.inlineCodeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_inlineCode"/> + <MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.blockCode }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.blockCodeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_blockCode"/> + <MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.inlineMath }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.inlineMathDescription }}</p> + <div class="preview"> + <Mfm :text="preview_inlineMath"/> + <MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <!-- deprecated + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.search }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.searchDescription }}</p> + <div class="preview"> + <Mfm :text="preview_search"/> + <MkTextarea v-model="preview_search"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + --> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.flip }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.flipDescription }}</p> + <div class="preview"> + <Mfm :text="preview_flip"/> + <MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.font }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.fontDescription }}</p> + <div class="preview"> + <Mfm :text="preview_font"/> + <MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.x2 }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.x2Description }}</p> + <div class="preview"> + <Mfm :text="preview_x2"/> + <MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.x3 }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.x3Description }}</p> + <div class="preview"> + <Mfm :text="preview_x3"/> + <MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.x4 }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.x4Description }}</p> + <div class="preview"> + <Mfm :text="preview_x4"/> + <MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.blur }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.blurDescription }}</p> + <div class="preview"> + <Mfm :text="preview_blur"/> + <MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.jelly }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.jellyDescription }}</p> + <div class="preview"> + <Mfm :text="preview_jelly"/> + <MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.tada }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.tadaDescription }}</p> + <div class="preview"> + <Mfm :text="preview_tada"/> + <MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.jump }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.jumpDescription }}</p> + <div class="preview"> + <Mfm :text="preview_jump"/> + <MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.bounce }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.bounceDescription }}</p> + <div class="preview"> + <Mfm :text="preview_bounce"/> + <MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.spin }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.spinDescription }}</p> + <div class="preview"> + <Mfm :text="preview_spin"/> + <MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.shake }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.shakeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_shake"/> + <MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.twitch }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.twitchDescription }}</p> + <div class="preview"> + <Mfm :text="preview_twitch"/> + <MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.rainbow }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.rainbowDescription }}</p> + <div class="preview"> + <Mfm :text="preview_rainbow"/> + <MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.sparkle }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.sparkleDescription }}</p> + <div class="preview"> + <Mfm :text="preview_sparkle"/> + <MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.rotate }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.rotateDescription }}</p> + <div class="preview"> + <Mfm :text="preview_rotate"/> + <MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.plain }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.plainDescription }}</p> + <div class="preview"> + <Mfm :text="preview_plain"/> + <MkTextarea v-model="preview_plain"><span>MFM</span></MkTextarea> + </div> + </div> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineComponent } from 'vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; + +let preview_mention = $ref('@example'); +let preview_hashtag = $ref('#test'); +let preview_url = $ref('https://example.com'); +let preview_link = $ref(`[${i18n.ts._mfm.dummy}](https://example.com)`); +let preview_emoji = $ref(instance.emojis.length ? `:${instance.emojis[0].name}:` : ':emojiname:'); +let preview_bold = $ref(`**${i18n.ts._mfm.dummy}**`); +let preview_small = $ref(`<small>${i18n.ts._mfm.dummy}</small>`); +let preview_center = $ref(`<center>${i18n.ts._mfm.dummy}</center>`); +let preview_inlineCode = $ref('`<: "Hello, world!"`'); +let preview_blockCode = $ref('```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```'); +let preview_inlineMath = $ref('\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)'); +let preview_quote = $ref(`> ${i18n.ts._mfm.dummy}`); +let preview_search = $ref(`${i18n.ts._mfm.dummy} 検索`); +let preview_jelly = $ref('$[jelly 🍮] $[jelly.speed=5s 🍮]'); +let preview_tada = $ref('$[tada 🍮] $[tada.speed=5s 🍮]'); +let preview_jump = $ref('$[jump 🍮] $[jump.speed=5s 🍮]'); +let preview_bounce = $ref('$[bounce 🍮] $[bounce.speed=5s 🍮]'); +let preview_shake = $ref('$[shake 🍮] $[shake.speed=5s 🍮]'); +let preview_twitch = $ref('$[twitch 🍮] $[twitch.speed=5s 🍮]'); +let preview_spin = $ref('$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]'); +let preview_flip = $ref(`$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`); +let preview_font = $ref(`$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]\n$[font.cursive ${i18n.ts._mfm.dummy}]\n$[font.fantasy ${i18n.ts._mfm.dummy}]`); +let preview_x2 = $ref('$[x2 🍮]'); +let preview_x3 = $ref('$[x3 🍮]'); +let preview_x4 = $ref('$[x4 🍮]'); +let preview_blur = $ref(`$[blur ${i18n.ts._mfm.dummy}]`); +let preview_rainbow = $ref('$[rainbow 🍮] $[rainbow.speed=5s 🍮]'); +let preview_sparkle = $ref('$[sparkle 🍮]'); +let preview_rotate = $ref('$[rotate 🍮]'); +let preview_plain = $ref('<plain>**bold** @mention #hashtag `code` $[x2 🍮]</plain>'); + +definePageMetadata({ + title: i18n.ts._mfm.cheatSheet, + icon: 'ti ti-question-circle', +}); +</script> + +<style lang="scss" scoped> +.mwysmxbg { + background: var(--bg); + + > .section { + > .title { + position: sticky; + z-index: 1; + top: var(--stickyTop, 0px); + padding: 16px; + font-weight: bold; + -webkit-backdrop-filter: var(--blur, blur(10px)); + backdrop-filter: var(--blur, blur(10px)); + background-color: var(--X16); + } + + > .content { + > p { + margin: 0; + padding: 16px; + } + + > .preview { + border-top: solid 0.5px var(--divider); + padding: 16px; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue new file mode 100644 index 0000000000..5de072cbfa --- /dev/null +++ b/packages/frontend/src/pages/miauth.vue @@ -0,0 +1,90 @@ +<template> +<MkSpacer :content-max="800"> + <div v-if="$i"> + <div v-if="state == 'waiting'" class="waiting _section"> + <div class="_content"> + <MkLoading/> + </div> + </div> + <div v-if="state == 'denied'" class="denied _section"> + <div class="_content"> + <p>{{ i18n.ts._auth.denied }}</p> + </div> + </div> + <div v-else-if="state == 'accepted'" class="accepted _section"> + <div class="_content"> + <p v-if="callback">{{ i18n.ts._auth.callback }}<MkEllipsis/></p> + <p v-else>{{ i18n.ts._auth.pleaseGoBack }}</p> + </div> + </div> + <div v-else class="_section"> + <div v-if="name" class="_title">{{ $t('_auth.shareAccess', { name: name }) }}</div> + <div v-else class="_title">{{ i18n.ts._auth.shareAccessAsk }}</div> + <div class="_content"> + <p>{{ i18n.ts._auth.permissionAsk }}</p> + <ul> + <li v-for="p in _permissions" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </div> + <div class="_footer"> + <MkButton inline @click="deny">{{ i18n.ts.cancel }}</MkButton> + <MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton> + </div> + </div> + </div> + <div v-else class="signin"> + <MkSignin @login="onLogin"/> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkSignin from '@/components/MkSignin.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { $i, login } from '@/account'; +import { appendQuery, query } from '@/scripts/url'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + session: string; + callback?: string; + name: string; + icon: string; + permission: string; // コンマ区切り +}>(); + +const _permissions = props.permission.split(','); + +let state = $ref<string | null>(null); + +async function accept(): Promise<void> { + state = 'waiting'; + await os.api('miauth/gen-token', { + session: props.session, + name: props.name, + iconUrl: props.icon, + permission: _permissions, + }); + + state = 'accepted'; + if (props.callback) { + location.href = appendQuery(props.callback, query({ + session: props.session, + })); + } +} + +function deny(): void { + state = 'denied'; +} + +function onLogin(res): void { + login(res.i); +} +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue new file mode 100644 index 0000000000..005b036696 --- /dev/null +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -0,0 +1,46 @@ +<template> +<div class="geegznzt"> + <XAntenna :antenna="draft" @created="onAntennaCreated"/> +</div> +</template> + +<script lang="ts" setup> +import { inject } from 'vue'; +import XAntenna from './editor.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { useRouter } from '@/router'; + +const router = useRouter(); + +let draft = $ref({ + name: '', + src: 'all', + userListId: null, + userGroupId: null, + users: [], + keywords: [], + excludeKeywords: [], + withReplies: false, + caseSensitive: false, + withFile: false, + notify: false, +}); + +function onAntennaCreated() { + router.push('/my/antennas'); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.manageAntennas, + icon: 'ti ti-antenna', +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue new file mode 100644 index 0000000000..cb583faaeb --- /dev/null +++ b/packages/frontend/src/pages/my-antennas/edit.vue @@ -0,0 +1,43 @@ +<template> +<div class=""> + <XAntenna v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/> +</div> +</template> + +<script lang="ts" setup> +import { inject, watch } from 'vue'; +import XAntenna from './editor.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const router = useRouter(); + +let antenna: any = $ref(null); + +const props = defineProps<{ + antennaId: string +}>(); + +function onAntennaUpdated() { + router.push('/my/antennas'); +} + +os.api('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) => { + antenna = antennaResponse; +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.manageAntennas, + icon: 'ti ti-antenna', +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue new file mode 100644 index 0000000000..a409a734b5 --- /dev/null +++ b/packages/frontend/src/pages/my-antennas/editor.vue @@ -0,0 +1,155 @@ +<template> +<div class="shaynizk"> + <div class="form"> + <MkInput v-model="name" class="_formBlock"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + <MkSelect v-model="src" class="_formBlock"> + <template #label>{{ i18n.ts.antennaSource }}</template> + <option value="all">{{ i18n.ts._antennaSources.all }}</option> + <!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>--> + <option value="users">{{ i18n.ts._antennaSources.users }}</option> + <!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>--> + <!--<option value="group">{{ i18n.ts._antennaSources.userGroup }}</option>--> + </MkSelect> + <MkSelect v-if="src === 'list'" v-model="userListId" class="_formBlock"> + <template #label>{{ i18n.ts.userList }}</template> + <option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option> + </MkSelect> + <MkSelect v-else-if="src === 'group'" v-model="userGroupId" class="_formBlock"> + <template #label>{{ i18n.ts.userGroup }}</template> + <option v-for="group in userGroups" :key="group.id" :value="group.id">{{ group.name }}</option> + </MkSelect> + <MkTextarea v-else-if="src === 'users'" v-model="users" class="_formBlock"> + <template #label>{{ i18n.ts.users }}</template> + <template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template> + </MkTextarea> + <MkSwitch v-model="withReplies" class="_formBlock">{{ i18n.ts.withReplies }}</MkSwitch> + <MkTextarea v-model="keywords" class="_formBlock"> + <template #label>{{ i18n.ts.antennaKeywords }}</template> + <template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template> + </MkTextarea> + <MkTextarea v-model="excludeKeywords" class="_formBlock"> + <template #label>{{ i18n.ts.antennaExcludeKeywords }}</template> + <template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template> + </MkTextarea> + <MkSwitch v-model="caseSensitive" class="_formBlock">{{ i18n.ts.caseSensitive }}</MkSwitch> + <MkSwitch v-model="withFile" class="_formBlock">{{ i18n.ts.withFileAntenna }}</MkSwitch> + <MkSwitch v-model="notify" class="_formBlock">{{ i18n.ts.notifyAntenna }}</MkSwitch> + </div> + <div class="actions"> + <MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton v-if="antenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { watch } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + antenna: any +}>(); + +const emit = defineEmits<{ + (ev: 'created'): void, + (ev: 'updated'): void, + (ev: 'deleted'): void, +}>(); + +let name: string = $ref(props.antenna.name); +let src: string = $ref(props.antenna.src); +let userListId: any = $ref(props.antenna.userListId); +let userGroupId: any = $ref(props.antenna.userGroupId); +let users: string = $ref(props.antenna.users.join('\n')); +let keywords: string = $ref(props.antenna.keywords.map(x => x.join(' ')).join('\n')); +let excludeKeywords: string = $ref(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n')); +let caseSensitive: boolean = $ref(props.antenna.caseSensitive); +let withReplies: boolean = $ref(props.antenna.withReplies); +let withFile: boolean = $ref(props.antenna.withFile); +let notify: boolean = $ref(props.antenna.notify); +let userLists: any = $ref(null); +let userGroups: any = $ref(null); + +watch(() => src, async () => { + if (src === 'list' && userLists === null) { + userLists = await os.api('users/lists/list'); + } + + if (src === 'group' && userGroups === null) { + const groups1 = await os.api('users/groups/owned'); + const groups2 = await os.api('users/groups/joined'); + + userGroups = [...groups1, ...groups2]; + } +}); + +async function saveAntenna() { + const antennaData = { + name, + src, + userListId, + userGroupId, + withReplies, + withFile, + notify, + caseSensitive, + users: users.trim().split('\n').map(x => x.trim()), + keywords: keywords.trim().split('\n').map(x => x.trim().split(' ')), + excludeKeywords: excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')), + }; + + if (props.antenna.id == null) { + await os.apiWithDialog('antennas/create', antennaData); + emit('created'); + } else { + antennaData['antennaId'] = props.antenna.id; + await os.apiWithDialog('antennas/update', antennaData); + emit('updated'); + } +} + +async function deleteAntenna() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: props.antenna.name }), + }); + if (canceled) return; + + await os.api('antennas/delete', { + antennaId: props.antenna.id, + }); + + os.success(); + emit('deleted'); +} + +function addUser() { + os.selectUser().then(user => { + users = users.trim(); + users += '\n@' + Acct.toString(user as any); + users = users.trim(); + }); +} +</script> + +<style lang="scss" scoped> +.shaynizk { + > .form { + padding: 32px; + } + + > .actions { + padding: 24px 32px; + border-top: solid 0.5px var(--divider); + } +} +</style> diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue new file mode 100644 index 0000000000..9daf23f9b5 --- /dev/null +++ b/packages/frontend/src/pages/my-antennas/index.vue @@ -0,0 +1,64 @@ +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="ieepwinx"> + <MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + + <div class=""> + <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> + <MkA v-for="antenna in items" :key="antenna.id" class="ljoevbzj" :to="`/my/antennas/${antenna.id}`"> + <div class="name">{{ antenna.name }}</div> + </MkA> + </MkPagination> + </div> + </div> +</MkSpacer></MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'antennas/list' as const, + limit: 10, +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.manageAntennas, + icon: 'ti ti-antenna', +}); +</script> + +<style lang="scss" scoped> +.ieepwinx { + + > .add { + margin: 0 auto 16px auto; + } + + .ljoevbzj { + display: block; + padding: 16px; + margin-bottom: 8px; + border: solid 1px var(--divider); + border-radius: 6px; + + &:hover { + border: solid 1px var(--accent); + text-decoration: none; + } + + > .name { + font-weight: bold; + } + } +} +</style> diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue new file mode 100644 index 0000000000..dd6b5b3a37 --- /dev/null +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -0,0 +1,100 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="qtcaoidl"> + <MkButton primary class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + + <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="list"> + <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + </MkA> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'clips/list' as const, + limit: 10, +}; + +const pagingComponent = $ref<InstanceType<typeof MkPagination>>(); + +async function create() { + const { canceled, result } = await os.form(i18n.ts.createNewClip, { + name: { + type: 'string', + label: i18n.ts.name, + }, + description: { + type: 'string', + required: false, + multiline: true, + label: i18n.ts.description, + }, + isPublic: { + type: 'boolean', + label: i18n.ts.public, + default: false, + }, + }); + if (canceled) return; + + os.apiWithDialog('clips/create', result); + + pagingComponent.reload(); +} + +function onClipCreated() { + pagingComponent.reload(); +} + +function onClipDeleted() { + pagingComponent.reload(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.clip, + icon: 'ti ti-paperclip', + action: { + icon: 'ti ti-plus', + handler: create, + }, +}); +</script> + +<style lang="scss" scoped> +.qtcaoidl { + > .add { + margin: 0 auto 16px auto; + } + + > .list { + > .item { + display: block; + padding: 16px; + + > .description { + margin-top: 8px; + padding-top: 8px; + border-top: solid 0.5px var(--divider); + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue new file mode 100644 index 0000000000..3476436b27 --- /dev/null +++ b/packages/frontend/src/pages/my-lists/index.vue @@ -0,0 +1,82 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="qkcjvfiv"> + <MkButton primary class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton> + + <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="lists _content"> + <MkA v-for="list in items" :key="list.id" class="list _panel" :to="`/my/lists/${ list.id }`"> + <div class="name">{{ list.name }}</div> + <MkAvatars :user-ids="list.userIds"/> + </MkA> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkAvatars from '@/components/MkAvatars.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagingComponent = $ref<InstanceType<typeof MkPagination>>(); + +const pagination = { + endpoint: 'users/lists/list' as const, + limit: 10, +}; + +async function create() { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts.enterListName, + }); + if (canceled) return; + await os.apiWithDialog('users/lists/create', { name: name }); + pagingComponent.reload(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.manageLists, + icon: 'ti ti-list', + action: { + icon: 'ti ti-plus', + handler: create, + }, +}); +</script> + +<style lang="scss" scoped> +.qkcjvfiv { + > .add { + margin: 0 auto var(--margin) auto; + } + + > .lists { + > .list { + display: block; + padding: 16px; + border: solid 1px var(--divider); + border-radius: 6px; + + &:hover { + border: solid 1px var(--accent); + text-decoration: none; + } + + > .name { + margin-bottom: 4px; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue new file mode 100644 index 0000000000..f6234ffe44 --- /dev/null +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -0,0 +1,162 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="mk-list-page"> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <div v-if="list" class="_section"> + <div class="_content"> + <MkButton inline @click="addUser()">{{ i18n.ts.addUser }}</MkButton> + <MkButton inline @click="renameList()">{{ i18n.ts.rename }}</MkButton> + <MkButton inline @click="deleteList()">{{ i18n.ts.delete }}</MkButton> + </div> + </div> + </transition> + + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <div v-if="list" class="_section members _gap"> + <div class="_title">{{ i18n.ts.members }}</div> + <div class="_content"> + <div class="users"> + <div v-for="user in users" :key="user.id" class="user _panel"> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> + </div> + <div class="action"> + <button class="_button" @click="removeUser(user)"><i class="ti ti-x"></i></button> + </div> + </div> + </div> + </div> + </div> + </transition> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { mainRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + listId: string; +}>(); + +let list = $ref(null); +let users = $ref([]); + +function fetchList() { + os.api('users/lists/show', { + listId: props.listId, + }).then(_list => { + list = _list; + os.api('users/show', { + userIds: list.userIds, + }).then(_users => { + users = _users; + }); + }); +} + +function addUser() { + os.selectUser().then(user => { + os.apiWithDialog('users/lists/push', { + listId: list.id, + userId: user.id, + }).then(() => { + users.push(user); + }); + }); +} + +function removeUser(user) { + os.api('users/lists/pull', { + listId: list.id, + userId: user.id, + }).then(() => { + users = users.filter(x => x.id !== user.id); + }); +} + +async function renameList() { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts.enterListName, + default: list.name, + }); + if (canceled) return; + + await os.api('users/lists/update', { + listId: list.id, + name: name, + }); + + list.name = name; +} + +async function deleteList() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: list.name }), + }); + if (canceled) return; + + await os.api('users/lists/delete', { + listId: list.id, + }); + os.success(); + mainRouter.push('/my/lists'); +} + +watch(() => props.listId, fetchList, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => list ? { + title: list.name, + icon: 'ti ti-list', +} : null)); +</script> + +<style lang="scss" scoped> +.mk-list-page { + > .members { + > ._content { + > .users { + > .user { + display: flex; + align-items: center; + padding: 16px; + + > .avatar { + width: 50px; + height: 50px; + } + + > .body { + flex: 1; + padding: 8px; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue new file mode 100644 index 0000000000..e58e44ef79 --- /dev/null +++ b/packages/frontend/src/pages/not-found.vue @@ -0,0 +1,22 @@ +<template> +<div class="ipledcug"> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/not-found.jpg" class="_ghost"/> + <div>{{ i18n.ts.notFoundDescription }}</div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.notFound, + icon: 'ti ti-alert-triangle', +}); +</script> diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue new file mode 100644 index 0000000000..ba2bb91239 --- /dev/null +++ b/packages/frontend/src/pages/note.vue @@ -0,0 +1,206 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div class="fcuexfpr"> + <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <div v-if="note" class="note"> + <div v-if="showNext" class="_gap"> + <XNotes class="_content" :pagination="nextPagination" :no-gap="true"/> + </div> + + <div class="main _gap"> + <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="ti ti-chevron-up"></i></MkButton> + <div class="note _gap"> + <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> + <XNoteDetailed :key="note.id" v-model:note="note" class="note"/> + </div> + <div v-if="clips && clips.length > 0" class="_content clips _gap"> + <div class="title">{{ i18n.ts.clip }}</div> + <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + <div class="user"> + <MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/> + </div> + </MkA> + </div> + <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="ti ti-chevron-down"></i></MkButton> + </div> + + <div v-if="showPrev" class="_gap"> + <XNotes class="_content" :pagination="prevPagination" :no-gap="true"/> + </div> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, defineComponent, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import XNote from '@/components/MkNote.vue'; +import XNoteDetailed from '@/components/MkNoteDetailed.vue'; +import XNotes from '@/components/MkNotes.vue'; +import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + noteId: string; +}>(); + +let note = $ref<null | misskey.entities.Note>(); +let clips = $ref(); +let hasPrev = $ref(false); +let hasNext = $ref(false); +let showPrev = $ref(false); +let showNext = $ref(false); +let error = $ref(); + +const prevPagination = { + endpoint: 'users/notes' as const, + limit: 10, + params: computed(() => note ? ({ + userId: note.userId, + untilId: note.id, + }) : null), +}; + +const nextPagination = { + reversed: true, + endpoint: 'users/notes' as const, + limit: 10, + params: computed(() => note ? ({ + userId: note.userId, + sinceId: note.id, + }) : null), +}; + +function fetchNote() { + hasPrev = false; + hasNext = false; + showPrev = false; + showNext = false; + note = null; + os.api('notes/show', { + noteId: props.noteId, + }).then(res => { + note = res; + Promise.all([ + os.api('notes/clips', { + noteId: note.id, + }), + os.api('users/notes', { + userId: note.userId, + untilId: note.id, + limit: 1, + }), + os.api('users/notes', { + userId: note.userId, + sinceId: note.id, + limit: 1, + }), + ]).then(([_clips, prev, next]) => { + clips = _clips; + hasPrev = prev.length !== 0; + hasNext = next.length !== 0; + }); + }).catch(err => { + error = err; + }); +} + +watch(() => props.noteId, fetchNote, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => note ? { + title: i18n.ts.note, + subtitle: new Date(note.createdAt).toLocaleString(), + avatar: note.user, + path: `/notes/${note.id}`, + share: { + title: i18n.t('noteOf', { user: note.user.name }), + text: note.text, + }, +} : null)); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.fcuexfpr { + background: var(--bg); + + > .note { + > .main { + > .load { + min-width: 0; + margin: 0 auto; + border-radius: 999px; + + &.next { + margin-bottom: var(--margin); + } + + &.prev { + margin-top: var(--margin); + } + } + + > .note { + > .note { + border-radius: var(--radius); + background: var(--panel); + } + } + + > .clips { + > .title { + font-weight: bold; + padding: 12px; + } + + > .item { + display: block; + padding: 16px; + + > .description { + padding: 8px 0; + } + + > .user { + $height: 32px; + padding-top: 16px; + border-top: solid 0.5px var(--divider); + line-height: $height; + + > .avatar { + width: $height; + height: $height; + } + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue new file mode 100644 index 0000000000..7106951de2 --- /dev/null +++ b/packages/frontend/src/pages/notifications.vue @@ -0,0 +1,95 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div v-if="tab === 'all' || tab === 'unread'"> + <XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/> + </div> + <div v-else-if="tab === 'mentions'"> + <XNotes :pagination="mentionsPagination"/> + </div> + <div v-else-if="tab === 'directNotes'"> + <XNotes :pagination="directNotesPagination"/> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import { notificationTypes } from 'misskey-js'; +import XNotifications from '@/components/MkNotifications.vue'; +import XNotes from '@/components/MkNotes.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let tab = $ref('all'); +let includeTypes = $ref<string[] | null>(null); +let unreadOnly = $computed(() => tab === 'unread'); + +const mentionsPagination = { + endpoint: 'notes/mentions' as const, + limit: 10, +}; + +const directNotesPagination = { + endpoint: 'notes/mentions' as const, + limit: 10, + params: { + visibility: 'specified', + }, +}; + +function setFilter(ev) { + const typeItems = notificationTypes.map(t => ({ + text: i18n.t(`_notification._types.${t}`), + active: includeTypes && includeTypes.includes(t), + action: () => { + includeTypes = [t]; + }, + })); + const items = includeTypes != null ? [{ + icon: 'ti ti-x', + text: i18n.ts.clear, + action: () => { + includeTypes = null; + }, + }, null, ...typeItems] : typeItems; + os.popupMenu(items, ev.currentTarget ?? ev.target); +} + +const headerActions = $computed(() => [tab === 'all' ? { + text: i18n.ts.filter, + icon: 'ti ti-filter', + highlighted: includeTypes != null, + handler: setFilter, +} : undefined, tab === 'all' ? { + text: i18n.ts.markAllAsRead, + icon: 'ti ti-check', + handler: () => { + os.apiWithDialog('notifications/mark-all-as-read'); + }, +} : undefined].filter(x => x !== undefined)); + +const headerTabs = $computed(() => [{ + key: 'all', + title: i18n.ts.all, +}, { + key: 'unread', + title: i18n.ts.unread, +}, { + key: 'mentions', + title: i18n.ts.mentions, + icon: 'ti ti-at', +}, { + key: 'directNotes', + title: i18n.ts.directNotes, + icon: 'ti ti-mail', +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.notifications, + icon: 'ti ti-bell', +}))); +</script> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue new file mode 100644 index 0000000000..a84cb1e80e --- /dev/null +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue @@ -0,0 +1,63 @@ +<template> +<!-- eslint-disable vue/no-mutating-props --> +<XContainer :draggable="true" @remove="() => $emit('remove')"> + <template #header><i class="fas fa-image"></i> {{ $ts._pages.blocks.image }}</template> + <template #func> + <button @click="choose()"> + <i class="fas fa-folder-open"></i> + </button> + </template> + + <section class="oyyftmcf"> + <MkDriveFileThumbnail v-if="file" class="preview" :file="file" fit="contain" @click="choose()"/> + </section> +</XContainer> +</template> + +<script lang="ts" setup> +/* eslint-disable vue/no-mutating-props */ +import { onMounted } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; +import * as os from '@/os'; + +const props = defineProps<{ + modelValue: any +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: any): void; +}>(); + +let file: any = $ref(null); + +async function choose() { + os.selectDriveFile(false).then((fileResponse: any) => { + file = fileResponse; + emit('update:modelValue', { + ...props.modelValue, + fileId: fileResponse.id, + }); + }); +} + +onMounted(async () => { + if (props.modelValue.fileId == null) { + await choose(); + } else { + os.api('drive/files/show', { + fileId: props.modelValue.fileId, + }).then(fileResponse => { + file = fileResponse; + }); + } +}); +</script> + +<style lang="scss" scoped> +.oyyftmcf { + > .preview { + height: 150px; + } +} +</style> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue new file mode 100644 index 0000000000..dc2a620c09 --- /dev/null +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue @@ -0,0 +1,57 @@ +<template> +<!-- eslint-disable vue/no-mutating-props --> +<XContainer :draggable="true" @remove="() => $emit('remove')"> + <template #header><i class="ti ti-note"></i> {{ $ts._pages.blocks.note }}</template> + + <section style="padding: 0 16px 0 16px;"> + <MkInput v-model="id"> + <template #label>{{ $ts._pages.blocks._note.id }}</template> + <template #caption>{{ $ts._pages.blocks._note.idDescription }}</template> + </MkInput> + <MkSwitch v-model="props.modelValue.detailed"><span>{{ $ts._pages.blocks._note.detailed }}</span></MkSwitch> + + <XNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" style="margin-bottom: 16px;"/> + <XNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" style="margin-bottom: 16px;"/> + </section> +</XContainer> +</template> + +<script lang="ts" setup> +/* eslint-disable vue/no-mutating-props */ +import { watch } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import XNote from '@/components/MkNote.vue'; +import XNoteDetailed from '@/components/MkNoteDetailed.vue'; +import * as os from '@/os'; + +const props = defineProps<{ + modelValue: any +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: any): void; +}>(); + +let id: any = $ref(props.modelValue.note); +let note: any = $ref(null); + +watch(id, async () => { + if (id && (id.startsWith('http://') || id.startsWith('https://'))) { + emit('update:modelValue', { + ...props.modelValue, + note: (id.endsWith('/') ? id.slice(0, -1) : id).split('/').pop(), + }); + } else { + emit('update:modelValue', { + ...props.modelValue, + note: id, + }); + } + + note = await os.api('notes/show', { noteId: props.modelValue.note }); +}, { + immediate: true, +}); +</script> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue new file mode 100644 index 0000000000..27324bdaef --- /dev/null +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue @@ -0,0 +1,97 @@ +<template> +<!-- eslint-disable vue/no-mutating-props --> +<XContainer :draggable="true" @remove="() => $emit('remove')"> + <template #header><i class="ti ti-note"></i> {{ props.modelValue.title }}</template> + <template #func> + <button class="_button" @click="rename()"> + <i class="ti ti-pencil"></i> + </button> + </template> + + <section class="ilrvjyvi"> + <XBlocks v-model="children" class="children"/> + <MkButton rounded class="add" @click="add()"><i class="ti ti-plus"></i></MkButton> + </section> +</XContainer> +</template> + +<script lang="ts" setup> +/* eslint-disable vue/no-mutating-props */ +import { defineAsyncComponent, inject, onMounted, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XContainer from '../page-editor.container.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { deepClone } from '@/scripts/clone'; +import MkButton from '@/components/MkButton.vue'; + +const XBlocks = defineAsyncComponent(() => import('../page-editor.blocks.vue')); + +const props = withDefaults(defineProps<{ + modelValue: any, +}>(), { + modelValue: {}, +}); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: any): void; +}>(); + +const children = $ref(deepClone(props.modelValue.children ?? [])); + +watch($$(children), () => { + emit('update:modelValue', { + ...props.modelValue, + children, + }); +}, { + deep: true, +}); + +const getPageBlockList = inject<(any) => any>('getPageBlockList'); + +async function rename() { + const { canceled, result: title } = await os.inputText({ + title: 'Enter title', + default: props.modelValue.title, + }); + if (canceled) return; + emit('update:modelValue', { + ...props.modelValue, + title, + }); +} + +async function add() { + const { canceled, result: type } = await os.select({ + title: i18n.ts._pages.chooseBlock, + items: getPageBlockList(), + }); + if (canceled) return; + + const id = uuid(); + children.push({ id, type }); +} + +onMounted(() => { + if (props.modelValue.title == null) { + rename(); + } +}); +</script> + +<style lang="scss" scoped> +.ilrvjyvi { + > .children { + margin: 16px; + + &:empty { + display: none; + } + } + + > .add { + margin: 16px auto; + } +} +</style> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue new file mode 100644 index 0000000000..6f11e2a08b --- /dev/null +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue @@ -0,0 +1,54 @@ +<template> +<!-- eslint-disable vue/no-mutating-props --> +<XContainer :draggable="true" @remove="() => $emit('remove')"> + <template #header><i class="fas fa-align-left"></i> {{ $ts._pages.blocks.text }}</template> + + <section class="vckmsadr"> + <textarea v-model="text"></textarea> + </section> +</XContainer> +</template> + +<script lang="ts" setup> +/* eslint-disable vue/no-mutating-props */ +import { watch } from 'vue'; +import XContainer from '../page-editor.container.vue'; + +const props = defineProps<{ + modelValue: any +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: any): void; +}>(); + +const text = $ref(props.modelValue.text ?? ''); + +watch($$(text), () => { + emit('update:modelValue', { + ...props.modelValue, + text, + }); +}); +</script> + +<style lang="scss" scoped> +.vckmsadr { + > textarea { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + min-width: 100%; + min-height: 150px; + border: none; + box-shadow: none; + padding: 16px; + background: transparent; + color: var(--fg); + font-size: 14px; + box-sizing: border-box; + } +} +</style> diff --git a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue new file mode 100644 index 0000000000..f99fcb202f --- /dev/null +++ b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue @@ -0,0 +1,65 @@ +<template> +<Sortable :model-value="modelValue" tag="div" item-key="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swap-threshold="0.5" @update:model-value="v => $emit('update:modelValue', v)"> + <template #item="{element}"> + <div :class="$style.item"> + <!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 --> + <component :is="'x-' + element.type" :model-value="element" @update:model-value="updateItem" @remove="() => removeItem(element)"/> + </div> + </template> +</Sortable> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import XSection from './els/page-editor.el.section.vue'; +import XText from './els/page-editor.el.text.vue'; +import XImage from './els/page-editor.el.image.vue'; +import XNote from './els/page-editor.el.note.vue'; +import * as os from '@/os'; +import { deepClone } from '@/scripts/clone'; + +export default defineComponent({ + components: { + Sortable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), + XSection, XText, XImage, XNote, + }, + + props: { + modelValue: { + type: Array, + required: true, + }, + }, + + emits: ['update:modelValue'], + + methods: { + updateItem(v) { + const i = this.modelValue.findIndex(x => x.id === v.id); + const newValue = [ + ...this.modelValue.slice(0, i), + v, + ...this.modelValue.slice(i + 1), + ]; + this.$emit('update:modelValue', newValue); + }, + + removeItem(el) { + const i = this.modelValue.findIndex(x => x.id === el.id); + const newValue = [ + ...this.modelValue.slice(0, i), + ...this.modelValue.slice(i + 1), + ]; + this.$emit('update:modelValue', newValue); + }, + }, +}); +</script> + +<style lang="scss" module> +.item { + & + .item { + margin-top: 16px; + } +} +</style> diff --git a/packages/frontend/src/pages/page-editor/page-editor.container.vue b/packages/frontend/src/pages/page-editor/page-editor.container.vue new file mode 100644 index 0000000000..15cdda5efb --- /dev/null +++ b/packages/frontend/src/pages/page-editor/page-editor.container.vue @@ -0,0 +1,155 @@ +<template> +<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }"> + <header> + <div class="title"><slot name="header"></slot></div> + <div class="buttons"> + <slot name="func"></slot> + <button v-if="removable" class="_button" @click="remove()"> + <i class="ti ti-trash"></i> + </button> + <button v-if="draggable" class="drag-handle _button"> + <i class="ti ti-menu-2"></i> + </button> + <button class="_button" @click="toggleContent(!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> + <p v-show="showBody" v-if="error != null" class="error">{{ $t('_pages.script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p> + <p v-show="showBody" v-if="warn != null" class="warn">{{ $t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p> + <div v-show="showBody" class="body"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + expanded: { + type: Boolean, + default: true, + }, + removable: { + type: Boolean, + default: true, + }, + draggable: { + type: Boolean, + default: false, + }, + error: { + required: false, + default: null, + }, + warn: { + required: false, + default: null, + }, + }, + emits: ['toggle', 'remove'], + data() { + return { + showBody: this.expanded, + }; + }, + methods: { + toggleContent(show: boolean) { + this.showBody = show; + this.$emit('toggle', show); + }, + remove() { + this.$emit('remove'); + }, + }, +}); +</script> + +<style lang="scss" scoped> +.cpjygsrt { + position: relative; + overflow: hidden; + background: var(--panel); + border: solid 2px var(--X12); + border-radius: 8px; + + &:hover { + border: solid 2px var(--X13); + } + + &.warn { + border: solid 2px #dec44c; + } + + &.error { + border: solid 2px #f00; + } + + > header { + > .title { + z-index: 1; + margin: 0; + padding: 0 16px; + line-height: 42px; + font-size: 0.9em; + font-weight: bold; + box-shadow: 0 1px rgba(#000, 0.07); + + > i { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > .buttons { + position: absolute; + z-index: 2; + top: 0; + right: 0; + + > button { + padding: 0; + width: 42px; + font-size: 0.9em; + line-height: 42px; + } + + .drag-handle { + cursor: move; + } + } + } + + > .warn { + color: #b19e49; + margin: 0; + padding: 16px 16px 0 16px; + font-size: 14px; + } + + > .error { + color: #f00; + margin: 0; + padding: 16px 16px 0 16px; + font-size: 14px; + } + + > .body { + ::v-deep(.juejbjww), ::v-deep(.eiipwacr) { + &:not(.inline):first-child { + margin-top: 28px; + } + + &:not(.inline):last-child { + margin-bottom: 20px; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue new file mode 100644 index 0000000000..968aa12de2 --- /dev/null +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -0,0 +1,394 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="jqqmcavi"> + <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ $ts._pages.viewPage }}</MkButton> + <MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ $ts.save }}</MkButton> + <MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="ti ti-copy"></i> {{ $ts.duplicate }}</MkButton> + <MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="ti ti-trash"></i> {{ $ts.delete }}</MkButton> + </div> + + <div v-if="tab === 'settings'"> + <div class="_formRoot"> + <MkInput v-model="title" class="_formBlock"> + <template #label>{{ $ts._pages.title }}</template> + </MkInput> + + <MkInput v-model="summary" class="_formBlock"> + <template #label>{{ $ts._pages.summary }}</template> + </MkInput> + + <MkInput v-model="name" class="_formBlock"> + <template #prefix>{{ url }}/@{{ author.username }}/pages/</template> + <template #label>{{ $ts._pages.url }}</template> + </MkInput> + + <MkSwitch v-model="alignCenter" class="_formBlock">{{ $ts._pages.alignCenter }}</MkSwitch> + + <MkSelect v-model="font" class="_formBlock"> + <template #label>{{ $ts._pages.font }}</template> + <option value="serif">{{ $ts._pages.fontSerif }}</option> + <option value="sans-serif">{{ $ts._pages.fontSansSerif }}</option> + </MkSelect> + + <MkSwitch v-model="hideTitleWhenPinned" class="_formBlock">{{ $ts._pages.hideTitleWhenPinned }}</MkSwitch> + + <div class="eyeCatch"> + <MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="ti ti-plus"></i> {{ $ts._pages.eyeCatchingImageSet }}</MkButton> + <div v-else-if="eyeCatchingImage"> + <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/> + <MkButton v-if="!readonly" @click="removeEyeCatchingImage()"><i class="ti ti-trash"></i> {{ $ts._pages.eyeCatchingImageRemove }}</MkButton> + </div> + </div> + </div> + </div> + + <div v-else-if="tab === 'contents'"> + <div :class="$style.contents"> + <XBlocks v-model="content" class="content"/> + + <MkButton v-if="!readonly" rounded class="add" @click="add()"><i class="ti ti-plus"></i></MkButton> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, provide, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XBlocks from './page-editor.blocks.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkInput from '@/components/form/input.vue'; +import { url } from '@/config'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import { mainRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; + +const props = defineProps<{ + initPageId?: string; + initPageName?: string; + initUser?: string; +}>(); + +let tab = $ref('settings'); +let author = $ref($i); +let readonly = $ref(false); +let page = $ref(null); +let pageId = $ref(null); +let currentName = $ref(null); +let title = $ref(''); +let summary = $ref(null); +let name = $ref(Date.now().toString()); +let eyeCatchingImage = $ref(null); +let eyeCatchingImageId = $ref(null); +let font = $ref('sans-serif'); +let content = $ref([]); +let alignCenter = $ref(false); +let hideTitleWhenPinned = $ref(false); + +provide('readonly', readonly); +provide('getPageBlockList', getPageBlockList); + +watch($$(eyeCatchingImageId), async () => { + if (eyeCatchingImageId == null) { + eyeCatchingImage = null; + } else { + eyeCatchingImage = await os.api('drive/files/show', { + fileId: eyeCatchingImageId, + }); + } +}); + +function getSaveOptions() { + return { + title: title.trim(), + name: name.trim(), + summary: summary, + font: font, + script: '', + hideTitleWhenPinned: hideTitleWhenPinned, + alignCenter: alignCenter, + content: content, + variables: [], + eyeCatchingImageId: eyeCatchingImageId, + }; +} + +function save() { + const options = getSaveOptions(); + + const onError = err => { + if (err.id === '3d81ceae-475f-4600-b2a8-2bc116157532') { + if (err.info.param === 'name') { + os.alert({ + type: 'error', + title: i18n.ts._pages.invalidNameTitle, + text: i18n.ts._pages.invalidNameText, + }); + } + } else if (err.code === 'NAME_ALREADY_EXISTS') { + os.alert({ + type: 'error', + text: i18n.ts._pages.nameAlreadyExists, + }); + } + }; + + if (pageId) { + options.pageId = pageId; + os.api('pages/update', options) + .then(page => { + currentName = name.trim(); + os.alert({ + type: 'success', + text: i18n.ts._pages.updated, + }); + }).catch(onError); + } else { + os.api('pages/create', options) + .then(created => { + pageId = created.id; + currentName = name.trim(); + os.alert({ + type: 'success', + text: i18n.ts._pages.created, + }); + mainRouter.push(`/pages/edit/${pageId}`); + }).catch(onError); + } +} + +function del() { + os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: title.trim() }), + }).then(({ canceled }) => { + if (canceled) return; + os.api('pages/delete', { + pageId: pageId, + }).then(() => { + os.alert({ + type: 'success', + text: i18n.ts._pages.deleted, + }); + mainRouter.push('/pages'); + }); + }); +} + +function duplicate() { + title = title + ' - copy'; + name = name + '-copy'; + os.api('pages/create', getSaveOptions()).then(created => { + pageId = created.id; + currentName = name.trim(); + os.alert({ + type: 'success', + text: i18n.ts._pages.created, + }); + mainRouter.push(`/pages/edit/${pageId}`); + }); +} + +async function add() { + const { canceled, result: type } = await os.select({ + type: null, + title: i18n.ts._pages.chooseBlock, + items: getPageBlockList(), + }); + if (canceled) return; + + const id = uuid(); + content.push({ id, type }); +} + +function getPageBlockList() { + return [ + { value: 'section', text: i18n.ts._pages.blocks.section }, + { value: 'text', text: i18n.ts._pages.blocks.text }, + { value: 'image', text: i18n.ts._pages.blocks.image }, + { value: 'note', text: i18n.ts._pages.blocks.note }, + ]; +} + +function setEyeCatchingImage(img) { + selectFile(img.currentTarget ?? img.target, null).then(file => { + eyeCatchingImageId = file.id; + }); +} + +function removeEyeCatchingImage() { + eyeCatchingImageId = null; +} + +async function init() { + if (props.initPageId) { + page = await os.api('pages/show', { + pageId: props.initPageId, + }); + } else if (props.initPageName && props.initUser) { + page = await os.api('pages/show', { + name: props.initPageName, + username: props.initUser, + }); + readonly = true; + } + + if (page) { + author = page.user; + pageId = page.id; + title = page.title; + name = page.name; + currentName = page.name; + summary = page.summary; + font = page.font; + hideTitleWhenPinned = page.hideTitleWhenPinned; + alignCenter = page.alignCenter; + content = page.content; + eyeCatchingImageId = page.eyeCatchingImageId; + } else { + const id = uuid(); + content = [{ + id, + type: 'text', + text: 'Hello World!', + }]; + } +} + +init(); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'settings', + title: i18n.ts._pages.pageSetting, + icon: 'ti ti-settings', +}, { + key: 'contents', + title: i18n.ts._pages.contents, + icon: 'ti ti-note', +}]); + +definePageMetadata(computed(() => { + let title = i18n.ts._pages.newPage; + if (props.initPageId) { + title = i18n.ts._pages.editPage; + } + else if (props.initPageName && props.initUser) { + title = i18n.ts._pages.readPage; + } + return { + title: title, + icon: 'ti ti-pencil', + }; +})); +</script> + +<style lang="scss" module> +.contents { + &:global { + > .add { + margin: 16px auto 0 auto; + } + } +} +</style> + +<style lang="scss" scoped> +.jqqmcavi { + margin-bottom: 16px; + + > .button { + & + .button { + margin-left: 8px; + } + } +} + +.gwbmwxkm { + position: relative; + + > header { + > .title { + z-index: 1; + margin: 0; + padding: 0 16px; + line-height: 42px; + font-size: 0.9em; + font-weight: bold; + box-shadow: 0 1px rgba(#000, 0.07); + + > i { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > .buttons { + position: absolute; + z-index: 2; + top: 0; + right: 0; + + > button { + padding: 0; + width: 42px; + font-size: 0.9em; + line-height: 42px; + } + } + } + + > section { + padding: 0 32px 32px 32px; + + @media (max-width: 500px) { + padding: 0 16px 16px 16px; + } + + > .view { + display: inline-block; + margin: 16px 0 0 0; + font-size: 14px; + } + + > .content { + margin-bottom: 16px; + } + + > .eyeCatch { + margin-bottom: 16px; + + > div { + > img { + max-width: 100%; + } + } + } + } +} + +.qmuvgica { + padding: 16px; + + > .variables { + margin-bottom: 16px; + } + + > .add { + margin-bottom: 16px; + } +} +</style> diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue new file mode 100644 index 0000000000..a95bfe485c --- /dev/null +++ b/packages/frontend/src/pages/page.vue @@ -0,0 +1,277 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <div v-if="page" :key="page.id" class="xcukqgmh"> + <div class="_block main"> + <!-- + <div class="header"> + <h1>{{ page.title }}</h1> + </div> + --> + <div class="banner"> + <img v-if="page.eyeCatchingImageId" :src="page.eyeCatchingImage.url"/> + </div> + <div class="content"> + <XPage :page="page"/> + </div> + <div class="actions"> + <div class="like"> + <MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> + </div> + <div class="other"> + <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button> + <button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button> + </div> + </div> + <div class="user"> + <MkAvatar :user="page.user" class="avatar"/> + <div class="name"> + <MkUserName :user="page.user" style="display: block;"/> + <MkAcct :user="page.user"/> + </div> + <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + </div> + <div class="links"> + <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA> + <template v-if="$i && $i.id === page.userId"> + <MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA> + <button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button> + <button v-else class="link _textButton" @click="pin(true)">{{ i18n.ts.pin }}</button> + </template> + </div> + </div> + <div class="footer"> + <div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> + <div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> + </div> + <MkAd :prefer="['horizontal', 'horizontal-big']"/> + <MkContainer :max-height="300" :foldable="true" class="other"> + <template #header><i class="ti ti-clock"></i> {{ i18n.ts.recentPosts }}</template> + <MkPagination v-slot="{items}" :pagination="otherPostsPagination"> + <MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/> + </MkPagination> + </MkContainer> + </div> + <MkError v-else-if="error" @retry="fetchPage()"/> + <MkLoading v-else/> + </transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import XPage from '@/components/page/page.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { url } from '@/config'; +import MkFollowButton from '@/components/MkFollowButton.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkPagePreview from '@/components/MkPagePreview.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + pageName: string; + username: string; +}>(); + +let page = $ref(null); +let error = $ref(null); +const otherPostsPagination = { + endpoint: 'users/pages' as const, + limit: 6, + params: computed(() => ({ + userId: page.user.id, + })), +}; +const path = $computed(() => props.username + '/' + props.pageName); + +function fetchPage() { + page = null; + os.api('pages/show', { + name: props.pageName, + username: props.username, + }).then(_page => { + page = _page; + }).catch(err => { + error = err; + }); +} + +function share() { + navigator.share({ + title: page.title ?? page.name, + text: page.summary, + url: `${url}/@${page.user.username}/pages/${page.name}`, + }); +} + +function shareWithNote() { + os.post({ + initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`, + }); +} + +function like() { + os.apiWithDialog('pages/like', { + pageId: page.id, + }).then(() => { + page.isLiked = true; + page.likedCount++; + }); +} + +async function unlike() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.unlikeConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('pages/unlike', { + pageId: page.id, + }).then(() => { + page.isLiked = false; + page.likedCount--; + }); +} + +function pin(pin) { + os.apiWithDialog('i/update', { + pinnedPageId: pin ? page.id : null, + }); +} + +watch(() => path, fetchPage, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => page ? { + title: computed(() => page.title || page.name), + avatar: page.user, + path: `/@${page.user.username}/pages/${page.name}`, + share: { + title: page.title || page.name, + text: page.summary, + }, +} : null)); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.xcukqgmh { + > .main { + padding: 32px; + + > .header { + padding: 16px; + + > h1 { + margin: 0; + } + } + + > .banner { + > img { + // TODO: 良い感じのアスペクト比で表示 + display: block; + width: 100%; + height: 150px; + object-fit: cover; + } + } + + > .content { + margin-top: 16px; + padding: 16px 0 0 0; + } + + > .actions { + display: flex; + align-items: center; + margin-top: 16px; + padding: 16px 0 0 0; + border-top: solid 0.5px var(--divider); + + > .like { + > .button { + --accent: rgb(241 97 132); + --X8: rgb(241 92 128); + --buttonBg: rgb(216 71 106 / 5%); + --buttonHoverBg: rgb(216 71 106 / 10%); + color: #ff002f; + + ::v-deep(.count) { + margin-left: 0.5em; + } + } + } + + > .other { + margin-left: auto; + + > button { + padding: 8px; + margin: 0 8px; + + &:hover { + color: var(--fgHighlighted); + } + } + } + } + + > .user { + margin-top: 16px; + padding: 16px 0 0 0; + border-top: solid 0.5px var(--divider); + display: flex; + align-items: center; + + > .avatar { + width: 52px; + height: 52px; + } + + > .name { + margin: 0 0 0 12px; + font-size: 90%; + } + + > .koudoku { + margin-left: auto; + } + } + + > .links { + margin-top: 16px; + padding: 24px 0 0 0; + border-top: solid 0.5px var(--divider); + + > .link { + margin-right: 0.75em; + } + } + } + + > .footer { + margin: var(--margin) 0 var(--margin) 0; + font-size: 85%; + opacity: 0.75; + } +} +</style> diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue new file mode 100644 index 0000000000..b077180df8 --- /dev/null +++ b/packages/frontend/src/pages/pages.vue @@ -0,0 +1,99 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div v-if="tab === 'featured'" class="rknalgpo"> + <MkPagination v-slot="{items}" :pagination="featuredPagesPagination"> + <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/> + </MkPagination> + </div> + + <div v-else-if="tab === 'my'" class="rknalgpo my"> + <MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> + <MkPagination v-slot="{items}" :pagination="myPagesPagination"> + <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/> + </MkPagination> + </div> + + <div v-else-if="tab === 'liked'" class="rknalgpo"> + <MkPagination v-slot="{items}" :pagination="likedPagesPagination"> + <MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, inject } from 'vue'; +import MkPagePreview from '@/components/MkPagePreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const router = useRouter(); + +let tab = $ref('featured'); + +const featuredPagesPagination = { + endpoint: 'pages/featured' as const, + noPaging: true, +}; +const myPagesPagination = { + endpoint: 'i/pages' as const, + limit: 5, +}; +const likedPagesPagination = { + endpoint: 'i/page-likes' as const, + limit: 5, +}; + +function create() { + router.push('/pages/new'); +} + +const headerActions = $computed(() => [{ + icon: 'ti ti-plus', + text: i18n.ts.create, + handler: create, +}]); + +const headerTabs = $computed(() => [{ + key: 'featured', + title: i18n.ts._pages.featured, + icon: 'fas fa-fire-alt', +}, { + key: 'my', + title: i18n.ts._pages.my, + icon: 'ti ti-edit', +}, { + key: 'liked', + title: i18n.ts._pages.liked, + icon: 'ti ti-heart', +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.pages, + icon: 'ti ti-note', +}))); +</script> + +<style lang="scss" scoped> +.rknalgpo { + &.my .ckltabjg:first-child { + margin-top: 16px; + } + + .ckltabjg:not(:last-child) { + margin-bottom: 8px; + } + + @media (min-width: 500px) { + .ckltabjg:not(:last-child) { + margin-bottom: 16px; + } + } +} +</style> diff --git a/packages/frontend/src/pages/preview.vue b/packages/frontend/src/pages/preview.vue new file mode 100644 index 0000000000..354f686e46 --- /dev/null +++ b/packages/frontend/src/pages/preview.vue @@ -0,0 +1,27 @@ +<template> +<div class="graojtoi"> + <MkSample/> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import MkSample from '@/components/MkSample.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.preview, + icon: 'ti ti-eye', +}))); +</script> + +<style lang="scss" scoped> +.graojtoi { + padding: var(--margin); +} +</style> diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue new file mode 100644 index 0000000000..f179fbe957 --- /dev/null +++ b/packages/frontend/src/pages/registry.keys.vue @@ -0,0 +1,96 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600" :margin-min="16"> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.domain }}</template> + <template #value>{{ i18n.ts.system }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.scope }}</template> + <template #value>{{ scope.join('/') }}</template> + </MkKeyValue> + </FormSplit> + + <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> + + <FormSection v-if="keys"> + <template #label>{{ i18n.ts.keys }}</template> + <div class="_formLinks"> + <FormLink v-for="key in keys" :to="`/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink> + </div> + </FormSection> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import JSON5 from 'json5'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormSplit from '@/components/form/split.vue'; + +const props = defineProps<{ + path: string; +}>(); + +const scope = $computed(() => props.path.split('/')); + +let keys = $ref(null); + +function fetchKeys() { + os.api('i/registry/keys-with-type', { + scope: scope, + }).then(res => { + keys = Object.entries(res).sort((a, b) => a[0].localeCompare(b[0])); + }); +} + +async function createKey() { + const { canceled, result } = await os.form(i18n.ts._registry.createKey, { + key: { + type: 'string', + label: i18n.ts._registry.key, + }, + value: { + type: 'string', + multiline: true, + label: i18n.ts.value, + }, + scope: { + type: 'string', + label: i18n.ts._registry.scope, + default: scope.join('/'), + }, + }); + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: result.scope.split('/'), + key: result.key, + value: JSON5.parse(result.value), + }).then(() => { + fetchKeys(); + }); +} + +watch(() => props.path, fetchKeys, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.registry, + icon: 'ti ti-adjustments', +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/pages/registry.value.vue b/packages/frontend/src/pages/registry.value.vue new file mode 100644 index 0000000000..378420b1ba --- /dev/null +++ b/packages/frontend/src/pages/registry.value.vue @@ -0,0 +1,123 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600" :margin-min="16"> + <FormInfo warn>{{ i18n.ts.editTheseSettingsMayBreakAccount }}</FormInfo> + + <template v-if="value"> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.domain }}</template> + <template #value>{{ i18n.ts.system }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.scope }}</template> + <template #value>{{ scope.join('/') }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.key }}</template> + <template #value>{{ key }}</template> + </MkKeyValue> + </FormSplit> + + <FormTextarea v-model="valueForEditor" tall class="_formBlock _monospace"> + <template #label>{{ i18n.ts.value }} (JSON)</template> + </FormTextarea> + + <MkButton class="_formBlock" primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.updatedAt }}</template> + <template #value><MkTime :time="value.updatedAt" mode="detail"/></template> + </MkKeyValue> + + <MkButton danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </template> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import JSON5 from 'json5'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormInfo from '@/components/MkInfo.vue'; + +const props = defineProps<{ + path: string; +}>(); + +const scope = $computed(() => props.path.split('/').slice(0, -1)); +const key = $computed(() => props.path.split('/').at(-1)); + +let value = $ref(null); +let valueForEditor = $ref(null); + +function fetchValue() { + os.api('i/registry/get-detail', { + scope, + key, + }).then(res => { + value = res; + valueForEditor = JSON5.stringify(res.value, null, '\t'); + }); +} + +async function save() { + try { + JSON5.parse(valueForEditor); + } catch (err) { + os.alert({ + type: 'error', + text: i18n.ts.invalidValue, + }); + return; + } + os.confirm({ + type: 'warning', + text: i18n.ts.saveConfirm, + }).then(({ canceled }) => { + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope, + key, + value: JSON5.parse(valueForEditor), + }); + }); +} + +function del() { + os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }).then(({ canceled }) => { + if (canceled) return; + os.apiWithDialog('i/registry/remove', { + scope, + key, + }); + }); +} + +watch(() => props.path, fetchValue, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.registry, + icon: 'ti ti-adjustments', +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/pages/registry.vue b/packages/frontend/src/pages/registry.vue new file mode 100644 index 0000000000..a2c65294fc --- /dev/null +++ b/packages/frontend/src/pages/registry.vue @@ -0,0 +1,74 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600" :margin-min="16"> + <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> + + <FormSection v-if="scopes"> + <template #label>{{ i18n.ts.system }}</template> + <div class="_formLinks"> + <FormLink v-for="scope in scopes" :to="`/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink> + </div> + </FormSection> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import JSON5 from 'json5'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; + +let scopes = $ref(null); + +function fetchScopes() { + os.api('i/registry/scopes').then(res => { + scopes = res.slice().sort((a, b) => a.join('/').localeCompare(b.join('/'))); + }); +} + +async function createKey() { + const { canceled, result } = await os.form(i18n.ts._registry.createKey, { + key: { + type: 'string', + label: i18n.ts._registry.key, + }, + value: { + type: 'string', + multiline: true, + label: i18n.ts.value, + }, + scope: { + type: 'string', + label: i18n.ts._registry.scope, + }, + }); + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: result.scope.split('/'), + key: result.key, + value: JSON5.parse(result.value), + }).then(() => { + fetchScopes(); + }); +} + +fetchScopes(); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.registry, + icon: 'ti ti-adjustments', +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue new file mode 100644 index 0000000000..8ec15f6425 --- /dev/null +++ b/packages/frontend/src/pages/reset-password.vue @@ -0,0 +1,59 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32"> + <div class="_formRoot"> + <FormInput v-model="password" type="password" class="_formBlock"> + <template #prefix><i class="ti ti-lock"></i></template> + <template #label>{{ i18n.ts.newPassword }}</template> + </FormInput> + + <FormButton primary class="_formBlock" @click="save">{{ i18n.ts.save }}</FormButton> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, onMounted } from 'vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { mainRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + token?: string; +}>(); + +let password = $ref(''); + +async function save() { + await os.apiWithDialog('reset-password', { + token: props.token, + password: password, + }); + mainRouter.push('/'); +} + +onMounted(() => { + if (props.token == null) { + os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {}, 'closed'); + mainRouter.push('/'); + } +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.resetPassword, + icon: 'ti ti-lock', +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue new file mode 100644 index 0000000000..edb2d8e18c --- /dev/null +++ b/packages/frontend/src/pages/scratchpad.vue @@ -0,0 +1,137 @@ +<template> +<div class="iltifgqe"> + <div class="editor _panel _gap"> + <PrismEditor v-model="code" class="_code code" :highlight="highlighter" :line-numbers="false"/> + <MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton> + </div> + + <MkContainer :foldable="true" class="_gap"> + <template #header>{{ i18n.ts.output }}</template> + <div class="bepmlvbi"> + <div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div> + </div> + </MkContainer> + + <div class="_gap"> + {{ i18n.ts.scratchpadDescription }} + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import 'prismjs'; +import { highlight, languages } from 'prismjs/components/prism-core'; +import 'prismjs/components/prism-clike'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/themes/prism-okaidia.css'; +import { PrismEditor } from 'vue-prism-editor'; +import 'vue-prism-editor/dist/prismeditor.min.css'; +import { AiScript, parse, utils } from '@syuilo/aiscript'; +import MkContainer from '@/components/MkContainer.vue'; +import MkButton from '@/components/MkButton.vue'; +import { createAiScriptEnv } from '@/scripts/aiscript/api'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const code = ref(''); +const logs = ref<any[]>([]); + +const saved = localStorage.getItem('scratchpad'); +if (saved) { + code.value = saved; +} + +watch(code, () => { + localStorage.setItem('scratchpad', code.value); +}); + +async function run() { + logs.value = []; + const aiscript = new AiScript(createAiScriptEnv({ + storageKey: 'scratchpad', + token: $i?.token, + }), { + in: (q) => { + return new Promise(ok => { + os.inputText({ + title: q, + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + logs.value.push({ + id: Math.random(), + text: value.type === 'str' ? value.value : utils.valToString(value), + print: true, + }); + }, + log: (type, params) => { + switch (type) { + case 'end': logs.value.push({ + id: Math.random(), + text: utils.valToString(params.val, true), + print: false, + }); break; + default: break; + } + }, + }); + + let ast; + try { + ast = parse(code.value); + } catch (error) { + os.alert({ + type: 'error', + text: 'Syntax error :(', + }); + return; + } + try { + await aiscript.exec(ast); + } catch (error: any) { + os.alert({ + type: 'error', + text: error.message, + }); + } +} + +function highlighter(code) { + return highlight(code, languages.js, 'javascript'); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.scratchpad, + icon: 'ti ti-terminal-2', +}); +</script> + +<style lang="scss" scoped> +.iltifgqe { + padding: 16px; + + > .editor { + position: relative; + } +} + +.bepmlvbi { + padding: 16px; + + > .log { + &:not(.print) { + opacity: 0.7; + } + } +} +</style> diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue new file mode 100644 index 0000000000..c080b763bb --- /dev/null +++ b/packages/frontend/src/pages/search.vue @@ -0,0 +1,38 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <XNotes ref="notes" :pagination="pagination"/> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import XNotes from '@/components/MkNotes.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + query: string; + channel?: string; +}>(); + +const pagination = { + endpoint: 'notes/search' as const, + limit: 10, + params: computed(() => ({ + query: props.query, + channelId: props.channel, + })), +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.t('searchWith', { q: props.query }), + icon: 'ti ti-search', +}))); +</script> diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue new file mode 100644 index 0000000000..1803129aaa --- /dev/null +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -0,0 +1,216 @@ +<template> +<div> + <MkButton v-if="!twoFactorData && !$i.twoFactorEnabled" @click="register">{{ i18n.ts._2fa.registerDevice }}</MkButton> + <template v-if="$i.twoFactorEnabled"> + <p>{{ i18n.ts._2fa.alreadyRegistered }}</p> + <MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton> + + <template v-if="supportsCredentials"> + <hr class="totp-method-sep"> + + <h2 class="heading">{{ i18n.ts.securityKey }}</h2> + <p>{{ i18n.ts._2fa.securityKeyInfo }}</p> + <div class="key-list"> + <div v-for="key in $i.securityKeysList" class="key"> + <h3>{{ key.name }}</h3> + <div class="last-used">{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed"/></div> + <MkButton @click="unregisterKey(key)">{{ i18n.ts.unregister }}</MkButton> + </div> + </div> + + <MkSwitch v-if="$i.securityKeysList.length > 0" v-model="usePasswordLessLogin" @update:model-value="updatePasswordLessLogin">{{ i18n.ts.passwordLessLogin }}</MkSwitch> + + <MkInfo v-if="registration && registration.error" warn>{{ i18n.ts.error }} {{ registration.error }}</MkInfo> + <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ i18n.ts._2fa.registerKey }}</MkButton> + + <ol v-if="registration && !registration.error"> + <li v-if="registration.stage >= 0"> + {{ i18n.ts.tapSecurityKey }} + <MkLoading v-if="registration.saving && registration.stage == 0" :em="true"/> + </li> + <li v-if="registration.stage >= 1"> + <MkForm :disabled="registration.stage != 1 || registration.saving"> + <MkInput v-model="keyName" :max="30"> + <template #label>{{ i18n.ts.securityKeyName }}</template> + </MkInput> + <MkButton :disabled="keyName.length == 0" @click="registerKey">{{ i18n.ts.registerSecurityKey }}</MkButton> + <MkLoading v-if="registration.saving && registration.stage == 1" :em="true"/> + </MkForm> + </li> + </ol> + </template> + </template> + <div v-if="twoFactorData && !$i.twoFactorEnabled"> + <ol style="margin: 0; padding: 0 0 0 1em;"> + <li> + <I18n :src="i18n.ts._2fa.step1" tag="span"> + <template #a> + <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a> + </template> + <template #b> + <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a> + </template> + </I18n> + </li> + <li>{{ i18n.ts._2fa.step2 }}<br><img :src="twoFactorData.qr"><p>{{ $ts._2fa.step2Url }}<br>{{ twoFactorData.url }}</p></li> + <li> + {{ i18n.ts._2fa.step3 }}<br> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput> + <MkButton primary @click="submit">{{ i18n.ts.done }}</MkButton> + </li> + </ol> + <MkInfo>{{ i18n.ts._2fa.step4 }}</MkInfo> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import { hostname } from '@/config'; +import { byteify, hexify, stringify } from '@/scripts/2fa'; +import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; + +const twoFactorData = ref<any>(null); +const supportsCredentials = ref(!!navigator.credentials); +const usePasswordLessLogin = ref($i!.usePasswordLessLogin); +const registration = ref<any>(null); +const keyName = ref(''); +const token = ref(null); + +function register() { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/register', { + password: password, + }).then(data => { + twoFactorData.value = data; + }); + }); +} + +function unregister() { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/unregister', { + password: password, + }).then(() => { + usePasswordLessLogin.value = false; + updatePasswordLessLogin(); + }).then(() => { + os.success(); + $i!.twoFactorEnabled = false; + }); + }); +} + +function submit() { + os.api('i/2fa/done', { + token: token.value, + }).then(() => { + os.success(); + $i!.twoFactorEnabled = true; + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); + }); +} + +function registerKey() { + registration.value.saving = true; + os.api('i/2fa/key-done', { + password: registration.value.password, + name: keyName.value, + challengeId: registration.value.challengeId, + // we convert each 16 bits to a string to serialise + clientDataJSON: stringify(registration.value.credential.response.clientDataJSON), + attestationObject: hexify(registration.value.credential.response.attestationObject), + }).then(key => { + registration.value = null; + key.lastUsed = new Date(); + os.success(); + }); +} + +function unregisterKey(key) { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + return os.api('i/2fa/remove-key', { + password, + credentialId: key.id, + }).then(() => { + usePasswordLessLogin.value = false; + updatePasswordLessLogin(); + }).then(() => { + os.success(); + }); + }); +} + +function addSecurityKey() { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/register-key', { + password, + }).then(reg => { + registration.value = { + password, + challengeId: reg!.challengeId, + stage: 0, + publicKeyOptions: { + challenge: byteify(reg!.challenge, 'base64'), + rp: { + id: hostname, + name: 'Misskey', + }, + user: { + id: byteify($i!.id, 'ascii'), + name: $i!.username, + displayName: $i!.name, + }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + timeout: 60000, + attestation: 'direct', + }, + saving: true, + }; + return navigator.credentials.create({ + publicKey: registration.value.publicKeyOptions, + }); + }).then(credential => { + registration.value.credential = credential; + registration.value.saving = false; + registration.value.stage = 1; + }).catch(err => { + console.warn('Error while registering?', err); + registration.value.error = err.message; + registration.value.stage = -1; + }); + }); +} + +async function updatePasswordLessLogin() { + await os.api('i/2fa/password-less', { + value: !!usePasswordLessLogin.value, + }); +} +</script> diff --git a/packages/frontend/src/pages/settings/account-info.vue b/packages/frontend/src/pages/settings/account-info.vue new file mode 100644 index 0000000000..ccd99c162a --- /dev/null +++ b/packages/frontend/src/pages/settings/account-info.vue @@ -0,0 +1,158 @@ +<template> +<div class="_formRoot"> + <MkKeyValue> + <template #key>ID</template> + <template #value><span class="_monospace">{{ $i.id }}</span></template> + </MkKeyValue> + + <FormSection> + <MkKeyValue> + <template #key>{{ i18n.ts.registeredDate }}</template> + <template #value><MkTime :time="$i.createdAt" mode="detail"/></template> + </MkKeyValue> + </FormSection> + + <FormSection v-if="stats"> + <template #label>{{ i18n.ts.statistics }}</template> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.notesCount }}</template> + <template #value>{{ number(stats.notesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.repliesCount }}</template> + <template #value>{{ number(stats.repliesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.renotesCount }}</template> + <template #value>{{ number(stats.renotesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.repliedCount }}</template> + <template #value>{{ number(stats.repliedCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.renotedCount }}</template> + <template #value>{{ number(stats.renotedCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.pollVotesCount }}</template> + <template #value>{{ number(stats.pollVotesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.pollVotedCount }}</template> + <template #value>{{ number(stats.pollVotedCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.sentReactionsCount }}</template> + <template #value>{{ number(stats.sentReactionsCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.receivedReactionsCount }}</template> + <template #value>{{ number(stats.receivedReactionsCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.noteFavoritesCount }}</template> + <template #value>{{ number(stats.noteFavoritesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followingCount }}</template> + <template #value>{{ number(stats.followingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followingCount }} ({{ i18n.ts.local }})</template> + <template #value>{{ number(stats.localFollowingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followingCount }} ({{ i18n.ts.remote }})</template> + <template #value>{{ number(stats.remoteFollowingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followersCount }}</template> + <template #value>{{ number(stats.followersCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followersCount }} ({{ i18n.ts.local }})</template> + <template #value>{{ number(stats.localFollowersCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followersCount }} ({{ i18n.ts.remote }})</template> + <template #value>{{ number(stats.remoteFollowersCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.pageLikesCount }}</template> + <template #value>{{ number(stats.pageLikesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.pageLikedCount }}</template> + <template #value>{{ number(stats.pageLikedCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.driveFilesCount }}</template> + <template #value>{{ number(stats.driveFilesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.driveUsage }}</template> + <template #value>{{ bytes(stats.driveUsage) }}</template> + </MkKeyValue> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.other }}</template> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>emailVerified</template> + <template #value>{{ $i.emailVerified ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>twoFactorEnabled</template> + <template #value>{{ $i.twoFactorEnabled ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>securityKeys</template> + <template #value>{{ $i.securityKeys ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>usePasswordLessLogin</template> + <template #value>{{ $i.usePasswordLessLogin ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>isModerator</template> + <template #value>{{ $i.isModerator ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>isAdmin</template> + <template #value>{{ $i.isAdmin ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref } from 'vue'; +import FormSection from '@/components/form/section.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const stats = ref<any>({}); + +onMounted(() => { + os.api('users/stats', { + userId: $i!.id, + }).then(response => { + stats.value = response; + }); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.accountInfo, + icon: 'ti ti-info-circle', +}); +</script> diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue new file mode 100644 index 0000000000..493d3b2618 --- /dev/null +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -0,0 +1,143 @@ +<template> +<div class="_formRoot"> + <FormSuspense :p="init"> + <FormButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</FormButton> + + <div v-for="account in accounts" :key="account.id" class="_panel _button lcjjdxlm" @click="menu(account, $event)"> + <div class="avatar"> + <MkAvatar :user="account" class="avatar"/> + </div> + <div class="body"> + <div class="name"> + <MkUserName :user="account"/> + </div> + <div class="acct"> + <MkAcct :user="account"/> + </div> + </div> + </div> + </FormSuspense> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, ref } from 'vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const storedAccounts = ref<any>(null); +const accounts = ref<any>(null); + +const init = async () => { + getAccounts().then(accounts => { + storedAccounts.value = accounts.filter(x => x.id !== $i!.id); + + console.log(storedAccounts.value); + + return os.api('users/show', { + userIds: storedAccounts.value.map(x => x.id), + }); + }).then(response => { + accounts.value = response; + console.log(accounts.value); + }); +}; + +function menu(account, ev) { + os.popupMenu([{ + text: i18n.ts.switch, + icon: 'ti ti-switch-horizontal', + action: () => switchAccount(account), + }, { + text: i18n.ts.remove, + icon: 'ti ti-trash', + danger: true, + action: () => removeAccount(account), + }], ev.currentTarget ?? ev.target); +} + +function addAccount(ev) { + os.popupMenu([{ + text: i18n.ts.existingAccount, + action: () => { addExistingAccount(); }, + }, { + text: i18n.ts.createAccount, + action: () => { createAccount(); }, + }], ev.currentTarget ?? ev.target); +} + +function removeAccount(account) { + _removeAccount(account.id); +} + +function addExistingAccount() { + os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: res => { + addAccounts(res.id, res.i); + os.success(); + }, + }, 'closed'); +} + +function createAccount() { + os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { + done: res => { + addAccounts(res.id, res.i); + switchAccountWithToken(res.i); + }, + }, 'closed'); +} + +async function switchAccount(account: any) { + const fetchedAccounts: any[] = await getAccounts(); + const token = fetchedAccounts.find(x => x.id === account.id).token; + switchAccountWithToken(token); +} + +function switchAccountWithToken(token: string) { + login(token); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.accounts, + icon: 'ti ti-users', +}); +</script> + +<style lang="scss" scoped> +.lcjjdxlm { + display: flex; + padding: 16px; + + > .avatar { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + + > .avatar { + width: 50px; + height: 50px; + } + } + + > .body { + display: flex; + flex-direction: column; + justify-content: center; + width: calc(100% - 62px); + position: relative; + + > .name { + font-weight: bold; + } + } +} +</style> diff --git a/packages/frontend/src/pages/settings/api.vue b/packages/frontend/src/pages/settings/api.vue new file mode 100644 index 0000000000..8d7291cd10 --- /dev/null +++ b/packages/frontend/src/pages/settings/api.vue @@ -0,0 +1,46 @@ +<template> +<div class="_formRoot"> + <FormButton primary class="_formBlock" @click="generateToken">{{ i18n.ts.generateAccessToken }}</FormButton> + <FormLink to="/settings/apps" class="_formBlock">{{ i18n.ts.manageAccessTokens }}</FormLink> + <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null" class="_formBlock">API console</FormLink> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, ref } from 'vue'; +import FormLink from '@/components/form/link.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const isDesktop = ref(window.innerWidth >= 1100); + +function generateToken() { + os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, { + done: async result => { + const { name, permissions } = result; + const { token } = await os.api('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + + os.alert({ + type: 'success', + title: i18n.ts.token, + text: token, + }); + }, + }, 'closed'); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'API', + icon: 'ti ti-api', +}); +</script> diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue new file mode 100644 index 0000000000..05abadff23 --- /dev/null +++ b/packages/frontend/src/pages/settings/apps.vue @@ -0,0 +1,96 @@ +<template> +<div class="_formRoot"> + <FormPagination ref="list" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.nothing }}</div> + </div> + </template> + <template #default="{items}"> + <div v-for="token in items" :key="token.id" class="_panel bfomjevm"> + <img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/> + <div class="body"> + <div class="name">{{ token.name }}</div> + <div class="description">{{ token.description }}</div> + <div class="_keyValue"> + <div>{{ i18n.ts.installedDate }}:</div> + <div><MkTime :time="token.createdAt"/></div> + </div> + <div class="_keyValue"> + <div>{{ i18n.ts.lastUsedDate }}:</div> + <div><MkTime :time="token.lastUsedAt"/></div> + </div> + <div class="actions"> + <button class="_button" @click="revoke(token)"><i class="ti ti-trash"></i></button> + </div> + <details> + <summary>{{ i18n.ts.details }}</summary> + <ul> + <li v-for="p in token.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </details> + </div> + </div> + </template> + </FormPagination> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import FormPagination from '@/components/MkPagination.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const list = ref<any>(null); + +const pagination = { + endpoint: 'i/apps' as const, + limit: 100, + params: { + sort: '+lastUsedAt', + }, +}; + +function revoke(token) { + os.api('i/revoke-token', { tokenId: token.id }).then(() => { + list.value.reload(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.installedApps, + icon: 'ti ti-plug', +}); +</script> + +<style lang="scss" scoped> +.bfomjevm { + display: flex; + padding: 16px; + + > .icon { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + width: 50px; + height: 50px; + border-radius: 8px; + } + + > .body { + width: calc(100% - 62px); + position: relative; + + > .name { + font-weight: bold; + } + } +} +</style> diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue new file mode 100644 index 0000000000..2caad22b7b --- /dev/null +++ b/packages/frontend/src/pages/settings/custom-css.vue @@ -0,0 +1,46 @@ +<template> +<div class="_formRoot"> + <FormInfo warn class="_formBlock">{{ i18n.ts.customCssWarn }}</FormInfo> + + <FormTextarea v-model="localCustomCss" manual-save tall class="_monospace _formBlock" style="tab-size: 2;"> + <template #label>CSS</template> + </FormTextarea> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const localCustomCss = ref(localStorage.getItem('customCss') ?? ''); + +async function apply() { + localStorage.setItem('customCss', localCustomCss.value); + + const { canceled } = await os.confirm({ + type: 'info', + text: i18n.ts.reloadToApplySetting, + }); + if (canceled) return; + + unisonReload(); +} + +watch(localCustomCss, async () => { + await apply(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.customCss, + icon: 'ti ti-code', +}); +</script> diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue new file mode 100644 index 0000000000..82cefe05d5 --- /dev/null +++ b/packages/frontend/src/pages/settings/deck.vue @@ -0,0 +1,39 @@ +<template> +<div class="_formRoot"> + <FormSwitch v-model="navWindow">{{ i18n.ts.defaultNavigationBehaviour }}: {{ i18n.ts.openInWindow }}</FormSwitch> + + <FormSwitch v-model="alwaysShowMainColumn" class="_formBlock">{{ i18n.ts._deck.alwaysShowMainColumn }}</FormSwitch> + + <FormRadios v-model="columnAlign" class="_formBlock"> + <template #label>{{ i18n.ts._deck.columnAlign }}</template> + <option value="left">{{ i18n.ts.left }}</option> + <option value="center">{{ i18n.ts.center }}</option> + </FormRadios> +</div> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormLink from '@/components/form/link.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormInput from '@/components/form/input.vue'; +import { deckStore } from '@/ui/deck/deck-store'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const navWindow = computed(deckStore.makeGetterSetter('navWindow')); +const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn')); +const columnAlign = computed(deckStore.makeGetterSetter('columnAlign')); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.deck, + icon: 'ti ti-columns', +}); +</script> diff --git a/packages/frontend/src/pages/settings/delete-account.vue b/packages/frontend/src/pages/settings/delete-account.vue new file mode 100644 index 0000000000..8a25ff39f0 --- /dev/null +++ b/packages/frontend/src/pages/settings/delete-account.vue @@ -0,0 +1,52 @@ +<template> +<div class="_formRoot"> + <FormInfo warn class="_formBlock">{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo> + <FormInfo class="_formBlock">{{ i18n.ts._accountDelete.sendEmail }}</FormInfo> + <FormButton v-if="!$i.isDeleted" danger class="_formBlock" @click="deleteAccount">{{ i18n.ts._accountDelete.requestAccountDelete }}</FormButton> + <FormButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import FormInfo from '@/components/MkInfo.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { signout } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +async function deleteAccount() { + { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteAccountConfirm, + }); + if (canceled) return; + } + + const { canceled, result: password } = await os.inputText({ + title: i18n.ts.password, + type: 'password', + }); + if (canceled) return; + + await os.apiWithDialog('i/delete-account', { + password: password, + }); + + await os.alert({ + title: i18n.ts._accountDelete.started, + }); + + await signout(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._accountDelete.accountDelete, + icon: 'ti ti-alert-triangle', +}); +</script> diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue new file mode 100644 index 0000000000..2d45b1add8 --- /dev/null +++ b/packages/frontend/src/pages/settings/drive.vue @@ -0,0 +1,145 @@ +<template> +<div class="_formRoot"> + <FormSection v-if="!fetching"> + <template #label>{{ i18n.ts.usageAmount }}</template> + <div class="_formBlock uawsfosz"> + <div class="meter"><div :style="meterStyle"></div></div> + </div> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.capacity }}</template> + <template #value>{{ bytes(capacity, 1) }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.inUse }}</template> + <template #value>{{ bytes(usage, 1) }}</template> + </MkKeyValue> + </FormSplit> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.statistics }}</template> + <MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="6"/> + </FormSection> + + <FormSection> + <FormLink @click="chooseUploadFolder()"> + {{ i18n.ts.uploadFolder }} + <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> + <template #suffixIcon><i class="fas fa-folder-open"></i></template> + </FormLink> + <FormSwitch v-model="keepOriginalUploading" class="_formBlock"> + <template #label>{{ i18n.ts.keepOriginalUploading }}</template> + <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template> + </FormSwitch> + <FormSwitch v-model="alwaysMarkNsfw" class="_formBlock" @update:model-value="saveProfile()"> + <template #label>{{ i18n.ts.alwaysMarkSensitive }}</template> + </FormSwitch> + <FormSwitch v-model="autoSensitive" class="_formBlock" @update:model-value="saveProfile()"> + <template #label>{{ i18n.ts.enableAutoSensitive }}<span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption>{{ i18n.ts.enableAutoSensitiveDescription }}</template> + </FormSwitch> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import tinycolor from 'tinycolor2'; +import FormLink from '@/components/form/link.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSection from '@/components/form/section.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormSplit from '@/components/form/split.vue'; +import * as os from '@/os'; +import bytes from '@/filters/bytes'; +import { defaultStore } from '@/store'; +import MkChart from '@/components/MkChart.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; + +const fetching = ref(true); +const usage = ref<any>(null); +const capacity = ref<any>(null); +const uploadFolder = ref<any>(null); +let alwaysMarkNsfw = $ref($i.alwaysMarkNsfw); +let autoSensitive = $ref($i.autoSensitive); + +const meterStyle = computed(() => { + return { + width: `${usage.value / capacity.value * 100}%`, + background: tinycolor({ + h: 180 - (usage.value / capacity.value * 180), + s: 0.7, + l: 0.5, + }), + }; +}); + +const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading')); + +os.api('drive').then(info => { + capacity.value = info.capacity; + usage.value = info.usage; + fetching.value = false; +}); + +if (defaultStore.state.uploadFolder) { + os.api('drive/folders/show', { + folderId: defaultStore.state.uploadFolder, + }).then(response => { + uploadFolder.value = response; + }); +} + +function chooseUploadFolder() { + os.selectDriveFolder(false).then(async folder => { + defaultStore.set('uploadFolder', folder ? folder.id : null); + os.success(); + if (defaultStore.state.uploadFolder) { + uploadFolder.value = await os.api('drive/folders/show', { + folderId: defaultStore.state.uploadFolder, + }); + } else { + uploadFolder.value = null; + } + }); +} + +function saveProfile() { + os.api('i/update', { + alwaysMarkNsfw: !!alwaysMarkNsfw, + autoSensitive: !!autoSensitive, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.drive, + icon: 'ti ti-cloud', +}); +</script> + +<style lang="scss" scoped> + +@use "sass:math"; + +.uawsfosz { + + > .meter { + $size: 12px; + background: rgba(0, 0, 0, 0.1); + border-radius: math.div($size, 2); + overflow: hidden; + + > div { + height: $size; + border-radius: math.div($size, 2); + } + } +} +</style> diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue new file mode 100644 index 0000000000..3fff8c6b1d --- /dev/null +++ b/packages/frontend/src/pages/settings/email.vue @@ -0,0 +1,111 @@ +<template> +<div class="_formRoot"> + <FormSection> + <template #label>{{ i18n.ts.emailAddress }}</template> + <FormInput v-model="emailAddress" type="email" manual-save> + <template #prefix><i class="ti ti-mail"></i></template> + <template v-if="$i.email && !$i.emailVerified" #caption>{{ i18n.ts.verificationEmailSent }}</template> + <template v-else-if="emailAddress === $i.email && $i.emailVerified" #caption><i class="ti ti-check" style="color: var(--success);"></i> {{ i18n.ts.emailVerified }}</template> + </FormInput> + </FormSection> + + <FormSection> + <FormSwitch :model-value="$i.receiveAnnouncementEmail" @update:model-value="onChangeReceiveAnnouncementEmail"> + {{ i18n.ts.receiveAnnouncementFromInstance }} + </FormSwitch> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.emailNotification }}</template> + <FormSwitch v-model="emailNotification_mention" class="_formBlock"> + {{ i18n.ts._notification._types.mention }} + </FormSwitch> + <FormSwitch v-model="emailNotification_reply" class="_formBlock"> + {{ i18n.ts._notification._types.reply }} + </FormSwitch> + <FormSwitch v-model="emailNotification_quote" class="_formBlock"> + {{ i18n.ts._notification._types.quote }} + </FormSwitch> + <FormSwitch v-model="emailNotification_follow" class="_formBlock"> + {{ i18n.ts._notification._types.follow }} + </FormSwitch> + <FormSwitch v-model="emailNotification_receiveFollowRequest" class="_formBlock"> + {{ i18n.ts._notification._types.receiveFollowRequest }} + </FormSwitch> + <FormSwitch v-model="emailNotification_groupInvited" class="_formBlock"> + {{ i18n.ts._notification._types.groupInvited }} + </FormSwitch> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref, watch } from 'vue'; +import FormSection from '@/components/form/section.vue'; +import FormInput from '@/components/form/input.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const emailAddress = ref($i!.email); + +const onChangeReceiveAnnouncementEmail = (v) => { + os.api('i/update', { + receiveAnnouncementEmail: v, + }); +}; + +const saveEmailAddress = () => { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.apiWithDialog('i/update-email', { + password: password, + email: emailAddress.value, + }); + }); +}; + +const emailNotification_mention = ref($i!.emailNotificationTypes.includes('mention')); +const emailNotification_reply = ref($i!.emailNotificationTypes.includes('reply')); +const emailNotification_quote = ref($i!.emailNotificationTypes.includes('quote')); +const emailNotification_follow = ref($i!.emailNotificationTypes.includes('follow')); +const emailNotification_receiveFollowRequest = ref($i!.emailNotificationTypes.includes('receiveFollowRequest')); +const emailNotification_groupInvited = ref($i!.emailNotificationTypes.includes('groupInvited')); + +const saveNotificationSettings = () => { + os.api('i/update', { + emailNotificationTypes: [ + ...[emailNotification_mention.value ? 'mention' : null], + ...[emailNotification_reply.value ? 'reply' : null], + ...[emailNotification_quote.value ? 'quote' : null], + ...[emailNotification_follow.value ? 'follow' : null], + ...[emailNotification_receiveFollowRequest.value ? 'receiveFollowRequest' : null], + ...[emailNotification_groupInvited.value ? 'groupInvited' : null], + ].filter(x => x != null), + }); +}; + +watch([emailNotification_mention, emailNotification_reply, emailNotification_quote, emailNotification_follow, emailNotification_receiveFollowRequest, emailNotification_groupInvited], () => { + saveNotificationSettings(); +}); + +onMounted(() => { + watch(emailAddress, () => { + saveEmailAddress(); + }); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.email, + icon: 'ti ti-mail', +}); +</script> diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue new file mode 100644 index 0000000000..84d99d2fd7 --- /dev/null +++ b/packages/frontend/src/pages/settings/general.vue @@ -0,0 +1,196 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="lang" class="_formBlock"> + <template #label>{{ i18n.ts.uiLanguage }}</template> + <option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option> + <template #caption> + <I18n :src="i18n.ts.i18nInfo" tag="span"> + <template #link> + <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink> + </template> + </I18n> + </template> + </FormSelect> + + <FormRadios v-model="overridedDeviceKind" class="_formBlock"> + <template #label>{{ i18n.ts.overridedDeviceKind }}</template> + <option :value="null">{{ i18n.ts.auto }}</option> + <option value="smartphone"><i class="ti ti-device-mobile"/> {{ i18n.ts.smartphone }}</option> + <option value="tablet"><i class="ti ti-device-tablet"/> {{ i18n.ts.tablet }}</option> + <option value="desktop"><i class="ti ti-device-desktop"/> {{ i18n.ts.desktop }}</option> + </FormRadios> + + <FormSwitch v-model="showFixedPostForm" class="_formBlock">{{ i18n.ts.showFixedPostForm }}</FormSwitch> + + <FormSection> + <template #label>{{ i18n.ts.behavior }}</template> + <FormSwitch v-model="imageNewTab" class="_formBlock">{{ i18n.ts.openImageInNewTab }}</FormSwitch> + <FormSwitch v-model="enableInfiniteScroll" class="_formBlock">{{ i18n.ts.enableInfiniteScroll }}</FormSwitch> + <FormSwitch v-model="useReactionPickerForContextMenu" class="_formBlock">{{ i18n.ts.useReactionPickerForContextMenu }}</FormSwitch> + <FormSwitch v-model="disablePagesScript" class="_formBlock">{{ i18n.ts.disablePagesScript }}</FormSwitch> + + <FormSelect v-model="serverDisconnectedBehavior" class="_formBlock"> + <template #label>{{ i18n.ts.whenServerDisconnected }}</template> + <option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option> + <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option> + <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option> + </FormSelect> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.appearance }}</template> + <FormSwitch v-model="disableAnimatedMfm" class="_formBlock">{{ i18n.ts.disableAnimatedMfm }}</FormSwitch> + <FormSwitch v-model="reduceAnimation" class="_formBlock">{{ i18n.ts.reduceUiAnimation }}</FormSwitch> + <FormSwitch v-model="useBlurEffect" class="_formBlock">{{ i18n.ts.useBlurEffect }}</FormSwitch> + <FormSwitch v-model="useBlurEffectForModal" class="_formBlock">{{ i18n.ts.useBlurEffectForModal }}</FormSwitch> + <FormSwitch v-model="showGapBetweenNotesInTimeline" class="_formBlock">{{ i18n.ts.showGapBetweenNotesInTimeline }}</FormSwitch> + <FormSwitch v-model="loadRawImages" class="_formBlock">{{ i18n.ts.loadRawImages }}</FormSwitch> + <FormSwitch v-model="disableShowingAnimatedImages" class="_formBlock">{{ i18n.ts.disableShowingAnimatedImages }}</FormSwitch> + <FormSwitch v-model="squareAvatars" class="_formBlock">{{ i18n.ts.squareAvatars }}</FormSwitch> + <FormSwitch v-model="useSystemFont" class="_formBlock">{{ i18n.ts.useSystemFont }}</FormSwitch> + <div class="_formBlock"> + <FormRadios v-model="emojiStyle"> + <template #label>{{ i18n.ts.emojiStyle }}</template> + <option value="native">{{ i18n.ts.native }}</option> + <option value="fluentEmoji">Fluent Emoji</option> + <option value="twemoji">Twemoji</option> + </FormRadios> + <div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div> + </div> + + <FormSwitch v-model="disableDrawer" class="_formBlock">{{ i18n.ts.disableDrawer }}</FormSwitch> + + <FormRadios v-model="fontSize" class="_formBlock"> + <template #label>{{ i18n.ts.fontSize }}</template> + <option :value="null"><span style="font-size: 14px;">Aa</span></option> + <option value="1"><span style="font-size: 15px;">Aa</span></option> + <option value="2"><span style="font-size: 16px;">Aa</span></option> + <option value="3"><span style="font-size: 17px;">Aa</span></option> + </FormRadios> + </FormSection> + + <FormSection> + <FormSwitch v-model="aiChanMode">{{ i18n.ts.aiChanMode }}</FormSwitch> + </FormSection> + + <FormSelect v-model="instanceTicker" class="_formBlock"> + <template #label>{{ i18n.ts.instanceTicker }}</template> + <option value="none">{{ i18n.ts._instanceTicker.none }}</option> + <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option> + <option value="always">{{ i18n.ts._instanceTicker.always }}</option> + </FormSelect> + + <FormSelect v-model="nsfw" class="_formBlock"> + <template #label>{{ i18n.ts.nsfw }}</template> + <option value="respect">{{ i18n.ts._nsfw.respect }}</option> + <option value="ignore">{{ i18n.ts._nsfw.ignore }}</option> + <option value="force">{{ i18n.ts._nsfw.force }}</option> + </FormSelect> + + <FormRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing class="_formBlock"> + <template #label>{{ i18n.ts.numberOfPageCache }}</template> + <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template> + </FormRange> + + <FormLink to="/settings/deck" class="_formBlock">{{ i18n.ts.deck }}</FormLink> + + <FormLink to="/settings/custom-css" class="_formBlock"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormRange from '@/components/form/range.vue'; +import FormSection from '@/components/form/section.vue'; +import FormLink from '@/components/form/link.vue'; +import MkLink from '@/components/MkLink.vue'; +import { langs } from '@/config'; +import { defaultStore } from '@/store'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const lang = ref(localStorage.getItem('lang')); +const fontSize = ref(localStorage.getItem('fontSize')); +const useSystemFont = ref(localStorage.getItem('useSystemFont') != null); + +async function reloadAsk() { + const { canceled } = await os.confirm({ + type: 'info', + text: i18n.ts.reloadToApplySetting, + }); + if (canceled) return; + + unisonReload(); +} + +const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind')); +const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior')); +const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v)); +const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal')); +const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect')); +const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline')); +const disableAnimatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm', v => !v, v => !v)); +const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle')); +const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer')); +const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages')); +const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages')); +const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); +const nsfw = computed(defaultStore.makeGetterSetter('nsfw')); +const disablePagesScript = computed(defaultStore.makeGetterSetter('disablePagesScript')); +const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm')); +const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache')); +const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker')); +const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll')); +const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu')); +const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars')); +const aiChanMode = computed(defaultStore.makeGetterSetter('aiChanMode')); + +watch(lang, () => { + localStorage.setItem('lang', lang.value as string); + localStorage.removeItem('locale'); +}); + +watch(fontSize, () => { + if (fontSize.value == null) { + localStorage.removeItem('fontSize'); + } else { + localStorage.setItem('fontSize', fontSize.value); + } +}); + +watch(useSystemFont, () => { + if (useSystemFont.value) { + localStorage.setItem('useSystemFont', 't'); + } else { + localStorage.removeItem('useSystemFont'); + } +}); + +watch([ + lang, + fontSize, + useSystemFont, + enableInfiniteScroll, + squareAvatars, + aiChanMode, + showGapBetweenNotesInTimeline, + instanceTicker, + overridedDeviceKind, +], async () => { + await reloadAsk(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.general, + icon: 'ti ti-adjustments', +}); +</script> diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue new file mode 100644 index 0000000000..7db267c142 --- /dev/null +++ b/packages/frontend/src/pages/settings/import-export.vue @@ -0,0 +1,165 @@ +<template> +<div class="_formRoot"> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.allNotes }}</template> + <FormFolder> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.followingList }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <FormSwitch v-model="excludeMutingUsers" class="_formBlock"> + {{ i18n.ts._exportOrImport.excludeMutingUsers }} + </FormSwitch> + <FormSwitch v-model="excludeInactiveUsers" class="_formBlock"> + {{ i18n.ts._exportOrImport.excludeInactiveUsers }} + </FormSwitch> + <MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.userLists }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.muteList }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.blockingList }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import FormSection from '@/components/form/section.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const excludeMutingUsers = ref(false); +const excludeInactiveUsers = ref(false); + +const onExportSuccess = () => { + os.alert({ + type: 'info', + text: i18n.ts.exportRequested, + }); +}; + +const onImportSuccess = () => { + os.alert({ + type: 'info', + text: i18n.ts.importRequested, + }); +}; + +const onError = (ev) => { + os.alert({ + type: 'error', + text: ev.message, + }); +}; + +const exportNotes = () => { + os.api('i/export-notes', {}).then(onExportSuccess).catch(onError); +}; + +const exportFollowing = () => { + os.api('i/export-following', { + excludeMuting: excludeMutingUsers.value, + excludeInactive: excludeInactiveUsers.value, + }) + .then(onExportSuccess).catch(onError); +}; + +const exportBlocking = () => { + os.api('i/export-blocking', {}).then(onExportSuccess).catch(onError); +}; + +const exportUserLists = () => { + os.api('i/export-user-lists', {}).then(onExportSuccess).catch(onError); +}; + +const exportMuting = () => { + os.api('i/export-mute', {}).then(onExportSuccess).catch(onError); +}; + +const importFollowing = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('i/import-following', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importUserLists = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importMuting = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importBlocking = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.importAndExport, + icon: 'ti ti-package', +}); +</script> + +<style module> +.button { + margin-right: 16px; +} +</style> diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue new file mode 100644 index 0000000000..01436cd554 --- /dev/null +++ b/packages/frontend/src/pages/settings/index.vue @@ -0,0 +1,291 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900" :margin-min="20" :margin-max="32"> + <div ref="el" class="vvcocwet" :class="{ wide: !narrow }"> + <div class="body"> + <div v-if="!narrow || currentPage?.route.name == null" class="nav"> + <div class="baaadecd"> + <MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu> + </div> + </div> + <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> + <div class="bkzroven"> + <RouterView/> + </div> + </div> + </div> + </div> + </MkSpacer> +</mkstickycontainer> +</template> + +<script setup lang="ts"> +import { computed, defineAsyncComponent, inject, nextTick, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue'; +import { i18n } from '@/i18n'; +import MkInfo from '@/components/MkInfo.vue'; +import MkSuperMenu from '@/components/MkSuperMenu.vue'; +import { scroll } from '@/scripts/scroll'; +import { signout, $i } from '@/account'; +import { unisonReload } from '@/scripts/unison-reload'; +import { instance } from '@/instance'; +import { useRouter } from '@/router'; +import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import * as os from '@/os'; + +const indexInfo = { + title: i18n.ts.settings, + icon: 'ti ti-settings', + hideHeader: true, +}; +const INFO = ref(indexInfo); +const el = ref<HTMLElement | null>(null); +const childInfo = ref(null); + +const router = useRouter(); + +let narrow = $ref(false); +const NARROW_THRESHOLD = 600; + +let currentPage = $computed(() => router.currentRef.value.child); + +const ro = new ResizeObserver((entries, observer) => { + if (entries.length === 0) return; + narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD; +}); + +const menuDef = computed(() => [{ + title: i18n.ts.basicSettings, + items: [{ + icon: 'ti ti-user', + text: i18n.ts.profile, + to: '/settings/profile', + active: currentPage?.route.name === 'profile', + }, { + icon: 'ti ti-lock-open', + text: i18n.ts.privacy, + to: '/settings/privacy', + active: currentPage?.route.name === 'privacy', + }, { + icon: 'ti ti-mood-happy', + text: i18n.ts.reaction, + to: '/settings/reaction', + active: currentPage?.route.name === 'reaction', + }, { + icon: 'ti ti-cloud', + text: i18n.ts.drive, + to: '/settings/drive', + active: currentPage?.route.name === 'drive', + }, { + icon: 'ti ti-bell', + text: i18n.ts.notifications, + to: '/settings/notifications', + active: currentPage?.route.name === 'notifications', + }, { + icon: 'ti ti-mail', + text: i18n.ts.email, + to: '/settings/email', + active: currentPage?.route.name === 'email', + }, { + icon: 'ti ti-share', + text: i18n.ts.integration, + to: '/settings/integration', + active: currentPage?.route.name === 'integration', + }, { + icon: 'ti ti-lock', + text: i18n.ts.security, + to: '/settings/security', + active: currentPage?.route.name === 'security', + }], +}, { + title: i18n.ts.clientSettings, + items: [{ + icon: 'ti ti-adjustments', + text: i18n.ts.general, + to: '/settings/general', + active: currentPage?.route.name === 'general', + }, { + icon: 'ti ti-palette', + text: i18n.ts.theme, + to: '/settings/theme', + active: currentPage?.route.name === 'theme', + }, { + icon: 'ti ti-menu-2', + text: i18n.ts.navbar, + to: '/settings/navbar', + active: currentPage?.route.name === 'navbar', + }, { + icon: 'ti ti-equal-double', + text: i18n.ts.statusbar, + to: '/settings/statusbar', + active: currentPage?.route.name === 'statusbar', + }, { + icon: 'ti ti-music', + text: i18n.ts.sounds, + to: '/settings/sounds', + active: currentPage?.route.name === 'sounds', + }, { + icon: 'ti ti-plug', + text: i18n.ts.plugins, + to: '/settings/plugin', + active: currentPage?.route.name === 'plugin', + }], +}, { + title: i18n.ts.otherSettings, + items: [{ + icon: 'ti ti-package', + text: i18n.ts.importAndExport, + to: '/settings/import-export', + active: currentPage?.route.name === 'import-export', + }, { + icon: 'ti ti-planet-off', + text: i18n.ts.instanceMute, + to: '/settings/instance-mute', + active: currentPage?.route.name === 'instance-mute', + }, { + icon: 'ti ti-ban', + text: i18n.ts.muteAndBlock, + to: '/settings/mute-block', + active: currentPage?.route.name === 'mute-block', + }, { + icon: 'ti ti-message-off', + text: i18n.ts.wordMute, + to: '/settings/word-mute', + active: currentPage?.route.name === 'word-mute', + }, { + icon: 'ti ti-api', + text: 'API', + to: '/settings/api', + active: currentPage?.route.name === 'api', + }, { + icon: 'ti ti-webhook', + text: 'Webhook', + to: '/settings/webhook', + active: currentPage?.route.name === 'webhook', + }, { + icon: 'ti ti-dots', + text: i18n.ts.other, + to: '/settings/other', + active: currentPage?.route.name === 'other', + }], +}, { + items: [{ + icon: 'ti ti-device-floppy', + text: i18n.ts.preferencesBackups, + to: '/settings/preferences-backups', + active: currentPage?.route.name === 'preferences-backups', + }, { + type: 'button', + icon: 'ti ti-trash', + text: i18n.ts.clearCache, + action: () => { + localStorage.removeItem('locale'); + localStorage.removeItem('theme'); + unisonReload(); + }, + }, { + type: 'button', + icon: 'ti ti-power', + text: i18n.ts.logout, + action: async () => { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.logoutConfirm, + }); + if (canceled) return; + signout(); + }, + danger: true, + }], +}]); + +watch($$(narrow), () => { +}); + +onMounted(() => { + ro.observe(el.value); + + narrow = el.value.offsetWidth < NARROW_THRESHOLD; + + if (!narrow && currentPage?.route.name == null) { + router.replace('/settings/profile'); + } +}); + +onActivated(() => { + narrow = el.value.offsetWidth < NARROW_THRESHOLD; + + if (!narrow && currentPage?.route.name == null) { + router.replace('/settings/profile'); + } +}); + +onUnmounted(() => { + ro.disconnect(); +}); + +const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified)); + +provideMetadataReceiver((info) => { + if (info == null) { + childInfo.value = null; + } else { + childInfo.value = info; + } +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(INFO); +// w 890 +// h 700 +</script> + +<style lang="scss" scoped> +.vvcocwet { + > .body { + > .nav { + .baaadecd { + > .info { + margin: 16px 0; + } + + > .accounts { + > .avatar { + display: block; + width: 50px; + height: 50px; + margin: 8px auto 16px auto; + } + } + } + } + + > .main { + .bkzroven { + } + } + } + + &.wide { + > .body { + display: flex; + height: 100%; + + > .nav { + width: 34%; + padding-right: 32px; + box-sizing: border-box; + } + + > .main { + flex: 1; + min-width: 0; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/settings/instance-mute.vue b/packages/frontend/src/pages/settings/instance-mute.vue new file mode 100644 index 0000000000..54504de188 --- /dev/null +++ b/packages/frontend/src/pages/settings/instance-mute.vue @@ -0,0 +1,53 @@ +<template> +<div class="_formRoot"> + <MkInfo>{{ i18n.ts._instanceMute.title }}</MkInfo> + <FormTextarea v-model="instanceMutes" class="_formBlock"> + <template #label>{{ i18n.ts._instanceMute.heading }}</template> + <template #caption>{{ i18n.ts._instanceMute.instanceMuteDescription }}<br>{{ i18n.ts._instanceMute.instanceMuteDescription2 }}</template> + </FormTextarea> + <MkButton primary :disabled="!changed" class="_formBlock" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const instanceMutes = ref($i!.mutedInstances.join('\n')); +const changed = ref(false); + +async function save() { + let mutes = instanceMutes.value + .trim().split('\n') + .map(el => el.trim()) + .filter(el => el); + + await os.api('i/update', { + mutedInstances: mutes, + }); + + changed.value = false; + + // Refresh filtered list to signal to the user how they've been saved + instanceMutes.value = mutes.join('\n'); +} + +watch(instanceMutes, () => { + changed.value = true; +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.instanceMute, + icon: 'ti ti-planet-off', +}); +</script> diff --git a/packages/frontend/src/pages/settings/integration.vue b/packages/frontend/src/pages/settings/integration.vue new file mode 100644 index 0000000000..557fe778e6 --- /dev/null +++ b/packages/frontend/src/pages/settings/integration.vue @@ -0,0 +1,99 @@ +<template> +<div class="_formRoot"> + <FormSection v-if="instance.enableTwitterIntegration"> + <template #label><i class="ti ti-brand-twitter"></i> Twitter</template> + <p v-if="integrations.twitter">{{ i18n.ts.connectedTo }}: <a :href="`https://twitter.com/${integrations.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ integrations.twitter.screenName }}</a></p> + <MkButton v-if="integrations.twitter" danger @click="disconnectTwitter">{{ i18n.ts.disconnectService }}</MkButton> + <MkButton v-else primary @click="connectTwitter">{{ i18n.ts.connectService }}</MkButton> + </FormSection> + + <FormSection v-if="instance.enableDiscordIntegration"> + <template #label><i class="ti ti-brand-discord"></i> Discord</template> + <p v-if="integrations.discord">{{ i18n.ts.connectedTo }}: <a :href="`https://discord.com/users/${integrations.discord.id}`" rel="nofollow noopener" target="_blank">@{{ integrations.discord.username }}#{{ integrations.discord.discriminator }}</a></p> + <MkButton v-if="integrations.discord" danger @click="disconnectDiscord">{{ i18n.ts.disconnectService }}</MkButton> + <MkButton v-else primary @click="connectDiscord">{{ i18n.ts.connectService }}</MkButton> + </FormSection> + + <FormSection v-if="instance.enableGithubIntegration"> + <template #label><i class="ti ti-brand-github"></i> GitHub</template> + <p v-if="integrations.github">{{ i18n.ts.connectedTo }}: <a :href="`https://github.com/${integrations.github.login}`" rel="nofollow noopener" target="_blank">@{{ integrations.github.login }}</a></p> + <MkButton v-if="integrations.github" danger @click="disconnectGithub">{{ i18n.ts.disconnectService }}</MkButton> + <MkButton v-else primary @click="connectGithub">{{ i18n.ts.connectService }}</MkButton> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import { apiUrl } from '@/config'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import { $i } from '@/account'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const twitterForm = ref<Window | null>(null); +const discordForm = ref<Window | null>(null); +const githubForm = ref<Window | null>(null); + +const integrations = computed(() => $i!.integrations); + +function openWindow(service: string, type: string) { + return window.open(`${apiUrl}/${type}/${service}`, + `${service}_${type}_window`, + 'height=570, width=520', + ); +} + +function connectTwitter() { + twitterForm.value = openWindow('twitter', 'connect'); +} + +function disconnectTwitter() { + openWindow('twitter', 'disconnect'); +} + +function connectDiscord() { + discordForm.value = openWindow('discord', 'connect'); +} + +function disconnectDiscord() { + openWindow('discord', 'disconnect'); +} + +function connectGithub() { + githubForm.value = openWindow('github', 'connect'); +} + +function disconnectGithub() { + openWindow('github', 'disconnect'); +} + +onMounted(() => { + document.cookie = `igi=${$i!.token}; path=/;` + + ' max-age=31536000;' + + (document.location.protocol.startsWith('https') ? ' secure' : ''); + + watch(integrations, () => { + if (integrations.value.twitter) { + if (twitterForm.value) twitterForm.value.close(); + } + if (integrations.value.discord) { + if (discordForm.value) discordForm.value.close(); + } + if (integrations.value.github) { + if (githubForm.value) githubForm.value.close(); + } + }); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.integration, + icon: 'ti ti-share', +}); +</script> diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue new file mode 100644 index 0000000000..1cf33d34db --- /dev/null +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -0,0 +1,61 @@ +<template> +<div class="_formRoot"> + <MkTab v-model="tab" style="margin-bottom: var(--margin);"> + <option value="mute">{{ i18n.ts.mutedUsers }}</option> + <option value="block">{{ i18n.ts.blockedUsers }}</option> + </MkTab> + <div v-if="tab === 'mute'"> + <MkPagination :pagination="mutingPagination" class="muting"> + <template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template> + <template #default="{items}"> + <FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)"> + <MkAcct :user="mute.mutee"/> + </FormLink> + </template> + </MkPagination> + </div> + <div v-if="tab === 'block'"> + <MkPagination :pagination="blockingPagination" class="blocking"> + <template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template> + <template #default="{items}"> + <FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)"> + <MkAcct :user="block.blockee"/> + </FormLink> + </template> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkTab from '@/components/MkTab.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormLink from '@/components/form/link.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let tab = $ref('mute'); + +const mutingPagination = { + endpoint: 'mute/list' as const, + limit: 10, +}; + +const blockingPagination = { + endpoint: 'blocking/list' as const, + limit: 10, +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.muteAndBlock, + icon: 'ti ti-ban', +}); +</script> diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue new file mode 100644 index 0000000000..0b2776ec90 --- /dev/null +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -0,0 +1,87 @@ +<template> +<div class="_formRoot"> + <FormTextarea v-model="items" tall manual-save class="_formBlock"> + <template #label>{{ i18n.ts.navbar }}</template> + <template #caption><button class="_textButton" @click="addItem">{{ i18n.ts.addItem }}</button></template> + </FormTextarea> + + <FormRadios v-model="menuDisplay" class="_formBlock"> + <template #label>{{ i18n.ts.display }}</template> + <option value="sideFull">{{ i18n.ts._menuDisplay.sideFull }}</option> + <option value="sideIcon">{{ i18n.ts._menuDisplay.sideIcon }}</option> + <option value="top">{{ i18n.ts._menuDisplay.top }}</option> + <!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ i18n.ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 --> + </FormRadios> + + <FormButton danger class="_formBlock" @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const items = ref(defaultStore.state.menu.join('\n')); + +const split = computed(() => items.value.trim().split('\n').filter(x => x.trim() !== '')); +const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay')); + +async function reloadAsk() { + const { canceled } = await os.confirm({ + type: 'info', + text: i18n.ts.reloadToApplySetting, + }); + if (canceled) return; + + unisonReload(); +} + +async function addItem() { + const menu = Object.keys(navbarItemDef).filter(k => !defaultStore.state.menu.includes(k)); + const { canceled, result: item } = await os.select({ + title: i18n.ts.addItem, + items: [...menu.map(k => ({ + value: k, text: i18n.ts[navbarItemDef[k].title], + })), { + value: '-', text: i18n.ts.divider, + }], + }); + if (canceled) return; + items.value = [...split.value, item].join('\n'); +} + +async function save() { + defaultStore.set('menu', split.value); + await reloadAsk(); +} + +function reset() { + defaultStore.reset('menu'); + items.value = defaultStore.state.menu.join('\n'); +} + +watch(items, async () => { + await save(); +}); + +watch(menuDisplay, async () => { + await reloadAsk(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.navbar, + icon: 'ti ti-list', +}); +</script> diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue new file mode 100644 index 0000000000..e85fede157 --- /dev/null +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -0,0 +1,90 @@ +<template> +<div class="_formRoot"> + <FormLink class="_formBlock" @click="configure"><template #icon><i class="ti ti-settings"></i></template>{{ i18n.ts.notificationSetting }}</FormLink> + <FormSection> + <FormLink class="_formBlock" @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> + <FormLink class="_formBlock" @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink> + <FormLink class="_formBlock" @click="readAllMessagingMessages">{{ i18n.ts.markAsReadAllTalkMessages }}</FormLink> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts.pushNotification }}</template> + <MkPushNotificationAllowButton ref="allowButton" /> + <FormSwitch class="_formBlock" :disabled="!pushRegistrationInServer" :model-value="sendReadMessage" @update:model-value="onChangeSendReadMessage"> + <template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template> + <template #caption> + <I18n :src="i18n.ts.sendPushNotificationReadMessageCaption"> + <template #emptyPushNotificationMessage>{{ i18n.ts._notification.emptyPushNotificationMessage }}</template> + </I18n> + </template> + </FormSwitch> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import { notificationTypes } from 'misskey-js'; +import FormButton from '@/components/MkButton.vue'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; + +let allowButton = $ref<InstanceType<typeof MkPushNotificationAllowButton>>(); +let pushRegistrationInServer = $computed(() => allowButton?.pushRegistrationInServer); +let sendReadMessage = $computed(() => pushRegistrationInServer?.sendReadMessage || false); + +async function readAllUnreadNotes() { + await os.api('i/read-all-unread-notes'); +} + +async function readAllMessagingMessages() { + await os.api('i/read-all-messaging-messages'); +} + +async function readAllNotifications() { + await os.api('notifications/mark-all-as-read'); +} + +function configure() { + const includingTypes = notificationTypes.filter(x => !$i!.mutingNotificationTypes.includes(x)); + os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { + includingTypes, + showGlobalToggle: false, + }, { + done: async (res) => { + const { includingTypes: value } = res; + await os.apiWithDialog('i/update', { + mutingNotificationTypes: notificationTypes.filter(x => !value.includes(x)), + }).then(i => { + $i!.mutingNotificationTypes = i.mutingNotificationTypes; + }); + }, + }, 'closed'); +} + +function onChangeSendReadMessage(v: boolean) { + if (!pushRegistrationInServer) return; + + os.apiWithDialog('sw/update-registration', { + endpoint: pushRegistrationInServer.endpoint, + sendReadMessage: v, + }).then(res => { + if (!allowButton) return; + allowButton.pushRegistrationInServer = res; + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.notifications, + icon: 'ti ti-bell', +}); +</script> diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue new file mode 100644 index 0000000000..40bb202789 --- /dev/null +++ b/packages/frontend/src/pages/settings/other.vue @@ -0,0 +1,47 @@ +<template> +<div class="_formRoot"> + <FormSwitch v-model="$i.injectFeaturedNote" class="_formBlock" @update:model-value="onChangeInjectFeaturedNote"> + {{ i18n.ts.showFeaturedNotesInTimeline }} + </FormSwitch> + + <!-- + <FormSwitch v-model="reportError" class="_formBlock">{{ i18n.ts.sendErrorReports }}<template #caption>{{ i18n.ts.sendErrorReportsDescription }}</template></FormSwitch> + --> + + <FormLink to="/settings/account-info" class="_formBlock">{{ i18n.ts.accountInfo }}</FormLink> + + <FormLink to="/registry" class="_formBlock"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink> + + <FormLink to="/settings/delete-account" class="_formBlock"><template #icon><i class="ti ti-alert-triangle"></i></template>{{ i18n.ts.closeAccount }}</FormLink> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormLink from '@/components/form/link.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const reportError = computed(defaultStore.makeGetterSetter('reportError')); + +function onChangeInjectFeaturedNote(v) { + os.api('i/update', { + injectFeaturedNote: v, + }).then((i) => { + $i!.injectFeaturedNote = i.injectFeaturedNote; + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.other, + icon: 'ti ti-dots', +}); +</script> diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue new file mode 100644 index 0000000000..550bba242e --- /dev/null +++ b/packages/frontend/src/pages/settings/plugin.install.vue @@ -0,0 +1,124 @@ +<template> +<div class="_formRoot"> + <FormInfo warn class="_formBlock">{{ i18n.ts._plugin.installWarn }}</FormInfo> + + <FormTextarea v-model="code" tall class="_formBlock"> + <template #label>{{ i18n.ts.code }}</template> + </FormTextarea> + + <div class="_formBlock"> + <FormButton :disabled="code == null" primary inline @click="install"><i class="ti ti-check"></i> {{ i18n.ts.install }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, nextTick, ref } from 'vue'; +import { AiScript, parse } from '@syuilo/aiscript'; +import { serialize } from '@syuilo/aiscript/built/serializer'; +import { v4 as uuid } from 'uuid'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const code = ref(null); + +function installPlugin({ id, meta, ast, token }) { + ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({ + ...meta, + id, + active: true, + configData: {}, + token: token, + ast: ast, + })); +} + +async function install() { + let ast; + try { + ast = parse(code.value); + } catch (err) { + os.alert({ + type: 'error', + text: 'Syntax error :(', + }); + return; + } + + const meta = AiScript.collectMetadata(ast); + if (meta == null) { + os.alert({ + type: 'error', + text: 'No metadata found :(', + }); + return; + } + + const metadata = meta.get(null); + if (metadata == null) { + os.alert({ + type: 'error', + text: 'No metadata found :(', + }); + return; + } + + const { name, version, author, description, permissions, config } = metadata; + if (name == null || version == null || author == null) { + os.alert({ + type: 'error', + text: 'Required property not found :(', + }); + return; + } + + const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => { + os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), { + title: i18n.ts.tokenRequested, + information: i18n.ts.pluginTokenRequestedDescription, + initialName: name, + initialPermissions: permissions, + }, { + done: async result => { + const { name, permissions } = result; + const { token } = await os.api('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + res(token); + }, + }, 'closed'); + }); + + installPlugin({ + id: uuid(), + meta: { + name, version, author, description, permissions, config, + }, + token, + ast: serialize(ast), + }); + + os.success(); + + nextTick(() => { + unisonReload(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._plugin.install, + icon: 'ti ti-download', +}); +</script> diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue new file mode 100644 index 0000000000..905efd833d --- /dev/null +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -0,0 +1,98 @@ +<template> +<div class="_formRoot"> + <FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink> + + <FormSection> + <template #label>{{ i18n.ts.manage }}</template> + <div v-for="plugin in plugins" :key="plugin.id" class="_formBlock _panel" style="padding: 20px;"> + <span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span> + + <FormSwitch class="_formBlock" :model-value="plugin.active" @update:model-value="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch> + + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ plugin.author }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ plugin.description }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.permission }}</template> + <template #value>{{ plugin.permission }}</template> + </MkKeyValue> + + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton> + <MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> + </div> + </div> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { nextTick, ref } from 'vue'; +import FormLink from '@/components/form/link.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const plugins = ref(ColdDeviceStorage.get('plugins')); + +function uninstall(plugin) { + ColdDeviceStorage.set('plugins', plugins.value.filter(x => x.id !== plugin.id)); + os.success(); + nextTick(() => { + unisonReload(); + }); +} + +// TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする +async function config(plugin) { + const config = plugin.config; + for (const key in plugin.configData) { + config[key].default = plugin.configData[key]; + } + + const { canceled, result } = await os.form(plugin.name, config); + if (canceled) return; + + const coldPlugins = ColdDeviceStorage.get('plugins'); + coldPlugins.find(p => p.id === plugin.id)!.configData = result; + ColdDeviceStorage.set('plugins', coldPlugins); + + nextTick(() => { + location.reload(); + }); +} + +function changeActive(plugin, active) { + const coldPlugins = ColdDeviceStorage.get('plugins'); + coldPlugins.find(p => p.id === plugin.id)!.active = active; + ColdDeviceStorage.set('plugins', coldPlugins); + + nextTick(() => { + location.reload(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.plugins, + icon: 'ti ti-plug', +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue new file mode 100644 index 0000000000..f427a170c4 --- /dev/null +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -0,0 +1,444 @@ +<template> +<div class="_formRoot"> + <div :class="$style.buttons"> + <MkButton inline primary @click="saveNew">{{ ts._preferencesBackups.saveNew }}</MkButton> + <MkButton inline @click="loadFile">{{ ts._preferencesBackups.loadFile }}</MkButton> + </div> + + <FormSection> + <template #label>{{ ts._preferencesBackups.list }}</template> + <template v-if="profiles && Object.keys(profiles).length > 0"> + <div + v-for="(profile, id) in profiles" + :key="id" + class="_formBlock _panel" + :class="$style.profile" + @click="$event => menu($event, id)" + @contextmenu.prevent.stop="$event => menu($event, id)" + > + <div :class="$style.profileName">{{ profile.name }}</div> + <div :class="$style.profileTime">{{ t('_preferencesBackups.createdAt', { date: (new Date(profile.createdAt)).toLocaleDateString(), time: (new Date(profile.createdAt)).toLocaleTimeString() }) }}</div> + <div v-if="profile.updatedAt" :class="$style.profileTime">{{ t('_preferencesBackups.updatedAt', { date: (new Date(profile.updatedAt)).toLocaleDateString(), time: (new Date(profile.updatedAt)).toLocaleTimeString() }) }}</div> + </div> + </template> + <div v-else-if="profiles"> + <MkInfo>{{ ts._preferencesBackups.noBackups }}</MkInfo> + </div> + <MkLoading v-else/> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, onUnmounted, useCssModule } from 'vue'; +import { v4 as uuid } from 'uuid'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage, defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { stream } from '@/stream'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { version, host } from '@/config'; +import { definePageMetadata } from '@/scripts/page-metadata'; +const { t, ts } = i18n; + +useCssModule(); + +const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ + 'menu', + 'visibility', + 'localOnly', + 'statusbars', + 'widgets', + 'tl', + 'overridedDeviceKind', + 'serverDisconnectedBehavior', + 'nsfw', + 'animation', + 'animatedMfm', + 'loadRawImages', + 'imageNewTab', + 'disableShowingAnimatedImages', + 'disablePagesScript', + 'emojiStyle', + 'disableDrawer', + 'useBlurEffectForModal', + 'useBlurEffect', + 'showFixedPostForm', + 'enableInfiniteScroll', + 'useReactionPickerForContextMenu', + 'showGapBetweenNotesInTimeline', + 'instanceTicker', + 'reactionPickerSize', + 'reactionPickerWidth', + 'reactionPickerHeight', + 'reactionPickerUseDrawerForMobile', + 'defaultSideView', + 'menuDisplay', + 'reportError', + 'squareAvatars', + 'numberOfPageCache', + 'aiChanMode', +]; +const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ + 'lightTheme', + 'darkTheme', + 'syncDeviceDarkMode', + 'plugins', + 'mediaVolume', + 'sound_masterVolume', + 'sound_note', + 'sound_noteMy', + 'sound_notification', + 'sound_chat', + 'sound_chatBg', + 'sound_antenna', + 'sound_channel', +]; + +const scope = ['clientPreferencesProfiles']; + +const profileProps = ['name', 'createdAt', 'updatedAt', 'misskeyVersion', 'settings']; + +type Profile = { + name: string; + createdAt: string; + updatedAt: string | null; + misskeyVersion: string; + host: string; + settings: { + hot: Record<keyof typeof defaultStoreSaveKeys, unknown>; + cold: Record<keyof typeof coldDeviceStorageSaveKeys, unknown>; + fontSize: string | null; + useSystemFont: 't' | null; + wallpaper: string | null; + }; +}; + +const connection = $i && stream.useChannel('main'); + +let profiles = $ref<Record<string, Profile> | null>(null); + +os.api('i/registry/get-all', { scope }) + .then(res => { + profiles = res || {}; + }); + +function isObject(value: unknown): value is Record<string, unknown> { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function validate(profile: unknown): void { + if (!isObject(profile)) throw new Error('not an object'); + + // Check if unnecessary properties exist + if (Object.keys(profile).some(key => !profileProps.includes(key))) throw new Error('Unnecessary properties exist'); + + if (!profile.name) throw new Error('Missing required prop: name'); + if (!profile.misskeyVersion) throw new Error('Missing required prop: misskeyVersion'); + + // Check if createdAt and updatedAt is Date + // https://zenn.dev/lollipop_onl/articles/eoz-judge-js-invalid-date + if (!profile.createdAt || Number.isNaN(new Date(profile.createdAt).getTime())) throw new Error('createdAt is falsy or not Date'); + if (profile.updatedAt) { + if (Number.isNaN(new Date(profile.updatedAt).getTime())) { + throw new Error('updatedAt is not Date'); + } + } else if (profile.updatedAt !== null) { + throw new Error('updatedAt is not null'); + } + + if (!profile.settings) throw new Error('Missing required prop: settings'); + if (!isObject(profile.settings)) throw new Error('Invalid prop: settings'); +} + +function getSettings(): Profile['settings'] { + const hot = {} as Record<keyof typeof defaultStoreSaveKeys, unknown>; + for (const key of defaultStoreSaveKeys) { + hot[key] = defaultStore.state[key]; + } + + const cold = {} as Record<keyof typeof coldDeviceStorageSaveKeys, unknown>; + for (const key of coldDeviceStorageSaveKeys) { + cold[key] = ColdDeviceStorage.get(key); + } + + return { + hot, + cold, + fontSize: localStorage.getItem('fontSize'), + useSystemFont: localStorage.getItem('useSystemFont') as 't' | null, + wallpaper: localStorage.getItem('wallpaper'), + }; +} + +async function saveNew(): Promise<void> { + if (!profiles) return; + + const { canceled, result: name } = await os.inputText({ + title: ts._preferencesBackups.inputName, + }); + if (canceled) return; + + if (Object.values(profiles).some(x => x.name === name)) { + return os.alert({ + title: ts._preferencesBackups.cannotSave, + text: t('_preferencesBackups.nameAlreadyExists', { name }), + }); + } + + const id = uuid(); + const profile: Profile = { + name, + createdAt: (new Date()).toISOString(), + updatedAt: null, + misskeyVersion: version, + host, + settings: getSettings(), + }; + await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); +} + +function loadFile(): void { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = false; + input.onchange = async () => { + if (!profiles) return; + if (!input.files || input.files.length === 0) return; + + const file = input.files[0]; + + if (file.type !== 'application/json') { + return os.alert({ + type: 'error', + title: ts._preferencesBackups.cannotLoad, + text: ts._preferencesBackups.invalidFile, + }); + } + + let profile: Profile; + try { + profile = JSON.parse(await file.text()) as unknown as Profile; + validate(profile); + } catch (err) { + return os.alert({ + type: 'error', + title: ts._preferencesBackups.cannotLoad, + text: err?.message, + }); + } + + const id = uuid(); + await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); + + // 一応廃棄 + (window as any).__misskey_input_ref__ = null; + }; + + // https://qiita.com/fukasawah/items/b9dc732d95d99551013d + // iOS Safari で正常に動かす為のおまじない + (window as any).__misskey_input_ref__ = input; + + input.click(); +} + +async function applyProfile(id: string): Promise<void> { + if (!profiles) return; + + const profile = profiles[id]; + + const { canceled: cancel1 } = await os.confirm({ + type: 'warning', + title: ts._preferencesBackups.apply, + text: t('_preferencesBackups.applyConfirm', { name: profile.name }), + }); + if (cancel1) return; + + // TODO: バージョン or ホストが違ったらさらに警告を表示 + + const settings = profile.settings; + + // defaultStore + for (const key of defaultStoreSaveKeys) { + if (settings.hot[key] !== undefined) { + defaultStore.set(key, settings.hot[key]); + } + } + + // coldDeviceStorage + for (const key of coldDeviceStorageSaveKeys) { + if (settings.cold[key] !== undefined) { + ColdDeviceStorage.set(key, settings.cold[key]); + } + } + + // fontSize + if (settings.fontSize) { + localStorage.setItem('fontSize', settings.fontSize); + } else { + localStorage.removeItem('fontSize'); + } + + // useSystemFont + if (settings.useSystemFont) { + localStorage.setItem('useSystemFont', settings.useSystemFont); + } else { + localStorage.removeItem('useSystemFont'); + } + + // wallpaper + if (settings.wallpaper != null) { + localStorage.setItem('wallpaper', settings.wallpaper); + } else { + localStorage.removeItem('wallpaper'); + } + + const { canceled: cancel2 } = await os.confirm({ + type: 'info', + text: ts.reloadToApplySetting, + }); + if (cancel2) return; + + unisonReload(); +} + +async function deleteProfile(id: string): Promise<void> { + if (!profiles) return; + + const { canceled } = await os.confirm({ + type: 'info', + title: ts.delete, + text: t('deleteAreYouSure', { x: profiles[id].name }), + }); + if (canceled) return; + + await os.apiWithDialog('i/registry/remove', { scope, key: id }); + delete profiles[id]; +} + +async function save(id: string): Promise<void> { + if (!profiles) return; + + const { name, createdAt } = profiles[id]; + + const { canceled } = await os.confirm({ + type: 'info', + title: ts._preferencesBackups.save, + text: t('_preferencesBackups.saveConfirm', { name }), + }); + if (canceled) return; + + const profile: Profile = { + name, + createdAt, + updatedAt: (new Date()).toISOString(), + misskeyVersion: version, + host, + settings: getSettings(), + }; + await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); +} + +async function rename(id: string): Promise<void> { + if (!profiles) return; + + const { canceled: cancel1, result: name } = await os.inputText({ + title: ts._preferencesBackups.inputName, + }); + if (cancel1 || profiles[id].name === name) return; + + if (Object.values(profiles).some(x => x.name === name)) { + return os.alert({ + title: ts._preferencesBackups.cannotSave, + text: t('_preferencesBackups.nameAlreadyExists', { name }), + }); + } + + const registry = Object.assign({}, { ...profiles[id] }); + + const { canceled: cancel2 } = await os.confirm({ + type: 'info', + title: ts._preferencesBackups.rename, + text: t('_preferencesBackups.renameConfirm', { old: registry.name, new: name }), + }); + if (cancel2) return; + + registry.name = name; + await os.apiWithDialog('i/registry/set', { scope, key: id, value: registry }); +} + +function menu(ev: MouseEvent, profileId: string) { + if (!profiles) return; + + return os.popupMenu([{ + text: ts._preferencesBackups.apply, + icon: 'ti ti-check', + action: () => applyProfile(profileId), + }, { + type: 'a', + text: ts.download, + icon: 'ti ti-download', + href: URL.createObjectURL(new Blob([JSON.stringify(profiles[profileId], null, 2)], { type: 'application/json' })), + download: `${profiles[profileId].name}.json`, + }, null, { + text: ts.rename, + icon: 'ti ti-forms', + action: () => rename(profileId), + }, { + text: ts._preferencesBackups.save, + icon: 'ti ti-device-floppy', + action: () => save(profileId), + }, null, { + text: ts._preferencesBackups.delete, + icon: 'ti ti-trash', + action: () => deleteProfile(profileId), + danger: true, + }], ev.currentTarget ?? ev.target); +} + +onMounted(() => { + // streamingのuser storage updateイベントを監視して更新 + connection?.on('registryUpdated', ({ scope: recievedScope, key, value }) => { + if (!recievedScope || recievedScope.length !== scope.length || recievedScope[0] !== scope[0]) return; + if (!profiles) return; + + profiles[key] = value; + }); +}); + +onUnmounted(() => { + connection?.off('registryUpdated'); +}); + +definePageMetadata(computed(() => ({ + title: ts.preferencesBackups, + icon: 'ti ti-device-floppy', + bg: 'var(--bg)', +}))); +</script> + +<style lang="scss" module> +.buttons { + display: flex; + gap: var(--margin); + flex-wrap: wrap; +} + +.profile { + padding: 20px; + cursor: pointer; + + &Name { + font-weight: 700; + } + + &Time { + font-size: .85em; + opacity: .7; + } +} +</style> diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue new file mode 100644 index 0000000000..915ca05767 --- /dev/null +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -0,0 +1,100 @@ +<template> +<div class="_formRoot"> + <FormSwitch v-model="isLocked" class="_formBlock" @update:model-value="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></FormSwitch> + <FormSwitch v-if="isLocked" v-model="autoAcceptFollowed" class="_formBlock" @update:model-value="save()">{{ i18n.ts.autoAcceptFollowed }}</FormSwitch> + + <FormSwitch v-model="publicReactions" class="_formBlock" @update:model-value="save()"> + {{ i18n.ts.makeReactionsPublic }} + <template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template> + </FormSwitch> + + <FormSelect v-model="ffVisibility" class="_formBlock" @update:model-value="save()"> + <template #label>{{ i18n.ts.ffVisibility }}</template> + <option value="public">{{ i18n.ts._ffVisibility.public }}</option> + <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option> + <option value="private">{{ i18n.ts._ffVisibility.private }}</option> + <template #caption>{{ i18n.ts.ffVisibilityDescription }}</template> + </FormSelect> + + <FormSwitch v-model="hideOnlineStatus" class="_formBlock" @update:model-value="save()"> + {{ i18n.ts.hideOnlineStatus }} + <template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template> + </FormSwitch> + <FormSwitch v-model="noCrawle" class="_formBlock" @update:model-value="save()"> + {{ i18n.ts.noCrawle }} + <template #caption>{{ i18n.ts.noCrawleDescription }}</template> + </FormSwitch> + <FormSwitch v-model="isExplorable" class="_formBlock" @update:model-value="save()"> + {{ i18n.ts.makeExplorable }} + <template #caption>{{ i18n.ts.makeExplorableDescription }}</template> + </FormSwitch> + + <FormSection> + <FormSwitch v-model="rememberNoteVisibility" class="_formBlock" @update:model-value="save()">{{ i18n.ts.rememberNoteVisibility }}</FormSwitch> + <FormFolder v-if="!rememberNoteVisibility" class="_formBlock"> + <template #label>{{ i18n.ts.defaultNoteVisibility }}</template> + <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template> + <template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template> + <template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template> + <template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template> + + <FormSelect v-model="defaultNoteVisibility" class="_formBlock"> + <option value="public">{{ i18n.ts._visibility.public }}</option> + <option value="home">{{ i18n.ts._visibility.home }}</option> + <option value="followers">{{ i18n.ts._visibility.followers }}</option> + <option value="specified">{{ i18n.ts._visibility.specified }}</option> + </FormSelect> + <FormSwitch v-model="defaultNoteLocalOnly" class="_formBlock">{{ i18n.ts._visibility.localOnly }}</FormSwitch> + </FormFolder> + </FormSection> + + <FormSwitch v-model="keepCw" class="_formBlock" @update:model-value="save()">{{ i18n.ts.keepCw }}</FormSwitch> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormSection from '@/components/form/section.vue'; +import FormFolder from '@/components/form/folder.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let isLocked = $ref($i.isLocked); +let autoAcceptFollowed = $ref($i.autoAcceptFollowed); +let noCrawle = $ref($i.noCrawle); +let isExplorable = $ref($i.isExplorable); +let hideOnlineStatus = $ref($i.hideOnlineStatus); +let publicReactions = $ref($i.publicReactions); +let ffVisibility = $ref($i.ffVisibility); + +let defaultNoteVisibility = $computed(defaultStore.makeGetterSetter('defaultNoteVisibility')); +let defaultNoteLocalOnly = $computed(defaultStore.makeGetterSetter('defaultNoteLocalOnly')); +let rememberNoteVisibility = $computed(defaultStore.makeGetterSetter('rememberNoteVisibility')); +let keepCw = $computed(defaultStore.makeGetterSetter('keepCw')); + +function save() { + os.api('i/update', { + isLocked: !!isLocked, + autoAcceptFollowed: !!autoAcceptFollowed, + noCrawle: !!noCrawle, + isExplorable: !!isExplorable, + hideOnlineStatus: !!hideOnlineStatus, + publicReactions: !!publicReactions, + ffVisibility: ffVisibility, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.privacy, + icon: 'ti ti-lock-open', +}); +</script> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue new file mode 100644 index 0000000000..14eeeaaa11 --- /dev/null +++ b/packages/frontend/src/pages/settings/profile.vue @@ -0,0 +1,220 @@ +<template> +<div class="_formRoot"> + <div class="llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> + <div class="avatar"> + <MkAvatar class="avatar" :user="$i" :disable-link="true" @click="changeAvatar"/> + <MkButton primary rounded class="avatarEdit" @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton> + </div> + <MkButton primary rounded class="bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton> + </div> + + <FormInput v-model="profile.name" :max="30" manual-save class="_formBlock"> + <template #label>{{ i18n.ts._profile.name }}</template> + </FormInput> + + <FormTextarea v-model="profile.description" :max="500" tall manual-save class="_formBlock"> + <template #label>{{ i18n.ts._profile.description }}</template> + <template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template> + </FormTextarea> + + <FormInput v-model="profile.location" manual-save class="_formBlock"> + <template #label>{{ i18n.ts.location }}</template> + <template #prefix><i class="ti ti-map-pin"></i></template> + </FormInput> + + <FormInput v-model="profile.birthday" type="date" manual-save class="_formBlock"> + <template #label>{{ i18n.ts.birthday }}</template> + <template #prefix><i class="ti ti-cake"></i></template> + </FormInput> + + <FormSelect v-model="profile.lang" class="_formBlock"> + <template #label>{{ i18n.ts.language }}</template> + <option v-for="x in Object.keys(langmap)" :key="x" :value="x">{{ langmap[x].nativeName }}</option> + </FormSelect> + + <FormSlot class="_formBlock"> + <FormFolder> + <template #icon><i class="ti ti-list"></i></template> + <template #label>{{ i18n.ts._profile.metadataEdit }}</template> + + <div class="_formRoot"> + <FormSplit v-for="(record, i) in fields" :min-width="250" class="_formBlock"> + <FormInput v-model="record.name" small> + <template #label>{{ i18n.ts._profile.metadataLabel }} #{{ i + 1 }}</template> + </FormInput> + <FormInput v-model="record.value" small> + <template #label>{{ i18n.ts._profile.metadataContent }} #{{ i + 1 }}</template> + </FormInput> + </FormSplit> + <MkButton :disabled="fields.length >= 16" inline style="margin-right: 8px;" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton inline primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + </div> + </FormFolder> + <template #caption>{{ i18n.ts._profile.metadataDescription }}</template> + </FormSlot> + + <FormFolder> + <template #label>{{ i18n.ts.advancedSettings }}</template> + + <div class="_formRoot"> + <FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></FormSwitch> + <FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></FormSwitch> + </div> + </FormFolder> + + <FormSwitch v-model="profile.showTimelineReplies" class="_formBlock">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></FormSwitch> +</div> +</template> + +<script lang="ts" setup> +import { reactive, watch } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import FormInput from '@/components/form/input.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormSlot from '@/components/form/slot.vue'; +import { host } from '@/config'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { langmap } from '@/scripts/langmap'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const profile = reactive({ + name: $i.name, + description: $i.description, + location: $i.location, + birthday: $i.birthday, + lang: $i.lang, + isBot: $i.isBot, + isCat: $i.isCat, + showTimelineReplies: $i.showTimelineReplies, +}); + +watch(() => profile, () => { + save(); +}, { + deep: true, +}); + +const fields = reactive($i.fields.map(field => ({ name: field.name, value: field.value }))); + +function addField() { + fields.push({ + name: '', + value: '', + }); +} + +while (fields.length < 4) { + addField(); +} + +function saveFields() { + os.apiWithDialog('i/update', { + fields: fields.filter(field => field.name !== '' && field.value !== ''), + }); +} + +function save() { + os.apiWithDialog('i/update', { + name: profile.name || null, + description: profile.description || null, + location: profile.location || null, + birthday: profile.birthday || null, + lang: profile.lang || null, + isBot: !!profile.isBot, + isCat: !!profile.isCat, + showTimelineReplies: !!profile.showTimelineReplies, + }); +} + +function changeAvatar(ev) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => { + let originalOrCropped = file; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('cropImageAsk'), + }); + + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 1, + }); + } + + const i = await os.apiWithDialog('i/update', { + avatarId: originalOrCropped.id, + }); + $i.avatarId = i.avatarId; + $i.avatarUrl = i.avatarUrl; + }); +} + +function changeBanner(ev) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => { + let originalOrCropped = file; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('cropImageAsk'), + }); + + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 2, + }); + } + + const i = await os.apiWithDialog('i/update', { + bannerId: originalOrCropped.id, + }); + $i.bannerId = i.bannerId; + $i.bannerUrl = i.bannerUrl; + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.profile, + icon: 'ti ti-user', +}); +</script> + +<style lang="scss" scoped> +.llvierxe { + position: relative; + background-size: cover; + background-position: center; + border: solid 1px var(--divider); + border-radius: 10px; + overflow: clip; + + > .avatar { + display: inline-block; + text-align: center; + padding: 16px; + + > .avatar { + display: inline-block; + width: 72px; + height: 72px; + margin: 0 auto 16px auto; + } + } + + > .bannerEdit { + position: absolute; + top: 16px; + right: 16px; + } +} +</style> diff --git a/packages/frontend/src/pages/settings/reaction.vue b/packages/frontend/src/pages/settings/reaction.vue new file mode 100644 index 0000000000..2748cd7d4e --- /dev/null +++ b/packages/frontend/src/pages/settings/reaction.vue @@ -0,0 +1,154 @@ +<template> +<div class="_formRoot"> + <FromSlot class="_formBlock"> + <template #label>{{ i18n.ts.reactionSettingDescription }}</template> + <div v-panel style="border-radius: 6px;"> + <Sortable v-model="reactions" class="zoaiodol" :item-key="item => item" :animation="150" :delay="100" :delay-on-touch-only="true"> + <template #item="{element}"> + <button class="_button item" @click="remove(element, $event)"> + <MkEmoji :emoji="element" :normal="true"/> + </button> + </template> + <template #footer> + <button class="_button add" @click="chooseEmoji"><i class="ti ti-plus"></i></button> + </template> + </Sortable> + </div> + <template #caption>{{ i18n.ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ i18n.ts.preview }}</button></template> + </FromSlot> + + <FormRadios v-model="reactionPickerSize" class="_formBlock"> + <template #label>{{ i18n.ts.size }}</template> + <option :value="1">{{ i18n.ts.small }}</option> + <option :value="2">{{ i18n.ts.medium }}</option> + <option :value="3">{{ i18n.ts.large }}</option> + </FormRadios> + <FormRadios v-model="reactionPickerWidth" class="_formBlock"> + <template #label>{{ i18n.ts.numberOfColumn }}</template> + <option :value="1">5</option> + <option :value="2">6</option> + <option :value="3">7</option> + <option :value="4">8</option> + <option :value="5">9</option> + </FormRadios> + <FormRadios v-model="reactionPickerHeight" class="_formBlock"> + <template #label>{{ i18n.ts.height }}</template> + <option :value="1">{{ i18n.ts.small }}</option> + <option :value="2">{{ i18n.ts.medium }}</option> + <option :value="3">{{ i18n.ts.large }}</option> + <option :value="4">{{ i18n.ts.large }}+</option> + </FormRadios> + + <FormSwitch v-model="reactionPickerUseDrawerForMobile" class="_formBlock"> + {{ i18n.ts.useDrawerReactionPickerForMobile }} + <template #caption>{{ i18n.ts.needReloadToApply }}</template> + </FormSwitch> + + <FormSection> + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton inline @click="preview"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</FormButton> + <FormButton inline danger @click="setDefault"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton> + </div> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, watch } from 'vue'; +import Sortable from 'vuedraggable'; +import FormInput from '@/components/form/input.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FromSlot from '@/components/form/slot.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { deepClone } from '@/scripts/clone'; + +let reactions = $ref(deepClone(defaultStore.state.reactions)); + +const reactionPickerSize = $computed(defaultStore.makeGetterSetter('reactionPickerSize')); +const reactionPickerWidth = $computed(defaultStore.makeGetterSetter('reactionPickerWidth')); +const reactionPickerHeight = $computed(defaultStore.makeGetterSetter('reactionPickerHeight')); +const reactionPickerUseDrawerForMobile = $computed(defaultStore.makeGetterSetter('reactionPickerUseDrawerForMobile')); + +function save() { + defaultStore.set('reactions', reactions); +} + +function remove(reaction, ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts.remove, + action: () => { + reactions = reactions.filter(x => x !== reaction); + }, + }], ev.currentTarget ?? ev.target); +} + +function preview(ev: MouseEvent) { + os.popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { + asReactionPicker: true, + src: ev.currentTarget ?? ev.target, + }, {}, 'closed'); +} + +async function setDefault() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.resetAreYouSure, + }); + if (canceled) return; + + reactions = deepClone(defaultStore.def.reactions.default); +} + +function chooseEmoji(ev: MouseEvent) { + os.pickEmoji(ev.currentTarget ?? ev.target, { + showPinned: false, + }).then(emoji => { + if (!reactions.includes(emoji)) { + reactions.push(emoji); + } + }); +} + +watch($$(reactions), () => { + save(); +}, { + deep: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.reaction, + icon: 'ti ti-mood-happy', + action: { + icon: 'ti ti-eye', + handler: preview, + }, +}); +</script> + +<style lang="scss" scoped> +.zoaiodol { + padding: 12px; + font-size: 1.1em; + + > .item { + display: inline-block; + padding: 8px; + cursor: move; + } + + > .add { + display: inline-block; + padding: 8px; + } +} +</style> diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue new file mode 100644 index 0000000000..33f49eb3ef --- /dev/null +++ b/packages/frontend/src/pages/settings/security.vue @@ -0,0 +1,160 @@ +<template> +<div class="_formRoot"> + <FormSection> + <template #label>{{ i18n.ts.password }}</template> + <FormButton primary @click="change()">{{ i18n.ts.changePassword }}</FormButton> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.twoStepAuthentication }}</template> + <X2fa/> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.signinHistory }}</template> + <MkPagination :pagination="pagination" disable-auto-load> + <template #default="{items}"> + <div> + <div v-for="item in items" :key="item.id" v-panel class="timnmucd"> + <header> + <i v-if="item.success" class="ti ti-check icon succ"></i> + <i v-else class="ti ti-circle-x icon fail"></i> + <code class="ip _monospace">{{ item.ip }}</code> + <MkTime :time="item.createdAt" class="time"/> + </header> + </div> + </div> + </template> + </MkPagination> + </FormSection> + + <FormSection> + <FormSlot> + <FormButton danger @click="regenerateToken"><i class="ti ti-refresh"></i> {{ i18n.ts.regenerateLoginToken }}</FormButton> + <template #caption>{{ i18n.ts.regenerateLoginTokenDescription }}</template> + </FormSlot> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import X2fa from './2fa.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSlot from '@/components/form/slot.vue'; +import FormButton from '@/components/MkButton.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'i/signin-history' as const, + limit: 5, +}; + +async function change() { + const { canceled: canceled1, result: currentPassword } = await os.inputText({ + title: i18n.ts.currentPassword, + type: 'password', + }); + if (canceled1) return; + + const { canceled: canceled2, result: newPassword } = await os.inputText({ + title: i18n.ts.newPassword, + type: 'password', + }); + if (canceled2) return; + + const { canceled: canceled3, result: newPassword2 } = await os.inputText({ + title: i18n.ts.newPasswordRetype, + type: 'password', + }); + if (canceled3) return; + + if (newPassword !== newPassword2) { + os.alert({ + type: 'error', + text: i18n.ts.retypedNotMatch, + }); + return; + } + + os.apiWithDialog('i/change-password', { + currentPassword, + newPassword, + }); +} + +function regenerateToken() { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/regenerate_token', { + password: password, + }); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.security, + icon: 'ti ti-lock', +}); +</script> + +<style lang="scss" scoped> +.timnmucd { + padding: 16px; + + &:first-child { + border-top-left-radius: 6px; + border-top-right-radius: 6px; + } + + &:last-child { + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + } + + &:not(:last-child) { + border-bottom: solid 0.5px var(--divider); + } + + > header { + display: flex; + align-items: center; + + > .icon { + width: 1em; + margin-right: 0.75em; + + &.succ { + color: var(--success); + } + + &.fail { + color: var(--error); + } + } + + > .ip { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 12px; + } + + > .time { + margin-left: auto; + opacity: 0.7; + } + } +} +</style> diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue new file mode 100644 index 0000000000..62627c6333 --- /dev/null +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -0,0 +1,45 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="type"> + <template #label>{{ i18n.ts.sound }}</template> + <option v-for="x in soundsTypes" :key="x" :value="x">{{ x == null ? i18n.ts.none : x }}</option> + </FormSelect> + <FormRange v-model="volume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`" class="_formBlock"> + <template #label>{{ i18n.ts.volume }}</template> + </FormRange> + + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton inline @click="listen"><i class="ti ti-player-play"></i> {{ i18n.ts.listen }}</FormButton> + <FormButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSelect from '@/components/form/select.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormRange from '@/components/form/range.vue'; +import { i18n } from '@/i18n'; +import { playFile, soundsTypes } from '@/scripts/sound'; + +const props = defineProps<{ + type: string; + volume: number; +}>(); + +const emit = defineEmits<{ + (ev: 'update', result: { type: string; volume: number; }): void; +}>(); + +let type = $ref(props.type); +let volume = $ref(props.volume); + +function listen() { + playFile(type, volume); +} + +function save() { + emit('update', { type, volume }); +} +</script> diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue new file mode 100644 index 0000000000..ef60b2c3c9 --- /dev/null +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -0,0 +1,82 @@ +<template> +<div class="_formRoot"> + <FormRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`" class="_formBlock"> + <template #label>{{ i18n.ts.masterVolume }}</template> + </FormRange> + + <FormSection> + <template #label>{{ i18n.ts.sounds }}</template> + <FormFolder v-for="type in Object.keys(sounds)" :key="type" style="margin-bottom: 8px;"> + <template #label>{{ $t('_sfx.' + type) }}</template> + <template #suffix>{{ sounds[type].type ?? i18n.ts.none }}</template> + + <XSound :type="sounds[type].type" :volume="sounds[type].volume" @update="(res) => updated(type, res)"/> + </FormFolder> + </FormSection> + + <FormButton danger class="_formBlock" @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import XSound from './sounds.sound.vue'; +import FormRange from '@/components/form/range.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import FormFolder from '@/components/form/folder.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { playFile } from '@/scripts/sound'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const masterVolume = computed({ + get: () => { + return ColdDeviceStorage.get('sound_masterVolume'); + }, + set: (value) => { + ColdDeviceStorage.set('sound_masterVolume', value); + }, +}); + +const volumeIcon = computed(() => masterVolume.value === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up'); + +const sounds = ref({ + note: ColdDeviceStorage.get('sound_note'), + noteMy: ColdDeviceStorage.get('sound_noteMy'), + notification: ColdDeviceStorage.get('sound_notification'), + chat: ColdDeviceStorage.get('sound_chat'), + chatBg: ColdDeviceStorage.get('sound_chatBg'), + antenna: ColdDeviceStorage.get('sound_antenna'), + channel: ColdDeviceStorage.get('sound_channel'), +}); + +async function updated(type, sound) { + const v = { + type: sound.type, + volume: sound.volume, + }; + + ColdDeviceStorage.set('sound_' + type, v); + sounds.value[type] = v; +} + +function reset() { + for (const sound of Object.keys(sounds.value)) { + const v = ColdDeviceStorage.default['sound_' + sound]; + ColdDeviceStorage.set('sound_' + sound, v); + sounds.value[sound] = v; + } +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.sounds, + icon: 'ti ti-music', +}); +</script> diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue new file mode 100644 index 0000000000..608222386e --- /dev/null +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -0,0 +1,140 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="statusbar.type" placeholder="Please select" class="_formBlock"> + <template #label>{{ i18n.ts.type }}</template> + <option value="rss">RSS</option> + <option value="federation">Federation</option> + <option value="userList">User list timeline</option> + </FormSelect> + + <MkInput v-model="statusbar.name" manual-save class="_formBlock"> + <template #label>{{ i18n.ts.label }}</template> + </MkInput> + + <MkSwitch v-model="statusbar.black" class="_formBlock"> + <template #label>Black</template> + </MkSwitch> + + <FormRadios v-model="statusbar.size" class="_formBlock"> + <template #label>{{ i18n.ts.size }}</template> + <option value="verySmall">{{ i18n.ts.small }}+</option> + <option value="small">{{ i18n.ts.small }}</option> + <option value="medium">{{ i18n.ts.medium }}</option> + <option value="large">{{ i18n.ts.large }}</option> + <option value="veryLarge">{{ i18n.ts.large }}+</option> + </FormRadios> + + <template v-if="statusbar.type === 'rss'"> + <MkInput v-model="statusbar.props.url" manual-save class="_formBlock" type="url"> + <template #label>URL</template> + </MkInput> + <MkSwitch v-model="statusbar.props.shuffle" class="_formBlock"> + <template #label>{{ i18n.ts.shuffle }}</template> + </MkSwitch> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + </template> + <template v-else-if="statusbar.type === 'federation'"> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + <MkSwitch v-model="statusbar.props.colored" class="_formBlock"> + <template #label>{{ i18n.ts.colored }}</template> + </MkSwitch> + </template> + <template v-else-if="statusbar.type === 'userList' && userLists != null"> + <FormSelect v-model="statusbar.props.userListId" class="_formBlock"> + <template #label>{{ i18n.ts.userList }}</template> + <option v-for="list in userLists" :value="list.id">{{ list.name }}</option> + </FormSelect> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + </template> + + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton danger @click="del">{{ i18n.ts.remove }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, reactive, ref, watch } from 'vue'; +import FormSelect from '@/components/form/select.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormRange from '@/components/form/range.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { deepClone } from '@/scripts/clone'; + +const props = defineProps<{ + _id: string; + userLists: any[] | null; +}>(); + +const statusbar = reactive(deepClone(defaultStore.state.statusbars.find(x => x.id === props._id))); + +watch(() => statusbar.type, () => { + if (statusbar.type === 'rss') { + statusbar.name = 'NEWS'; + statusbar.props.url = 'http://feeds.afpbb.com/rss/afpbb/afpbbnews'; + statusbar.props.shuffle = true; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + } else if (statusbar.type === 'federation') { + statusbar.name = 'FEDERATION'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + statusbar.props.colored = false; + } else if (statusbar.type === 'userList') { + statusbar.name = 'LIST TL'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + } +}); + +watch(statusbar, save); + +async function save() { + const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id); + const statusbars = deepClone(defaultStore.state.statusbars); + statusbars[i] = deepClone(statusbar); + defaultStore.set('statusbars', statusbars); +} + +function del() { + defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id)); +} +</script> diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue new file mode 100644 index 0000000000..86c69fa2c3 --- /dev/null +++ b/packages/frontend/src/pages/settings/statusbar.vue @@ -0,0 +1,54 @@ +<template> +<div class="_formRoot"> + <FormFolder v-for="x in statusbars" :key="x.id" class="_formBlock"> + <template #label>{{ x.type ?? i18n.ts.notSet }}</template> + <template #suffix>{{ x.name }}</template> + <XStatusbar :_id="x.id" :user-lists="userLists"/> + </FormFolder> + <FormButton primary @click="add">{{ i18n.ts.add }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XStatusbar from './statusbar.statusbar.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const statusbars = defaultStore.reactiveState.statusbars; + +let userLists = $ref(); + +onMounted(() => { + os.api('users/lists/list').then(res => { + userLists = res; + }); +}); + +async function add() { + defaultStore.push('statusbars', { + id: uuid(), + type: null, + black: false, + size: 'medium', + props: {}, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.statusbar, + icon: 'ti ti-list', + bg: 'var(--bg)', +}); +</script> diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue new file mode 100644 index 0000000000..52a436e18d --- /dev/null +++ b/packages/frontend/src/pages/settings/theme.install.vue @@ -0,0 +1,80 @@ +<template> +<div class="_formRoot"> + <FormTextarea v-model="installThemeCode" class="_formBlock"> + <template #label>{{ i18n.ts._theme.code }}</template> + </FormTextarea> + + <div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton :disabled="installThemeCode == null" inline @click="() => preview(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</FormButton> + <FormButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import JSON5 from 'json5'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormButton from '@/components/MkButton.vue'; +import { applyTheme, validateTheme } from '@/scripts/theme'; +import * as os from '@/os'; +import { addTheme, getThemes } from '@/theme-store'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let installThemeCode = $ref(null); + +function parseThemeCode(code: string) { + let theme; + + try { + theme = JSON5.parse(code); + } catch (err) { + os.alert({ + type: 'error', + text: i18n.ts._theme.invalid, + }); + return false; + } + if (!validateTheme(theme)) { + os.alert({ + type: 'error', + text: i18n.ts._theme.invalid, + }); + return false; + } + if (getThemes().some(t => t.id === theme.id)) { + os.alert({ + type: 'info', + text: i18n.ts._theme.alreadyInstalled, + }); + return false; + } + + return theme; +} + +function preview(code: string): void { + const theme = parseThemeCode(code); + if (theme) applyTheme(theme, false); +} + +async function install(code: string): Promise<void> { + const theme = parseThemeCode(code); + if (!theme) return; + await addTheme(theme); + os.alert({ + type: 'success', + text: i18n.t('_theme.installed', { name: theme.name }), + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._theme.install, + icon: 'ti ti-download', +}); +</script> diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue new file mode 100644 index 0000000000..409f0af650 --- /dev/null +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -0,0 +1,78 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="selectedThemeId" class="_formBlock"> + <template #label>{{ i18n.ts.theme }}</template> + <optgroup :label="i18n.ts._theme.installedThemes"> + <option v-for="x in installedThemes" :key="x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="i18n.ts._theme.builtinThemes"> + <option v-for="x in builtinThemes" :key="x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <template v-if="selectedTheme"> + <FormInput readonly :model-value="selectedTheme.author" class="_formBlock"> + <template #label>{{ i18n.ts.author }}</template> + </FormInput> + <FormTextarea v-if="selectedTheme.desc" readonly :model-value="selectedTheme.desc" class="_formBlock"> + <template #label>{{ i18n.ts._theme.description }}</template> + </FormTextarea> + <FormTextarea readonly tall :model-value="selectedThemeCode" class="_formBlock"> + <template #label>{{ i18n.ts._theme.code }}</template> + <template #caption><button class="_textButton" @click="copyThemeCode()">{{ i18n.ts.copy }}</button></template> + </FormTextarea> + <FormButton v-if="!builtinThemes.some(t => t.id == selectedTheme.id)" class="_formBlock" danger @click="uninstall()"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</FormButton> + </template> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import JSON5 from 'json5'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import { Theme, getBuiltinThemesRef } from '@/scripts/theme'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import * as os from '@/os'; +import { getThemes, removeTheme } from '@/theme-store'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const installedThemes = ref(getThemes()); +const builtinThemes = getBuiltinThemesRef(); +const selectedThemeId = ref(null); + +const themes = computed(() => [...installedThemes.value, ...builtinThemes.value]); + +const selectedTheme = computed(() => { + if (selectedThemeId.value == null) return null; + return themes.value.find(x => x.id === selectedThemeId.value); +}); + +const selectedThemeCode = computed(() => { + if (selectedTheme.value == null) return null; + return JSON5.stringify(selectedTheme.value, null, '\t'); +}); + +function copyThemeCode() { + copyToClipboard(selectedThemeCode.value); + os.success(); +} + +function uninstall() { + removeTheme(selectedTheme.value as Theme); + installedThemes.value = installedThemes.value.filter(t => t.id !== selectedThemeId.value); + selectedThemeId.value = null; + os.success(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._theme.manage, + icon: 'ti ti-tool', +}); +</script> diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue new file mode 100644 index 0000000000..f37c213b06 --- /dev/null +++ b/packages/frontend/src/pages/settings/theme.vue @@ -0,0 +1,409 @@ +<template> +<div class="_formRoot rsljpzjq"> + <div v-adaptive-border class="rfqxtzch _panel _formBlock"> + <div class="toggle"> + <div class="toggleWrapper"> + <input id="dn" v-model="darkMode" type="checkbox" class="dn"/> + <label for="dn" class="toggle"> + <span class="before">{{ i18n.ts.light }}</span> + <span class="after">{{ i18n.ts.dark }}</span> + <span class="toggle__handler"> + <span class="crater crater--1"></span> + <span class="crater crater--2"></span> + <span class="crater crater--3"></span> + </span> + <span class="star star--1"></span> + <span class="star star--2"></span> + <span class="star star--3"></span> + <span class="star star--4"></span> + <span class="star star--5"></span> + <span class="star star--6"></span> + </label> + </div> + </div> + <div class="sync"> + <FormSwitch v-model="syncDeviceDarkMode">{{ i18n.ts.syncDeviceDarkMode }}</FormSwitch> + </div> + </div> + + <div class="selects _formBlock"> + <FormSelect v-model="lightThemeId" large class="select"> + <template #label>{{ i18n.ts.themeForLightMode }}</template> + <template #prefix><i class="ti ti-sun"></i></template> + <option v-if="instanceLightTheme" :key="'instance:' + instanceLightTheme.id" :value="instanceLightTheme.id">{{ instanceLightTheme.name }}</option> + <optgroup v-if="installedLightThemes.length > 0" :label="i18n.ts._theme.installedThemes"> + <option v-for="x in installedLightThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="i18n.ts._theme.builtinThemes"> + <option v-for="x in builtinLightThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <FormSelect v-model="darkThemeId" large class="select"> + <template #label>{{ i18n.ts.themeForDarkMode }}</template> + <template #prefix><i class="ti ti-moon"></i></template> + <option v-if="instanceDarkTheme" :key="'instance:' + instanceDarkTheme.id" :value="instanceDarkTheme.id">{{ instanceDarkTheme.name }}</option> + <optgroup v-if="installedDarkThemes.length > 0" :label="i18n.ts._theme.installedThemes"> + <option v-for="x in installedDarkThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="i18n.ts._theme.builtinThemes"> + <option v-for="x in builtinDarkThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + </div> + + <FormSection> + <div class="_formLinksGrid"> + <FormLink to="/settings/theme/manage"><template #icon><i class="ti ti-tool"></i></template>{{ i18n.ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink> + <FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="ti ti-world"></i></template>{{ i18n.ts._theme.explore }}</FormLink> + <FormLink to="/settings/theme/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._theme.install }}</FormLink> + <FormLink to="/theme-editor"><template #icon><i class="ti ti-paint"></i></template>{{ i18n.ts._theme.make }}</FormLink> + </div> + </FormSection> + + <FormButton v-if="wallpaper == null" class="_formBlock" @click="setWallpaper">{{ i18n.ts.setWallpaper }}</FormButton> + <FormButton v-else class="_formBlock" @click="wallpaper = null">{{ i18n.ts.removeWallpaper }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, onActivated, ref, watch } from 'vue'; +import JSON5 from 'json5'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormSection from '@/components/form/section.vue'; +import FormLink from '@/components/form/link.vue'; +import FormButton from '@/components/MkButton.vue'; +import { getBuiltinThemesRef } from '@/scripts/theme'; +import { selectFile } from '@/scripts/select-file'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; +import { ColdDeviceStorage, defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import { uniqueBy } from '@/scripts/array'; +import { fetchThemes, getThemes } from '@/theme-store'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const installedThemes = ref(getThemes()); +const builtinThemes = getBuiltinThemesRef(); + +const instanceDarkTheme = computed(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null); +const installedDarkThemes = computed(() => installedThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); +const builtinDarkThemes = computed(() => builtinThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); +const instanceLightTheme = computed(() => instance.defaultLightTheme ? JSON5.parse(instance.defaultLightTheme) : null); +const installedLightThemes = computed(() => installedThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); +const builtinLightThemes = computed(() => builtinThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); +const themes = computed(() => uniqueBy([instanceDarkTheme.value, instanceLightTheme.value, ...builtinThemes.value, ...installedThemes.value].filter(x => x != null), theme => theme.id)); + +const darkTheme = ColdDeviceStorage.ref('darkTheme'); +const darkThemeId = computed({ + get() { + return darkTheme.value.id; + }, + set(id) { + const t = themes.value.find(x => x.id === id); + if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる + ColdDeviceStorage.set('darkTheme', t); + } + }, +}); +const lightTheme = ColdDeviceStorage.ref('lightTheme'); +const lightThemeId = computed({ + get() { + return lightTheme.value.id; + }, + set(id) { + const t = themes.value.find(x => x.id === id); + if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる + ColdDeviceStorage.set('lightTheme', t); + } + }, +}); +const darkMode = computed(defaultStore.makeGetterSetter('darkMode')); +const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode')); +const wallpaper = ref(localStorage.getItem('wallpaper')); +const themesCount = installedThemes.value.length; + +watch(syncDeviceDarkMode, () => { + if (syncDeviceDarkMode.value) { + defaultStore.set('darkMode', isDeviceDarkmode()); + } +}); + +watch(wallpaper, () => { + if (wallpaper.value == null) { + localStorage.removeItem('wallpaper'); + } else { + localStorage.setItem('wallpaper', wallpaper.value); + } + location.reload(); +}); + +onActivated(() => { + fetchThemes().then(() => { + installedThemes.value = getThemes(); + }); +}); + +fetchThemes().then(() => { + installedThemes.value = getThemes(); +}); + +function setWallpaper(event) { + selectFile(event.currentTarget ?? event.target, null).then(file => { + wallpaper.value = file.url; + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.theme, + icon: 'ti ti-palette', +}); +</script> + +<style lang="scss" scoped> +.rfqxtzch { + border-radius: 6px; + + > .toggle { + position: relative; + padding: 26px 0; + text-align: center; + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + + > .toggleWrapper { + display: inline-block; + text-align: left; + overflow: clip; + padding: 0 100px; + vertical-align: bottom; + + input { + position: absolute; + left: -99em; + } + } + + .toggle { + cursor: pointer; + display: inline-block; + position: relative; + width: 90px; + height: 50px; + background-color: #83D8FF; + border-radius: 90px - 6; + transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + + > .before, > .after { + position: absolute; + top: 15px; + transition: color 1s ease; + } + + > .before { + left: -70px; + color: var(--accent); + } + + > .after { + right: -68px; + color: var(--fg); + } + } + + .toggle__handler { + display: inline-block; + position: relative; + z-index: 1; + top: 3px; + left: 3px; + width: 50px - 6; + height: 50px - 6; + background-color: #FFCF96; + border-radius: 50px; + box-shadow: 0 2px 6px rgba(0,0,0,.3); + transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important; + transform: rotate(-45deg); + + .crater { + position: absolute; + background-color: #E8CDA5; + opacity: 0; + transition: opacity 200ms ease-in-out !important; + border-radius: 100%; + } + + .crater--1 { + top: 18px; + left: 10px; + width: 4px; + height: 4px; + } + + .crater--2 { + top: 28px; + left: 22px; + width: 6px; + height: 6px; + } + + .crater--3 { + top: 10px; + left: 25px; + width: 8px; + height: 8px; + } + } + + .star { + position: absolute; + background-color: #ffffff; + transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + border-radius: 50%; + } + + .star--1 { + top: 10px; + left: 35px; + z-index: 0; + width: 30px; + height: 3px; + } + + .star--2 { + top: 18px; + left: 28px; + z-index: 1; + width: 30px; + height: 3px; + } + + .star--3 { + top: 27px; + left: 40px; + z-index: 0; + width: 30px; + height: 3px; + } + + .star--4, + .star--5, + .star--6 { + opacity: 0; + transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + + .star--4 { + top: 16px; + left: 11px; + z-index: 0; + width: 2px; + height: 2px; + transform: translate3d(3px,0,0); + } + + .star--5 { + top: 32px; + left: 17px; + z-index: 0; + width: 3px; + height: 3px; + transform: translate3d(3px,0,0); + } + + .star--6 { + top: 36px; + left: 28px; + z-index: 0; + width: 2px; + height: 2px; + transform: translate3d(3px,0,0); + } + + input:checked { + + .toggle { + background-color: #749DD6; + + > .before { + color: var(--fg); + } + + > .after { + color: var(--accent); + } + + .toggle__handler { + background-color: #FFE5B5; + transform: translate3d(40px, 0, 0) rotate(0); + + .crater { opacity: 1; } + } + + .star--1 { + width: 2px; + height: 2px; + } + + .star--2 { + width: 4px; + height: 4px; + transform: translate3d(-5px, 0, 0); + } + + .star--3 { + width: 2px; + height: 2px; + transform: translate3d(-7px, 0, 0); + } + + .star--4, + .star--5, + .star--6 { + opacity: 1; + transform: translate3d(0,0,0); + } + + .star--4 { + transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + + .star--5 { + transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + + .star--6 { + transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + } + } + } + + > .sync { + padding: 14px 16px; + border-top: solid 0.5px var(--divider); + } +} + +.rsljpzjq { + > .selects { + display: flex; + gap: 1.5em var(--margin); + flex-wrap: wrap; + + > .select { + flex: 1; + min-width: 280px; + } + } +} +</style> diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue new file mode 100644 index 0000000000..c8ec1ea586 --- /dev/null +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -0,0 +1,95 @@ +<template> +<div class="_formRoot"> + <FormInput v-model="name" class="_formBlock"> + <template #label>Name</template> + </FormInput> + + <FormInput v-model="url" type="url" class="_formBlock"> + <template #label>URL</template> + </FormInput> + + <FormInput v-model="secret" class="_formBlock"> + <template #prefix><i class="ti ti-lock"></i></template> + <template #label>Secret</template> + </FormInput> + + <FormSection> + <template #label>Events</template> + + <FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch> + <FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch> + <FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch> + <FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch> + <FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch> + <FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch> + <FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch> + </FormSection> + + <FormSwitch v-model="active" class="_formBlock">Active</FormSwitch> + + <div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton primary inline @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormInput from '@/components/form/input.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + webhookId: string; +}>(); + +const webhook = await os.api('i/webhooks/show', { + webhookId: props.webhookId, +}); + +let name = $ref(webhook.name); +let url = $ref(webhook.url); +let secret = $ref(webhook.secret); +let active = $ref(webhook.active); + +let event_follow = $ref(webhook.on.includes('follow')); +let event_followed = $ref(webhook.on.includes('followed')); +let event_note = $ref(webhook.on.includes('note')); +let event_reply = $ref(webhook.on.includes('reply')); +let event_renote = $ref(webhook.on.includes('renote')); +let event_reaction = $ref(webhook.on.includes('reaction')); +let event_mention = $ref(webhook.on.includes('mention')); + +async function save(): Promise<void> { + const events = []; + if (event_follow) events.push('follow'); + if (event_followed) events.push('followed'); + if (event_note) events.push('note'); + if (event_reply) events.push('reply'); + if (event_renote) events.push('renote'); + if (event_reaction) events.push('reaction'); + if (event_mention) events.push('mention'); + + os.apiWithDialog('i/webhooks/update', { + name, + url, + secret, + webhookId: props.webhookId, + on: events, + active, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'Edit webhook', + icon: 'ti ti-webhook', +}); +</script> diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue new file mode 100644 index 0000000000..00a547da69 --- /dev/null +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -0,0 +1,82 @@ +<template> +<div class="_formRoot"> + <FormInput v-model="name" class="_formBlock"> + <template #label>Name</template> + </FormInput> + + <FormInput v-model="url" type="url" class="_formBlock"> + <template #label>URL</template> + </FormInput> + + <FormInput v-model="secret" class="_formBlock"> + <template #prefix><i class="ti ti-lock"></i></template> + <template #label>Secret</template> + </FormInput> + + <FormSection> + <template #label>Events</template> + + <FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch> + <FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch> + <FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch> + <FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch> + <FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch> + <FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch> + <FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch> + </FormSection> + + <div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton primary inline @click="create"><i class="ti ti-check"></i> {{ i18n.ts.create }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormInput from '@/components/form/input.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let name = $ref(''); +let url = $ref(''); +let secret = $ref(''); + +let event_follow = $ref(true); +let event_followed = $ref(true); +let event_note = $ref(true); +let event_reply = $ref(true); +let event_renote = $ref(true); +let event_reaction = $ref(true); +let event_mention = $ref(true); + +async function create(): Promise<void> { + const events = []; + if (event_follow) events.push('follow'); + if (event_followed) events.push('followed'); + if (event_note) events.push('note'); + if (event_reply) events.push('reply'); + if (event_renote) events.push('renote'); + if (event_reaction) events.push('reaction'); + if (event_mention) events.push('mention'); + + os.apiWithDialog('i/webhooks/create', { + name, + url, + secret, + on: events, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'Create new webhook', + icon: 'ti ti-webhook', +}); +</script> diff --git a/packages/frontend/src/pages/settings/webhook.vue b/packages/frontend/src/pages/settings/webhook.vue new file mode 100644 index 0000000000..9be23ee4f0 --- /dev/null +++ b/packages/frontend/src/pages/settings/webhook.vue @@ -0,0 +1,53 @@ +<template> +<div class="_formRoot"> + <FormSection> + <FormLink :to="`/settings/webhook/new`"> + Create webhook + </FormLink> + </FormSection> + + <FormSection> + <MkPagination :pagination="pagination"> + <template #default="{items}"> + <FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`" class="_formBlock"> + <template #icon> + <i v-if="webhook.active === false" class="ti ti-player-pause"></i> + <i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i> + <i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--success)' }"></i> + <i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"></i> + </template> + {{ webhook.name || webhook.url }} + <template #suffix> + <MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime> + </template> + </FormLink> + </template> + </MkPagination> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import FormSection from '@/components/form/section.vue'; +import FormLink from '@/components/form/link.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'i/webhooks/list' as const, + limit: 10, +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'Webhook', + icon: 'ti ti-webhook', +}); +</script> diff --git a/packages/frontend/src/pages/settings/word-mute.vue b/packages/frontend/src/pages/settings/word-mute.vue new file mode 100644 index 0000000000..6961d8151d --- /dev/null +++ b/packages/frontend/src/pages/settings/word-mute.vue @@ -0,0 +1,128 @@ +<template> +<div class="_formRoot"> + <MkTab v-model="tab" class="_formBlock"> + <option value="soft">{{ i18n.ts._wordMute.soft }}</option> + <option value="hard">{{ i18n.ts._wordMute.hard }}</option> + </MkTab> + <div class="_formBlock"> + <div v-show="tab === 'soft'"> + <MkInfo class="_formBlock">{{ i18n.ts._wordMute.softDescription }}</MkInfo> + <FormTextarea v-model="softMutedWords" class="_formBlock"> + <span>{{ i18n.ts._wordMute.muteWords }}</span> + <template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template> + </FormTextarea> + </div> + <div v-show="tab === 'hard'"> + <MkInfo class="_formBlock">{{ i18n.ts._wordMute.hardDescription }} {{ i18n.ts.reflectMayTakeTime }}</MkInfo> + <FormTextarea v-model="hardMutedWords" class="_formBlock"> + <span>{{ i18n.ts._wordMute.muteWords }}</span> + <template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template> + </FormTextarea> + <MkKeyValue v-if="hardWordMutedNotesCount != null" class="_formBlock"> + <template #key>{{ i18n.ts._wordMute.mutedNotes }}</template> + <template #value>{{ number(hardWordMutedNotesCount) }}</template> + </MkKeyValue> + </div> + </div> + <MkButton primary inline :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkTab from '@/components/MkTab.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import { defaultStore } from '@/store'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const render = (mutedWords) => mutedWords.map(x => { + if (Array.isArray(x)) { + return x.join(' '); + } else { + return x; + } +}).join('\n'); + +const tab = ref('soft'); +const softMutedWords = ref(render(defaultStore.state.mutedWords)); +const hardMutedWords = ref(render($i!.mutedWords)); +const hardWordMutedNotesCount = ref(null); +const changed = ref(false); + +os.api('i/get-word-muted-notes-count', {}).then(response => { + hardWordMutedNotesCount.value = response?.count; +}); + +watch(softMutedWords, () => { + changed.value = true; +}); + +watch(hardMutedWords, () => { + changed.value = true; +}); + +async function save() { + const parseMutes = (mutes, tab) => { + // split into lines, remove empty lines and unnecessary whitespace + let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== ''); + + // check each line if it is a RegExp or not + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const regexp = line.match(/^\/(.+)\/(.*)$/); + if (regexp) { + // check that the RegExp is valid + try { + new RegExp(regexp[1], regexp[2]); + // note that regex lines will not be split by spaces! + } catch (err: any) { + // invalid syntax: do not save, do not reset changed flag + os.alert({ + type: 'error', + title: i18n.ts.regexpError, + text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + '\n' + err.toString(), + }); + // re-throw error so these invalid settings are not saved + throw err; + } + } else { + lines[i] = line.split(' '); + } + } + + return lines; + }; + + let softMutes, hardMutes; + try { + softMutes = parseMutes(softMutedWords.value, i18n.ts._wordMute.soft); + hardMutes = parseMutes(hardMutedWords.value, i18n.ts._wordMute.hard); + } catch (err) { + // already displayed error message in parseMutes + return; + } + + defaultStore.set('mutedWords', softMutes); + await os.api('i/update', { + mutedWords: hardMutes, + }); + + changed.value = false; +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.wordMute, + icon: 'ti ti-message-off', +}); +</script> diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue new file mode 100644 index 0000000000..a7e797eeab --- /dev/null +++ b/packages/frontend/src/pages/share.vue @@ -0,0 +1,169 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <XPostForm + v-if="state === 'writing'" + fixed + :instant="true" + :initial-text="initialText" + :initial-visibility="visibility" + :initial-files="files" + :initial-local-only="localOnly" + :reply="reply" + :renote="renote" + :initial-visible-users="visibleUsers" + class="_panel" + @posted="state = 'posted'" + /> + <MkButton v-else-if="state === 'posted'" primary class="close" @click="close()">{{ i18n.ts.close }}</MkButton> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +// SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html + +import { } from 'vue'; +import { noteVisibilities } from 'misskey-js'; +import * as Acct from 'misskey-js/built/acct'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import XPostForm from '@/components/MkPostForm.vue'; +import * as os from '@/os'; +import { mainRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const urlParams = new URLSearchParams(window.location.search); +const localOnlyQuery = urlParams.get('localOnly'); +const visibilityQuery = urlParams.get('visibility'); + +let state = $ref('fetching' as 'fetching' | 'writing' | 'posted'); +let title = $ref(urlParams.get('title')); +const text = urlParams.get('text'); +const url = urlParams.get('url'); +let initialText = $ref(null as string | null); +let reply = $ref(null as Misskey.entities.Note | null); +let renote = $ref(null as Misskey.entities.Note | null); +let visibility = $ref(noteVisibilities.includes(visibilityQuery) ? visibilityQuery : null); +let localOnly = $ref(localOnlyQuery === '0' ? false : localOnlyQuery === '1' ? true : null); +let files = $ref([] as Misskey.entities.DriveFile[]); +let visibleUsers = $ref([] as Misskey.entities.User[]); + +async function init() { + let noteText = ''; + if (title) noteText += `[ ${title} ]\n`; + // Googleニュース対策 + if (text?.startsWith(`${title}.\n`)) noteText += text.replace(`${title}.\n`, ''); + else if (text && title !== text) noteText += `${text}\n`; + if (url) noteText += `${url}`; + initialText = noteText.trim(); + + if (visibility === 'specified') { + const visibleUserIds = urlParams.get('visibleUserIds'); + const visibleAccts = urlParams.get('visibleAccts'); + await Promise.all( + [ + ...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []), + ...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : []), + ] + // TypeScriptの指示通りに変換する + .map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q) + .map(q => os.api('users/show', q) + .then(user => { + visibleUsers.push(user); + }, () => { + console.error(`Invalid user query: ${JSON.stringify(q)}`); + }), + ), + ); + } + + try { + //#region Reply + const replyId = urlParams.get('replyId'); + const replyUri = urlParams.get('replyUri'); + if (replyId) { + reply = await os.api('notes/show', { + noteId: replyId, + }); + } else if (replyUri) { + const obj = await os.api('ap/show', { + uri: replyUri, + }); + if (obj.type === 'Note') { + reply = obj.object; + } + } + //#endregion + + //#region Renote + const renoteId = urlParams.get('renoteId'); + const renoteUri = urlParams.get('renoteUri'); + if (renoteId) { + renote = await os.api('notes/show', { + noteId: renoteId, + }); + } else if (renoteUri) { + const obj = await os.api('ap/show', { + uri: renoteUri, + }); + if (obj.type === 'Note') { + renote = obj.object; + } + } + //#endregion + + //#region Drive files + const fileIds = urlParams.get('fileIds'); + if (fileIds) { + await Promise.all( + fileIds.split(',') + .map(fileId => os.api('drive/files/show', { fileId }) + .then(file => { + files.push(file); + }, () => { + console.error(`Failed to fetch a file ${fileId}`); + }), + ), + ); + } + //#endregion + } catch (err) { + os.alert({ + type: 'error', + title: err.message, + text: err.name, + }); + } + + state = 'writing'; +} + +init(); + +function close(): void { + window.close(); + + // 閉じなければ100ms後タイムラインに + window.setTimeout(() => { + mainRouter.push('/'); + }, 100); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.share, + icon: 'ti ti-share', +}); +</script> + +<style lang="scss" scoped> +.close { + margin: 16px auto; +} +</style> diff --git a/packages/frontend/src/pages/signup-complete.vue b/packages/frontend/src/pages/signup-complete.vue new file mode 100644 index 0000000000..5459532310 --- /dev/null +++ b/packages/frontend/src/pages/signup-complete.vue @@ -0,0 +1,41 @@ +<template> +<div> + {{ i18n.ts.processing }} +</div> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import * as os from '@/os'; +import { login } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + code: string; +}>(); + +onMounted(async () => { + await os.alert({ + type: 'info', + text: i18n.t('clickToFinishEmailVerification', { ok: i18n.ts.gotIt }), + }); + const res = await os.apiWithDialog('signup-pending', { + code: props.code, + }); + login(res.i, '/'); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.signup, + icon: 'ti ti-user', +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue new file mode 100644 index 0000000000..72775ed5c9 --- /dev/null +++ b/packages/frontend/src/pages/tag.vue @@ -0,0 +1,35 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <XNotes class="_content" :pagination="pagination"/> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import XNotes from '@/components/MkNotes.vue'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + tag: string; +}>(); + +const pagination = { + endpoint: 'notes/search-by-tag' as const, + limit: 10, + params: computed(() => ({ + tag: props.tag, + })), +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: props.tag, + icon: 'ti ti-hash', +}))); +</script> diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue new file mode 100644 index 0000000000..d8ff170ca2 --- /dev/null +++ b/packages/frontend/src/pages/theme-editor.vue @@ -0,0 +1,283 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <div class="cwepdizn _formRoot"> + <FormFolder :default-open="true" class="_formBlock"> + <template #label>{{ i18n.ts.backgroundColor }}</template> + <div class="cwepdizn-colors"> + <div class="row"> + <button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> + <div class="preview" :style="{ background: color.forPreview }"></div> + </button> + </div> + <div class="row"> + <button v-for="color in bgColors.filter(x => x.kind === 'dark')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> + <div class="preview" :style="{ background: color.forPreview }"></div> + </button> + </div> + </div> + </FormFolder> + + <FormFolder :default-open="true" class="_formBlock"> + <template #label>{{ i18n.ts.accentColor }}</template> + <div class="cwepdizn-colors"> + <div class="row"> + <button v-for="color in accentColors" :key="color" class="color rounded _button" :class="{ active: theme.props.accent === color }" @click="setAccentColor(color)"> + <div class="preview" :style="{ background: color }"></div> + </button> + </div> + </div> + </FormFolder> + + <FormFolder :default-open="true" class="_formBlock"> + <template #label>{{ i18n.ts.textColor }}</template> + <div class="cwepdizn-colors"> + <div class="row"> + <button v-for="color in fgColors" :key="color" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }" @click="setFgColor(color)"> + <div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div> + </button> + </div> + </div> + </FormFolder> + + <FormFolder :default-open="false" class="_formBlock"> + <template #icon><i class="ti ti-code"></i></template> + <template #label>{{ i18n.ts.editCode }}</template> + + <div class="_formRoot"> + <FormTextarea v-model="themeCode" tall class="_formBlock"> + <template #label>{{ i18n.ts._theme.code }}</template> + </FormTextarea> + <FormButton primary class="_formBlock" @click="applyThemeCode">{{ i18n.ts.apply }}</FormButton> + </div> + </FormFolder> + + <FormFolder :default-open="false" class="_formBlock"> + <template #label>{{ i18n.ts.addDescription }}</template> + + <div class="_formRoot"> + <FormTextarea v-model="description"> + <template #label>{{ i18n.ts._theme.description }}</template> + </FormTextarea> + </div> + </FormFolder> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { watch } from 'vue'; +import { toUnicode } from 'punycode/'; +import tinycolor from 'tinycolor2'; +import { v4 as uuid } from 'uuid'; +import JSON5 from 'json5'; + +import FormButton from '@/components/MkButton.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormFolder from '@/components/form/folder.vue'; + +import { $i } from '@/account'; +import { Theme, applyTheme } from '@/scripts/theme'; +import lightTheme from '@/themes/_light.json5'; +import darkTheme from '@/themes/_dark.json5'; +import { host } from '@/config'; +import * as os from '@/os'; +import { ColdDeviceStorage, defaultStore } from '@/store'; +import { addTheme } from '@/theme-store'; +import { i18n } from '@/i18n'; +import { useLeaveGuard } from '@/scripts/use-leave-guard'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const bgColors = [ + { color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' }, + { color: '#f0eee9', kind: 'light', forPreview: '#f3e2b9' }, + { color: '#e9eff0', kind: 'light', forPreview: '#bfe3e8' }, + { color: '#f0e9ee', kind: 'light', forPreview: '#f1d1e8' }, + { color: '#dce2e0', kind: 'light', forPreview: '#a4dccc' }, + { color: '#e2e0dc', kind: 'light', forPreview: '#d8c7a5' }, + { color: '#d5dbe0', kind: 'light', forPreview: '#b0cae0' }, + { color: '#dad5d5', kind: 'light', forPreview: '#d6afaf' }, + { color: '#2b2b2b', kind: 'dark', forPreview: '#444444' }, + { color: '#362e29', kind: 'dark', forPreview: '#735c4d' }, + { color: '#303629', kind: 'dark', forPreview: '#506d2f' }, + { color: '#293436', kind: 'dark', forPreview: '#258192' }, + { color: '#2e2936', kind: 'dark', forPreview: '#504069' }, + { color: '#252722', kind: 'dark', forPreview: '#3c462f' }, + { color: '#212525', kind: 'dark', forPreview: '#303e3e' }, + { color: '#191919', kind: 'dark', forPreview: '#272727' }, +] as const; +const accentColors = ['#e36749', '#f29924', '#98c934', '#34c9a9', '#34a1c9', '#606df7', '#8d34c9', '#e84d83']; +const fgColors = [ + { color: 'none', forLight: '#5f5f5f', forDark: '#dadada', forPreview: null }, + { color: 'red', forLight: '#7f6666', forDark: '#e4d1d1', forPreview: '#ca4343' }, + { color: 'yellow', forLight: '#736955', forDark: '#e0d5c0', forPreview: '#d49923' }, + { color: 'green', forLight: '#586d5b', forDark: '#d1e4d4', forPreview: '#4cbd5c' }, + { color: 'cyan', forLight: '#5d7475', forDark: '#d1e3e4', forPreview: '#2abdc3' }, + { color: 'blue', forLight: '#676880', forDark: '#d1d2e4', forPreview: '#7275d8' }, + { color: 'pink', forLight: '#84667d', forDark: '#e4d1e0', forPreview: '#b12390' }, +]; + +let theme = $ref<Partial<Theme>>({ + base: 'light', + props: lightTheme.props, +}); +let description = $ref<string | null>(null); +let themeCode = $ref<string | null>(null); +let changed = $ref(false); + +useLeaveGuard($$(changed)); + +function showPreview() { + os.pageWindow('/preview'); +} + +function setBgColor(color: typeof bgColors[number]) { + if (theme.base !== color.kind) { + const base = color.kind === 'dark' ? darkTheme : lightTheme; + for (const prop of Object.keys(base.props)) { + if (prop === 'accent') continue; + if (prop === 'fg') continue; + theme.props[prop] = base.props[prop]; + } + } + theme.base = color.kind; + theme.props.bg = color.color; + + if (theme.props.fg) { + const matchedFgColor = fgColors.find(x => [tinycolor(x.forLight).toRgbString(), tinycolor(x.forDark).toRgbString()].includes(tinycolor(theme.props.fg).toRgbString())); + if (matchedFgColor) setFgColor(matchedFgColor); + } +} + +function setAccentColor(color) { + theme.props.accent = color; +} + +function setFgColor(color) { + theme.props.fg = theme.base === 'light' ? color.forLight : color.forDark; +} + +function apply() { + themeCode = JSON5.stringify(theme, null, '\t'); + applyTheme(theme, false); + changed = true; +} + +function applyThemeCode() { + let parsed; + + try { + parsed = JSON5.parse(themeCode); + } catch (err) { + os.alert({ + type: 'error', + text: i18n.ts._theme.invalid, + }); + return; + } + + theme = parsed; +} + +async function saveAs() { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts.name, + allowEmpty: false, + }); + if (canceled) return; + + theme.id = uuid(); + theme.name = name; + theme.author = `@${$i.username}@${toUnicode(host)}`; + if (description) theme.desc = description; + await addTheme(theme); + applyTheme(theme); + if (defaultStore.state.darkMode) { + ColdDeviceStorage.set('darkTheme', theme); + } else { + ColdDeviceStorage.set('lightTheme', theme); + } + changed = false; + os.alert({ + type: 'success', + text: i18n.t('_theme.installed', { name: theme.name }), + }); +} + +watch($$(theme), apply, { deep: true }); + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-eye', + text: i18n.ts.preview, + handler: showPreview, +}, { + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.saveAs, + handler: saveAs, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.themeEditor, + icon: 'ti ti-palette', +}); +</script> + +<style lang="scss" scoped> +.cwepdizn { + ::v-deep(.cwepdizn-colors) { + text-align: center; + + > .row { + > .color { + display: inline-block; + position: relative; + width: 64px; + height: 64px; + border-radius: 8px; + + > .preview { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 42px; + height: 42px; + border-radius: 4px; + box-shadow: 0 2px 4px rgb(0 0 0 / 30%); + transition: transform 0.15s ease; + } + + &:hover { + > .preview { + transform: scale(1.1); + } + } + + &.active { + box-shadow: 0 0 0 2px var(--divider) inset; + } + + &.rounded { + border-radius: 999px; + + > .preview { + border-radius: 999px; + } + } + + &.char { + line-height: 42px; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/timeline.tutorial.vue b/packages/frontend/src/pages/timeline.tutorial.vue new file mode 100644 index 0000000000..ae7b098b90 --- /dev/null +++ b/packages/frontend/src/pages/timeline.tutorial.vue @@ -0,0 +1,142 @@ +<template> +<div class="_card"> + <div :class="$style.title" class="_title"> + <div :class="$style.titleText"><i class="ti ti-info-circle"></i> {{ i18n.ts._tutorial.title }}</div> + <div :class="$style.step"> + <button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--"> + <i class="ti ti-chevron-left"></i> + </button> + <span :class="$style.stepNumber">{{ tutorial + 1 }} / {{ tutorialsNumber }}</span> + <button class="_button" :class="$style.stepArrow" :disabled="tutorial === tutorialsNumber - 1" @click="tutorial++"> + <i class="ti ti-chevron-right"></i> + </button> + </div> + </div> + <div v-if="tutorial === 0" class="_content"> + <div>{{ i18n.ts._tutorial.step1_1 }}</div> + <div>{{ i18n.ts._tutorial.step1_2 }}</div> + <div>{{ i18n.ts._tutorial.step1_3 }}</div> + </div> + <div v-else-if="tutorial === 1" class="_content"> + <div>{{ i18n.ts._tutorial.step2_1 }}</div> + <div>{{ i18n.ts._tutorial.step2_2 }}</div> + <MkA class="_link" to="/settings/profile">{{ i18n.ts.editProfile }}</MkA> + </div> + <div v-else-if="tutorial === 2" class="_content"> + <div>{{ i18n.ts._tutorial.step3_1 }}</div> + <div>{{ i18n.ts._tutorial.step3_2 }}</div> + <div>{{ i18n.ts._tutorial.step3_3 }}</div> + <small :class="$style.small">{{ i18n.ts._tutorial.step3_4 }}</small> + </div> + <div v-else-if="tutorial === 3" class="_content"> + <div>{{ i18n.ts._tutorial.step4_1 }}</div> + <div>{{ i18n.ts._tutorial.step4_2 }}</div> + </div> + <div v-else-if="tutorial === 4" class="_content"> + <div>{{ i18n.ts._tutorial.step5_1 }}</div> + <I18n :src="i18n.ts._tutorial.step5_2" tag="div"> + <template #featured> + <MkA class="_link" to="/featured">{{ i18n.ts.featured }}</MkA> + </template> + <template #explore> + <MkA class="_link" to="/explore">{{ i18n.ts.explore }}</MkA> + </template> + </I18n> + <div>{{ i18n.ts._tutorial.step5_3 }}</div> + <small :class="$style.small">{{ i18n.ts._tutorial.step5_4 }}</small> + </div> + <div v-else-if="tutorial === 5" class="_content"> + <div>{{ i18n.ts._tutorial.step6_1 }}</div> + <div>{{ i18n.ts._tutorial.step6_2 }}</div> + <div>{{ i18n.ts._tutorial.step6_3 }}</div> + </div> + <div v-else-if="tutorial === 6" class="_content"> + <div>{{ i18n.ts._tutorial.step7_1 }}</div> + <I18n :src="i18n.ts._tutorial.step7_2" tag="div"> + <template #help> + <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a> + </template> + </I18n> + <div>{{ i18n.ts._tutorial.step7_3 }}</div> + </div> + <div v-else-if="tutorial === 7" class="_content"> + <div>{{ i18n.ts._tutorial.step8_1 }}</div> + <div>{{ i18n.ts._tutorial.step8_2 }}</div> + <small :class="$style.small">{{ i18n.ts._tutorial.step8_3 }}</small> + </div> + + <div class="_footer" :class="$style.footer"> + <template v-if="tutorial === tutorialsNumber - 1"> + <MkPushNotificationAllowButton :class="$style.footerItem" primary show-only-to-register @click="tutorial = -1" /> + <MkButton :class="$style.footerItem" :primary="false" @click="tutorial = -1">{{ i18n.ts.noThankYou }}</MkButton> + </template> + <template v-else> + <MkButton :class="$style.footerItem" primary @click="tutorial++"><i class="ti ti-check"></i> {{ i18n.ts.next }}</MkButton> + </template> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; + +const tutorialsNumber = 8; + +const tutorial = computed({ + get() { return defaultStore.reactiveState.tutorial.value || 0; }, + set(value) { defaultStore.set('tutorial', value); }, +}); +</script> + +<style lang="scss" module> +.small { + opacity: 0.7; +} + +.title { + display: flex; + flex-wrap: wrap; + + &Text { + margin: 4px 0; + padding-right: 4px; + } +} + +.step { + margin-left: auto; + + &Arrow { + padding: 4px; + &:disabled { + opacity: 0.5; + } + &:first-child { + padding-right: 8px; + } + &:last-child { + padding-left: 8px; + } + } + + &Number { + font-weight: normal; + margin: 4px; + } +} + +.footer { + display: flex; + flex-wrap: wrap; + flex-direction: row; + justify-content: right; + + &Item { + margin: 4px; + } +} +</style> diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue new file mode 100644 index 0000000000..1c9e389367 --- /dev/null +++ b/packages/frontend/src/pages/timeline.vue @@ -0,0 +1,183 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="headerTabs" :display-my-avatar="true"/></template> + <MkSpacer :content-max="800"> + <div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf"> + <XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/> + <XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/> + + <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline + ref="tl" :key="src" + class="tl" + :src="src" + :sound="true" + @queue="queueUpdated" + /> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, watch } from 'vue'; +import XTimeline from '@/components/MkTimeline.vue'; +import XPostForm from '@/components/MkPostForm.vue'; +import { scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import { $i } from '@/account'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue')); + +const isLocalTimelineAvailable = !instance.disableLocalTimeline || ($i != null && ($i.isModerator || $i.isAdmin)); +const isGlobalTimelineAvailable = !instance.disableGlobalTimeline || ($i != null && ($i.isModerator || $i.isAdmin)); +const keymap = { + 't': focus, +}; + +const tlComponent = $ref<InstanceType<typeof XTimeline>>(); +const rootEl = $ref<HTMLElement>(); + +let queue = $ref(0); +const src = $computed({ get: () => defaultStore.reactiveState.tl.value.src, set: (x) => saveSrc(x) }); + +watch ($$(src), () => queue = 0); + +function queueUpdated(q: number): void { + queue = q; +} + +function top(): void { + scroll(rootEl, { top: 0 }); +} + +async function chooseList(ev: MouseEvent): Promise<void> { + const lists = await os.api('users/lists/list'); + const items = lists.map(list => ({ + type: 'link' as const, + text: list.name, + to: `/timeline/list/${list.id}`, + })); + os.popupMenu(items, ev.currentTarget ?? ev.target); +} + +async function chooseAntenna(ev: MouseEvent): Promise<void> { + const antennas = await os.api('antennas/list'); + const items = antennas.map(antenna => ({ + type: 'link' as const, + text: antenna.name, + indicate: antenna.hasUnreadNote, + to: `/timeline/antenna/${antenna.id}`, + })); + os.popupMenu(items, ev.currentTarget ?? ev.target); +} + +async function chooseChannel(ev: MouseEvent): Promise<void> { + const channels = await os.api('channels/followed'); + const items = channels.map(channel => ({ + type: 'link' as const, + text: channel.name, + indicate: channel.hasUnreadNote, + to: `/channels/${channel.id}`, + })); + os.popupMenu(items, ev.currentTarget ?? ev.target); +} + +function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global'): void { + defaultStore.set('tl', { + ...defaultStore.state.tl, + src: newSrc, + }); +} + +async function timetravel(): Promise<void> { + const { canceled, result: date } = await os.inputDate({ + title: i18n.ts.date, + }); + if (canceled) return; + + tlComponent.timetravel(date); +} + +function focus(): void { + tlComponent.focus(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'home', + title: i18n.ts._timelines.home, + icon: 'ti ti-home', + iconOnly: true, +}, ...(isLocalTimelineAvailable ? [{ + key: 'local', + title: i18n.ts._timelines.local, + icon: 'ti ti-messages', + iconOnly: true, +}, { + key: 'social', + title: i18n.ts._timelines.social, + icon: 'ti ti-share', + iconOnly: true, +}] : []), ...(isGlobalTimelineAvailable ? [{ + key: 'global', + title: i18n.ts._timelines.global, + icon: 'ti ti-world', + iconOnly: true, +}] : []), { + icon: 'ti ti-list', + title: i18n.ts.lists, + iconOnly: true, + onClick: chooseList, +}, { + icon: 'ti ti-antenna', + title: i18n.ts.antennas, + iconOnly: true, + onClick: chooseAntenna, +}, { + icon: 'ti ti-device-tv', + title: i18n.ts.channel, + iconOnly: true, + onClick: chooseChannel, +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.timeline, + icon: src === 'local' ? 'ti ti-messages' : src === 'social' ? 'ti ti-share' : src === 'global' ? 'ti ti-world' : 'ti ti-home', +}))); +</script> + +<style lang="scss" scoped> +.cmuxhskf { + > .new { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + z-index: 1000; + width: 100%; + + > button { + display: block; + margin: var(--margin) auto 0 auto; + padding: 8px 16px; + border-radius: 32px; + } + } + + > .post-form { + border-radius: var(--radius); + } + + > .tl { + background: var(--bg); + border-radius: var(--radius); + overflow: clip; + } +} +</style> diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue new file mode 100644 index 0000000000..addc8db9e6 --- /dev/null +++ b/packages/frontend/src/pages/user-info.vue @@ -0,0 +1,485 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div v-if="tab === 'overview'" class="_formRoot"> + <div class="_formBlock aeakzknw"> + <MkAvatar class="avatar" :user="user" :show-indicator="true"/> + <div class="body"> + <span class="name"><MkUserName class="name" :user="user"/></span> + <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> + <span class="state"> + <span v-if="suspended" class="suspended">Suspended</span> + <span v-if="silenced" class="silenced">Silenced</span> + <span v-if="moderator" class="moderator">Moderator</span> + </span> + </div> + </div> + + <MkInfo v-if="user.username.includes('.')" class="_formBlock">{{ i18n.ts.isSystemAccount }}</MkInfo> + + <div v-if="user.url" class="_formLinksGrid _formBlock"> + <FormLink :to="userPage(user)">Profile</FormLink> + <FormLink :to="user.url" :external="true">Profile (remote)</FormLink> + </div> + <FormLink v-else class="_formBlock" :to="userPage(user)">Profile</FormLink> + + <FormLink v-if="user.host" class="_formBlock" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink> + + <div class="_formBlock"> + <MkKeyValue :copy="user.id" oneline style="margin: 1em 0;"> + <template #key>ID</template> + <template #value><span class="_monospace">{{ user.id }}</span></template> + </MkKeyValue> + <!-- 要る? + <MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline style="margin: 1em 0;"> + <template #key>IP (recent)</template> + <template #value><span class="_monospace">{{ ips[0].ip }}</span></template> + </MkKeyValue> + --> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.createdAt }}</template> + <template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.lastActiveDate }}</template> + <template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.email }}</template> + <template #value><span class="_monospace">{{ info.email }}</span></template> + </MkKeyValue> + </div> + + <FormSection> + <template #label>ActivityPub</template> + + <div class="_formBlock"> + <MkKeyValue v-if="user.host" oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.instanceInfo }}</template> + <template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="ti ti-chevron-right"></i></MkA></template> + </MkKeyValue> + <MkKeyValue v-else oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.instanceInfo }}</template> + <template #value>(Local user)</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.updatedAt }}</template> + <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template> + </MkKeyValue> + <MkKeyValue v-if="ap" oneline style="margin: 1em 0;"> + <template #key>Type</template> + <template #value><span class="_monospace">{{ ap.type }}</span></template> + </MkKeyValue> + </div> + + <FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="ti ti-refresh"></i> {{ i18n.ts.updateRemoteUser }}</FormButton> + + <FormFolder class="_formBlock"> + <template #label>Raw</template> + + <MkObjectView v-if="ap" tall :value="ap"> + </MkObjectView> + </FormFolder> + </FormSection> + </div> + <div v-else-if="tab === 'moderation'" class="_formRoot"> + <FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:model-value="toggleModerator">{{ i18n.ts.moderator }}</FormSwitch> + <FormSwitch v-model="silenced" class="_formBlock" @update:model-value="toggleSilence">{{ i18n.ts.silence }}</FormSwitch> + <FormSwitch v-model="suspended" class="_formBlock" @update:model-value="toggleSuspend">{{ i18n.ts.suspend }}</FormSwitch> + {{ i18n.ts.reflectMayTakeTime }} + <div class="_formBlock"> + <FormButton v-if="user.host == null && iAmModerator" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</FormButton> + <FormButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</FormButton> + </div> + <FormTextarea v-model="moderationNote" manual-save class="_formBlock"> + <template #label>Moderation note</template> + </FormTextarea> + <FormFolder class="_formBlock"> + <template #label>IP</template> + <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo> + <MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo> + <template v-if="iAmAdmin && ips"> + <div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;"> + <span class="date">{{ record.createdAt }}</span> + <span class="ip">{{ record.ip }}</span> + </div> + </template> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.files }}</template> + + <MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/> + </FormFolder> + <FormSection> + <template #label>Drive Capacity Override</template> + + <FormInput v-if="user.host == null" v-model="driveCapacityOverrideMb" inline :manual-save="true" type="number" :placeholder="i18n.t('defaultValueIs', { value: instance.driveCapacityPerLocalUserMb })" @update:model-value="applyDriveCapacityOverride"> + <template #label>{{ i18n.ts.driveCapOverrideLabel }}</template> + <template #suffix>MB</template> + <template #caption> + {{ i18n.ts.driveCapOverrideCaption }} + </template> + </FormInput> + </FormSection> + </div> + <div v-else-if="tab === 'chart'" class="_formRoot"> + <div class="cmhjzshm"> + <div class="selects"> + <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> + <option value="per-user-notes">{{ i18n.ts.notes }}</option> + </MkSelect> + </div> + <div class="charts"> + <div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> + <div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> + </div> + </div> + </div> + <div v-else-if="tab === 'raw'" class="_formRoot"> + <MkObjectView v-if="info && $i.isAdmin" tall :value="info"> + </MkObjectView> + + <MkObjectView tall :value="user"> + </MkObjectView> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MkChart from '@/components/MkChart.vue'; +import MkObjectView from '@/components/MkObjectView.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInput from '@/components/form/input.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormFolder from '@/components/form/folder.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkSelect from '@/components/form/select.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import { url } from '@/config'; +import { userPage, acct } from '@/filters/user'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { iAmAdmin, iAmModerator } from '@/account'; +import { instance } from '@/instance'; + +const props = defineProps<{ + userId: string; +}>(); + +let tab = $ref('overview'); +let chartSrc = $ref('per-user-notes'); +let user = $ref<null | misskey.entities.UserDetailed>(); +let init = $ref<ReturnType<typeof createFetcher>>(); +let info = $ref(); +let ips = $ref(null); +let ap = $ref(null); +let moderator = $ref(false); +let silenced = $ref(false); +let suspended = $ref(false); +let driveCapacityOverrideMb: number | null = $ref(0); +let moderationNote = $ref(''); +const filesPagination = { + endpoint: 'admin/drive/files' as const, + limit: 10, + params: computed(() => ({ + userId: props.userId, + })), +}; + +function createFetcher() { + if (iAmModerator) { + return () => Promise.all([os.api('users/show', { + userId: props.userId, + }), os.api('admin/show-user', { + userId: props.userId, + }), iAmAdmin ? os.api('admin/get-user-ips', { + userId: props.userId, + }) : Promise.resolve(null)]).then(([_user, _info, _ips]) => { + user = _user; + info = _info; + ips = _ips; + moderator = info.isModerator; + silenced = info.isSilenced; + suspended = info.isSuspended; + driveCapacityOverrideMb = user.driveCapacityOverrideMb; + moderationNote = info.moderationNote; + + watch($$(moderationNote), async () => { + await os.api('admin/update-user-note', { userId: user.id, text: moderationNote }); + await refreshUser(); + }); + }); + } else { + return () => os.api('users/show', { + userId: props.userId, + }).then((res) => { + user = res; + }); + } +} + +function refreshUser() { + init = createFetcher(); +} + +async function updateRemoteUser() { + await os.apiWithDialog('federation/update-remote-user', { userId: user.id }); + refreshUser(); +} + +async function resetPassword() { + const { password } = await os.api('admin/reset-password', { + userId: user.id, + }); + + os.alert({ + type: 'success', + text: i18n.t('newPasswordIs', { password }), + }); +} + +async function toggleSilence(v) { + const confirm = await os.confirm({ + type: 'warning', + text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm, + }); + if (confirm.canceled) { + silenced = !v; + } else { + await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.id }); + await refreshUser(); + } +} + +async function toggleSuspend(v) { + const confirm = await os.confirm({ + type: 'warning', + text: v ? i18n.ts.suspendConfirm : i18n.ts.unsuspendConfirm, + }); + if (confirm.canceled) { + suspended = !v; + } else { + await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.id }); + await refreshUser(); + } +} + +async function toggleModerator(v) { + await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: user.id }); + await refreshUser(); +} + +async function deleteAllFiles() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteAllFilesConfirm, + }); + if (confirm.canceled) return; + const process = async () => { + await os.api('admin/delete-all-files-of-a-user', { userId: user.id }); + os.success(); + }; + await process().catch(err => { + os.alert({ + type: 'error', + text: err.toString(), + }); + }); + await refreshUser(); +} + +async function applyDriveCapacityOverride() { + let driveCapOrMb = driveCapacityOverrideMb; + if (driveCapacityOverrideMb && driveCapacityOverrideMb < 0) { + driveCapOrMb = null; + } + try { + await os.apiWithDialog('admin/drive-capacity-override', { userId: user.id, overrideMb: driveCapOrMb }); + await refreshUser(); + } catch (err) { + os.alert({ + type: 'error', + text: err.toString(), + }); + } +} + +async function deleteAccount() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteAccountConfirm, + }); + if (confirm.canceled) return; + + const typed = await os.inputText({ + text: i18n.t('typeToConfirm', { x: user?.username }), + }); + if (typed.canceled) return; + + if (typed.result === user?.username) { + await os.apiWithDialog('admin/delete-account', { + userId: user.id, + }); + } else { + os.alert({ + type: 'error', + text: 'input not match', + }); + } +} + +watch(() => props.userId, () => { + init = createFetcher(); +}, { + immediate: true, +}); + +watch($$(user), () => { + os.api('ap/get', { + uri: user.uri ?? `${url}/users/${user.id}`, + }).then(res => { + ap = res; + }); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, + icon: 'ti ti-info-circle', +}, iAmModerator ? { + key: 'moderation', + title: i18n.ts.moderation, + icon: 'ti ti-user-exclamation', +} : null, { + key: 'chart', + title: i18n.ts.charts, + icon: 'ti ti-chart-line', +}, { + key: 'raw', + title: 'Raw', + icon: 'ti ti-code', +}].filter(x => x != null)); + +definePageMetadata(computed(() => ({ + title: user ? acct(user) : i18n.ts.userInfo, + icon: 'ti ti-info-circle', +}))); +</script> + +<style lang="scss" scoped> +.aeakzknw { + display: flex; + align-items: center; + + > .avatar { + display: block; + width: 64px; + height: 64px; + margin-right: 16px; + } + + > .body { + flex: 1; + overflow: hidden; + + > .name { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .sub { + display: block; + width: 100%; + font-size: 85%; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .state { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; + + &:empty { + display: none; + } + + > .suspended, > .silenced, > .moderator { + display: inline-block; + border: solid 1px; + border-radius: 6px; + padding: 2px 6px; + font-size: 85%; + } + + > .suspended { + color: var(--error); + border-color: var(--error); + } + + > .silenced { + color: var(--warn); + border-color: var(--warn); + } + + > .moderator { + color: var(--success); + border-color: var(--success); + } + } + } +} + +.cmhjzshm { + > .selects { + display: flex; + margin: 0 0 16px 0; + } + + > .charts { + > .label { + margin-bottom: 12px; + font-weight: bold; + } + } +} +</style> + +<style lang="scss" module> +.ip { + display: flex; + + > :global(.date) { + opacity: 0.7; + } + + > :global(.ip) { + margin-left: auto; + } +} +</style> diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue new file mode 100644 index 0000000000..fdb3167375 --- /dev/null +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -0,0 +1,121 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <div ref="rootEl" v-size="{ min: [800] }" class="eqqrhokj"> + <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline + ref="tlEl" :key="listId" + class="tl" + src="list" + :list="listId" + :sound="true" + @queue="queueUpdated" + /> + </div> + </div> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch, inject } from 'vue'; +import XTimeline from '@/components/MkTimeline.vue'; +import { scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const router = useRouter(); + +const props = defineProps<{ + listId: string; +}>(); + +let list = $ref(null); +let queue = $ref(0); +let tlEl = $ref<InstanceType<typeof XTimeline>>(); +let rootEl = $ref<HTMLElement>(); + +watch(() => props.listId, async () => { + list = await os.api('users/lists/show', { + listId: props.listId, + }); +}, { immediate: true }); + +function queueUpdated(q) { + queue = q; +} + +function top() { + scroll(rootEl, { top: 0 }); +} + +function settings() { + router.push(`/my/lists/${props.listId}`); +} + +async function timetravel() { + const { canceled, result: date } = await os.inputDate({ + title: i18n.ts.date, + }); + if (canceled) return; + + tlEl.timetravel(date); +} + +const headerActions = $computed(() => list ? [{ + icon: 'fas fa-calendar-alt', + text: i18n.ts.jumpToSpecifiedDate, + handler: timetravel, +}, { + icon: 'ti ti-settings', + text: i18n.ts.settings, + handler: settings, +}] : []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => list ? { + title: list.name, + icon: 'ti ti-list', +} : null)); +</script> + +<style lang="scss" scoped> +.eqqrhokj { + padding: var(--margin); + + > .new { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + z-index: 1000; + width: 100%; + + > button { + display: block; + margin: var(--margin) auto 0 auto; + padding: 8px 16px; + border-radius: 32px; + } + } + + > .tl { + background: var(--bg); + border-radius: var(--radius); + overflow: clip; + } + + &.min-width_800px { + max-width: 800px; + margin: 0 auto; + } +} + +@container (min-width: 800px) { + .eqqrhokj { + max-width: 800px; + margin: 0 auto; + } +} +</style> diff --git a/packages/frontend/src/pages/user/clips.vue b/packages/frontend/src/pages/user/clips.vue new file mode 100644 index 0000000000..8c71aacb0c --- /dev/null +++ b/packages/frontend/src/pages/user/clips.vue @@ -0,0 +1,47 @@ +<template> +<MkSpacer :content-max="700"> + <div class="pages-user-clips"> + <MkPagination v-slot="{items}" ref="list" :pagination="pagination" class="list"> + <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + </MkA> + </MkPagination> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkPagination from '@/components/MkPagination.vue'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +const pagination = { + endpoint: 'users/clips' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</script> + +<style lang="scss" scoped> +.pages-user-clips { + > .list { + > .item { + display: block; + padding: 16px; + + > .description { + margin-top: 8px; + padding-top: 8px; + border-top: solid 0.5px var(--divider); + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/user/follow-list.vue b/packages/frontend/src/pages/user/follow-list.vue new file mode 100644 index 0000000000..d42acd838f --- /dev/null +++ b/packages/frontend/src/pages/user/follow-list.vue @@ -0,0 +1,47 @@ +<template> +<div> + <MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers"> + <div class="users"> + <MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" class="user" :user="user"/> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkUserInfo from '@/components/MkUserInfo.vue'; +import MkPagination from '@/components/MkPagination.vue'; + +const props = defineProps<{ + user: misskey.entities.User; + type: 'following' | 'followers'; +}>(); + +const followingPagination = { + endpoint: 'users/following' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; + +const followersPagination = { + endpoint: 'users/followers' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</script> + +<style lang="scss" scoped> +.mk-following-or-followers { + > .users { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); + } +} +</style> diff --git a/packages/frontend/src/pages/user/followers.vue b/packages/frontend/src/pages/user/followers.vue new file mode 100644 index 0000000000..17c2843381 --- /dev/null +++ b/packages/frontend/src/pages/user/followers.vue @@ -0,0 +1,61 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000"> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XFollowList :user="user" type="followers"/> + </div> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> + </transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import XFollowList from './follow-list.vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + acct: string; +}>(), { +}); + +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); + +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} + +watch(() => props.acct, fetchUser, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => user ? { + icon: 'ti ti-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: i18n.ts.followers, + userName: user, + avatar: user, +} : null)); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/pages/user/following.vue b/packages/frontend/src/pages/user/following.vue new file mode 100644 index 0000000000..03892ec03d --- /dev/null +++ b/packages/frontend/src/pages/user/following.vue @@ -0,0 +1,61 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000"> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XFollowList :user="user" type="following"/> + </div> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> + </transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import XFollowList from './follow-list.vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + acct: string; +}>(), { +}); + +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); + +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} + +watch(() => props.acct, fetchUser, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => user ? { + icon: 'ti ti-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: i18n.ts.following, + userName: user, + avatar: user, +} : null)); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/pages/user/gallery.vue b/packages/frontend/src/pages/user/gallery.vue new file mode 100644 index 0000000000..b80e83fb11 --- /dev/null +++ b/packages/frontend/src/pages/user/gallery.vue @@ -0,0 +1,38 @@ +<template> +<MkSpacer :content-max="700"> + <MkPagination v-slot="{items}" :pagination="pagination"> + <div class="jrnovfpt"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; + +const props = withDefaults(defineProps<{ + user: misskey.entities.User; +}>(), { +}); + +const pagination = { + endpoint: 'users/gallery/posts' as const, + limit: 6, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</script> + +<style lang="scss" scoped> +.jrnovfpt { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: 12px; + margin: var(--margin); +} +</style> diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue new file mode 100644 index 0000000000..43c1b37e1d --- /dev/null +++ b/packages/frontend/src/pages/user/home.vue @@ -0,0 +1,530 @@ +<template> +<MkSpacer :content-max="narrow ? 800 : 1100"> + <div ref="rootEl" v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }"> + <div class="main"> + <!-- TODO --> + <!-- <div class="punished" v-if="user.isSuspended"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> --> + <!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> --> + + <div class="profile"> + <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/> + + <div :key="user.id" class="_block main"> + <div class="banner-container" :style="style"> + <div ref="bannerEl" class="banner" :style="style"></div> + <div class="fade"></div> + <div class="title"> + <MkUserName class="name" :user="user" :nowrap="true"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true"/></span> + <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="ti ti-shield"></i></span> + <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> + <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> + </div> + </div> + <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> + <div v-if="$i" class="actions"> + <button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button> + <MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + </div> + </div> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> + <div class="title"> + <MkUserName :user="user" :nowrap="false" class="name"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true"/></span> + <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="ti ti-shield"></i></span> + <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> + <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> + </div> + </div> + <div class="description"> + <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/> + <p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p> + </div> + <div class="fields system"> + <dl v-if="user.location" class="field"> + <dt class="name"><i class="ti ti-map-pin ti-fw"></i> {{ i18n.ts.location }}</dt> + <dd class="value">{{ user.location }}</dd> + </dl> + <dl v-if="user.birthday" class="field"> + <dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> + </dl> + <dl class="field"> + <dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt> + <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd> + </dl> + </div> + <div v-if="user.fields.length > 0" class="fields"> + <dl v-for="(field, i) in user.fields" :key="i" class="field"> + <dt class="name"> + <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> + </dt> + <dd class="value"> + <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/> + </dd> + </dl> + </div> + <div class="status"> + <MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }"> + <b>{{ number(user.notesCount) }}</b> + <span>{{ i18n.ts.notes }}</span> + </MkA> + <MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }"> + <b>{{ number(user.followingCount) }}</b> + <span>{{ i18n.ts.following }}</span> + </MkA> + <MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }"> + <b>{{ number(user.followersCount) }}</b> + <span>{{ i18n.ts.followers }}</span> + </MkA> + </div> + </div> + </div> + + <div class="contents"> + <div v-if="user.pinnedNotes.length > 0" class="_gap"> + <XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/> + </div> + <MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> + <template v-if="narrow"> + <XPhotos :key="user.id" :user="user"/> + <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> + </template> + </div> + <div> + <XUserTimeline :user="user"/> + </div> + </div> + <div v-if="!narrow" class="sub"> + <XPhotos :key="user.id" :user="user"/> + <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> + </div> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import calcAge from 's-age'; +import * as misskey from 'misskey-js'; +import XUserTimeline from './index.timeline.vue'; +import XNote from '@/components/MkNote.vue'; +import MkFollowButton from '@/components/MkFollowButton.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import { getScrollPosition } from '@/scripts/scroll'; +import { getUserMenu } from '@/scripts/get-user-menu'; +import number from '@/filters/number'; +import { userPage, acct as getAcct } from '@/filters/user'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; + +const XPhotos = defineAsyncComponent(() => import('./index.photos.vue')); +const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); + +const props = withDefaults(defineProps<{ + user: misskey.entities.UserDetailed; +}>(), { +}); + +const router = useRouter(); + +let parallaxAnimationId = $ref<null | number>(null); +let narrow = $ref<null | boolean>(null); +let rootEl = $ref<null | HTMLElement>(null); +let bannerEl = $ref<null | HTMLElement>(null); + +const style = $computed(() => { + if (props.user.bannerUrl == null) return {}; + return { + backgroundImage: `url(${ props.user.bannerUrl })`, + }; +}); + +const age = $computed(() => { + return calcAge(props.user.birthday); +}); + +function menu(ev) { + os.popupMenu(getUserMenu(props.user, router), ev.currentTarget ?? ev.target); +} + +function parallaxLoop() { + parallaxAnimationId = window.requestAnimationFrame(parallaxLoop); + parallax(); +} + +function parallax() { + const banner = bannerEl as any; + if (banner == null) return; + + const top = getScrollPosition(rootEl); + + if (top < 0) return; + + const z = 1.75; // 奥行き(小さいほど奥) + const pos = -(top / z); + banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; +} + +onMounted(() => { + window.requestAnimationFrame(parallaxLoop); + narrow = rootEl!.clientWidth < 1000; +}); + +onUnmounted(() => { + if (parallaxAnimationId) { + window.cancelAnimationFrame(parallaxAnimationId); + } +}); +</script> + +<style lang="scss" scoped> +.ftskorzw { + + > .main { + + > .punished { + font-size: 0.8em; + padding: 16px; + } + + > .profile { + + > .main { + position: relative; + overflow: hidden; + + > .banner-container { + position: relative; + height: 250px; + overflow: hidden; + background-size: cover; + background-position: center; + + > .banner { + height: 100%; + background-color: #4c5e6d; + background-size: cover; + background-position: center; + box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; + will-change: background-position; + } + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 78px; + background: linear-gradient(transparent, rgba(#000, 0.7)); + } + + > .followed { + position: absolute; + top: 12px; + left: 12px; + padding: 4px 8px; + color: #fff; + background: rgba(0, 0, 0, 0.7); + font-size: 0.7em; + border-radius: 6px; + } + + > .actions { + position: absolute; + top: 12px; + right: 12px; + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + background: rgba(0, 0, 0, 0.2); + padding: 8px; + border-radius: 24px; + + > .menu { + vertical-align: bottom; + height: 31px; + width: 31px; + color: #fff; + text-shadow: 0 0 8px #000; + font-size: 16px; + } + + > .koudoku { + margin-left: 4px; + vertical-align: bottom; + } + } + + > .title { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 0 0 8px 154px; + box-sizing: border-box; + color: #fff; + + > .name { + display: block; + margin: 0; + line-height: 32px; + font-weight: bold; + font-size: 1.8em; + text-shadow: 0 0 8px #000; + } + + > .bottom { + > * { + display: inline-block; + margin-right: 16px; + line-height: 20px; + opacity: 0.8; + + &.username { + font-weight: bold; + } + } + } + } + } + + > .title { + display: none; + text-align: center; + padding: 50px 8px 16px 8px; + font-weight: bold; + border-bottom: solid 0.5px var(--divider); + + > .bottom { + > * { + display: inline-block; + margin-right: 8px; + opacity: 0.8; + } + } + } + + > .avatar { + display: block; + position: absolute; + top: 170px; + left: 16px; + z-index: 2; + width: 120px; + height: 120px; + box-shadow: 1px 1px 3px rgba(#000, 0.2); + } + + > .description { + padding: 24px 24px 24px 154px; + font-size: 0.95em; + + > .empty { + margin: 0; + opacity: 0.5; + } + } + + > .fields { + padding: 24px; + font-size: 0.9em; + border-top: solid 0.5px var(--divider); + + > .field { + display: flex; + padding: 0; + margin: 0; + align-items: center; + + &:not(:last-child) { + margin-bottom: 8px; + } + + > .name { + width: 30%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: bold; + text-align: center; + } + + > .value { + width: 70%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0; + } + } + + &.system > .field > .name { + } + } + + > .status { + display: flex; + padding: 24px; + border-top: solid 0.5px var(--divider); + + > a { + flex: 1; + text-align: center; + + &.active { + color: var(--accent); + } + + &:hover { + text-decoration: none; + } + + > b { + display: block; + line-height: 16px; + } + + > span { + font-size: 70%; + } + } + } + } + } + + > .contents { + > .content { + margin-bottom: var(--margin); + } + } + } + + &.max-width_500px { + > .main { + > .profile > .main { + > .banner-container { + height: 140px; + + > .fade { + display: none; + } + + > .title { + display: none; + } + } + + > .title { + display: block; + } + + > .avatar { + top: 90px; + left: 0; + right: 0; + width: 92px; + height: 92px; + margin: auto; + } + + > .description { + padding: 16px; + text-align: center; + } + + > .fields { + padding: 16px; + } + + > .status { + padding: 16px; + } + } + + > .contents { + > .nav { + font-size: 80%; + } + } + } + } + + &.wide { + display: flex; + width: 100%; + + > .main { + width: 100%; + min-width: 0; + } + + > .sub { + max-width: 350px; + min-width: 350px; + margin-left: var(--margin); + } + } +} + +@container (max-width: 500px) { + .ftskorzw { + > .main { + > .profile > .main { + > .banner-container { + height: 140px; + + > .fade { + display: none; + } + + > .title { + display: none; + } + } + + > .title { + display: block; + } + + > .avatar { + top: 90px; + left: 0; + right: 0; + width: 92px; + height: 92px; + margin: auto; + } + + > .description { + padding: 16px; + text-align: center; + } + + > .fields { + padding: 16px; + } + + > .status { + padding: 16px; + } + } + + > .contents { + > .nav { + font-size: 80%; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue new file mode 100644 index 0000000000..523072d2e6 --- /dev/null +++ b/packages/frontend/src/pages/user/index.activity.vue @@ -0,0 +1,52 @@ +<template> +<MkContainer> + <template #header><i class="ti ti-chart-line" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template> + <template #func> + <button class="_button" @click="showMenu"> + <i class="ti ti-dots"></i> + </button> + </template> + + <div style="padding: 8px;"> + <MkChart :src="chartSrc" :args="{ user, withoutAll: true }" span="day" :limit="limit" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="5"/> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as misskey from 'misskey-js'; +import MkContainer from '@/components/MkContainer.vue'; +import MkChart from '@/components/MkChart.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + user: misskey.entities.User; + limit?: number; +}>(), { + limit: 50, +}); + +let chartSrc = $ref('per-user-notes'); + +function showMenu(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts.notes, + active: true, + action: () => { + chartSrc = 'per-user-notes'; + }, + }, /*, { + text: i18n.ts.following, + action: () => { + chartSrc = 'per-user-following'; + } + }, { + text: i18n.ts.followers, + action: () => { + chartSrc = 'per-user-followers'; + } + }*/], ev.currentTarget ?? ev.target); +} +</script> diff --git a/packages/frontend/src/pages/user/index.photos.vue b/packages/frontend/src/pages/user/index.photos.vue new file mode 100644 index 0000000000..b33979a79d --- /dev/null +++ b/packages/frontend/src/pages/user/index.photos.vue @@ -0,0 +1,102 @@ +<template> +<MkContainer :max-height="300" :foldable="true"> + <template #header><i class="ti ti-photo" style="margin-right: 0.5em;"></i>{{ $ts.images }}</template> + <div class="ujigsodd"> + <MkLoading v-if="fetching"/> + <div v-if="!fetching && images.length > 0" class="stream"> + <MkA + v-for="image in images" + :key="image.note.id + image.file.id" + class="img" + :to="notePage(image.note)" + > + <ImgWithBlurhash :hash="image.file.blurhash" :src="thumbnail(image.file)" :title="image.file.name"/> + </MkA> + </div> + <p v-if="!fetching && images.length == 0" class="empty">{{ $ts.nothing }}</p> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import * as misskey from 'misskey-js'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { notePage } from '@/filters/note'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import { defaultStore } from '@/store'; + +const props = defineProps<{ + user: misskey.entities.UserDetailed; +}>(); + +let fetching = $ref(true); +let images = $ref<{ + note: misskey.entities.Note; + file: misskey.entities.DriveFile; +}[]>([]); + +function thumbnail(image: misskey.entities.DriveFile): string { + return defaultStore.state.disableShowingAnimatedImages + ? getStaticImageUrl(image.thumbnailUrl) + : image.thumbnailUrl; +} + +onMounted(() => { + const image = [ + 'image/jpeg', + 'image/webp', + 'image/avif', + 'image/png', + 'image/gif', + 'image/apng', + 'image/vnd.mozilla.apng', + ]; + os.api('users/notes', { + userId: props.user.id, + fileType: image, + excludeNsfw: defaultStore.state.nsfw !== 'ignore', + limit: 10, + }).then(notes => { + for (const note of notes) { + for (const file of note.files) { + images.push({ + note, + file, + }); + } + } + fetching = false; + }); +}); +</script> + +<style lang="scss" scoped> +.ujigsodd { + padding: 8px; + + > .stream { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + grid-gap: 6px; + + > .img { + height: 128px; + border-radius: 6px; + overflow: clip; + } + } + + > .empty { + margin: 0; + padding: 16px; + text-align: center; + + > i { + margin-right: 4px; + } + } +} +</style> diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue new file mode 100644 index 0000000000..41983a5ae8 --- /dev/null +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -0,0 +1,45 @@ +<template> +<MkStickyContainer> + <template #header> + <MkTab v-model="include" :class="$style.tab"> + <option :value="null">{{ i18n.ts.notes }}</option> + <option value="replies">{{ i18n.ts.notesAndReplies }}</option> + <option value="files">{{ i18n.ts.withFiles }}</option> + </MkTab> + </template> + <XNotes :no-gap="true" :pagination="pagination"/> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import * as misskey from 'misskey-js'; +import XNotes from '@/components/MkNotes.vue'; +import MkTab from '@/components/MkTab.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + user: misskey.entities.UserDetailed; +}>(); + +const include = ref<string | null>(null); + +const pagination = { + endpoint: 'users/notes' as const, + limit: 10, + params: computed(() => ({ + userId: props.user.id, + includeReplies: include.value === 'replies', + withFiles: include.value === 'files', + })), +}; +</script> + +<style lang="scss" module> +.tab { + margin: calc(var(--margin) / 2) 0; + padding: calc(var(--margin) / 2) 0; + background: var(--bg); +} +</style> diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue new file mode 100644 index 0000000000..6e895cd8d7 --- /dev/null +++ b/packages/frontend/src/pages/user/index.vue @@ -0,0 +1,113 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <div> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XHome v-if="tab === 'home'" :user="user"/> + <XReactions v-else-if="tab === 'reactions'" :user="user"/> + <XClips v-else-if="tab === 'clips'" :user="user"/> + <XPages v-else-if="tab === 'pages'" :user="user"/> + <XGallery v-else-if="tab === 'gallery'" :user="user"/> + </div> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> + </transition> + </div> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import calcAge from 's-age'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import { getScrollPosition } from '@/scripts/scroll'; +import number from '@/filters/number'; +import { userPage, acct as getAcct } from '@/filters/user'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; + +const XHome = defineAsyncComponent(() => import('./home.vue')); +const XReactions = defineAsyncComponent(() => import('./reactions.vue')); +const XClips = defineAsyncComponent(() => import('./clips.vue')); +const XPages = defineAsyncComponent(() => import('./pages.vue')); +const XGallery = defineAsyncComponent(() => import('./gallery.vue')); + +const props = withDefaults(defineProps<{ + acct: string; + page?: string; +}>(), { + page: 'home', +}); + +const router = useRouter(); + +let tab = $ref(props.page); +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); + +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} + +watch(() => props.acct, fetchUser, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => user ? [{ + key: 'home', + title: i18n.ts.overview, + icon: 'ti ti-home', +}, ...($i && ($i.id === user.id)) || user.publicReactions ? [{ + key: 'reactions', + title: i18n.ts.reaction, + icon: 'ti ti-mood-happy', +}] : [], { + key: 'clips', + title: i18n.ts.clips, + icon: 'ti ti-paperclip', +}, { + key: 'pages', + title: i18n.ts.pages, + icon: 'ti ti-news', +}, { + key: 'gallery', + title: i18n.ts.gallery, + icon: 'ti ti-icons', +}] : null); + +definePageMetadata(computed(() => user ? { + icon: 'ti ti-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: `@${getAcct(user)}`, + userName: user, + avatar: user, + path: `/@${user.username}`, + share: { + title: user.name, + }, +} : null)); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} +</style> diff --git a/packages/frontend/src/pages/user/pages.vue b/packages/frontend/src/pages/user/pages.vue new file mode 100644 index 0000000000..7833d6c42c --- /dev/null +++ b/packages/frontend/src/pages/user/pages.vue @@ -0,0 +1,30 @@ +<template> +<MkSpacer :content-max="700"> + <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> + <MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/> + </MkPagination> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkPagePreview from '@/components/MkPagePreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +const pagination = { + endpoint: 'users/pages' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/user/reactions.vue b/packages/frontend/src/pages/user/reactions.vue new file mode 100644 index 0000000000..ab3df34301 --- /dev/null +++ b/packages/frontend/src/pages/user/reactions.vue @@ -0,0 +1,61 @@ +<template> +<MkSpacer :content-max="700"> + <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> + <div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap afdcfbfb"> + <div class="header"> + <MkAvatar class="avatar" :user="user"/> + <MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/> + <MkTime :time="item.createdAt" class="createdAt"/> + </div> + <MkNote :key="item.id" :note="item.note"/> + </div> + </MkPagination> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkPagination from '@/components/MkPagination.vue'; +import MkNote from '@/components/MkNote.vue'; +import MkReactionIcon from '@/components/MkReactionIcon.vue'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +const pagination = { + endpoint: 'users/reactions' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</script> + +<style lang="scss" scoped> +.afdcfbfb { + > .header { + display: flex; + align-items: center; + padding: 8px 16px; + margin-bottom: 8px; + border-bottom: solid 2px var(--divider); + + > .avatar { + width: 24px; + height: 24px; + margin-right: 8px; + } + + > .reaction { + width: 32px; + height: 32px; + } + + > .createdAt { + margin-left: auto; + } + } +} +</style> diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue new file mode 100644 index 0000000000..bfa54d39f2 --- /dev/null +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -0,0 +1,309 @@ +<template> +<div v-if="meta" class="rsqzvsbo"> + <div class="top"> + <MkFeaturedPhotos class="bg"/> + <XTimeline class="tl"/> + <div class="shape1"></div> + <div class="shape2"></div> + <img src="/client-assets/misskey.svg" class="misskey"/> + <div class="emojis"> + <MkEmoji :normal="true" :no-style="true" emoji="👍"/> + <MkEmoji :normal="true" :no-style="true" emoji="❤"/> + <MkEmoji :normal="true" :no-style="true" emoji="😆"/> + <MkEmoji :normal="true" :no-style="true" emoji="🎉"/> + <MkEmoji :normal="true" :no-style="true" emoji="🍮"/> + </div> + <div class="main"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + <button class="_button _acrylic menu" @click="showMenu"><i class="ti ti-dots"></i></button> + <div class="fg"> + <h1> + <!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に --> + <!-- <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> --> + <span class="text">{{ instanceName }}</span> + </h1> + <div class="about"> + <!-- eslint-disable-next-line vue/no-v-html --> + <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div> + </div> + <div class="action"> + <MkButton inline rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.signup }}</MkButton> + <MkButton inline rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton> + </div> + </div> + </div> + <div v-if="instances" class="federation"> + <MarqueeText :duration="40"> + <MkA v-for="instance in instances" :key="instance.id" :class="$style.federationInstance" :to="`/instance-info/${instance.host}`" behavior="window"> + <!--<MkInstanceCardMini :instance="instance"/>--> + <img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/> + <span class="name _monospace">{{ instance.host }}</span> + </MkA> + </MarqueeText> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import { toUnicode } from 'punycode/'; +import XTimeline from './welcome.timeline.vue'; +import MarqueeText from '@/components/MkMarquee.vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; +import XNote from '@/components/MkNote.vue'; +import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; + +let meta = $ref(); +let stats = $ref(); +let tags = $ref(); +let onlineUsersCount = $ref(); +let instances = $ref(); + +os.api('meta', { detail: true }).then(_meta => { + meta = _meta; +}); + +os.api('stats').then(_stats => { + stats = _stats; +}); + +os.api('get-online-users-count').then(res => { + onlineUsersCount = res.count; +}); + +os.api('hashtags/list', { + sort: '+mentionedLocalUsers', + limit: 8, +}).then(_tags => { + tags = _tags; +}); + +os.api('federation/instances', { + sort: '+pubSub', + limit: 20, +}).then(_instances => { + instances = _instances; +}); + +function signin() { + os.popup(XSigninDialog, { + autoSet: true, + }, {}, 'closed'); +} + +function signup() { + os.popup(XSignupDialog, { + autoSet: true, + }, {}, 'closed'); +} + +function showMenu(ev) { + os.popupMenu([{ + text: i18n.ts.instanceInfo, + icon: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about'); + }, + }, { + text: i18n.ts.aboutMisskey, + icon: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + }, + }, null, { + text: i18n.ts.help, + icon: 'ti ti-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.md', '_blank'); + }, + }], ev.currentTarget ?? ev.target); +} +</script> + +<style lang="scss" scoped> +.rsqzvsbo { + > .top { + display: flex; + text-align: center; + min-height: 100vh; + box-sizing: border-box; + padding: 16px; + + > .bg { + position: absolute; + top: 0; + right: 0; + width: 80%; // 100%からshapeの幅を引いている + height: 100%; + } + + > .tl { + position: absolute; + top: 0; + bottom: 0; + right: 64px; + margin: auto; + width: 500px; + height: calc(100% - 128px); + overflow: hidden; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + + @media (max-width: 1200px) { + display: none; + } + } + + > .shape1 { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--accent); + clip-path: polygon(0% 0%, 45% 0%, 20% 100%, 0% 100%); + } + > .shape2 { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--accent); + clip-path: polygon(0% 0%, 25% 0%, 35% 100%, 0% 100%); + opacity: 0.5; + } + + > .misskey { + position: absolute; + top: 42px; + left: 42px; + width: 140px; + + @media (max-width: 450px) { + width: 130px; + } + } + + > .emojis { + position: absolute; + bottom: 32px; + left: 35px; + + > * { + margin-right: 8px; + } + + @media (max-width: 1200px) { + display: none; + } + } + + > .main { + position: relative; + width: min(480px, 100%); + margin: auto auto auto 128px; + background: var(--panel); + border-radius: var(--radius); + box-shadow: 0 12px 32px rgb(0 0 0 / 25%); + + @media (max-width: 1200px) { + margin: auto; + } + + > .icon { + width: 85px; + margin-top: -47px; + border-radius: 100%; + vertical-align: bottom; + } + + > .menu { + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + border-radius: 8px; + font-size: 18px; + } + + > .fg { + position: relative; + z-index: 1; + + > h1 { + display: block; + margin: 0; + padding: 16px 32px 24px 32px; + font-size: 1.4em; + + > .logo { + vertical-align: bottom; + max-height: 120px; + max-width: min(100%, 300px); + } + } + + > .about { + padding: 0 32px; + } + + > .action { + padding: 32px; + + > * { + line-height: 28px; + } + } + } + } + + > .federation { + position: absolute; + bottom: 16px; + left: 0; + right: 0; + margin: auto; + background: var(--acrylicPanel); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + border-radius: 999px; + overflow: clip; + width: 800px; + padding: 8px 0; + + @media (max-width: 900px) { + display: none; + } + } + } +} +</style> + +<style lang="scss" module> +.federationInstance { + display: inline-flex; + align-items: center; + vertical-align: bottom; + padding: 6px 12px 6px 6px; + margin: 0 10px 0 0; + background: var(--panel); + border-radius: 999px; + + > :global(.icon) { + display: inline-block; + width: 20px; + height: 20px; + margin-right: 5px; + border-radius: 999px; + } +} +</style> diff --git a/packages/frontend/src/pages/welcome.entrance.b.vue b/packages/frontend/src/pages/welcome.entrance.b.vue new file mode 100644 index 0000000000..8230adaf1f --- /dev/null +++ b/packages/frontend/src/pages/welcome.entrance.b.vue @@ -0,0 +1,237 @@ +<template> +<div v-if="meta" class="rsqzvsbo"> + <div class="top"> + <MkFeaturedPhotos class="bg"/> + <XTimeline class="tl"/> + <div class="shape"></div> + <div class="main"> + <h1> + <img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> + </h1> + <div class="about"> + <!-- eslint-disable-next-line vue/no-v-html --> + <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + </div> + <div class="action"> + <MkButton class="signup" inline gradate @click="signup()">{{ $ts.signup }}</MkButton> + <MkButton class="signin" inline @click="signin()">{{ $ts.login }}</MkButton> + </div> + <div v-if="onlineUsersCount && stats" class="status"> + <div> + <I18n :src="$ts.nUsers" text-tag="span" class="users"> + <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> + </I18n> + <I18n :src="$ts.nNotes" text-tag="span" class="notes"> + <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> + </I18n> + </div> + <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online"> + <template #n><b>{{ onlineUsersCount }}</b></template> + </I18n> + </div> + </div> + <img src="/client-assets/misskey.svg" class="misskey"/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; +import XNote from '@/components/MkNote.vue'; +import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; +import XTimeline from './welcome.timeline.vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + MkButton, + XNote, + XTimeline, + MkFeaturedPhotos, + }, + + data() { + return { + host: toUnicode(host), + instanceName, + meta: null, + stats: null, + tags: [], + onlineUsersCount: null, + }; + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + + os.api('stats').then(stats => { + this.stats = stats; + }); + + os.api('get-online-users-count').then(res => { + this.onlineUsersCount = res.count; + }); + + os.api('hashtags/list', { + sort: '+mentionedLocalUsers', + limit: 8, + }).then(tags => { + this.tags = tags; + }); + }, + + methods: { + signin() { + os.popup(XSigninDialog, { + autoSet: true, + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true, + }, {}, 'closed'); + }, + + showMenu(ev) { + os.popupMenu([{ + text: this.$t('aboutX', { x: instanceName }), + icon: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about'); + }, + }, { + text: this.$ts.aboutMisskey, + icon: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + }, + }, null, { + text: this.$ts.help, + icon: 'ti ti-question-circle', + action: () => { + window.open(`https://misskey-hub.net/help.md`, '_blank'); + }, + }], ev.currentTarget ?? ev.target); + }, + + number, + }, +}); +</script> + +<style lang="scss" scoped> +.rsqzvsbo { + > .top { + min-height: 100vh; + box-sizing: border-box; + + > .bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + > .tl { + position: absolute; + top: 0; + bottom: 0; + right: 64px; + margin: auto; + width: 500px; + height: calc(100% - 128px); + overflow: hidden; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + } + + > .shape { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--accent); + clip-path: polygon(0% 0%, 40% 0%, 22% 100%, 0% 100%); + } + + > .misskey { + position: absolute; + bottom: 64px; + left: 64px; + width: 160px; + } + + > .main { + position: relative; + width: min(450px, 100%); + padding: 64px; + color: #fff; + font-size: 1.1em; + + @media (max-width: 1200px) { + margin: auto; + } + + > h1 { + display: block; + margin: 0 0 32px 0; + padding: 0; + + > .logo { + vertical-align: bottom; + max-height: 100px; + } + } + + > .about { + padding: 0; + } + + > .action { + margin: 32px 0; + + > * { + line-height: 32px; + } + + > .signup { + background: var(--panel); + color: var(--fg); + } + + > .signin { + background: var(--accent); + color: inherit; + } + } + + > .status { + margin: 32px 0; + border-top: solid 1px rgba(255, 255, 255, 0.5); + font-size: 90%; + + > div { + padding: 16px 0; + + > span:not(:last-child) { + padding-right: 1em; + margin-right: 1em; + border-right: solid 1px rgba(255, 255, 255, 0.5); + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/welcome.entrance.c.vue b/packages/frontend/src/pages/welcome.entrance.c.vue new file mode 100644 index 0000000000..d2d07bb1f0 --- /dev/null +++ b/packages/frontend/src/pages/welcome.entrance.c.vue @@ -0,0 +1,306 @@ +<template> +<div v-if="meta" class="rsqzvsbo"> + <div class="top"> + <MkFeaturedPhotos class="bg"/> + <div class="fade"></div> + <div class="emojis"> + <MkEmoji :normal="true" :no-style="true" emoji="👍"/> + <MkEmoji :normal="true" :no-style="true" emoji="❤"/> + <MkEmoji :normal="true" :no-style="true" emoji="😆"/> + <MkEmoji :normal="true" :no-style="true" emoji="🎉"/> + <MkEmoji :normal="true" :no-style="true" emoji="🍮"/> + </div> + <div class="main"> + <img src="/client-assets/misskey.svg" class="misskey"/> + <div class="form _panel"> + <div class="bg"> + <div class="fade"></div> + </div> + <div class="fg"> + <h1> + <img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> + </h1> + <div class="about"> + <!-- eslint-disable-next-line vue/no-v-html --> + <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + </div> + <div class="action"> + <MkButton inline gradate @click="signup()">{{ $ts.signup }}</MkButton> + <MkButton inline @click="signin()">{{ $ts.login }}</MkButton> + </div> + <div v-if="onlineUsersCount && stats" class="status"> + <div> + <I18n :src="$ts.nUsers" text-tag="span" class="users"> + <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> + </I18n> + <I18n :src="$ts.nNotes" text-tag="span" class="notes"> + <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> + </I18n> + </div> + <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online"> + <template #n><b>{{ onlineUsersCount }}</b></template> + </I18n> + </div> + <button class="_button _acrylic menu" @click="showMenu"><i class="ti ti-dots"></i></button> + </div> + </div> + <nav class="nav"> + <MkA to="/announcements">{{ $ts.announcements }}</MkA> + <MkA to="/explore">{{ $ts.explore }}</MkA> + <MkA to="/channels">{{ $ts.channel }}</MkA> + <MkA to="/featured">{{ $ts.featured }}</MkA> + </nav> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; +import XNote from '@/components/MkNote.vue'; +import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; +import XTimeline from './welcome.timeline.vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + MkButton, + XNote, + MkFeaturedPhotos, + XTimeline, + }, + + data() { + return { + host: toUnicode(host), + instanceName, + meta: null, + stats: null, + tags: [], + onlineUsersCount: null, + }; + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + + os.api('stats').then(stats => { + this.stats = stats; + }); + + os.api('get-online-users-count').then(res => { + this.onlineUsersCount = res.count; + }); + + os.api('hashtags/list', { + sort: '+mentionedLocalUsers', + limit: 8, + }).then(tags => { + this.tags = tags; + }); + }, + + methods: { + signin() { + os.popup(XSigninDialog, { + autoSet: true, + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true, + }, {}, 'closed'); + }, + + showMenu(ev) { + os.popupMenu([{ + text: this.$t('aboutX', { x: instanceName }), + icon: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about'); + }, + }, { + text: this.$ts.aboutMisskey, + icon: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + }, + }, null, { + text: this.$ts.help, + icon: 'ti ti-question-circle', + action: () => { + window.open(`https://misskey-hub.net/help.md`, '_blank'); + }, + }], ev.currentTarget ?? ev.target); + }, + + number, + }, +}); +</script> + +<style lang="scss" scoped> +.rsqzvsbo { + > .top { + display: flex; + text-align: center; + min-height: 100vh; + box-sizing: border-box; + padding: 16px; + + > .bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + > .fade { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.25); + } + + > .emojis { + position: absolute; + bottom: 32px; + left: 35px; + + > * { + margin-right: 8px; + } + + @media (max-width: 1200px) { + display: none; + } + } + + > .main { + position: relative; + width: min(460px, 100%); + margin: auto; + + > .misskey { + width: 150px; + margin-bottom: 16px; + + @media (max-width: 450px) { + width: 130px; + } + } + + > .form { + position: relative; + box-shadow: 0 12px 32px rgb(0 0 0 / 25%); + + > .bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 128px; + background-position: center; + background-size: cover; + opacity: 0.75; + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 128px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + } + } + + > .fg { + position: relative; + z-index: 1; + + > h1 { + display: block; + margin: 0; + padding: 32px 32px 24px 32px; + + > .logo { + vertical-align: bottom; + max-height: 120px; + } + } + + > .about { + padding: 0 32px; + } + + > .action { + padding: 32px; + + > * { + line-height: 28px; + } + } + + > .status { + border-top: solid 0.5px var(--divider); + padding: 32px; + font-size: 90%; + + > div { + > span:not(:last-child) { + padding-right: 1em; + margin-right: 1em; + border-right: solid 0.5px var(--divider); + } + } + + > .online { + ::v-deep(b) { + color: #41b781; + } + + ::v-deep(span) { + opacity: 0.7; + } + } + } + + > .menu { + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + border-radius: 8px; + } + } + } + + > .nav { + position: relative; + z-index: 2; + margin-top: 20px; + color: #fff; + text-shadow: 0 0 8px black; + font-size: 0.9em; + + > *:not(:last-child) { + margin-right: 1.5em; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue new file mode 100644 index 0000000000..2729d30d4b --- /dev/null +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -0,0 +1,89 @@ +<template> +<form class="mk-setup" @submit.prevent="submit()"> + <h1>Welcome to Misskey!</h1> + <div class="_formRoot"> + <p>{{ $ts.intro }}</p> + <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username class="_formBlock"> + <template #label>{{ $ts.username }}</template> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + </MkInput> + <MkInput v-model="password" type="password" data-cy-admin-password class="_formBlock"> + <template #label>{{ $ts.password }}</template> + <template #prefix><i class="ti ti-lock"></i></template> + </MkInput> + <div class="bottom _formBlock"> + <MkButton gradate type="submit" :disabled="submitting" data-cy-admin-ok> + {{ submitting ? $ts.processing : $ts.done }}<MkEllipsis v-if="submitting"/> + </MkButton> + </div> + </div> +</form> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import { host } from '@/config'; +import * as os from '@/os'; +import { login } from '@/account'; +import { i18n } from '@/i18n'; + +let username = $ref(''); +let password = $ref(''); +let submitting = $ref(false); + +function submit() { + if (submitting) return; + submitting = true; + + os.api('admin/accounts/create', { + username: username, + password: password, + }).then(res => { + return login(res.token); + }).catch(() => { + submitting = false; + + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + }); +} +</script> + +<style lang="scss" scoped> +.mk-setup { + border-radius: var(--radius); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + overflow: hidden; + max-width: 500px; + margin: 32px auto; + + > h1 { + margin: 0; + font-size: 1.5em; + text-align: center; + padding: 32px; + background: var(--accent); + color: #fff; + } + + > div { + padding: 32px; + background: var(--panel); + + > p { + margin-top: 0; + } + + > .bottom { + > * { + margin: 0 auto; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue new file mode 100644 index 0000000000..d6a88540d1 --- /dev/null +++ b/packages/frontend/src/pages/welcome.timeline.vue @@ -0,0 +1,99 @@ +<template> +<div class="civpbkhh"> + <div ref="scroll" class="scrollbox" v-bind:class="{ scroll: isScrolling }"> + <div v-for="note in notes" class="note"> + <div class="content _panel"> + <div class="body"> + <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> + <div v-if="note.files.length > 0" class="richcontent"> + <XMediaList :media-list="note.files"/> + </div> + <div v-if="note.poll"> + <XPoll :note="note" :readOnly="true"/> + </div> + </div> + <XReactionsViewer ref="reactionsViewer" :note="note"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XReactionsViewer from '@/components/MkReactionsViewer.vue'; +import XMediaList from '@/components/MkMediaList.vue'; +import XPoll from '@/components/MkPoll.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XReactionsViewer, + XMediaList, + XPoll, + }, + + data() { + return { + notes: [], + isScrolling: false, + }; + }, + + created() { + os.api('notes/featured').then(notes => { + this.notes = notes; + }); + }, + + updated() { + if (this.$refs.scroll.clientHeight > window.innerHeight) { + this.isScrolling = true; + } + }, +}); +</script> + +<style lang="scss" scoped> +@keyframes scroll { + 0% { + transform: translate3d(0, 0, 0); + } + 5% { + transform: translate3d(0, 0, 0); + } + 75% { + transform: translate3d(0, calc(-100% + 90vh), 0); + } + 90% { + transform: translate3d(0, calc(-100% + 90vh), 0); + } +} + +.civpbkhh { + text-align: right; + + > .scrollbox { + &.scroll { + animation: scroll 45s linear infinite; + } + + > .note { + margin: 16px 0 16px auto; + + > .content { + padding: 16px; + margin: 0 0 0 auto; + max-width: max-content; + border-radius: 16px; + + > .richcontent { + min-width: 250px; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/welcome.vue b/packages/frontend/src/pages/welcome.vue new file mode 100644 index 0000000000..a1c3fc2abb --- /dev/null +++ b/packages/frontend/src/pages/welcome.vue @@ -0,0 +1,30 @@ +<template> +<div v-if="meta"> + <XSetup v-if="meta.requireSetup"/> + <XEntrance v-else/> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import XSetup from './welcome.setup.vue'; +import XEntrance from './welcome.entrance.a.vue'; +import { instanceName } from '@/config'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let meta = $ref(null); + +os.api('meta', { detail: true }).then(res => { + meta = res; +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: instanceName, + icon: null, +}))); +</script> diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts new file mode 100644 index 0000000000..642e1f4f7f --- /dev/null +++ b/packages/frontend/src/pizzax.ts @@ -0,0 +1,169 @@ +// PIZZAX --- A lightweight store + +import { onUnmounted, Ref, ref, watch } from 'vue'; +import { $i } from './account'; +import { api } from './os'; +import { stream } from './stream'; + +type StateDef = Record<string, { + where: 'account' | 'device' | 'deviceAccount'; + default: any; +}>; + +type ArrayElement<A> = A extends readonly (infer T)[] ? T : never; + +const connection = $i && stream.useChannel('main'); + +export class Storage<T extends StateDef> { + public readonly key: string; + public readonly keyForLocalStorage: string; + + public readonly def: T; + + // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 + public readonly state: { [K in keyof T]: T[K]['default'] }; + public readonly reactiveState: { [K in keyof T]: Ref<T[K]['default']> }; + + constructor(key: string, def: T) { + this.key = key; + this.keyForLocalStorage = 'pizzax::' + key; + this.def = def; + + // TODO: indexedDBにする + const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}'); + const deviceAccountState = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}') : {}; + const registryCache = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}') : {}; + + const state = {}; + const reactiveState = {}; + for (const [k, v] of Object.entries(def)) { + if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { + state[k] = deviceState[k]; + } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { + state[k] = registryCache[k]; + } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { + state[k] = deviceAccountState[k]; + } else { + state[k] = v.default; + if (_DEV_) console.log('Use default value', k, v.default); + } + } + for (const [k, v] of Object.entries(state)) { + reactiveState[k] = ref(v); + } + this.state = state as any; + this.reactiveState = reactiveState as any; + + if ($i) { + // なぜかsetTimeoutしないとapi関数内でエラーになる(おそらく循環参照してることに原因がありそう) + window.setTimeout(() => { + api('i/registry/get-all', { scope: ['client', this.key] }).then(kvs => { + const cache = {}; + for (const [k, v] of Object.entries(def)) { + if (v.where === 'account') { + if (Object.prototype.hasOwnProperty.call(kvs, k)) { + state[k] = kvs[k]; + reactiveState[k].value = kvs[k]; + cache[k] = kvs[k]; + } else { + state[k] = v.default; + reactiveState[k].value = v.default; + } + } + } + localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); + }); + }, 1); + // streamingのuser storage updateイベントを監視して更新 + connection?.on('registryUpdated', ({ scope, key, value }: { scope: string[], key: keyof T, value: T[typeof key]['default'] }) => { + if (scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.state[key] === value) return; + + this.state[key] = value; + this.reactiveState[key].value = value; + + const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}'); + if (cache[key] !== value) { + cache[key] = value; + localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); + } + }); + } + } + + public set<K extends keyof T>(key: K, value: T[K]['default']): void { + if (_DEV_) console.log('set', key, value); + + this.state[key] = value; + this.reactiveState[key].value = value; + + switch (this.def[key].where) { + case 'device': { + const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}'); + deviceState[key] = value; + localStorage.setItem(this.keyForLocalStorage, JSON.stringify(deviceState)); + break; + } + case 'deviceAccount': { + if ($i == null) break; + const deviceAccountState = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}'); + deviceAccountState[key] = value; + localStorage.setItem(this.keyForLocalStorage + '::' + $i.id, JSON.stringify(deviceAccountState)); + break; + } + case 'account': { + if ($i == null) break; + const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}'); + cache[key] = value; + localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); + api('i/registry/set', { + scope: ['client', this.key], + key: key, + value: value, + }); + break; + } + } + } + + public push<K extends keyof T>(key: K, value: ArrayElement<T[K]['default']>): void { + const currentState = this.state[key]; + this.set(key, [...currentState, value]); + } + + public reset(key: keyof T) { + this.set(key, this.def[key].default); + } + + /** + * 特定のキーの、簡易的なgetter/setterを作ります + * 主にvue場で設定コントロールのmodelとして使う用 + */ + public makeGetterSetter<K extends keyof T>(key: K, getter?: (v: T[K]) => unknown, setter?: (v: unknown) => T[K]) { + const valueRef = ref(this.state[key]); + + const stop = watch(this.reactiveState[key], val => { + valueRef.value = val; + }); + + // NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする + onUnmounted(() => { + stop(); + }); + + // TODO: VueのcustomRef使うと良い感じになるかも + return { + get: () => { + if (getter) { + return getter(valueRef.value); + } else { + return valueRef.value; + } + }, + set: (value: unknown) => { + const val = setter ? setter(value) : value; + this.set(key, val); + valueRef.value = val; + }, + }; + } +} diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts new file mode 100644 index 0000000000..3a00cd0455 --- /dev/null +++ b/packages/frontend/src/plugin.ts @@ -0,0 +1,123 @@ +import { AiScript, utils, values } from '@syuilo/aiscript'; +import { deserialize } from '@syuilo/aiscript/built/serializer'; +import { jsToVal } from '@syuilo/aiscript/built/interpreter/util'; +import { createAiScriptEnv } from '@/scripts/aiscript/api'; +import { inputText } from '@/os'; +import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions } from '@/store'; + +const pluginContexts = new Map<string, AiScript>(); + +export function install(plugin) { + console.info('Plugin installed:', plugin.name, 'v' + plugin.version); + + const aiscript = new AiScript(createPluginEnv({ + plugin: plugin, + storageKey: 'plugins:' + plugin.id, + }), { + in: (q) => { + return new Promise(ok => { + inputText({ + title: q, + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + console.log(value); + }, + log: (type, params) => { + }, + }); + + initPlugin({ plugin, aiscript }); + + aiscript.exec(deserialize(plugin.ast)); +} + +function createPluginEnv(opts) { + const config = new Map(); + for (const [k, v] of Object.entries(opts.plugin.config || {})) { + config.set(k, jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default)); + } + + return { + ...createAiScriptEnv({ ...opts, token: opts.plugin.token }), + //#region Deprecated + 'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => { + registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => { + registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + //#endregion + 'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => { + registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => { + registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => { + registerNoteViewInterruptor({ pluginId: opts.plugin.id, handler }); + }), + 'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => { + registerNotePostInterruptor({ pluginId: opts.plugin.id, handler }); + }), + 'Plugin:open_url': values.FN_NATIVE(([url]) => { + window.open(url.value, '_blank'); + }), + 'Plugin:config': values.OBJ(config), + }; +} + +function initPlugin({ plugin, aiscript }) { + pluginContexts.set(plugin.id, aiscript); +} + +function registerPostFormAction({ pluginId, title, handler }) { + postFormActions.push({ + title, handler: (form, update) => { + pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { + update(key.value, value.value); + })]); + }, + }); +} + +function registerUserAction({ pluginId, title, handler }) { + userActions.push({ + title, handler: (user) => { + pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(user)]); + }, + }); +} + +function registerNoteAction({ pluginId, title, handler }) { + noteActions.push({ + title, handler: (note) => { + pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]); + }, + }); +} + +function registerNoteViewInterruptor({ pluginId, handler }) { + noteViewInterruptors.push({ + handler: async (note) => { + return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)])); + }, + }); +} + +function registerNotePostInterruptor({ pluginId, handler }) { + notePostInterruptors.push({ + handler: async (note) => { + return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)])); + }, + }); +} diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts new file mode 100644 index 0000000000..111b15e0a6 --- /dev/null +++ b/packages/frontend/src/router.ts @@ -0,0 +1,501 @@ +import { AsyncComponentLoader, defineAsyncComponent, inject } from 'vue'; +import { Router } from '@/nirax'; +import { $i, iAmModerator } from '@/account'; +import MkLoading from '@/pages/_loading_.vue'; +import MkError from '@/pages/_error_.vue'; +import { ui } from '@/config'; + +const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({ + loader: loader, + loadingComponent: MkLoading, + errorComponent: MkError, +}); + +export const routes = [{ + path: '/@:initUser/pages/:initPageName/view-source', + component: page(() => import('./pages/page-editor/page-editor.vue')), +}, { + path: '/@:username/pages/:pageName', + component: page(() => import('./pages/page.vue')), +}, { + path: '/@:acct/following', + component: page(() => import('./pages/user/following.vue')), +}, { + path: '/@:acct/followers', + component: page(() => import('./pages/user/followers.vue')), +}, { + name: 'user', + path: '/@:acct/:page?', + component: page(() => import('./pages/user/index.vue')), +}, { + name: 'note', + path: '/notes/:noteId', + component: page(() => import('./pages/note.vue')), +}, { + path: '/clips/:clipId', + component: page(() => import('./pages/clip.vue')), +}, { + path: '/user-info/:userId', + component: page(() => import('./pages/user-info.vue')), +}, { + path: '/instance-info/:host', + component: page(() => import('./pages/instance-info.vue')), +}, { + name: 'settings', + path: '/settings', + component: page(() => import('./pages/settings/index.vue')), + loginRequired: true, + children: [{ + path: '/profile', + name: 'profile', + component: page(() => import('./pages/settings/profile.vue')), + }, { + path: '/privacy', + name: 'privacy', + component: page(() => import('./pages/settings/privacy.vue')), + }, { + path: '/reaction', + name: 'reaction', + component: page(() => import('./pages/settings/reaction.vue')), + }, { + path: '/drive', + name: 'drive', + component: page(() => import('./pages/settings/drive.vue')), + }, { + path: '/notifications', + name: 'notifications', + component: page(() => import('./pages/settings/notifications.vue')), + }, { + path: '/email', + name: 'email', + component: page(() => import('./pages/settings/email.vue')), + }, { + path: '/integration', + name: 'integration', + component: page(() => import('./pages/settings/integration.vue')), + }, { + path: '/security', + name: 'security', + component: page(() => import('./pages/settings/security.vue')), + }, { + path: '/general', + name: 'general', + component: page(() => import('./pages/settings/general.vue')), + }, { + path: '/theme/install', + name: 'theme', + component: page(() => import('./pages/settings/theme.install.vue')), + }, { + path: '/theme/manage', + name: 'theme', + component: page(() => import('./pages/settings/theme.manage.vue')), + }, { + path: '/theme', + name: 'theme', + component: page(() => import('./pages/settings/theme.vue')), + }, { + path: '/navbar', + name: 'navbar', + component: page(() => import('./pages/settings/navbar.vue')), + }, { + path: '/statusbar', + name: 'statusbar', + component: page(() => import('./pages/settings/statusbar.vue')), + }, { + path: '/sounds', + name: 'sounds', + component: page(() => import('./pages/settings/sounds.vue')), + }, { + path: '/plugin/install', + name: 'plugin', + component: page(() => import('./pages/settings/plugin.install.vue')), + }, { + path: '/plugin', + name: 'plugin', + component: page(() => import('./pages/settings/plugin.vue')), + }, { + path: '/import-export', + name: 'import-export', + component: page(() => import('./pages/settings/import-export.vue')), + }, { + path: '/instance-mute', + name: 'instance-mute', + component: page(() => import('./pages/settings/instance-mute.vue')), + }, { + path: '/mute-block', + name: 'mute-block', + component: page(() => import('./pages/settings/mute-block.vue')), + }, { + path: '/word-mute', + name: 'word-mute', + component: page(() => import('./pages/settings/word-mute.vue')), + }, { + path: '/api', + name: 'api', + component: page(() => import('./pages/settings/api.vue')), + }, { + path: '/apps', + name: 'api', + component: page(() => import('./pages/settings/apps.vue')), + }, { + path: '/webhook/edit/:webhookId', + name: 'webhook', + component: page(() => import('./pages/settings/webhook.edit.vue')), + }, { + path: '/webhook/new', + name: 'webhook', + component: page(() => import('./pages/settings/webhook.new.vue')), + }, { + path: '/webhook', + name: 'webhook', + component: page(() => import('./pages/settings/webhook.vue')), + }, { + path: '/deck', + name: 'deck', + component: page(() => import('./pages/settings/deck.vue')), + }, { + path: '/preferences-backups', + name: 'preferences-backups', + component: page(() => import('./pages/settings/preferences-backups.vue')), + }, { + path: '/custom-css', + name: 'general', + component: page(() => import('./pages/settings/custom-css.vue')), + }, { + path: '/accounts', + name: 'profile', + component: page(() => import('./pages/settings/accounts.vue')), + }, { + path: '/account-info', + name: 'other', + component: page(() => import('./pages/settings/account-info.vue')), + }, { + path: '/delete-account', + name: 'other', + component: page(() => import('./pages/settings/delete-account.vue')), + }, { + path: '/other', + name: 'other', + component: page(() => import('./pages/settings/other.vue')), + }, { + path: '/', + component: page(() => import('./pages/_empty_.vue')), + }], +}, { + path: '/reset-password/:token?', + component: page(() => import('./pages/reset-password.vue')), +}, { + path: '/signup-complete/:code', + component: page(() => import('./pages/signup-complete.vue')), +}, { + path: '/announcements', + component: page(() => import('./pages/announcements.vue')), +}, { + path: '/about', + component: page(() => import('./pages/about.vue')), + hash: 'initialTab', +}, { + path: '/about-misskey', + component: page(() => import('./pages/about-misskey.vue')), +}, { + path: '/theme-editor', + component: page(() => import('./pages/theme-editor.vue')), + loginRequired: true, +}, { + path: '/explore/tags/:tag', + component: page(() => import('./pages/explore.vue')), +}, { + path: '/explore', + component: page(() => import('./pages/explore.vue')), +}, { + path: '/search', + component: page(() => import('./pages/search.vue')), + query: { + q: 'query', + channel: 'channel', + }, +}, { + path: '/authorize-follow', + component: page(() => import('./pages/follow.vue')), + loginRequired: true, +}, { + path: '/share', + component: page(() => import('./pages/share.vue')), + loginRequired: true, +}, { + path: '/api-console', + component: page(() => import('./pages/api-console.vue')), + loginRequired: true, +}, { + path: '/mfm-cheat-sheet', + component: page(() => import('./pages/mfm-cheat-sheet.vue')), +}, { + path: '/scratchpad', + component: page(() => import('./pages/scratchpad.vue')), +}, { + path: '/preview', + component: page(() => import('./pages/preview.vue')), +}, { + path: '/auth/:token', + component: page(() => import('./pages/auth.vue')), +}, { + path: '/miauth/:session', + component: page(() => import('./pages/miauth.vue')), + query: { + callback: 'callback', + name: 'name', + icon: 'icon', + permission: 'permission', + }, +}, { + path: '/tags/:tag', + component: page(() => import('./pages/tag.vue')), +}, { + path: '/pages/new', + component: page(() => import('./pages/page-editor/page-editor.vue')), + loginRequired: true, +}, { + path: '/pages/edit/:initPageId', + component: page(() => import('./pages/page-editor/page-editor.vue')), + loginRequired: true, +}, { + path: '/pages', + component: page(() => import('./pages/pages.vue')), +}, { + path: '/gallery/:postId/edit', + component: page(() => import('./pages/gallery/edit.vue')), + loginRequired: true, +}, { + path: '/gallery/new', + component: page(() => import('./pages/gallery/edit.vue')), + loginRequired: true, +}, { + path: '/gallery/:postId', + component: page(() => import('./pages/gallery/post.vue')), +}, { + path: '/gallery', + component: page(() => import('./pages/gallery/index.vue')), +}, { + path: '/channels/:channelId/edit', + component: page(() => import('./pages/channel-editor.vue')), + loginRequired: true, +}, { + path: '/channels/new', + component: page(() => import('./pages/channel-editor.vue')), + loginRequired: true, +}, { + path: '/channels/:channelId', + component: page(() => import('./pages/channel.vue')), +}, { + path: '/channels', + component: page(() => import('./pages/channels.vue')), +}, { + path: '/registry/keys/system/:path(*)?', + component: page(() => import('./pages/registry.keys.vue')), +}, { + path: '/registry/value/system/:path(*)?', + component: page(() => import('./pages/registry.value.vue')), +}, { + path: '/registry', + component: page(() => import('./pages/registry.vue')), +}, { + path: '/admin/file/:fileId', + component: iAmModerator ? page(() => import('./pages/admin-file.vue')) : page(() => import('./pages/not-found.vue')), +}, { + path: '/admin', + component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page(() => import('./pages/not-found.vue')), + children: [{ + path: '/overview', + name: 'overview', + component: page(() => import('./pages/admin/overview.vue')), + }, { + path: '/users', + name: 'users', + component: page(() => import('./pages/admin/users.vue')), + }, { + path: '/emojis', + name: 'emojis', + component: page(() => import('./pages/admin/emojis.vue')), + }, { + path: '/queue', + name: 'queue', + component: page(() => import('./pages/admin/queue.vue')), + }, { + path: '/files', + name: 'files', + component: page(() => import('./pages/admin/files.vue')), + }, { + path: '/announcements', + name: 'announcements', + component: page(() => import('./pages/admin/announcements.vue')), + }, { + path: '/ads', + name: 'ads', + component: page(() => import('./pages/admin/ads.vue')), + }, { + path: '/database', + name: 'database', + component: page(() => import('./pages/admin/database.vue')), + }, { + path: '/abuses', + name: 'abuses', + component: page(() => import('./pages/admin/abuses.vue')), + }, { + path: '/settings', + name: 'settings', + component: page(() => import('./pages/admin/settings.vue')), + }, { + path: '/email-settings', + name: 'email-settings', + component: page(() => import('./pages/admin/email-settings.vue')), + }, { + path: '/object-storage', + name: 'object-storage', + component: page(() => import('./pages/admin/object-storage.vue')), + }, { + path: '/security', + name: 'security', + component: page(() => import('./pages/admin/security.vue')), + }, { + path: '/relays', + name: 'relays', + component: page(() => import('./pages/admin/relays.vue')), + }, { + path: '/integrations', + name: 'integrations', + component: page(() => import('./pages/admin/integrations.vue')), + }, { + path: '/instance-block', + name: 'instance-block', + component: page(() => import('./pages/admin/instance-block.vue')), + }, { + path: '/proxy-account', + name: 'proxy-account', + component: page(() => import('./pages/admin/proxy-account.vue')), + }, { + path: '/other-settings', + name: 'other-settings', + component: page(() => import('./pages/admin/other-settings.vue')), + }, { + path: '/', + component: page(() => import('./pages/_empty_.vue')), + }], +}, { + path: '/my/notifications', + component: page(() => import('./pages/notifications.vue')), + loginRequired: true, +}, { + path: '/my/favorites', + component: page(() => import('./pages/favorites.vue')), + loginRequired: true, +}, { + name: 'messaging', + path: '/my/messaging', + component: page(() => import('./pages/messaging/index.vue')), + loginRequired: true, +}, { + path: '/my/messaging/:userAcct', + component: page(() => import('./pages/messaging/messaging-room.vue')), + loginRequired: true, +}, { + path: '/my/messaging/group/:groupId', + component: page(() => import('./pages/messaging/messaging-room.vue')), + loginRequired: true, +}, { + path: '/my/drive/folder/:folder', + component: page(() => import('./pages/drive.vue')), + loginRequired: true, +}, { + path: '/my/drive', + component: page(() => import('./pages/drive.vue')), + loginRequired: true, +}, { + path: '/my/follow-requests', + component: page(() => import('./pages/follow-requests.vue')), + loginRequired: true, +}, { + path: '/my/lists/:listId', + component: page(() => import('./pages/my-lists/list.vue')), + loginRequired: true, +}, { + path: '/my/lists', + component: page(() => import('./pages/my-lists/index.vue')), + loginRequired: true, +}, { + path: '/my/clips', + component: page(() => import('./pages/my-clips/index.vue')), + loginRequired: true, +}, { + path: '/my/antennas/create', + component: page(() => import('./pages/my-antennas/create.vue')), + loginRequired: true, +}, { + path: '/my/antennas/:antennaId', + component: page(() => import('./pages/my-antennas/edit.vue')), + loginRequired: true, +}, { + path: '/my/antennas', + component: page(() => import('./pages/my-antennas/index.vue')), + loginRequired: true, +}, { + path: '/timeline/list/:listId', + component: page(() => import('./pages/user-list-timeline.vue')), + loginRequired: true, +}, { + path: '/timeline/antenna/:antennaId', + component: page(() => import('./pages/antenna-timeline.vue')), + loginRequired: true, +}, { + name: 'index', + path: '/', + component: $i ? page(() => import('./pages/timeline.vue')) : page(() => import('./pages/welcome.vue')), + globalCacheKey: 'index', +}, { + path: '/:(*)', + component: page(() => import('./pages/not-found.vue')), +}]; + +export const mainRouter = new Router(routes, location.pathname + location.search + location.hash); + +window.history.replaceState({ key: mainRouter.getCurrentKey() }, '', location.href); + +// TODO: このファイルでスクロール位置も管理する設計だとdeckに対応できないのでなんとかする +// スクロール位置取得+スクロール位置設定関数をprovideする感じでも良いかも + +const scrollPosStore = new Map<string, number>(); + +window.setInterval(() => { + scrollPosStore.set(window.history.state?.key, window.scrollY); +}, 1000); + +mainRouter.addListener('push', ctx => { + window.history.pushState({ key: ctx.key }, '', ctx.path); + const scrollPos = scrollPosStore.get(ctx.key) ?? 0; + window.scroll({ top: scrollPos, behavior: 'instant' }); + if (scrollPos !== 0) { + window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール + window.scroll({ top: scrollPos, behavior: 'instant' }); + }, 100); + } +}); + +mainRouter.addListener('replace', ctx => { + window.history.replaceState({ key: ctx.key }, '', ctx.path); +}); + +mainRouter.addListener('same', () => { + window.scroll({ top: 0, behavior: 'smooth' }); +}); + +window.addEventListener('popstate', (event) => { + mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key, false); + const scrollPos = scrollPosStore.get(event.state?.key) ?? 0; + window.scroll({ top: scrollPos, behavior: 'instant' }); + window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール + window.scroll({ top: scrollPos, behavior: 'instant' }); + }, 100); +}); + +export function useRouter(): Router { + return inject<Router | null>('router', null) ?? mainRouter; +} diff --git a/packages/frontend/src/scripts/2fa.ts b/packages/frontend/src/scripts/2fa.ts new file mode 100644 index 0000000000..62a38ff02a --- /dev/null +++ b/packages/frontend/src/scripts/2fa.ts @@ -0,0 +1,33 @@ +export function byteify(string: string, encoding: 'ascii' | 'base64' | 'hex') { + switch (encoding) { + case 'ascii': + return Uint8Array.from(string, c => c.charCodeAt(0)); + case 'base64': + return Uint8Array.from( + atob( + string + .replace(/-/g, '+') + .replace(/_/g, '/'), + ), + c => c.charCodeAt(0), + ); + case 'hex': + return new Uint8Array( + string + .match(/.{1,2}/g) + .map(byte => parseInt(byte, 16)), + ); + } +} + +export function hexify(buffer: ArrayBuffer) { + return Array.from(new Uint8Array(buffer)) + .reduce( + (str, byte) => str + byte.toString(16).padStart(2, '0'), + '', + ); +} + +export function stringify(buffer: ArrayBuffer) { + return String.fromCharCode(... new Uint8Array(buffer)); +} diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts new file mode 100644 index 0000000000..6debcb8a13 --- /dev/null +++ b/packages/frontend/src/scripts/aiscript/api.ts @@ -0,0 +1,43 @@ +import { utils, values } from '@syuilo/aiscript'; +import * as os from '@/os'; +import { $i } from '@/account'; + +export function createAiScriptEnv(opts) { + let apiRequests = 0; + return { + USER_ID: $i ? values.STR($i.id) : values.NULL, + USER_NAME: $i ? values.STR($i.name) : values.NULL, + USER_USERNAME: $i ? values.STR($i.username) : values.NULL, + 'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => { + await os.alert({ + type: type ? type.value : 'info', + title: title.value, + text: text.value, + }); + }), + 'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => { + const confirm = await os.confirm({ + type: type ? type.value : 'question', + title: title.value, + text: text.value, + }); + return confirm.canceled ? values.FALSE : values.TRUE; + }), + 'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => { + if (token) utils.assertString(token); + apiRequests++; + if (apiRequests > 16) return values.NULL; + const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token ?? null)); + return utils.jsToVal(res); + }), + 'Mk:save': values.FN_NATIVE(([key, value]) => { + utils.assertString(key); + localStorage.setItem('aiscript:' + opts.storageKey + ':' + key.value, JSON.stringify(utils.valToJs(value))); + return values.NULL; + }), + 'Mk:load': values.FN_NATIVE(([key]) => { + utils.assertString(key); + return utils.jsToVal(JSON.parse(localStorage.getItem('aiscript:' + opts.storageKey + ':' + key.value))); + }), + }; +} diff --git a/packages/frontend/src/scripts/array.ts b/packages/frontend/src/scripts/array.ts new file mode 100644 index 0000000000..4620c8b735 --- /dev/null +++ b/packages/frontend/src/scripts/array.ts @@ -0,0 +1,149 @@ +import { EndoRelation, Predicate } from './relation'; + +/** + * Count the number of elements that satisfy the predicate + */ + +export function countIf<T>(f: Predicate<T>, xs: T[]): number { + return xs.filter(f).length; +} + +/** + * Count the number of elements that is equal to the element + */ +export function count<T>(a: T, xs: T[]): number { + return countIf(x => x === a, xs); +} + +/** + * Concatenate an array of arrays + */ +export function concat<T>(xss: T[][]): T[] { + return ([] as T[]).concat(...xss); +} + +/** + * Intersperse the element between the elements of the array + * @param sep The element to be interspersed + */ +export function intersperse<T>(sep: T, xs: T[]): T[] { + return concat(xs.map(x => [sep, x])).slice(1); +} + +/** + * Returns the array of elements that is not equal to the element + */ +export function erase<T>(a: T, xs: T[]): T[] { + return xs.filter(x => x !== a); +} + +/** + * Finds the array of all elements in the first array not contained in the second array. + * The order of result values are determined by the first array. + */ +export function difference<T>(xs: T[], ys: T[]): T[] { + return xs.filter(x => !ys.includes(x)); +} + +/** + * Remove all but the first element from every group of equivalent elements + */ +export function unique<T>(xs: T[]): T[] { + return [...new Set(xs)]; +} + +export function uniqueBy<TValue, TKey>(values: TValue[], keySelector: (value: TValue) => TKey): TValue[] { + const map = new Map<TKey, TValue>(); + + for (const value of values) { + const key = keySelector(value); + if (!map.has(key)) map.set(key, value); + } + + return [...map.values()]; +} + +export function sum(xs: number[]): number { + return xs.reduce((a, b) => a + b, 0); +} + +export function maximum(xs: number[]): number { + return Math.max(...xs); +} + +/** + * Splits an array based on the equivalence relation. + * The concatenation of the result is equal to the argument. + */ +export function groupBy<T>(f: EndoRelation<T>, xs: T[]): T[][] { + const groups = [] as T[][]; + for (const x of xs) { + if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) { + groups[groups.length - 1].push(x); + } else { + groups.push([x]); + } + } + return groups; +} + +/** + * Splits an array based on the equivalence relation induced by the function. + * The concatenation of the result is equal to the argument. + */ +export function groupOn<T, S>(f: (x: T) => S, xs: T[]): T[][] { + return groupBy((a, b) => f(a) === f(b), xs); +} + +export function groupByX<T>(collections: T[], keySelector: (x: T) => string) { + return collections.reduce((obj: Record<string, T[]>, item: T) => { + const key = keySelector(item); + if (typeof obj[key] === 'undefined') { + obj[key] = []; + } + + obj[key].push(item); + + return obj; + }, {}); +} + +/** + * Compare two arrays by lexicographical order + */ +export function lessThan(xs: number[], ys: number[]): boolean { + for (let i = 0; i < Math.min(xs.length, ys.length); i++) { + if (xs[i] < ys[i]) return true; + if (xs[i] > ys[i]) return false; + } + return xs.length < ys.length; +} + +/** + * Returns the longest prefix of elements that satisfy the predicate + */ +export function takeWhile<T>(f: Predicate<T>, xs: T[]): T[] { + const ys: T[] = []; + for (const x of xs) { + if (f(x)) { + ys.push(x); + } else { + break; + } + } + return ys; +} + +export function cumulativeSum(xs: number[]): number[] { + const ys = Array.from(xs); // deep copy + for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1]; + return ys; +} + +export function toArray<T>(x: T | T[] | undefined): T[] { + return Array.isArray(x) ? x : x != null ? [x] : []; +} + +export function toSingle<T>(x: T | T[] | undefined): T | undefined { + return Array.isArray(x) ? x[0] : x; +} diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts new file mode 100644 index 0000000000..1bae3790f5 --- /dev/null +++ b/packages/frontend/src/scripts/autocomplete.ts @@ -0,0 +1,276 @@ +import { nextTick, Ref, ref, defineAsyncComponent } from 'vue'; +import getCaretCoordinates from 'textarea-caret'; +import { toASCII } from 'punycode/'; +import { popup } from '@/os'; + +export class Autocomplete { + private suggestion: { + x: Ref<number>; + y: Ref<number>; + q: Ref<string | null>; + close: () => void; + } | null; + private textarea: HTMLInputElement | HTMLTextAreaElement; + private currentType: string; + private textRef: Ref<string>; + private opening: boolean; + + private get text(): string { + // Use raw .value to get the latest value + // (Because v-model does not update while composition) + return this.textarea.value; + } + + private set text(text: string) { + // Use ref value to notify other watchers + // (Because .value setter never fires input/change events) + this.textRef.value = text; + } + + /** + * 対象のテキストエリアを与えてインスタンスを初期化します。 + */ + constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) { + //#region BIND + this.onInput = this.onInput.bind(this); + this.complete = this.complete.bind(this); + this.close = this.close.bind(this); + //#endregion + + this.suggestion = null; + this.textarea = textarea; + this.textRef = textRef; + this.opening = false; + + this.attach(); + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。 + */ + public attach() { + this.textarea.addEventListener('input', this.onInput); + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。 + */ + public detach() { + this.textarea.removeEventListener('input', this.onInput); + this.close(); + } + + /** + * テキスト入力時 + */ + private onInput() { + const caretPos = this.textarea.selectionStart; + const text = this.text.substr(0, caretPos).split('\n').pop()!; + + const mentionIndex = text.lastIndexOf('@'); + const hashtagIndex = text.lastIndexOf('#'); + const emojiIndex = text.lastIndexOf(':'); + const mfmTagIndex = text.lastIndexOf('$'); + + const max = Math.max( + mentionIndex, + hashtagIndex, + emojiIndex, + mfmTagIndex); + + if (max === -1) { + this.close(); + return; + } + + const isMention = mentionIndex !== -1; + const isHashtag = hashtagIndex !== -1; + const isMfmTag = mfmTagIndex !== -1; + const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':'); + + let opened = false; + + if (isMention) { + const username = text.substr(mentionIndex + 1); + if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) { + this.open('user', username); + opened = true; + } else if (username === '') { + this.open('user', null); + opened = true; + } + } + + if (isHashtag && !opened) { + const hashtag = text.substr(hashtagIndex + 1); + if (!hashtag.includes(' ')) { + this.open('hashtag', hashtag); + opened = true; + } + } + + if (isEmoji && !opened) { + const emoji = text.substr(emojiIndex + 1); + if (!emoji.includes(' ')) { + this.open('emoji', emoji); + opened = true; + } + } + + if (isMfmTag && !opened) { + const mfmTag = text.substr(mfmTagIndex + 1); + if (!mfmTag.includes(' ')) { + this.open('mfmTag', mfmTag.replace('[', '')); + opened = true; + } + } + + if (!opened) { + this.close(); + } + } + + /** + * サジェストを提示します。 + */ + private async open(type: string, q: string | null) { + if (type !== this.currentType) { + this.close(); + } + if (this.opening) return; + this.opening = true; + this.currentType = type; + + //#region サジェストを表示すべき位置を計算 + const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart); + + const rect = this.textarea.getBoundingClientRect(); + + const x = rect.left + caretPosition.left - this.textarea.scrollLeft; + const y = rect.top + caretPosition.top - this.textarea.scrollTop; + //#endregion + + if (this.suggestion) { + this.suggestion.x.value = x; + this.suggestion.y.value = y; + this.suggestion.q.value = q; + + this.opening = false; + } else { + const _x = ref(x); + const _y = ref(y); + const _q = ref(q); + + const { dispose } = await popup(defineAsyncComponent(() => import('@/components/MkAutocomplete.vue')), { + textarea: this.textarea, + close: this.close, + type: type, + q: _q, + x: _x, + y: _y, + }, { + done: (res) => { + this.complete(res); + }, + }); + + this.suggestion = { + q: _q, + x: _x, + y: _y, + close: () => dispose(), + }; + + this.opening = false; + } + } + + /** + * サジェストを閉じます。 + */ + private close() { + if (this.suggestion == null) return; + + this.suggestion.close(); + this.suggestion = null; + + this.textarea.focus(); + } + + /** + * オートコンプリートする + */ + private complete({ type, value }) { + this.close(); + + const caret = this.textarea.selectionStart; + + if (type === 'user') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('@')); + const after = source.substr(caret); + + const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`; + + // 挿入 + this.text = `${trimmedBefore}@${acct} ${after}`; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (acct.length + 2); + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type === 'hashtag') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('#')); + const after = source.substr(caret); + + // 挿入 + this.text = `${trimmedBefore}#${value} ${after}`; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.length + 2); + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type === 'emoji') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf(':')); + const after = source.substr(caret); + + // 挿入 + this.text = trimmedBefore + value + after; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + value.length; + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type === 'mfmTag') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('$')); + const after = source.substr(caret); + + // 挿入 + this.text = `${trimmedBefore}$[${value} ]${after}`; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.length + 3); + this.textarea.setSelectionRange(pos, pos); + }); + } + } +} diff --git a/packages/frontend/src/scripts/chart-vline.ts b/packages/frontend/src/scripts/chart-vline.ts new file mode 100644 index 0000000000..8e3c4436b2 --- /dev/null +++ b/packages/frontend/src/scripts/chart-vline.ts @@ -0,0 +1,21 @@ +export const chartVLine = (vLineColor: string) => ({ + id: 'vLine', + beforeDraw(chart, args, options) { + if (chart.tooltip?._active?.length) { + const activePoint = chart.tooltip._active[0]; + const ctx = chart.ctx; + const x = activePoint.element.x; + const topY = chart.scales.y.top; + const bottomY = chart.scales.y.bottom; + + ctx.save(); + ctx.beginPath(); + ctx.moveTo(x, bottomY); + ctx.lineTo(x, topY); + ctx.lineWidth = 1; + ctx.strokeStyle = vLineColor; + ctx.stroke(); + ctx.restore(); + } + }, +}); diff --git a/packages/frontend/src/scripts/check-word-mute.ts b/packages/frontend/src/scripts/check-word-mute.ts new file mode 100644 index 0000000000..35d40a6e08 --- /dev/null +++ b/packages/frontend/src/scripts/check-word-mute.ts @@ -0,0 +1,37 @@ +export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: Array<string | string[]>): boolean { + // 自分自身 + if (me && (note.userId === me.id)) return false; + + if (mutedWords.length > 0) { + const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim(); + + if (text === '') return false; + + const matched = mutedWords.some(filter => { + if (Array.isArray(filter)) { + // Clean up + const filteredFilter = filter.filter(keyword => keyword !== ''); + if (filteredFilter.length === 0) return false; + + return filteredFilter.every(keyword => text.includes(keyword)); + } else { + // represents RegExp + const regexp = filter.match(/^\/(.+)\/(.*)$/); + + // This should never happen due to input sanitisation. + if (!regexp) return false; + + try { + return new RegExp(regexp[1], regexp[2]).test(text); + } catch (err) { + // This should never happen due to input sanitisation. + return false; + } + } + }); + + if (matched) return true; + } + + return false; +} diff --git a/packages/frontend/src/scripts/clone.ts b/packages/frontend/src/scripts/clone.ts new file mode 100644 index 0000000000..16fad24129 --- /dev/null +++ b/packages/frontend/src/scripts/clone.ts @@ -0,0 +1,18 @@ +// structredCloneが遅いため +// SEE: http://var.blog.jp/archives/86038606.html + +type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[]; + +export function deepClone<T extends Cloneable>(x: T): T { + if (typeof x === 'object') { + if (x === null) return x; + if (Array.isArray(x)) return x.map(deepClone) as T; + const obj = {} as Record<string, Cloneable>; + for (const [k, v] of Object.entries(x)) { + obj[k] = deepClone(v); + } + return obj as T; + } else { + return x; + } +} diff --git a/packages/frontend/src/scripts/collect-page-vars.ts b/packages/frontend/src/scripts/collect-page-vars.ts new file mode 100644 index 0000000000..76b68beaf6 --- /dev/null +++ b/packages/frontend/src/scripts/collect-page-vars.ts @@ -0,0 +1,68 @@ +interface StringPageVar { + name: string, + type: 'string', + value: string +} + +interface NumberPageVar { + name: string, + type: 'number', + value: number +} + +interface BooleanPageVar { + name: string, + type: 'boolean', + value: boolean +} + +type PageVar = StringPageVar | NumberPageVar | BooleanPageVar; + +export function collectPageVars(content): PageVar[] { + const pageVars: PageVar[] = []; + const collect = (xs: any[]): void => { + for (const x of xs) { + if (x.type === 'textInput') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '', + }); + } else if (x.type === 'textareaInput') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '', + }); + } else if (x.type === 'numberInput') { + pageVars.push({ + name: x.name, + type: 'number', + value: x.default || 0, + }); + } else if (x.type === 'switch') { + pageVars.push({ + name: x.name, + type: 'boolean', + value: x.default || false, + }); + } else if (x.type === 'counter') { + pageVars.push({ + name: x.name, + type: 'number', + value: 0, + }); + } else if (x.type === 'radioButton') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '', + }); + } else if (x.children) { + collect(x.children); + } + } + }; + collect(content); + return pageVars; +} diff --git a/packages/frontend/src/scripts/contains.ts b/packages/frontend/src/scripts/contains.ts new file mode 100644 index 0000000000..256e09d293 --- /dev/null +++ b/packages/frontend/src/scripts/contains.ts @@ -0,0 +1,9 @@ +export default (parent, child, checkSame = true) => { + if (checkSame && parent === child) return true; + let node = child.parentNode; + while (node) { + if (node === parent) return true; + node = node.parentNode; + } + return false; +}; diff --git a/packages/frontend/src/scripts/copy-to-clipboard.ts b/packages/frontend/src/scripts/copy-to-clipboard.ts new file mode 100644 index 0000000000..ab13cab970 --- /dev/null +++ b/packages/frontend/src/scripts/copy-to-clipboard.ts @@ -0,0 +1,33 @@ +/** + * Clipboardに値をコピー(TODO: 文字列以外も対応) + */ +export default val => { + // 空div 生成 + const tmp = document.createElement('div'); + // 選択用のタグ生成 + const pre = document.createElement('pre'); + + // 親要素のCSSで user-select: none だとコピーできないので書き換える + pre.style.webkitUserSelect = 'auto'; + pre.style.userSelect = 'auto'; + + tmp.appendChild(pre).textContent = val; + + // 要素を画面外へ + const s = tmp.style; + s.position = 'fixed'; + s.right = '200%'; + + // body に追加 + document.body.appendChild(tmp); + // 要素を選択 + document.getSelection().selectAllChildren(tmp); + + // クリップボードにコピー + const result = document.execCommand('copy'); + + // 要素削除 + document.body.removeChild(tmp); + + return result; +}; diff --git a/packages/frontend/src/scripts/device-kind.ts b/packages/frontend/src/scripts/device-kind.ts new file mode 100644 index 0000000000..544cac0604 --- /dev/null +++ b/packages/frontend/src/scripts/device-kind.ts @@ -0,0 +1,10 @@ +import { defaultStore } from '@/store'; + +const ua = navigator.userAgent.toLowerCase(); +const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700); +const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua); + +export const deviceKind = defaultStore.state.overridedDeviceKind ? defaultStore.state.overridedDeviceKind + : isSmartphone ? 'smartphone' + : isTablet ? 'tablet' + : 'desktop'; diff --git a/packages/frontend/src/scripts/emoji-base.ts b/packages/frontend/src/scripts/emoji-base.ts new file mode 100644 index 0000000000..3f05642d57 --- /dev/null +++ b/packages/frontend/src/scripts/emoji-base.ts @@ -0,0 +1,20 @@ +const twemojiSvgBase = '/twemoji'; +const fluentEmojiPngBase = '/fluent-emoji'; + +export function char2twemojiFilePath(char: string): string { + let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16)); + if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); + codes = codes.filter(x => x && x.length); + const fileName = codes.join('-'); + return `${twemojiSvgBase}/${fileName}.svg`; +} + +export function char2fluentEmojiFilePath(char: string): string { + let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16)); + // Fluent Emojiは国旗非対応 https://github.com/microsoft/fluentui-emoji/issues/25 + if (codes[0]?.startsWith('1f1')) return char2twemojiFilePath(char); + if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); + codes = codes.filter(x => x && x.length); + const fileName = codes.map(x => x!.padStart(4, '0')).join('-'); + return `${fluentEmojiPngBase}/${fileName}.png`; +} diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend/src/scripts/emojilist.ts new file mode 100644 index 0000000000..bc52fa7a43 --- /dev/null +++ b/packages/frontend/src/scripts/emojilist.ts @@ -0,0 +1,17 @@ +export const unicodeEmojiCategories = ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'] as const; + +export type UnicodeEmojiDef = { + name: string; + keywords: string[]; + char: string; + category: typeof unicodeEmojiCategories[number]; +} + +// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb +import _emojilist from '../emojilist.json'; + +export const emojilist = _emojilist as UnicodeEmojiDef[]; + +export function getEmojiName(char: string): string | undefined { + return emojilist.find(emo => emo.char === char)?.name; +} diff --git a/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts b/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts new file mode 100644 index 0000000000..af517f2672 --- /dev/null +++ b/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts @@ -0,0 +1,9 @@ +export function extractAvgColorFromBlurhash(hash: string) { + return typeof hash === 'string' + ? '#' + [...hash.slice(2, 6)] + .map(x => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'.indexOf(x)) + .reduce((a, c) => a * 83 + c, 0) + .toString(16) + .padStart(6, '0') + : undefined; +} diff --git a/packages/frontend/src/scripts/extract-mentions.ts b/packages/frontend/src/scripts/extract-mentions.ts new file mode 100644 index 0000000000..cc19b161a8 --- /dev/null +++ b/packages/frontend/src/scripts/extract-mentions.ts @@ -0,0 +1,11 @@ +// test is located in test/extract-mentions + +import * as mfm from 'mfm-js'; + +export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] { + // TODO: 重複を削除 + const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention'); + const mentions = mentionNodes.map(x => x.props); + + return mentions; +} diff --git a/packages/frontend/src/scripts/extract-url-from-mfm.ts b/packages/frontend/src/scripts/extract-url-from-mfm.ts new file mode 100644 index 0000000000..34e3eb6c19 --- /dev/null +++ b/packages/frontend/src/scripts/extract-url-from-mfm.ts @@ -0,0 +1,19 @@ +import * as mfm from 'mfm-js'; +import { unique } from '@/scripts/array'; + +// unique without hash +// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] +const removeHash = (x: string) => x.replace(/#[^#]*$/, ''); + +export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] { + const urlNodes = mfm.extract(nodes, (node) => { + return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent)); + }); + const urls: string[] = unique(urlNodes.map(x => x.props.url)); + + return urls.reduce((array, url) => { + const urlWithoutHash = removeHash(url); + if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url); + return array; + }, [] as string[]); +} diff --git a/packages/frontend/src/scripts/focus.ts b/packages/frontend/src/scripts/focus.ts new file mode 100644 index 0000000000..d6802fa322 --- /dev/null +++ b/packages/frontend/src/scripts/focus.ts @@ -0,0 +1,27 @@ +export function focusPrev(el: Element | null, self = false, scroll = true) { + if (el == null) return; + if (!self) el = el.previousElementSibling; + if (el) { + if (el.hasAttribute('tabindex')) { + (el as HTMLElement).focus({ + preventScroll: !scroll, + }); + } else { + focusPrev(el.previousElementSibling, true); + } + } +} + +export function focusNext(el: Element | null, self = false, scroll = true) { + if (el == null) return; + if (!self) el = el.nextElementSibling; + if (el) { + if (el.hasAttribute('tabindex')) { + (el as HTMLElement).focus({ + preventScroll: !scroll, + }); + } else { + focusPrev(el.nextElementSibling, true); + } + } +} diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts new file mode 100644 index 0000000000..7f321cc0ae --- /dev/null +++ b/packages/frontend/src/scripts/form.ts @@ -0,0 +1,59 @@ +export type FormItem = { + label?: string; + type: 'string'; + default: string | null; + hidden?: boolean; + multiline?: boolean; +} | { + label?: string; + type: 'number'; + default: number | null; + hidden?: boolean; + step?: number; +} | { + label?: string; + type: 'boolean'; + default: boolean | null; + hidden?: boolean; +} | { + label?: string; + type: 'enum'; + default: string | null; + hidden?: boolean; + enum: string[]; +} | { + label?: string; + type: 'radio'; + default: unknown | null; + hidden?: boolean; + options: { + label: string; + value: unknown; + }[]; +} | { + label?: string; + type: 'object'; + default: Record<string, unknown> | null; + hidden: true; +} | { + label?: string; + type: 'array'; + default: unknown[] | null; + hidden: true; +}; + +export type Form = Record<string, FormItem>; + +type GetItemType<Item extends FormItem> = + Item['type'] extends 'string' ? string : + Item['type'] extends 'number' ? number : + Item['type'] extends 'boolean' ? boolean : + Item['type'] extends 'radio' ? unknown : + Item['type'] extends 'enum' ? string : + Item['type'] extends 'array' ? unknown[] : + Item['type'] extends 'object' ? Record<string, unknown> + : never; + +export type GetFormResultType<F extends Form> = { + [P in keyof F]: GetItemType<F[P]>; +}; diff --git a/packages/frontend/src/scripts/format-time-string.ts b/packages/frontend/src/scripts/format-time-string.ts new file mode 100644 index 0000000000..c20db5e827 --- /dev/null +++ b/packages/frontend/src/scripts/format-time-string.ts @@ -0,0 +1,50 @@ +const defaultLocaleStringFormats: {[index: string]: string} = { + 'weekday': 'narrow', + 'era': 'narrow', + 'year': 'numeric', + 'month': 'numeric', + 'day': 'numeric', + 'hour': 'numeric', + 'minute': 'numeric', + 'second': 'numeric', + 'timeZoneName': 'short', +}; + +function formatLocaleString(date: Date, format: string): string { + return format.replace(/\{\{(\w+)(:(\w+))?\}\}/g, (match: string, kind: string, unused?, option?: string) => { + if (['weekday', 'era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'timeZoneName'].includes(kind)) { + return date.toLocaleString(window.navigator.language, { [kind]: option ? option : defaultLocaleStringFormats[kind] }); + } else { + return match; + } + }); +} + +export function formatDateTimeString(date: Date, format: string): string { + return format + .replace(/yyyy/g, date.getFullYear().toString()) + .replace(/yy/g, date.getFullYear().toString().slice(-2)) + .replace(/MMMM/g, date.toLocaleString(window.navigator.language, { month: 'long' })) + .replace(/MMM/g, date.toLocaleString(window.navigator.language, { month: 'short' })) + .replace(/MM/g, (`0${date.getMonth() + 1}`).slice(-2)) + .replace(/M/g, (date.getMonth() + 1).toString()) + .replace(/dd/g, (`0${date.getDate()}`).slice(-2)) + .replace(/d/g, date.getDate().toString()) + .replace(/HH/g, (`0${date.getHours()}`).slice(-2)) + .replace(/H/g, date.getHours().toString()) + .replace(/hh/g, (`0${(date.getHours() % 12) || 12}`).slice(-2)) + .replace(/h/g, ((date.getHours() % 12) || 12).toString()) + .replace(/mm/g, (`0${date.getMinutes()}`).slice(-2)) + .replace(/m/g, date.getMinutes().toString()) + .replace(/ss/g, (`0${date.getSeconds()}`).slice(-2)) + .replace(/s/g, date.getSeconds().toString()) + .replace(/tt/g, date.getHours() >= 12 ? 'PM' : 'AM'); +} + +export function formatTimeString(date: Date, format: string): string { + return format.replace(/\[(([^\[]|\[\])*)\]|(([yMdHhmst])\4{0,3})/g, (match: string, localeformat?: string, unused?, datetimeformat?: string) => { + if (localeformat) return formatLocaleString(date, localeformat); + if (datetimeformat) return formatDateTimeString(date, datetimeformat); + return match; + }); +} diff --git a/packages/frontend/src/scripts/gen-search-query.ts b/packages/frontend/src/scripts/gen-search-query.ts new file mode 100644 index 0000000000..da7d622632 --- /dev/null +++ b/packages/frontend/src/scripts/gen-search-query.ts @@ -0,0 +1,30 @@ +import * as Acct from 'misskey-js/built/acct'; +import { host as localHost } from '@/config'; + +export async function genSearchQuery(v: any, q: string) { + let host: string; + let userId: string; + if (q.split(' ').some(x => x.startsWith('@'))) { + for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substr(1))) { + if (at.includes('.')) { + if (at === localHost || at === '.') { + host = null; + } else { + host = at; + } + } else { + const user = await v.os.api('users/show', Acct.parse(at)).catch(x => null); + if (user) { + userId = user.id; + } else { + // todo: show error + } + } + } + } + return { + query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '), + host: host, + userId: userId, + }; +} diff --git a/packages/frontend/src/scripts/get-account-from-id.ts b/packages/frontend/src/scripts/get-account-from-id.ts new file mode 100644 index 0000000000..1da897f176 --- /dev/null +++ b/packages/frontend/src/scripts/get-account-from-id.ts @@ -0,0 +1,7 @@ +import { get } from '@/scripts/idb-proxy'; + +export async function getAccountFromId(id: string) { + const accounts = await get('accounts') as { token: string; id: string; }[]; + if (!accounts) console.log('Accounts are not recorded'); + return accounts.find(account => account.id === id); +} diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts new file mode 100644 index 0000000000..7656770894 --- /dev/null +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -0,0 +1,341 @@ +import { defineAsyncComponent, Ref, inject } from 'vue'; +import * as misskey from 'misskey-js'; +import { pleaseLogin } from './please-login'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { url } from '@/config'; +import { noteActions } from '@/store'; +import { notePage } from '@/filters/note'; + +export function getNoteMenu(props: { + note: misskey.entities.Note; + menuButton: Ref<HTMLElement>; + translation: Ref<any>; + translating: Ref<boolean>; + isDeleted: Ref<boolean>; + currentClipPage?: Ref<misskey.entities.Clip>; +}) { + const isRenote = ( + props.note.renote != null && + props.note.text == null && + props.note.fileIds.length === 0 && + props.note.poll == null + ); + + const appearNote = isRenote ? props.note.renote as misskey.entities.Note : props.note; + + function del(): void { + os.confirm({ + type: 'warning', + text: i18n.ts.noteDeleteConfirm, + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: appearNote.id, + }); + }); + } + + function delEdit(): void { + os.confirm({ + type: 'warning', + text: i18n.ts.deleteAndEditConfirm, + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: appearNote.id, + }); + + os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); + }); + } + + function toggleFavorite(favorite: boolean): void { + os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { + noteId: appearNote.id, + }); + } + + function toggleThreadMute(mute: boolean): void { + os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { + noteId: appearNote.id, + }); + } + + function copyContent(): void { + copyToClipboard(appearNote.text); + os.success(); + } + + function copyLink(): void { + copyToClipboard(`${url}/notes/${appearNote.id}`); + os.success(); + } + + function togglePin(pin: boolean): void { + os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { + noteId: appearNote.id, + }, undefined, null, res => { + if (res.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { + os.alert({ + type: 'error', + text: i18n.ts.pinLimitExceeded, + }); + } + }); + } + + async function clip(): Promise<void> { + const clips = await os.api('clips/list'); + os.popupMenu([{ + icon: 'ti ti-plus', + text: i18n.ts.createNew, + action: async () => { + const { canceled, result } = await os.form(i18n.ts.createNewClip, { + name: { + type: 'string', + label: i18n.ts.name, + }, + description: { + type: 'string', + required: false, + multiline: true, + label: i18n.ts.description, + }, + isPublic: { + type: 'boolean', + label: i18n.ts.public, + default: false, + }, + }); + if (canceled) return; + + const clip = await os.apiWithDialog('clips/create', result); + + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); + }, + }, null, ...clips.map(clip => ({ + text: clip.name, + action: () => { + os.promiseDialog( + os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), + null, + async (err) => { + if (err.id === '734806c4-542c-463a-9311-15c512803965') { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }), + }); + if (!confirm.canceled) { + os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); + if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true; + } + } else { + os.alert({ + type: 'error', + text: err.message + '\n' + err.id, + }); + } + }, + ); + }, + }))], props.menuButton.value, { + }).then(focus); + } + + async function unclip(): Promise<void> { + os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id }); + props.isDeleted.value = true; + } + + async function promote(): Promise<void> { + const { canceled, result: days } = await os.inputNumber({ + title: i18n.ts.numberOfDays, + }); + + if (canceled) return; + + os.apiWithDialog('admin/promo/create', { + noteId: appearNote.id, + expiresAt: Date.now() + (86400000 * days), + }); + } + + function share(): void { + navigator.share({ + title: i18n.t('noteOf', { user: appearNote.user.name }), + text: appearNote.text, + url: `${url}/notes/${appearNote.id}`, + }); + } + function notedetails(): void { + os.pageWindow(`/notes/${appearNote.id}`); + } + async function translate(): Promise<void> { + if (props.translation.value != null) return; + props.translating.value = true; + const res = await os.api('notes/translate', { + noteId: appearNote.id, + targetLang: localStorage.getItem('lang') || navigator.language, + }); + props.translating.value = false; + props.translation.value = res; + } + + let menu; + if ($i) { + const statePromise = os.api('notes/state', { + noteId: appearNote.id, + }); + + menu = [ + ...( + props.currentClipPage?.value.userId === $i.id ? [{ + icon: 'ti ti-backspace', + text: i18n.ts.unclip, + danger: true, + action: unclip, + }, null] : [] + ), { + icon: 'ti ti-external-link', + text: i18n.ts.details, + action: notedetails, + }, { + icon: 'ti ti-copy', + text: i18n.ts.copyContent, + action: copyContent, + }, { + icon: 'ti ti-link', + text: i18n.ts.copyLink, + action: copyLink, + }, (appearNote.url || appearNote.uri) ? { + icon: 'ti ti-external-link', + text: i18n.ts.showOnRemote, + action: () => { + window.open(appearNote.url || appearNote.uri, '_blank'); + }, + } : undefined, + { + icon: 'ti ti-share', + text: i18n.ts.share, + action: share, + }, + instance.translatorAvailable ? { + icon: 'ti ti-language-hiragana', + text: i18n.ts.translate, + action: translate, + } : undefined, + null, + statePromise.then(state => state.isFavorited ? { + icon: 'ti ti-star-off', + text: i18n.ts.unfavorite, + action: () => toggleFavorite(false), + } : { + icon: 'ti ti-star', + text: i18n.ts.favorite, + action: () => toggleFavorite(true), + }), + { + icon: 'ti ti-paperclip', + text: i18n.ts.clip, + action: () => clip(), + }, + statePromise.then(state => state.isMutedThread ? { + icon: 'ti ti-message-off', + text: i18n.ts.unmuteThread, + action: () => toggleThreadMute(false), + } : { + icon: 'ti ti-message-off', + text: i18n.ts.muteThread, + action: () => toggleThreadMute(true), + }), + appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { + icon: 'ti ti-pinned-off', + text: i18n.ts.unpin, + action: () => togglePin(false), + } : { + icon: 'ti ti-pin', + text: i18n.ts.pin, + action: () => togglePin(true), + } : undefined, + /* + ...($i.isModerator || $i.isAdmin ? [ + null, + { + icon: 'fas fa-bullhorn', + text: i18n.ts.promote, + action: promote + }] + : [] + ),*/ + ...(appearNote.userId !== $i.id ? [ + null, + { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.reportAbuse, + action: () => { + const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`; + os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: appearNote.user, + initialComment: `Note: ${u}\n-----\n`, + }, {}, 'closed'); + }, + }] + : [] + ), + ...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [ + null, + appearNote.userId === $i.id ? { + icon: 'ti ti-edit', + text: i18n.ts.deleteAndEdit, + action: delEdit, + } : undefined, + { + icon: 'ti ti-trash', + text: i18n.ts.delete, + danger: true, + action: del, + }] + : [] + )] + .filter(x => x !== undefined); + } else { + menu = [{ + icon: 'ti ti-external-link', + text: i18n.ts.detailed, + action: openDetail, + }, { + icon: 'ti ti-copy', + text: i18n.ts.copyContent, + action: copyContent, + }, { + icon: 'ti ti-link', + text: i18n.ts.copyLink, + action: copyLink, + }, (appearNote.url || appearNote.uri) ? { + icon: 'ti ti-external-link', + text: i18n.ts.showOnRemote, + action: () => { + window.open(appearNote.url || appearNote.uri, '_blank'); + }, + } : undefined] + .filter(x => x !== undefined); + } + + if (noteActions.length > 0) { + menu = menu.concat([null, ...noteActions.map(action => ({ + icon: 'ti ti-plug', + text: action.title, + action: () => { + action.handler(appearNote); + }, + }))]); + } + + return menu; +} diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/scripts/get-note-summary.ts new file mode 100644 index 0000000000..d57e1c3029 --- /dev/null +++ b/packages/frontend/src/scripts/get-note-summary.ts @@ -0,0 +1,55 @@ +import * as misskey from 'misskey-js'; +import { i18n } from '@/i18n'; + +/** + * 投稿を表す文字列を取得します。 + * @param {*} note (packされた)投稿 + */ +export const getNoteSummary = (note: misskey.entities.Note): string => { + if (note.deletedAt) { + return `(${i18n.ts.deletedNote})`; + } + + if (note.isHidden) { + return `(${i18n.ts.invisibleNote})`; + } + + let summary = ''; + + // 本文 + if (note.cw != null) { + summary += note.cw; + } else { + summary += note.text ? note.text : ''; + } + + // ファイルが添付されているとき + if ((note.files || []).length !== 0) { + summary += ` (${i18n.t('withNFiles', { n: note.files.length })})`; + } + + // 投票が添付されているとき + if (note.poll) { + summary += ` (${i18n.ts.poll})`; + } + + // 返信のとき + if (note.replyId) { + if (note.reply) { + summary += `\n\nRE: ${getNoteSummary(note.reply)}`; + } else { + summary += '\n\nRE: ...'; + } + } + + // Renoteのとき + if (note.renoteId) { + if (note.renote) { + summary += `\n\nRN: ${getNoteSummary(note.renote)}`; + } else { + summary += '\n\nRN: ...'; + } + } + + return summary.trim(); +}; diff --git a/packages/frontend/src/scripts/get-static-image-url.ts b/packages/frontend/src/scripts/get-static-image-url.ts new file mode 100644 index 0000000000..cbd1761983 --- /dev/null +++ b/packages/frontend/src/scripts/get-static-image-url.ts @@ -0,0 +1,19 @@ +import { url as instanceUrl } from '@/config'; +import * as url from '@/scripts/url'; + +export function getStaticImageUrl(baseUrl: string): string { + const u = new URL(baseUrl); + if (u.href.startsWith(`${instanceUrl}/proxy/`)) { + // もう既にproxyっぽそうだったらsearchParams付けるだけ + u.searchParams.set('static', '1'); + return u.href; + } + + // 拡張子がないとキャッシュしてくれないCDNがあるのでダミーの名前を指定する + const dummy = `${encodeURIComponent(`${u.host}${u.pathname}`)}.webp`; + + return `${instanceUrl}/proxy/${dummy}?${url.query({ + url: u.href, + static: '1', + })}`; +} diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts new file mode 100644 index 0000000000..2faacffdfc --- /dev/null +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -0,0 +1,253 @@ +import * as Acct from 'misskey-js/built/acct'; +import { defineAsyncComponent } from 'vue'; +import { i18n } from '@/i18n'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { host } from '@/config'; +import * as os from '@/os'; +import { userActions } from '@/store'; +import { $i, iAmModerator } from '@/account'; +import { mainRouter } from '@/router'; +import { Router } from '@/nirax'; + +export function getUserMenu(user, router: Router = mainRouter) { + const meId = $i ? $i.id : null; + + async function pushList() { + const t = i18n.ts.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく + const lists = await os.api('users/lists/list'); + if (lists.length === 0) { + os.alert({ + type: 'error', + text: i18n.ts.youHaveNoLists, + }); + return; + } + const { canceled, result: listId } = await os.select({ + title: t, + items: lists.map(list => ({ + value: list.id, text: list.name, + })), + }); + if (canceled) return; + os.apiWithDialog('users/lists/push', { + listId: listId, + userId: user.id, + }); + } + + async function inviteGroup() { + const groups = await os.api('users/groups/owned'); + if (groups.length === 0) { + os.alert({ + type: 'error', + text: i18n.ts.youHaveNoGroups, + }); + return; + } + const { canceled, result: groupId } = await os.select({ + title: i18n.ts.group, + items: groups.map(group => ({ + value: group.id, text: group.name, + })), + }); + if (canceled) return; + os.apiWithDialog('users/groups/invite', { + groupId: groupId, + userId: user.id, + }); + } + + async function toggleMute() { + if (user.isMuted) { + os.apiWithDialog('mute/delete', { + userId: user.id, + }).then(() => { + user.isMuted = false; + }); + } else { + const { canceled, result: period } = await os.select({ + title: i18n.ts.mutePeriod, + items: [{ + value: 'indefinitely', text: i18n.ts.indefinitely, + }, { + value: 'tenMinutes', text: i18n.ts.tenMinutes, + }, { + value: 'oneHour', text: i18n.ts.oneHour, + }, { + value: 'oneDay', text: i18n.ts.oneDay, + }, { + value: 'oneWeek', text: i18n.ts.oneWeek, + }], + default: 'indefinitely', + }); + if (canceled) return; + + const expiresAt = period === 'indefinitely' ? null + : period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10) + : period === 'oneHour' ? Date.now() + (1000 * 60 * 60) + : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24) + : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7) + : null; + + os.apiWithDialog('mute/create', { + userId: user.id, + expiresAt, + }).then(() => { + user.isMuted = true; + }); + } + } + + async function toggleBlock() { + if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return; + + os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', { + userId: user.id, + }).then(() => { + user.isBlocking = !user.isBlocking; + }); + } + + async function toggleSilence() { + if (!await getConfirmed(i18n.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return; + + os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', { + userId: user.id, + }).then(() => { + user.isSilenced = !user.isSilenced; + }); + } + + async function toggleSuspend() { + if (!await getConfirmed(i18n.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return; + + os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', { + userId: user.id, + }).then(() => { + user.isSuspended = !user.isSuspended; + }); + } + + function reportAbuse() { + os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: user, + }, {}, 'closed'); + } + + async function getConfirmed(text: string): Promise<boolean> { + const confirm = await os.confirm({ + type: 'warning', + title: 'confirm', + text, + }); + + return !confirm.canceled; + } + + async function invalidateFollow() { + os.apiWithDialog('following/invalidate', { + userId: user.id, + }).then(() => { + user.isFollowed = !user.isFollowed; + }); + } + + let menu = [{ + icon: 'ti ti-at', + text: i18n.ts.copyUsername, + action: () => { + copyToClipboard(`@${user.username}@${user.host || host}`); + }, + }, { + icon: 'ti ti-rss', + text: i18n.ts.copyRSS, + action: () => { + copyToClipboard(`${user.host || host}/@${user.username}.atom`); + } + }, { + icon: 'ti ti-info-circle', + text: i18n.ts.info, + action: () => { + router.push(`/user-info/${user.id}`); + }, + }, { + icon: 'ti ti-mail', + text: i18n.ts.sendMessage, + action: () => { + os.post({ specified: user }); + }, + }, meId !== user.id ? { + type: 'link', + icon: 'ti ti-messages', + text: i18n.ts.startMessaging, + to: '/my/messaging/' + Acct.toString(user), + } : undefined, null, { + icon: 'ti ti-list', + text: i18n.ts.addToList, + action: pushList, + }, meId !== user.id ? { + icon: 'ti ti-users', + text: i18n.ts.inviteToGroup, + action: inviteGroup, + } : undefined] as any; + + if ($i && meId !== user.id) { + menu = menu.concat([null, { + icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off', + text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, + action: toggleMute, + }, { + icon: 'ti ti-ban', + text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block, + action: toggleBlock, + }]); + + if (user.isFollowed) { + menu = menu.concat([{ + icon: 'ti ti-link-off', + text: i18n.ts.breakFollow, + action: invalidateFollow, + }]); + } + + menu = menu.concat([null, { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.reportAbuse, + action: reportAbuse, + }]); + + if (iAmModerator) { + menu = menu.concat([null, { + icon: 'ti ti-microphone-2-off', + text: user.isSilenced ? i18n.ts.unsilence : i18n.ts.silence, + action: toggleSilence, + }, { + icon: 'ti ti-snowflake', + text: user.isSuspended ? i18n.ts.unsuspend : i18n.ts.suspend, + action: toggleSuspend, + }]); + } + } + + if ($i && meId === user.id) { + menu = menu.concat([null, { + icon: 'ti ti-pencil', + text: i18n.ts.editProfile, + action: () => { + router.push('/settings/profile'); + }, + }]); + } + + if (userActions.length > 0) { + menu = menu.concat([null, ...userActions.map(action => ({ + icon: 'ti ti-plug', + text: action.title, + action: () => { + action.handler(user); + }, + }))]); + } + + return menu; +} diff --git a/packages/frontend/src/scripts/get-user-name.ts b/packages/frontend/src/scripts/get-user-name.ts new file mode 100644 index 0000000000..d499ea0203 --- /dev/null +++ b/packages/frontend/src/scripts/get-user-name.ts @@ -0,0 +1,3 @@ +export default function(user: { name?: string | null, username: string }): string { + return user.name || user.username; +} diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts new file mode 100644 index 0000000000..4a0ded637d --- /dev/null +++ b/packages/frontend/src/scripts/hotkey.ts @@ -0,0 +1,90 @@ +import keyCode from './keycode'; + +type Callback = (ev: KeyboardEvent) => void; + +type Keymap = Record<string, Callback>; + +type Pattern = { + which: string[]; + ctrl?: boolean; + shift?: boolean; + alt?: boolean; +}; + +type Action = { + patterns: Pattern[]; + callback: Callback; + allowRepeat: boolean; +}; + +const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => { + const result = { + patterns: [], + callback, + allowRepeat: true, + } as Action; + + if (patterns.match(/^\(.*\)$/) !== null) { + result.allowRepeat = false; + patterns = patterns.slice(1, -1); + } + + result.patterns = patterns.split('|').map(part => { + const pattern = { + which: [], + ctrl: false, + alt: false, + shift: false, + } as Pattern; + + const keys = part.trim().split('+').map(x => x.trim().toLowerCase()); + for (const key of keys) { + switch (key) { + case 'ctrl': pattern.ctrl = true; break; + case 'alt': pattern.alt = true; break; + case 'shift': pattern.shift = true; break; + default: pattern.which = keyCode(key).map(k => k.toLowerCase()); + } + } + + return pattern; + }); + + return result; +}); + +const ignoreElemens = ['input', 'textarea']; + +function match(ev: KeyboardEvent, patterns: Action['patterns']): boolean { + const key = ev.code.toLowerCase(); + return patterns.some(pattern => pattern.which.includes(key) && + pattern.ctrl === ev.ctrlKey && + pattern.shift === ev.shiftKey && + pattern.alt === ev.altKey && + !ev.metaKey, + ); +} + +export const makeHotkey = (keymap: Keymap) => { + const actions = parseKeymap(keymap); + + return (ev: KeyboardEvent) => { + if (document.activeElement) { + if (ignoreElemens.some(el => document.activeElement!.matches(el))) return; + if (document.activeElement.attributes['contenteditable']) return; + } + + for (const action of actions) { + const matched = match(ev, action.patterns); + + if (matched) { + if (!action.allowRepeat && ev.repeat) return; + + ev.preventDefault(); + ev.stopPropagation(); + action.callback(ev); + break; + } + } + }; +}; diff --git a/packages/frontend/src/scripts/hpml/block.ts b/packages/frontend/src/scripts/hpml/block.ts new file mode 100644 index 0000000000..804c5c1124 --- /dev/null +++ b/packages/frontend/src/scripts/hpml/block.ts @@ -0,0 +1,109 @@ +// blocks + +export type BlockBase = { + id: string; + type: string; +}; + +export type TextBlock = BlockBase & { + type: 'text'; + text: string; +}; + +export type SectionBlock = BlockBase & { + type: 'section'; + title: string; + children: (Block | VarBlock)[]; +}; + +export type ImageBlock = BlockBase & { + type: 'image'; + fileId: string | null; +}; + +export type ButtonBlock = BlockBase & { + type: 'button'; + text: any; + primary: boolean; + action: string; + content: string; + event: string; + message: string; + var: string; + fn: string; +}; + +export type IfBlock = BlockBase & { + type: 'if'; + var: string; + children: Block[]; +}; + +export type TextareaBlock = BlockBase & { + type: 'textarea'; + text: string; +}; + +export type PostBlock = BlockBase & { + type: 'post'; + text: string; + attachCanvasImage: boolean; + canvasId: string; +}; + +export type CanvasBlock = BlockBase & { + type: 'canvas'; + name: string; // canvas id + width: number; + height: number; +}; + +export type NoteBlock = BlockBase & { + type: 'note'; + detailed: boolean; + note: string | null; +}; + +export type Block = + TextBlock | SectionBlock | ImageBlock | ButtonBlock | IfBlock | TextareaBlock | PostBlock | CanvasBlock | NoteBlock | VarBlock; + +// variable blocks + +export type VarBlockBase = BlockBase & { + name: string; +}; + +export type NumberInputVarBlock = VarBlockBase & { + type: 'numberInput'; + text: string; +}; + +export type TextInputVarBlock = VarBlockBase & { + type: 'textInput'; + text: string; +}; + +export type SwitchVarBlock = VarBlockBase & { + type: 'switch'; + text: string; +}; + +export type RadioButtonVarBlock = VarBlockBase & { + type: 'radioButton'; + title: string; + values: string[]; +}; + +export type CounterVarBlock = VarBlockBase & { + type: 'counter'; + text: string; + inc: number; +}; + +export type VarBlock = + NumberInputVarBlock | TextInputVarBlock | SwitchVarBlock | RadioButtonVarBlock | CounterVarBlock; + +const varBlock = ['numberInput', 'textInput', 'switch', 'radioButton', 'counter']; +export function isVarBlock(block: Block): block is VarBlock { + return varBlock.includes(block.type); +} diff --git a/packages/frontend/src/scripts/hpml/evaluator.ts b/packages/frontend/src/scripts/hpml/evaluator.ts new file mode 100644 index 0000000000..196b3142a1 --- /dev/null +++ b/packages/frontend/src/scripts/hpml/evaluator.ts @@ -0,0 +1,232 @@ +import autobind from 'autobind-decorator'; +import { PageVar, envVarsDef, Fn, HpmlScope, HpmlError } from '.'; +import { version } from '@/config'; +import { AiScript, utils, values } from '@syuilo/aiscript'; +import { createAiScriptEnv } from '../aiscript/api'; +import { collectPageVars } from '../collect-page-vars'; +import { initHpmlLib, initAiLib } from './lib'; +import * as os from '@/os'; +import { markRaw, ref, Ref, unref } from 'vue'; +import { Expr, isLiteralValue, Variable } from './expr'; + +/** + * Hpml evaluator + */ +export class Hpml { + private variables: Variable[]; + private pageVars: PageVar[]; + private envVars: Record<keyof typeof envVarsDef, any>; + public aiscript?: AiScript; + public pageVarUpdatedCallback?: values.VFn; + public canvases: Record<string, HTMLCanvasElement> = {}; + public vars: Ref<Record<string, any>> = ref({}); + public page: Record<string, any>; + + private opts: { + randomSeed: string; visitor?: any; url?: string; + enableAiScript: boolean; + }; + + constructor(page: Hpml['page'], opts: Hpml['opts']) { + this.page = page; + this.variables = this.page.variables; + this.pageVars = collectPageVars(this.page.content); + this.opts = opts; + + if (this.opts.enableAiScript) { + this.aiscript = markRaw(new AiScript({ ...createAiScriptEnv({ + storageKey: 'pages:' + this.page.id, + }), ...initAiLib(this) }, { + in: (q) => { + return new Promise(ok => { + os.inputText({ + title: q, + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + console.log(value); + }, + log: (type, params) => { + }, + })); + + this.aiscript.scope.opts.onUpdated = (name, value) => { + this.eval(); + }; + } + + const date = new Date(); + + this.envVars = { + AI: 'kawaii', + VERSION: version, + URL: this.page ? `${opts.url}/@${this.page.user.username}/pages/${this.page.name}` : '', + LOGIN: opts.visitor != null, + NAME: opts.visitor ? opts.visitor.name || opts.visitor.username : '', + USERNAME: opts.visitor ? opts.visitor.username : '', + USERID: opts.visitor ? opts.visitor.id : '', + NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0, + FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0, + FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0, + IS_CAT: opts.visitor ? opts.visitor.isCat : false, + SEED: opts.randomSeed ? opts.randomSeed : '', + YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`, + AISCRIPT_DISABLED: !this.opts.enableAiScript, + NULL: null, + }; + + this.eval(); + } + + @autobind + public eval() { + try { + this.vars.value = this.evaluateVars(); + } catch (err) { + //this.onError(e); + } + } + + @autobind + public interpolate(str: string) { + if (str == null) return null; + return str.replace(/{(.+?)}/g, match => { + const v = unref(this.vars)[match.slice(1, -1).trim()]; + return v == null ? 'NULL' : v.toString(); + }); + } + + @autobind + public callAiScript(fn: string) { + try { + if (this.aiscript) this.aiscript.execFn(this.aiscript.scope.get(fn), []); + } catch (err) {} + } + + @autobind + public registerCanvas(id: string, canvas: any) { + this.canvases[id] = canvas; + } + + @autobind + public updatePageVar(name: string, value: any) { + const pageVar = this.pageVars.find(v => v.name === name); + if (pageVar !== undefined) { + pageVar.value = value; + if (this.pageVarUpdatedCallback) { + if (this.aiscript) this.aiscript.execFn(this.pageVarUpdatedCallback, [values.STR(name), utils.jsToVal(value)]); + } + } else { + throw new HpmlError(`No such page var '${name}'`); + } + } + + @autobind + public updateRandomSeed(seed: string) { + this.opts.randomSeed = seed; + this.envVars.SEED = seed; + } + + @autobind + private _interpolateScope(str: string, scope: HpmlScope) { + return str.replace(/{(.+?)}/g, match => { + const v = scope.getState(match.slice(1, -1).trim()); + return v == null ? 'NULL' : v.toString(); + }); + } + + @autobind + public evaluateVars(): Record<string, any> { + const values: Record<string, any> = {}; + + for (const [k, v] of Object.entries(this.envVars)) { + values[k] = v; + } + + for (const v of this.pageVars) { + values[v.name] = v.value; + } + + for (const v of this.variables) { + values[v.name] = this.evaluate(v, new HpmlScope([values])); + } + + return values; + } + + @autobind + private evaluate(expr: Expr, scope: HpmlScope): any { + if (isLiteralValue(expr)) { + if (expr.type === null) { + return null; + } + + if (expr.type === 'number') { + return parseInt((expr.value as any), 10); + } + + if (expr.type === 'text' || expr.type === 'multiLineText') { + return this._interpolateScope(expr.value || '', scope); + } + + if (expr.type === 'textList') { + return this._interpolateScope(expr.value || '', scope).trim().split('\n'); + } + + if (expr.type === 'ref') { + return scope.getState(expr.value); + } + + if (expr.type === 'aiScriptVar') { + if (this.aiscript) { + try { + return utils.valToJs(this.aiscript.scope.get(expr.value)); + } catch (err) { + return null; + } + } else { + return null; + } + } + + // Define user function + if (expr.type === 'fn') { + return { + slots: expr.value.slots.map(x => x.name), + exec: (slotArg: Record<string, any>) => { + return this.evaluate(expr.value.expression, scope.createChildScope(slotArg, expr.id)); + }, + } as Fn; + } + return; + } + + // Call user function + if (expr.type.startsWith('fn:')) { + const fnName = expr.type.split(':')[1]; + const fn = scope.getState(fnName); + const args = {} as Record<string, any>; + for (let i = 0; i < fn.slots.length; i++) { + const name = fn.slots[i]; + args[name] = this.evaluate(expr.args[i], scope); + } + return fn.exec(args); + } + + if (expr.args === undefined) return null; + + const funcs = initHpmlLib(expr, scope, this.opts.randomSeed, this.opts.visitor); + + // Call function + const fnName = expr.type; + const fn = (funcs as any)[fnName]; + if (fn == null) { + throw new HpmlError(`No such function '${fnName}'`); + } else { + return fn(...expr.args.map(x => this.evaluate(x, scope))); + } + } +} diff --git a/packages/frontend/src/scripts/hpml/expr.ts b/packages/frontend/src/scripts/hpml/expr.ts new file mode 100644 index 0000000000..18c7c2a14b --- /dev/null +++ b/packages/frontend/src/scripts/hpml/expr.ts @@ -0,0 +1,79 @@ +import { literalDefs, Type } from '.'; + +export type ExprBase = { + id: string; +}; + +// value + +export type EmptyValue = ExprBase & { + type: null; + value: null; +}; + +export type TextValue = ExprBase & { + type: 'text'; + value: string; +}; + +export type MultiLineTextValue = ExprBase & { + type: 'multiLineText'; + value: string; +}; + +export type TextListValue = ExprBase & { + type: 'textList'; + value: string; +}; + +export type NumberValue = ExprBase & { + type: 'number'; + value: number; +}; + +export type RefValue = ExprBase & { + type: 'ref'; + value: string; // value is variable name +}; + +export type AiScriptRefValue = ExprBase & { + type: 'aiScriptVar'; + value: string; // value is variable name +}; + +export type UserFnValue = ExprBase & { + type: 'fn'; + value: UserFnInnerValue; +}; +type UserFnInnerValue = { + slots: { + name: string; + type: Type; + }[]; + expression: Expr; +}; + +export type Value = + EmptyValue | TextValue | MultiLineTextValue | TextListValue | NumberValue | RefValue | AiScriptRefValue | UserFnValue; + +export function isLiteralValue(expr: Expr): expr is Value { + if (expr.type == null) return true; + if (literalDefs[expr.type]) return true; + return false; +} + +// call function + +export type CallFn = ExprBase & { // "fn:hoge" or string + type: string; + args: Expr[]; + value: null; +}; + +// variable +export type Variable = (Value | CallFn) & { + name: string; +}; + +// expression +export type Expr = Variable | Value | CallFn; diff --git a/packages/frontend/src/scripts/hpml/index.ts b/packages/frontend/src/scripts/hpml/index.ts new file mode 100644 index 0000000000..9a55a5c286 --- /dev/null +++ b/packages/frontend/src/scripts/hpml/index.ts @@ -0,0 +1,103 @@ +/** + * Hpml + */ + +import autobind from 'autobind-decorator'; +import { Hpml } from './evaluator'; +import { funcDefs } from './lib'; + +export type Fn = { + slots: string[]; + exec: (args: Record<string, any>) => ReturnType<Hpml['evaluate']>; +}; + +export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null; + +export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = { + text: { out: 'string', category: 'value', icon: 'ti ti-quote' }, + multiLineText: { out: 'string', category: 'value', icon: 'fas fa-align-left' }, + textList: { out: 'stringArray', category: 'value', icon: 'fas fa-list' }, + number: { out: 'number', category: 'value', icon: 'fas fa-sort-numeric-up' }, + ref: { out: null, category: 'value', icon: 'fas fa-magic' }, + aiScriptVar: { out: null, category: 'value', icon: 'fas fa-magic' }, + fn: { out: 'function', category: 'value', icon: 'fas fa-square-root-alt' }, +}; + +export const blockDefs = [ + ...Object.entries(literalDefs).map(([k, v]) => ({ + type: k, out: v.out, category: v.category, icon: v.icon, + })), + ...Object.entries(funcDefs).map(([k, v]) => ({ + type: k, out: v.out, category: v.category, icon: v.icon, + })), +]; + +export type PageVar = { name: string; value: any; type: Type; }; + +export const envVarsDef: Record<string, Type> = { + AI: 'string', + URL: 'string', + VERSION: 'string', + LOGIN: 'boolean', + NAME: 'string', + USERNAME: 'string', + USERID: 'string', + NOTES_COUNT: 'number', + FOLLOWERS_COUNT: 'number', + FOLLOWING_COUNT: 'number', + IS_CAT: 'boolean', + SEED: null, + YMD: 'string', + AISCRIPT_DISABLED: 'boolean', + NULL: null, +}; + +export class HpmlScope { + private layerdStates: Record<string, any>[]; + public name: string; + + constructor(layerdStates: HpmlScope['layerdStates'], name?: HpmlScope['name']) { + this.layerdStates = layerdStates; + this.name = name || 'anonymous'; + } + + @autobind + public createChildScope(states: Record<string, any>, name?: HpmlScope['name']): HpmlScope { + const layer = [states, ...this.layerdStates]; + return new HpmlScope(layer, name); + } + + /** + * 指定した名前の変数の値を取得します + * @param name 変数名 + */ + @autobind + public getState(name: string): any { + for (const later of this.layerdStates) { + const state = later[name]; + if (state !== undefined) { + return state; + } + } + + throw new HpmlError( + `No such variable '${name}' in scope '${this.name}'`, { + scope: this.layerdStates, + }); + } +} + +export class HpmlError extends Error { + public info?: any; + + constructor(message: string, info?: any) { + super(message); + + this.info = info; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, HpmlError); + } + } +} diff --git a/packages/frontend/src/scripts/hpml/lib.ts b/packages/frontend/src/scripts/hpml/lib.ts new file mode 100644 index 0000000000..b684876a7f --- /dev/null +++ b/packages/frontend/src/scripts/hpml/lib.ts @@ -0,0 +1,247 @@ +import tinycolor from 'tinycolor2'; +import { Hpml } from './evaluator'; +import { values, utils } from '@syuilo/aiscript'; +import { Fn, HpmlScope } from '.'; +import { Expr } from './expr'; +import seedrandom from 'seedrandom'; + +/* TODO: https://www.chartjs.org/docs/latest/configuration/canvas-background.html#color +// https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs +Chart.pluginService.register({ + beforeDraw: (chart, easing) => { + if (chart.config.options.chartArea && chart.config.options.chartArea.backgroundColor) { + const ctx = chart.chart.ctx; + ctx.save(); + ctx.fillStyle = chart.config.options.chartArea.backgroundColor; + ctx.fillRect(0, 0, chart.chart.width, chart.chart.height); + ctx.restore(); + } + } +}); +*/ + +export function initAiLib(hpml: Hpml) { + return { + 'MkPages:updated': values.FN_NATIVE(([callback]) => { + hpml.pageVarUpdatedCallback = (callback as values.VFn); + }), + 'MkPages:get_canvas': values.FN_NATIVE(([id]) => { + utils.assertString(id); + const canvas = hpml.canvases[id.value]; + const ctx = canvas.getContext('2d'); + return values.OBJ(new Map([ + ['clear_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.clearRect(x.value, y.value, width.value, height.value); })], + ['fill_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.fillRect(x.value, y.value, width.value, height.value); })], + ['stroke_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.strokeRect(x.value, y.value, width.value, height.value); })], + ['fill_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.fillText(text.value, x.value, y.value, width ? width.value : undefined); })], + ['stroke_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.strokeText(text.value, x.value, y.value, width ? width.value : undefined); })], + ['set_line_width', values.FN_NATIVE(([width]) => { ctx.lineWidth = width.value; })], + ['set_font', values.FN_NATIVE(([font]) => { ctx.font = font.value; })], + ['set_fill_style', values.FN_NATIVE(([style]) => { ctx.fillStyle = style.value; })], + ['set_stroke_style', values.FN_NATIVE(([style]) => { ctx.strokeStyle = style.value; })], + ['begin_path', values.FN_NATIVE(() => { ctx.beginPath(); })], + ['close_path', values.FN_NATIVE(() => { ctx.closePath(); })], + ['move_to', values.FN_NATIVE(([x, y]) => { ctx.moveTo(x.value, y.value); })], + ['line_to', values.FN_NATIVE(([x, y]) => { ctx.lineTo(x.value, y.value); })], + ['arc', values.FN_NATIVE(([x, y, radius, startAngle, endAngle]) => { ctx.arc(x.value, y.value, radius.value, startAngle.value, endAngle.value); })], + ['rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.rect(x.value, y.value, width.value, height.value); })], + ['fill', values.FN_NATIVE(() => { ctx.fill(); })], + ['stroke', values.FN_NATIVE(() => { ctx.stroke(); })], + ])); + }), + 'MkPages:chart': values.FN_NATIVE(([id, opts]) => { + /* TODO + utils.assertString(id); + utils.assertObject(opts); + const canvas = hpml.canvases[id.value]; + const color = getComputedStyle(document.documentElement).getPropertyValue('--accent'); + Chart.defaults.color = '#555'; + const chart = new Chart(canvas, { + type: opts.value.get('type').value, + data: { + labels: opts.value.get('labels').value.map(x => x.value), + datasets: opts.value.get('datasets').value.map(x => ({ + label: x.value.has('label') ? x.value.get('label').value : '', + data: x.value.get('data').value.map(x => x.value), + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: x.value.has('color') ? x.value.get('color') : color, + backgroundColor: tinycolor(x.value.has('color') ? x.value.get('color') : color).setAlpha(0.1).toRgbString(), + })) + }, + options: { + responsive: false, + devicePixelRatio: 1.5, + title: { + display: opts.value.has('title'), + text: opts.value.has('title') ? opts.value.get('title').value : '', + fontSize: 14, + }, + layout: { + padding: { + left: 32, + right: 32, + top: opts.value.has('title') ? 16 : 32, + bottom: 16 + } + }, + legend: { + display: opts.value.get('datasets').value.filter(x => x.value.has('label') && x.value.get('label').value).length === 0 ? false : true, + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + tooltips: { + enabled: false, + }, + chartArea: { + backgroundColor: '#fff' + }, + ...(opts.value.get('type').value === 'radar' ? { + scale: { + ticks: { + display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : false, + min: opts.value.has('min') ? opts.value.get('min').value : undefined, + max: opts.value.has('max') ? opts.value.get('max').value : undefined, + maxTicksLimit: 8, + }, + pointLabels: { + fontSize: 12 + } + } + } : { + scales: { + yAxes: [{ + ticks: { + display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : true, + min: opts.value.has('min') ? opts.value.get('min').value : undefined, + max: opts.value.has('max') ? opts.value.get('max').value : undefined, + } + }] + } + }) + } + }); + */ + }), + }; +} + +export const funcDefs: Record<string, { in: any[]; out: any; category: string; icon: any; }> = { + if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'ti ti-share' }, + for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'fas fa-recycle' }, + not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-plus' }, + subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-minus' }, + multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-x' }, + divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' }, + mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' }, + round: { in: ['number'], out: 'number', category: 'operation', icon: 'fas fa-calculator' }, + eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-equals' }, + notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-not-equal' }, + gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than' }, + lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than' }, + gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than-equal' }, + ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than-equal' }, + strLen: { in: ['string'], out: 'number', category: 'text', icon: 'ti ti-quote' }, + strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'fas fa-exchange-alt' }, + numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'fas fa-exchange-alt' }, + splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'fas fa-exchange-alt' }, + pick: { in: [null, 'number'], out: null, category: 'list', icon: 'fas fa-indent' }, + listLen: { in: [null], out: 'number', category: 'list', icon: 'fas fa-indent' }, + rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + random: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + randomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' }, + dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' }, + seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'fas fa-dice' }, + DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'fas fa-dice' }, // dailyRandomPickWithProbabilityMapping +}; + +export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, visitor?: any) { + const date = new Date(); + const day = `${visitor ? visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; + + // SHOULD be fine to ignore since it's intended + function shape isn't defined + // eslint-disable-next-line @typescript-eslint/ban-types + const funcs: Record<string, Function> = { + not: (a: boolean) => !a, + or: (a: boolean, b: boolean) => a || b, + and: (a: boolean, b: boolean) => a && b, + eq: (a: any, b: any) => a === b, + notEq: (a: any, b: any) => a !== b, + gt: (a: number, b: number) => a > b, + lt: (a: number, b: number) => a < b, + gtEq: (a: number, b: number) => a >= b, + ltEq: (a: number, b: number) => a <= b, + if: (bool: boolean, a: any, b: any) => bool ? a : b, + for: (times: number, fn: Fn) => { + const result: any[] = []; + for (let i = 0; i < times; i++) { + result.push(fn.exec({ + [fn.slots[0]]: i + 1, + })); + } + return result; + }, + add: (a: number, b: number) => a + b, + subtract: (a: number, b: number) => a - b, + multiply: (a: number, b: number) => a * b, + divide: (a: number, b: number) => a / b, + mod: (a: number, b: number) => a % b, + round: (a: number) => Math.round(a), + strLen: (a: string) => a.length, + strPick: (a: string, b: number) => a[b - 1], + strReplace: (a: string, b: string, c: string) => a.split(b).join(c), + strReverse: (a: string) => a.split('').reverse().join(''), + join: (texts: string[], separator: string) => texts.join(separator || ''), + stringToNumber: (a: string) => parseInt(a), + numberToString: (a: number) => a.toString(), + splitStrByLine: (a: string) => a.split('\n'), + pick: (list: any[], i: number) => list[i - 1], + listLen: (list: any[]) => list.length, + random: (probability: number) => Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * 100) < probability, + rannum: (min: number, max: number) => min + Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * (max - min + 1)), + randomPick: (list: any[]) => list[Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * list.length)], + dailyRandom: (probability: number) => Math.floor(seedrandom(`${day}:${expr.id}`)() * 100) < probability, + dailyRannum: (min: number, max: number) => min + Math.floor(seedrandom(`${day}:${expr.id}`)() * (max - min + 1)), + dailyRandomPick: (list: any[]) => list[Math.floor(seedrandom(`${day}:${expr.id}`)() * list.length)], + seedRandom: (seed: any, probability: number) => Math.floor(seedrandom(seed)() * 100) < probability, + seedRannum: (seed: any, min: number, max: number) => min + Math.floor(seedrandom(seed)() * (max - min + 1)), + seedRandomPick: (seed: any, list: any[]) => list[Math.floor(seedrandom(seed)() * list.length)], + DRPWPM: (list: string[]) => { + const xs: any[] = []; + let totalFactor = 0; + for (const x of list) { + const parts = x.split(' '); + const factor = parseInt(parts.pop()!, 10); + const text = parts.join(' '); + totalFactor += factor; + xs.push({ factor, text }); + } + const r = seedrandom(`${day}:${expr.id}`)() * totalFactor; + let stackedFactor = 0; + for (const x of xs) { + if (r >= stackedFactor && r <= stackedFactor + x.factor) { + return x.text; + } else { + stackedFactor += x.factor; + } + } + return xs[0].text; + }, + }; + + return funcs; +} diff --git a/packages/frontend/src/scripts/hpml/type-checker.ts b/packages/frontend/src/scripts/hpml/type-checker.ts new file mode 100644 index 0000000000..24c9ed8bcb --- /dev/null +++ b/packages/frontend/src/scripts/hpml/type-checker.ts @@ -0,0 +1,191 @@ +import autobind from 'autobind-decorator'; +import { isLiteralValue } from './expr'; +import { funcDefs } from './lib'; +import { envVarsDef } from '.'; +import type { Type, PageVar } from '.'; +import type { Expr, Variable } from './expr'; + +type TypeError = { + arg: number; + expect: Type; + actual: Type; +}; + +/** + * Hpml type checker + */ +export class HpmlTypeChecker { + public variables: Variable[]; + public pageVars: PageVar[]; + + constructor(variables: HpmlTypeChecker['variables'] = [], pageVars: HpmlTypeChecker['pageVars'] = []) { + this.variables = variables; + this.pageVars = pageVars; + } + + @autobind + public typeCheck(v: Expr): TypeError | null { + if (isLiteralValue(v)) return null; + + const def = funcDefs[v.type || '']; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.infer(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } else if (type !== generic[arg]) { + return { + arg: i, + expect: generic[arg], + actual: type, + }; + } + } else if (type !== arg) { + return { + arg: i, + expect: arg, + actual: type, + }; + } + } + + return null; + } + + @autobind + public getExpectedType(v: Expr, slot: number): Type { + const def = funcDefs[v.type || '']; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.infer(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } + } + } + + if (typeof def.in[slot] === 'number') { + return generic[def.in[slot]] ?? null; + } else { + return def.in[slot]; + } + } + + @autobind + public infer(v: Expr): Type { + if (v.type === null) return null; + if (v.type === 'text') return 'string'; + if (v.type === 'multiLineText') return 'string'; + if (v.type === 'textList') return 'stringArray'; + if (v.type === 'number') return 'number'; + if (v.type === 'ref') { + const variable = this.variables.find(va => va.name === v.value); + if (variable) { + return this.infer(variable); + } + + const pageVar = this.pageVars.find(va => va.name === v.value); + if (pageVar) { + return pageVar.type; + } + + const envVar = envVarsDef[v.value || '']; + if (envVar !== undefined) { + return envVar; + } + + return null; + } + if (v.type === 'aiScriptVar') return null; + if (v.type === 'fn') return null; // todo + if (v.type.startsWith('fn:')) return null; // todo + + const generic: Type[] = []; + + const def = funcDefs[v.type]; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + if (typeof arg === 'number') { + const type = this.infer(v.args[i]); + + if (generic[arg] === undefined) { + generic[arg] = type; + } else { + if (type !== generic[arg]) { + generic[arg] = null; + } + } + } + } + + if (typeof def.out === 'number') { + return generic[def.out]; + } else { + return def.out; + } + } + + @autobind + public getVarByName(name: string): Variable { + const v = this.variables.find(x => x.name === name); + if (v !== undefined) { + return v; + } else { + throw new Error(`No such variable '${name}'`); + } + } + + @autobind + public getVarsByType(type: Type): Variable[] { + if (type == null) return this.variables; + return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type)); + } + + @autobind + public getEnvVarsByType(type: Type): string[] { + if (type == null) return Object.keys(envVarsDef); + return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k); + } + + @autobind + public getPageVarsByType(type: Type): string[] { + if (type == null) return this.pageVars.map(v => v.name); + return this.pageVars.filter(v => type === v.type).map(v => v.name); + } + + @autobind + public isUsedName(name: string) { + if (this.variables.some(v => v.name === name)) { + return true; + } + + if (this.pageVars.some(v => v.name === name)) { + return true; + } + + if (envVarsDef[name]) { + return true; + } + + return false; + } +} diff --git a/packages/frontend/src/scripts/i18n.ts b/packages/frontend/src/scripts/i18n.ts new file mode 100644 index 0000000000..54184386da --- /dev/null +++ b/packages/frontend/src/scripts/i18n.ts @@ -0,0 +1,29 @@ +export class I18n<T extends Record<string, any>> { + public ts: T; + + constructor(locale: T) { + this.ts = locale; + + //#region BIND + this.t = this.t.bind(this); + //#endregion + } + + // string にしているのは、ドット区切りでのパス指定を許可するため + // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも + public t(key: string, args?: Record<string, string | number>): string { + try { + let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string; + + if (args) { + for (const [k, v] of Object.entries(args)) { + str = str.replace(`{${k}}`, v.toString()); + } + } + return str; + } catch (err) { + console.warn(`missing localization '${key}'`); + return key; + } + } +} diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts new file mode 100644 index 0000000000..77bb84463c --- /dev/null +++ b/packages/frontend/src/scripts/idb-proxy.ts @@ -0,0 +1,36 @@ +// FirefoxのプライベートモードなどではindexedDBが使用不可能なので、 +// indexedDBが使えない環境ではlocalStorageを使う +import { + get as iget, + set as iset, + del as idel, +} from 'idb-keyval'; + +const fallbackName = (key: string) => `idbfallback::${key}`; + +let idbAvailable = typeof window !== 'undefined' ? !!window.indexedDB : true; + +if (idbAvailable) { + iset('idb-test', 'test').catch(err => { + console.error('idb error', err); + console.error('indexedDB is unavailable. It will use localStorage.'); + idbAvailable = false; + }); +} else { + console.error('indexedDB is unavailable. It will use localStorage.'); +} + +export async function get(key: string) { + if (idbAvailable) return iget(key); + return JSON.parse(localStorage.getItem(fallbackName(key))); +} + +export async function set(key: string, val: any) { + if (idbAvailable) return iset(key, val); + return localStorage.setItem(fallbackName(key), JSON.stringify(val)); +} + +export async function del(key: string) { + if (idbAvailable) return idel(key); + return localStorage.removeItem(fallbackName(key)); +} diff --git a/packages/frontend/src/scripts/initialize-sw.ts b/packages/frontend/src/scripts/initialize-sw.ts new file mode 100644 index 0000000000..de52f30523 --- /dev/null +++ b/packages/frontend/src/scripts/initialize-sw.ts @@ -0,0 +1,13 @@ +import { lang } from '@/config'; + +export async function initializeSw() { + if (!('serviceWorker' in navigator)) return; + + navigator.serviceWorker.register(`/sw.js`, { scope: '/', type: 'classic' }); + navigator.serviceWorker.ready.then(registration => { + registration.active?.postMessage({ + msg: 'initialize', + lang, + }); + }); +} diff --git a/packages/frontend/src/scripts/is-device-darkmode.ts b/packages/frontend/src/scripts/is-device-darkmode.ts new file mode 100644 index 0000000000..854f38e517 --- /dev/null +++ b/packages/frontend/src/scripts/is-device-darkmode.ts @@ -0,0 +1,3 @@ +export function isDeviceDarkmode() { + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} diff --git a/packages/frontend/src/scripts/keycode.ts b/packages/frontend/src/scripts/keycode.ts new file mode 100644 index 0000000000..69f6a82803 --- /dev/null +++ b/packages/frontend/src/scripts/keycode.ts @@ -0,0 +1,33 @@ +export default (input: string): string[] => { + if (Object.keys(aliases).some(a => a.toLowerCase() === input.toLowerCase())) { + const codes = aliases[input]; + return Array.isArray(codes) ? codes : [codes]; + } else { + return [input]; + } +}; + +export const aliases = { + 'esc': 'Escape', + 'enter': ['Enter', 'NumpadEnter'], + 'up': 'ArrowUp', + 'down': 'ArrowDown', + 'left': 'ArrowLeft', + 'right': 'ArrowRight', + 'plus': ['NumpadAdd', 'Semicolon'], +}; + +/*! +* Programmatically add the following +*/ + +// lower case chars +for (let i = 97; i < 123; i++) { + const char = String.fromCharCode(i); + aliases[char] = `Key${char.toUpperCase()}`; +} + +// numbers +for (let i = 0; i < 10; i++) { + aliases[i] = [`Numpad${i}`, `Digit${i}`]; +} diff --git a/packages/frontend/src/scripts/langmap.ts b/packages/frontend/src/scripts/langmap.ts new file mode 100644 index 0000000000..25f5b366c8 --- /dev/null +++ b/packages/frontend/src/scripts/langmap.ts @@ -0,0 +1,666 @@ +// TODO: sharedに置いてバックエンドのと統合したい +export const langmap = { + 'ach': { + nativeName: 'Lwo', + }, + 'ady': { + nativeName: 'Адыгэбзэ', + }, + 'af': { + nativeName: 'Afrikaans', + }, + 'af-NA': { + nativeName: 'Afrikaans (Namibia)', + }, + 'af-ZA': { + nativeName: 'Afrikaans (South Africa)', + }, + 'ak': { + nativeName: 'Tɕɥi', + }, + 'ar': { + nativeName: 'العربية', + }, + 'ar-AR': { + nativeName: 'العربية', + }, + 'ar-MA': { + nativeName: 'العربية', + }, + 'ar-SA': { + nativeName: 'العربية (السعودية)', + }, + 'ay-BO': { + nativeName: 'Aymar aru', + }, + 'az': { + nativeName: 'Azərbaycan dili', + }, + 'az-AZ': { + nativeName: 'Azərbaycan dili', + }, + 'be-BY': { + nativeName: 'Беларуская', + }, + 'bg': { + nativeName: 'Български', + }, + 'bg-BG': { + nativeName: 'Български', + }, + 'bn': { + nativeName: 'বাংলা', + }, + 'bn-IN': { + nativeName: 'বাংলা (ভারত)', + }, + 'bn-BD': { + nativeName: 'বাংলা(বাংলাদেশ)', + }, + 'br': { + nativeName: 'Brezhoneg', + }, + 'bs-BA': { + nativeName: 'Bosanski', + }, + 'ca': { + nativeName: 'Català', + }, + 'ca-ES': { + nativeName: 'Català', + }, + 'cak': { + nativeName: 'Maya Kaqchikel', + }, + 'ck-US': { + nativeName: 'ᏣᎳᎩ (tsalagi)', + }, + 'cs': { + nativeName: 'Čeština', + }, + 'cs-CZ': { + nativeName: 'Čeština', + }, + 'cy': { + nativeName: 'Cymraeg', + }, + 'cy-GB': { + nativeName: 'Cymraeg', + }, + 'da': { + nativeName: 'Dansk', + }, + 'da-DK': { + nativeName: 'Dansk', + }, + 'de': { + nativeName: 'Deutsch', + }, + 'de-AT': { + nativeName: 'Deutsch (Österreich)', + }, + 'de-DE': { + nativeName: 'Deutsch (Deutschland)', + }, + 'de-CH': { + nativeName: 'Deutsch (Schweiz)', + }, + 'dsb': { + nativeName: 'Dolnoserbšćina', + }, + 'el': { + nativeName: 'Ελληνικά', + }, + 'el-GR': { + nativeName: 'Ελληνικά', + }, + 'en': { + nativeName: 'English', + }, + 'en-GB': { + nativeName: 'English (UK)', + }, + 'en-AU': { + nativeName: 'English (Australia)', + }, + 'en-CA': { + nativeName: 'English (Canada)', + }, + 'en-IE': { + nativeName: 'English (Ireland)', + }, + 'en-IN': { + nativeName: 'English (India)', + }, + 'en-PI': { + nativeName: 'English (Pirate)', + }, + 'en-SG': { + nativeName: 'English (Singapore)', + }, + 'en-UD': { + nativeName: 'English (Upside Down)', + }, + 'en-US': { + nativeName: 'English (US)', + }, + 'en-ZA': { + nativeName: 'English (South Africa)', + }, + 'en@pirate': { + nativeName: 'English (Pirate)', + }, + 'eo': { + nativeName: 'Esperanto', + }, + 'eo-EO': { + nativeName: 'Esperanto', + }, + 'es': { + nativeName: 'Español', + }, + 'es-AR': { + nativeName: 'Español (Argentine)', + }, + 'es-419': { + nativeName: 'Español (Latinoamérica)', + }, + 'es-CL': { + nativeName: 'Español (Chile)', + }, + 'es-CO': { + nativeName: 'Español (Colombia)', + }, + 'es-EC': { + nativeName: 'Español (Ecuador)', + }, + 'es-ES': { + nativeName: 'Español (España)', + }, + 'es-LA': { + nativeName: 'Español (Latinoamérica)', + }, + 'es-NI': { + nativeName: 'Español (Nicaragua)', + }, + 'es-MX': { + nativeName: 'Español (México)', + }, + 'es-US': { + nativeName: 'Español (Estados Unidos)', + }, + 'es-VE': { + nativeName: 'Español (Venezuela)', + }, + 'et': { + nativeName: 'eesti keel', + }, + 'et-EE': { + nativeName: 'Eesti (Estonia)', + }, + 'eu': { + nativeName: 'Euskara', + }, + 'eu-ES': { + nativeName: 'Euskara', + }, + 'fa': { + nativeName: 'فارسی', + }, + 'fa-IR': { + nativeName: 'فارسی', + }, + 'fb-LT': { + nativeName: 'Leet Speak', + }, + 'ff': { + nativeName: 'Fulah', + }, + 'fi': { + nativeName: 'Suomi', + }, + 'fi-FI': { + nativeName: 'Suomi', + }, + 'fo': { + nativeName: 'Føroyskt', + }, + 'fo-FO': { + nativeName: 'Føroyskt (Færeyjar)', + }, + 'fr': { + nativeName: 'Français', + }, + 'fr-CA': { + nativeName: 'Français (Canada)', + }, + 'fr-FR': { + nativeName: 'Français (France)', + }, + 'fr-BE': { + nativeName: 'Français (Belgique)', + }, + 'fr-CH': { + nativeName: 'Français (Suisse)', + }, + 'fy-NL': { + nativeName: 'Frysk', + }, + 'ga': { + nativeName: 'Gaeilge', + }, + 'ga-IE': { + nativeName: 'Gaeilge', + }, + 'gd': { + nativeName: 'Gàidhlig', + }, + 'gl': { + nativeName: 'Galego', + }, + 'gl-ES': { + nativeName: 'Galego', + }, + 'gn-PY': { + nativeName: 'Avañe\'ẽ', + }, + 'gu-IN': { + nativeName: 'ગુજરાતી', + }, + 'gv': { + nativeName: 'Gaelg', + }, + 'gx-GR': { + nativeName: 'Ἑλληνική ἀρχαία', + }, + 'he': { + nativeName: 'עברית', + }, + 'he-IL': { + nativeName: 'עברית', + }, + 'hi': { + nativeName: 'हिन्दी', + }, + 'hi-IN': { + nativeName: 'हिन्दी', + }, + 'hr': { + nativeName: 'Hrvatski', + }, + 'hr-HR': { + nativeName: 'Hrvatski', + }, + 'hsb': { + nativeName: 'Hornjoserbšćina', + }, + 'ht': { + nativeName: 'Kreyòl', + }, + 'hu': { + nativeName: 'Magyar', + }, + 'hu-HU': { + nativeName: 'Magyar', + }, + 'hy': { + nativeName: 'Հայերեն', + }, + 'hy-AM': { + nativeName: 'Հայերեն (Հայաստան)', + }, + 'id': { + nativeName: 'Bahasa Indonesia', + }, + 'id-ID': { + nativeName: 'Bahasa Indonesia', + }, + 'is': { + nativeName: 'Íslenska', + }, + 'is-IS': { + nativeName: 'Íslenska (Iceland)', + }, + 'it': { + nativeName: 'Italiano', + }, + 'it-IT': { + nativeName: 'Italiano', + }, + 'ja': { + nativeName: '日本語', + }, + 'ja-JP': { + nativeName: '日本語 (日本)', + }, + 'jv-ID': { + nativeName: 'Basa Jawa', + }, + 'ka-GE': { + nativeName: 'ქართული', + }, + 'kk-KZ': { + nativeName: 'Қазақша', + }, + 'km': { + nativeName: 'ភាសាខ្មែរ', + }, + 'kl': { + nativeName: 'kalaallisut', + }, + 'km-KH': { + nativeName: 'ភាសាខ្មែរ', + }, + 'kab': { + nativeName: 'Taqbaylit', + }, + 'kn': { + nativeName: 'ಕನ್ನಡ', + }, + 'kn-IN': { + nativeName: 'ಕನ್ನಡ (India)', + }, + 'ko': { + nativeName: '한국어', + }, + 'ko-KR': { + nativeName: '한국어 (한국)', + }, + 'ku-TR': { + nativeName: 'Kurdî', + }, + 'kw': { + nativeName: 'Kernewek', + }, + 'la': { + nativeName: 'Latin', + }, + 'la-VA': { + nativeName: 'Latin', + }, + 'lb': { + nativeName: 'Lëtzebuergesch', + }, + 'li-NL': { + nativeName: 'Lèmbörgs', + }, + 'lt': { + nativeName: 'Lietuvių', + }, + 'lt-LT': { + nativeName: 'Lietuvių', + }, + 'lv': { + nativeName: 'Latviešu', + }, + 'lv-LV': { + nativeName: 'Latviešu', + }, + 'mai': { + nativeName: 'मैथिली, মৈথিলী', + }, + 'mg-MG': { + nativeName: 'Malagasy', + }, + 'mk': { + nativeName: 'Македонски', + }, + 'mk-MK': { + nativeName: 'Македонски (Македонски)', + }, + 'ml': { + nativeName: 'മലയാളം', + }, + 'ml-IN': { + nativeName: 'മലയാളം', + }, + 'mn-MN': { + nativeName: 'Монгол', + }, + 'mr': { + nativeName: 'मराठी', + }, + 'mr-IN': { + nativeName: 'मराठी', + }, + 'ms': { + nativeName: 'Bahasa Melayu', + }, + 'ms-MY': { + nativeName: 'Bahasa Melayu', + }, + 'mt': { + nativeName: 'Malti', + }, + 'mt-MT': { + nativeName: 'Malti', + }, + 'my': { + nativeName: 'ဗမာစကာ', + }, + 'no': { + nativeName: 'Norsk', + }, + 'nb': { + nativeName: 'Norsk (bokmål)', + }, + 'nb-NO': { + nativeName: 'Norsk (bokmål)', + }, + 'ne': { + nativeName: 'नेपाली', + }, + 'ne-NP': { + nativeName: 'नेपाली', + }, + 'nl': { + nativeName: 'Nederlands', + }, + 'nl-BE': { + nativeName: 'Nederlands (België)', + }, + 'nl-NL': { + nativeName: 'Nederlands (Nederland)', + }, + 'nn-NO': { + nativeName: 'Norsk (nynorsk)', + }, + 'oc': { + nativeName: 'Occitan', + }, + 'or-IN': { + nativeName: 'ଓଡ଼ିଆ', + }, + 'pa': { + nativeName: 'ਪੰਜਾਬੀ', + }, + 'pa-IN': { + nativeName: 'ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)', + }, + 'pl': { + nativeName: 'Polski', + }, + 'pl-PL': { + nativeName: 'Polski', + }, + 'ps-AF': { + nativeName: 'پښتو', + }, + 'pt': { + nativeName: 'Português', + }, + 'pt-BR': { + nativeName: 'Português (Brasil)', + }, + 'pt-PT': { + nativeName: 'Português (Portugal)', + }, + 'qu-PE': { + nativeName: 'Qhichwa', + }, + 'rm-CH': { + nativeName: 'Rumantsch', + }, + 'ro': { + nativeName: 'Română', + }, + 'ro-RO': { + nativeName: 'Română', + }, + 'ru': { + nativeName: 'Русский', + }, + 'ru-RU': { + nativeName: 'Русский', + }, + 'sa-IN': { + nativeName: 'संस्कृतम्', + }, + 'se-NO': { + nativeName: 'Davvisámegiella', + }, + 'sh': { + nativeName: 'српскохрватски', + }, + 'si-LK': { + nativeName: 'සිංහල', + }, + 'sk': { + nativeName: 'Slovenčina', + }, + 'sk-SK': { + nativeName: 'Slovenčina (Slovakia)', + }, + 'sl': { + nativeName: 'Slovenščina', + }, + 'sl-SI': { + nativeName: 'Slovenščina', + }, + 'so-SO': { + nativeName: 'Soomaaliga', + }, + 'sq': { + nativeName: 'Shqip', + }, + 'sq-AL': { + nativeName: 'Shqip', + }, + 'sr': { + nativeName: 'Српски', + }, + 'sr-RS': { + nativeName: 'Српски (Serbia)', + }, + 'su': { + nativeName: 'Basa Sunda', + }, + 'sv': { + nativeName: 'Svenska', + }, + 'sv-SE': { + nativeName: 'Svenska', + }, + 'sw': { + nativeName: 'Kiswahili', + }, + 'sw-KE': { + nativeName: 'Kiswahili', + }, + 'ta': { + nativeName: 'தமிழ்', + }, + 'ta-IN': { + nativeName: 'தமிழ்', + }, + 'te': { + nativeName: 'తెలుగు', + }, + 'te-IN': { + nativeName: 'తెలుగు', + }, + 'tg': { + nativeName: 'забо́ни тоҷикӣ́', + }, + 'tg-TJ': { + nativeName: 'тоҷикӣ', + }, + 'th': { + nativeName: 'ภาษาไทย', + }, + 'th-TH': { + nativeName: 'ภาษาไทย (ประเทศไทย)', + }, + 'fil': { + nativeName: 'Filipino', + }, + 'tlh': { + nativeName: 'tlhIngan-Hol', + }, + 'tr': { + nativeName: 'Türkçe', + }, + 'tr-TR': { + nativeName: 'Türkçe', + }, + 'tt-RU': { + nativeName: 'татарча', + }, + 'uk': { + nativeName: 'Українська', + }, + 'uk-UA': { + nativeName: 'Українська', + }, + 'ur': { + nativeName: 'اردو', + }, + 'ur-PK': { + nativeName: 'اردو', + }, + 'uz': { + nativeName: 'O\'zbek', + }, + 'uz-UZ': { + nativeName: 'O\'zbek', + }, + 'vi': { + nativeName: 'Tiếng Việt', + }, + 'vi-VN': { + nativeName: 'Tiếng Việt', + }, + 'xh-ZA': { + nativeName: 'isiXhosa', + }, + 'yi': { + nativeName: 'ייִדיש', + }, + 'yi-DE': { + nativeName: 'ייִדיש (German)', + }, + 'zh': { + nativeName: '中文', + }, + 'zh-Hans': { + nativeName: '中文简体', + }, + 'zh-Hant': { + nativeName: '中文繁體', + }, + 'zh-CN': { + nativeName: '中文(中国大陆)', + }, + 'zh-HK': { + nativeName: '中文(香港)', + }, + 'zh-SG': { + nativeName: '中文(新加坡)', + }, + 'zh-TW': { + nativeName: '中文(台灣)', + }, + 'zu-ZA': { + nativeName: 'isiZulu', + }, +}; diff --git a/packages/frontend/src/scripts/login-id.ts b/packages/frontend/src/scripts/login-id.ts new file mode 100644 index 0000000000..0f9c6be4a9 --- /dev/null +++ b/packages/frontend/src/scripts/login-id.ts @@ -0,0 +1,11 @@ +export function getUrlWithLoginId(url: string, loginId: string) { + const u = new URL(url, origin); + u.searchParams.append('loginId', loginId); + return u.toString(); +} + +export function getUrlWithoutLoginId(url: string) { + const u = new URL(url); + u.searchParams.delete('loginId'); + return u.toString(); +} diff --git a/packages/frontend/src/scripts/lookup-user.ts b/packages/frontend/src/scripts/lookup-user.ts new file mode 100644 index 0000000000..3ab9d55300 --- /dev/null +++ b/packages/frontend/src/scripts/lookup-user.ts @@ -0,0 +1,36 @@ +import * as Acct from 'misskey-js/built/acct'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +export async function lookupUser() { + const { canceled, result } = await os.inputText({ + title: i18n.ts.usernameOrUserId, + }); + if (canceled) return; + + const show = (user) => { + os.pageWindow(`/user-info/${user.id}`); + }; + + const usernamePromise = os.api('users/show', Acct.parse(result)); + const idPromise = os.api('users/show', { userId: result }); + let _notFound = false; + const notFound = () => { + if (_notFound) { + os.alert({ + type: 'error', + text: i18n.ts.noSuchUser, + }); + } else { + _notFound = true; + } + }; + usernamePromise.then(show).catch(err => { + if (err.code === 'NO_SUCH_USER') { + notFound(); + } + }); + idPromise.then(show).catch(err => { + notFound(); + }); +} diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts new file mode 100644 index 0000000000..aaf7f9e610 --- /dev/null +++ b/packages/frontend/src/scripts/media-proxy.ts @@ -0,0 +1,15 @@ +import { query } from '@/scripts/url'; +import { url } from '@/config'; + +export function getProxiedImageUrl(imageUrl: string, type?: 'preview'): string { + return `${url}/proxy/image.webp?${query({ + url: imageUrl, + fallback: '1', + ...(type ? { [type]: '1' } : {}), + })}`; +} + +export function getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null { + if (imageUrl == null) return null; + return getProxiedImageUrl(imageUrl, type); +} diff --git a/packages/frontend/src/scripts/mfm-tags.ts b/packages/frontend/src/scripts/mfm-tags.ts new file mode 100644 index 0000000000..18e8d7038a --- /dev/null +++ b/packages/frontend/src/scripts/mfm-tags.ts @@ -0,0 +1 @@ +export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle', 'rotate']; diff --git a/packages/frontend/src/scripts/page-metadata.ts b/packages/frontend/src/scripts/page-metadata.ts new file mode 100644 index 0000000000..0db8369f9d --- /dev/null +++ b/packages/frontend/src/scripts/page-metadata.ts @@ -0,0 +1,41 @@ +import * as misskey from 'misskey-js'; +import { ComputedRef, inject, isRef, onActivated, onMounted, provide, ref, Ref } from 'vue'; + +export const setPageMetadata = Symbol('setPageMetadata'); +export const pageMetadataProvider = Symbol('pageMetadataProvider'); + +export type PageMetadata = { + title: string; + subtitle?: string; + icon?: string | null; + avatar?: misskey.entities.User | null; + userName?: misskey.entities.User | null; + bg?: string; +}; + +export function definePageMetadata(metadata: PageMetadata | null | Ref<PageMetadata | null> | ComputedRef<PageMetadata | null>): void { + const _metadata = isRef(metadata) ? metadata : ref(metadata); + + provide(pageMetadataProvider, _metadata); + + const set = inject(setPageMetadata) as any; + if (set) { + set(_metadata); + + onMounted(() => { + set(_metadata); + }); + + onActivated(() => { + set(_metadata); + }); + } +} + +export function provideMetadataReceiver(callback: (info: ComputedRef<PageMetadata>) => void): void { + provide(setPageMetadata, callback); +} + +export function injectPageMetadata(): PageMetadata | undefined { + return inject(pageMetadataProvider); +} diff --git a/packages/frontend/src/scripts/physics.ts b/packages/frontend/src/scripts/physics.ts new file mode 100644 index 0000000000..efda80f074 --- /dev/null +++ b/packages/frontend/src/scripts/physics.ts @@ -0,0 +1,152 @@ +import * as Matter from 'matter-js'; + +export function physics(container: HTMLElement) { + const containerWidth = container.offsetWidth; + const containerHeight = container.offsetHeight; + const containerCenterX = containerWidth / 2; + + // サイズ固定化(要らないかも?) + container.style.position = 'relative'; + container.style.boxSizing = 'border-box'; + container.style.width = `${containerWidth}px`; + container.style.height = `${containerHeight}px`; + + // create engine + const engine = Matter.Engine.create({ + constraintIterations: 4, + positionIterations: 8, + velocityIterations: 8, + }); + + const world = engine.world; + + // create renderer + const render = Matter.Render.create({ + engine: engine, + //element: document.getElementById('debug'), + options: { + width: containerWidth, + height: containerHeight, + background: 'transparent', // transparent to hide + wireframeBackground: 'transparent', // transparent to hide + }, + }); + + // Disable to hide debug + Matter.Render.run(render); + + // create runner + const runner = Matter.Runner.create(); + Matter.Runner.run(runner, engine); + + const groundThickness = 1024; + const ground = Matter.Bodies.rectangle(containerCenterX, containerHeight + (groundThickness / 2), containerWidth, groundThickness, { + isStatic: true, + restitution: 0.1, + friction: 2, + }); + + //const wallRight = Matter.Bodies.rectangle(window.innerWidth+50, window.innerHeight/2, 100, window.innerHeight, wallopts); + //const wallLeft = Matter.Bodies.rectangle(-50, window.innerHeight/2, 100, window.innerHeight, wallopts); + + Matter.World.add(world, [ + ground, + //wallRight, + //wallLeft, + ]); + + const objEls = Array.from(container.children) as HTMLElement[]; + const objs: Matter.Body[] = []; + for (const objEl of objEls) { + const left = objEl.dataset.physicsX ? parseInt(objEl.dataset.physicsX) : objEl.offsetLeft; + const top = objEl.dataset.physicsY ? parseInt(objEl.dataset.physicsY) : objEl.offsetTop; + + let obj: Matter.Body; + if (objEl.classList.contains('_physics_circle_')) { + obj = Matter.Bodies.circle( + left + (objEl.offsetWidth / 2), + top + (objEl.offsetHeight / 2), + Math.max(objEl.offsetWidth, objEl.offsetHeight) / 2, + { + restitution: 0.5, + }, + ); + } else { + const style = window.getComputedStyle(objEl); + obj = Matter.Bodies.rectangle( + left + (objEl.offsetWidth / 2), + top + (objEl.offsetHeight / 2), + objEl.offsetWidth, + objEl.offsetHeight, + { + chamfer: { radius: parseInt(style.borderRadius || '0', 10) }, + restitution: 0.5, + }, + ); + } + objEl.id = obj.id.toString(); + objs.push(obj); + } + + Matter.World.add(engine.world, objs); + + // Add mouse control + + const mouse = Matter.Mouse.create(container); + const mouseConstraint = Matter.MouseConstraint.create(engine, { + mouse: mouse, + constraint: { + stiffness: 0.1, + render: { + visible: false, + }, + }, + }); + + Matter.World.add(engine.world, mouseConstraint); + + // keep the mouse in sync with rendering + render.mouse = mouse; + + for (const objEl of objEls) { + objEl.style.position = 'absolute'; + objEl.style.top = '0'; + objEl.style.left = '0'; + objEl.style.margin = '0'; + } + + window.requestAnimationFrame(update); + + let stop = false; + + function update() { + for (const objEl of objEls) { + const obj = objs.find(obj => obj.id.toString() === objEl.id.toString()); + if (obj == null) continue; + + const x = (obj.position.x - objEl.offsetWidth / 2); + const y = (obj.position.y - objEl.offsetHeight / 2); + const angle = obj.angle; + objEl.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`; + } + + if (!stop) { + window.requestAnimationFrame(update); + } + } + + // 奈落に落ちたオブジェクトは消す + const intervalId = window.setInterval(() => { + for (const obj of objs) { + if (obj.position.y > (containerHeight + 1024)) Matter.World.remove(world, obj); + } + }, 1000 * 10); + + return { + stop: () => { + stop = true; + Matter.Runner.stop(runner); + window.clearInterval(intervalId); + }, + }; +} diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts new file mode 100644 index 0000000000..b8fb853cc1 --- /dev/null +++ b/packages/frontend/src/scripts/please-login.ts @@ -0,0 +1,21 @@ +import { defineAsyncComponent } from 'vue'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { popup } from '@/os'; + +export function pleaseLogin(path?: string) { + if ($i) return; + + popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), { + autoSet: true, + message: i18n.ts.signinRequired, + }, { + cancelled: () => { + if (path) { + window.location.href = path; + } + }, + }, 'closed'); + + if (!path) throw new Error('signin required'); +} diff --git a/packages/frontend/src/scripts/popout.ts b/packages/frontend/src/scripts/popout.ts new file mode 100644 index 0000000000..580031d0a3 --- /dev/null +++ b/packages/frontend/src/scripts/popout.ts @@ -0,0 +1,23 @@ +import * as config from '@/config'; +import { appendQuery } from './url'; + +export function popout(path: string, w?: HTMLElement) { + let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path; + url = appendQuery(url, 'zen'); + if (w) { + const position = w.getBoundingClientRect(); + const width = parseInt(getComputedStyle(w, '').width, 10); + const height = parseInt(getComputedStyle(w, '').height, 10); + const x = window.screenX + position.left; + const y = window.screenY + position.top; + window.open(url, url, + `width=${width}, height=${height}, top=${y}, left=${x}`); + } else { + const width = 400; + const height = 500; + const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2); + const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2); + window.open(url, url, + `width=${width}, height=${height}, top=${x}, left=${y}`); + } +} diff --git a/packages/frontend/src/scripts/popup-position.ts b/packages/frontend/src/scripts/popup-position.ts new file mode 100644 index 0000000000..e84eebf103 --- /dev/null +++ b/packages/frontend/src/scripts/popup-position.ts @@ -0,0 +1,158 @@ +import { Ref } from 'vue'; + +export function calcPopupPosition(el: HTMLElement, props: { + anchorElement: HTMLElement | null; + innerMargin: number; + direction: 'top' | 'bottom' | 'left' | 'right'; + align: 'top' | 'bottom' | 'left' | 'right' | 'center'; + alignOffset?: number; + x?: number; + y?: number; +}): { top: number; left: number; transformOrigin: string; } { + const contentWidth = el.offsetWidth; + const contentHeight = el.offsetHeight; + + let rect: DOMRect; + + if (props.anchorElement) { + rect = props.anchorElement.getBoundingClientRect(); + } + + const calcPosWhenTop = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); + top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin; + } else { + left = props.x; + top = (props.y - contentHeight) - props.innerMargin; + } + + left -= (el.offsetWidth / 2); + + if (left + contentWidth - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - contentWidth + window.pageXOffset - 1; + } + + return [left, top]; + }; + + const calcPosWhenBottom = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); + top = (rect.top + window.pageYOffset + props.anchorElement.offsetHeight) + props.innerMargin; + } else { + left = props.x; + top = (props.y) + props.innerMargin; + } + + left -= (el.offsetWidth / 2); + + if (left + contentWidth - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - contentWidth + window.pageXOffset - 1; + } + + return [left, top]; + }; + + const calcPosWhenLeft = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin; + top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); + } else { + left = (props.x - contentWidth) - props.innerMargin; + top = props.y; + } + + top -= (el.offsetHeight / 2); + + if (top + contentHeight - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - contentHeight + window.pageYOffset - 1; + } + + return [left, top]; + }; + + const calcPosWhenRight = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = (rect.left + props.anchorElement.offsetWidth + window.pageXOffset) + props.innerMargin; + + if (props.align === 'top') { + top = rect.top + window.pageYOffset; + if (props.alignOffset != null) top += props.alignOffset; + } else if (props.align === 'bottom') { + // TODO + } else { // center + top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); + top -= (el.offsetHeight / 2); + } + } else { + left = props.x + props.innerMargin; + top = props.y; + top -= (el.offsetHeight / 2); + } + + if (top + contentHeight - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - contentHeight + window.pageYOffset - 1; + } + + return [left, top]; + }; + + const calc = (): { + left: number; + top: number; + transformOrigin: string; + } => { + switch (props.direction) { + case 'top': { + const [left, top] = calcPosWhenTop(); + + // ツールチップを上に向かって表示するスペースがなければ下に向かって出す + if (top - window.pageYOffset < 0) { + const [left, top] = calcPosWhenBottom(); + return { left, top, transformOrigin: 'center top' }; + } + + return { left, top, transformOrigin: 'center bottom' }; + } + + case 'bottom': { + const [left, top] = calcPosWhenBottom(); + // TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す + return { left, top, transformOrigin: 'center top' }; + } + + case 'left': { + const [left, top] = calcPosWhenLeft(); + + // ツールチップを左に向かって表示するスペースがなければ右に向かって出す + if (left - window.pageXOffset < 0) { + const [left, top] = calcPosWhenRight(); + return { left, top, transformOrigin: 'left center' }; + } + + return { left, top, transformOrigin: 'right center' }; + } + + case 'right': { + const [left, top] = calcPosWhenRight(); + // TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す + return { left, top, transformOrigin: 'left center' }; + } + } + }; + + return calc(); +} diff --git a/packages/frontend/src/scripts/reaction-picker.ts b/packages/frontend/src/scripts/reaction-picker.ts new file mode 100644 index 0000000000..fe32e719da --- /dev/null +++ b/packages/frontend/src/scripts/reaction-picker.ts @@ -0,0 +1,41 @@ +import { defineAsyncComponent, Ref, ref } from 'vue'; +import { popup } from '@/os'; + +class ReactionPicker { + private src: Ref<HTMLElement | null> = ref(null); + private manualShowing = ref(false); + private onChosen?: (reaction: string) => void; + private onClosed?: () => void; + + constructor() { + // nop + } + + public async init() { + await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { + src: this.src, + asReactionPicker: true, + manualShowing: this.manualShowing, + }, { + done: reaction => { + this.onChosen!(reaction); + }, + close: () => { + this.manualShowing.value = false; + }, + closed: () => { + this.src.value = null; + this.onClosed!(); + }, + }); + } + + public show(src: HTMLElement, onChosen: ReactionPicker['onChosen'], onClosed: ReactionPicker['onClosed']) { + this.src.value = src; + this.manualShowing.value = true; + this.onChosen = onChosen; + this.onClosed = onClosed; + } +} + +export const reactionPicker = new ReactionPicker(); diff --git a/packages/frontend/src/scripts/safe-uri-decode.ts b/packages/frontend/src/scripts/safe-uri-decode.ts new file mode 100644 index 0000000000..301b56d7fd --- /dev/null +++ b/packages/frontend/src/scripts/safe-uri-decode.ts @@ -0,0 +1,7 @@ +export function safeURIDecode(str: string): string { + try { + return decodeURIComponent(str); + } catch { + return str; + } +} diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend/src/scripts/scroll.ts new file mode 100644 index 0000000000..f5bc6bf9ce --- /dev/null +++ b/packages/frontend/src/scripts/scroll.ts @@ -0,0 +1,85 @@ +type ScrollBehavior = 'auto' | 'smooth' | 'instant'; + +export function getScrollContainer(el: HTMLElement | null): HTMLElement | null { + if (el == null || el.tagName === 'HTML') return null; + const overflow = window.getComputedStyle(el).getPropertyValue('overflow-y'); + if (overflow === 'scroll' || overflow === 'auto') { + return el; + } else { + return getScrollContainer(el.parentElement); + } +} + +export function getScrollPosition(el: Element | null): number { + const container = getScrollContainer(el); + return container == null ? window.scrollY : container.scrollTop; +} + +export function isTopVisible(el: Element | null): boolean { + const scrollTop = getScrollPosition(el); + const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる + + return scrollTop <= topPosition; +} + +export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) { + if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance; + return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance; +} + +export function onScrollTop(el: Element, cb) { + const container = getScrollContainer(el) || window; + const onScroll = ev => { + if (!document.body.contains(el)) return; + if (isTopVisible(el)) { + cb(); + container.removeEventListener('scroll', onScroll); + } + }; + container.addEventListener('scroll', onScroll, { passive: true }); +} + +export function onScrollBottom(el: Element, cb) { + const container = getScrollContainer(el) || window; + const onScroll = ev => { + if (!document.body.contains(el)) return; + const pos = getScrollPosition(el); + if (pos + el.clientHeight > el.scrollHeight - 1) { + cb(); + container.removeEventListener('scroll', onScroll); + } + }; + container.addEventListener('scroll', onScroll, { passive: true }); +} + +export function scroll(el: Element, options: { + top?: number; + left?: number; + behavior?: ScrollBehavior; +}) { + const container = getScrollContainer(el); + if (container == null) { + window.scroll(options); + } else { + container.scroll(options); + } +} + +export function scrollToTop(el: Element, options: { behavior?: ScrollBehavior; } = {}) { + scroll(el, { top: 0, ...options }); +} + +export function scrollToBottom(el: Element, options: { behavior?: ScrollBehavior; } = {}) { + scroll(el, { top: 99999, ...options }); // TODO: ちゃんと計算する +} + +export function isBottom(el: Element, asobi = 0) { + const container = getScrollContainer(el); + const current = container + ? el.scrollTop + el.offsetHeight + : window.scrollY + window.innerHeight; + const max = container + ? el.scrollHeight + : document.body.offsetHeight; + return current >= (max - asobi); +} diff --git a/packages/frontend/src/scripts/search.ts b/packages/frontend/src/scripts/search.ts new file mode 100644 index 0000000000..64914d3d65 --- /dev/null +++ b/packages/frontend/src/scripts/search.ts @@ -0,0 +1,63 @@ +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { mainRouter } from '@/router'; + +export async function search() { + const { canceled, result: query } = await os.inputText({ + title: i18n.ts.search, + }); + if (canceled || query == null || query === '') return; + + const q = query.trim(); + + if (q.startsWith('@') && !q.includes(' ')) { + mainRouter.push(`/${q}`); + return; + } + + if (q.startsWith('#')) { + mainRouter.push(`/tags/${encodeURIComponent(q.substr(1))}`); + return; + } + + // like 2018/03/12 + if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, '/'))) { + const date = new Date(q.replace(/-/g, '/')); + + // 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは + // 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので + // 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の + // 結果になってしまい、2018/03/12 のコンテンツは含まれない) + if (q.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) { + date.setHours(23, 59, 59, 999); + } + + // TODO + //v.$root.$emit('warp', date); + os.alert({ + icon: 'fas fa-history', + iconOnly: true, autoClose: true, + }); + return; + } + + if (q.startsWith('https://')) { + const promise = os.api('ap/show', { + uri: q, + }); + + os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + + const res = await promise; + + if (res.type === 'User') { + mainRouter.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type === 'Note') { + mainRouter.push(`/notes/${res.object.id}`); + } + + return; + } + + mainRouter.push(`/search?q=${encodeURIComponent(q)}`); +} diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts new file mode 100644 index 0000000000..ec5f8f65e9 --- /dev/null +++ b/packages/frontend/src/scripts/select-file.ts @@ -0,0 +1,103 @@ +import { ref } from 'vue'; +import { DriveFile } from 'misskey-js/built/entities'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; +import { uploadFile } from '@/scripts/upload'; + +function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> { + return new Promise((res, rej) => { + const keepOriginal = ref(defaultStore.state.keepOriginalUploading); + + const chooseFileFromPc = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = multiple; + input.onchange = () => { + const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value)); + + Promise.all(promises).then(driveFiles => { + res(multiple ? driveFiles : driveFiles[0]); + }).catch(err => { + // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない + }); + + // 一応廃棄 + (window as any).__misskey_input_ref__ = null; + }; + + // https://qiita.com/fukasawah/items/b9dc732d95d99551013d + // iOS Safari で正常に動かす為のおまじない + (window as any).__misskey_input_ref__ = input; + + input.click(); + }; + + const chooseFileFromDrive = () => { + os.selectDriveFile(multiple).then(files => { + res(files); + }); + }; + + const chooseFileFromUrl = () => { + os.inputText({ + title: i18n.ts.uploadFromUrl, + type: 'url', + placeholder: i18n.ts.uploadFromUrlDescription, + }).then(({ canceled, result: url }) => { + if (canceled) return; + + const marker = Math.random().toString(); // TODO: UUIDとか使う + + const connection = stream.useChannel('main'); + connection.on('urlUploadFinished', urlResponse => { + if (urlResponse.marker === marker) { + res(multiple ? [urlResponse.file] : urlResponse.file); + connection.dispose(); + } + }); + + os.api('drive/files/upload-from-url', { + url: url, + folderId: defaultStore.state.uploadFolder, + marker, + }); + + os.alert({ + title: i18n.ts.uploadFromUrlRequested, + text: i18n.ts.uploadFromUrlMayTakeTime, + }); + }); + }; + + os.popupMenu([label ? { + text: label, + type: 'label', + } : undefined, { + type: 'switch', + text: i18n.ts.keepOriginalUploading, + ref: keepOriginal, + }, { + text: i18n.ts.upload, + icon: 'ti ti-upload', + action: chooseFileFromPc, + }, { + text: i18n.ts.fromDrive, + icon: 'ti ti-cloud', + action: chooseFileFromDrive, + }, { + text: i18n.ts.fromUrl, + icon: 'ti ti-link', + action: chooseFileFromUrl, + }], src); + }); +} + +export function selectFile(src: any, label: string | null = null): Promise<DriveFile> { + return select(src, label, false) as Promise<DriveFile>; +} + +export function selectFiles(src: any, label: string | null = null): Promise<DriveFile[]> { + return select(src, label, true) as Promise<DriveFile[]>; +} diff --git a/packages/frontend/src/scripts/show-suspended-dialog.ts b/packages/frontend/src/scripts/show-suspended-dialog.ts new file mode 100644 index 0000000000..e11569ecd4 --- /dev/null +++ b/packages/frontend/src/scripts/show-suspended-dialog.ts @@ -0,0 +1,10 @@ +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +export function showSuspendedDialog() { + return os.alert({ + type: 'error', + title: i18n.ts.yourAccountSuspendedTitle, + text: i18n.ts.yourAccountSuspendedDescription, + }); +} diff --git a/packages/frontend/src/scripts/shuffle.ts b/packages/frontend/src/scripts/shuffle.ts new file mode 100644 index 0000000000..05e6cdfbcf --- /dev/null +++ b/packages/frontend/src/scripts/shuffle.ts @@ -0,0 +1,19 @@ +/** + * 配列をシャッフル (破壊的) + */ +export function shuffle<T extends any[]>(array: T): T { + let currentIndex = array.length, randomIndex; + + // While there remain elements to shuffle. + while (currentIndex !== 0) { + // Pick a remaining element. + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + + // And swap it with the current element. + [array[currentIndex], array[randomIndex]] = [ + array[randomIndex], array[currentIndex]]; + } + + return array; +} diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts new file mode 100644 index 0000000000..9d1f603235 --- /dev/null +++ b/packages/frontend/src/scripts/sound.ts @@ -0,0 +1,66 @@ +import { ColdDeviceStorage } from '@/store'; + +const cache = new Map<string, HTMLAudioElement>(); + +export const soundsTypes = [ + null, + 'syuilo/up', + 'syuilo/down', + 'syuilo/pope1', + 'syuilo/pope2', + 'syuilo/waon', + 'syuilo/popo', + 'syuilo/triple', + 'syuilo/poi1', + 'syuilo/poi2', + 'syuilo/pirori', + 'syuilo/pirori-wet', + 'syuilo/pirori-square-wet', + 'syuilo/square-pico', + 'syuilo/reverved', + 'syuilo/ryukyu', + 'syuilo/kick', + 'syuilo/snare', + 'syuilo/queue-jammed', + 'aisha/1', + 'aisha/2', + 'aisha/3', + 'noizenecio/kick_gaba1', + 'noizenecio/kick_gaba2', + 'noizenecio/kick_gaba3', + 'noizenecio/kick_gaba4', + 'noizenecio/kick_gaba5', + 'noizenecio/kick_gaba6', + 'noizenecio/kick_gaba7', +] as const; + +export function getAudio(file: string, useCache = true): HTMLAudioElement { + let audio: HTMLAudioElement; + if (useCache && cache.has(file)) { + audio = cache.get(file); + } else { + audio = new Audio(`/client-assets/sounds/${file}.mp3`); + if (useCache) cache.set(file, audio); + } + return audio; +} + +export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioElement { + const masterVolume = ColdDeviceStorage.get('sound_masterVolume'); + audio.volume = masterVolume - ((1 - volume) * masterVolume); + return audio; +} + +export function play(type: string) { + const sound = ColdDeviceStorage.get('sound_' + type as any); + if (sound.type == null) return; + playFile(sound.type, sound.volume); +} + +export function playFile(file: string, volume: number) { + const masterVolume = ColdDeviceStorage.get('sound_masterVolume'); + if (masterVolume === 0) return; + + const audio = setVolume(getAudio(file), volume); + audio.play(); +} diff --git a/packages/frontend/src/scripts/sticky-sidebar.ts b/packages/frontend/src/scripts/sticky-sidebar.ts new file mode 100644 index 0000000000..c67b8f37ac --- /dev/null +++ b/packages/frontend/src/scripts/sticky-sidebar.ts @@ -0,0 +1,50 @@ +export class StickySidebar { + private lastScrollTop = 0; + private container: HTMLElement; + private el: HTMLElement; + private spacer: HTMLElement; + private marginTop: number; + private isTop = false; + private isBottom = false; + private offsetTop: number; + private globalHeaderHeight: number = 59; + + constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) { + this.container = container; + this.el = this.container.children[0] as HTMLElement; + this.el.style.position = 'sticky'; + this.spacer = document.createElement('div'); + this.container.prepend(this.spacer); + this.marginTop = marginTop; + this.offsetTop = this.container.getBoundingClientRect().top; + this.globalHeaderHeight = globalHeaderHeight; + } + + public calc(scrollTop: number) { + if (scrollTop > this.lastScrollTop) { // downscroll + const overflow = Math.max(0, this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight); + this.el.style.bottom = null; + this.el.style.top = `${-overflow + this.marginTop + this.globalHeaderHeight}px`; + + this.isBottom = (scrollTop + window.innerHeight) >= (this.el.offsetTop + this.el.clientHeight); + + if (this.isTop) { + this.isTop = false; + this.spacer.style.marginTop = `${Math.max(0, this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop)}px`; + } + } else { // upscroll + const overflow = this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight; + this.el.style.top = null; + this.el.style.bottom = `${-overflow}px`; + + this.isTop = scrollTop + this.marginTop + this.globalHeaderHeight <= this.el.offsetTop; + + if (this.isBottom) { + this.isBottom = false; + this.spacer.style.marginTop = `${this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop - overflow}px`; + } + } + + this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop; + } +} diff --git a/packages/frontend/src/scripts/theme-editor.ts b/packages/frontend/src/scripts/theme-editor.ts new file mode 100644 index 0000000000..944875ff15 --- /dev/null +++ b/packages/frontend/src/scripts/theme-editor.ts @@ -0,0 +1,81 @@ +import { v4 as uuid } from 'uuid'; + +import { themeProps, Theme } from './theme'; + +export type Default = null; +export type Color = string; +export type FuncName = 'alpha' | 'darken' | 'lighten'; +export type Func = { type: 'func'; name: FuncName; arg: number; value: string; }; +export type RefProp = { type: 'refProp'; key: string; }; +export type RefConst = { type: 'refConst'; key: string; }; +export type Css = { type: 'css'; value: string; }; + +export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default; + +export type ThemeViewModel = [ string, ThemeValue ][]; + +export const fromThemeString = (str?: string) : ThemeValue => { + if (!str) return null; + if (str.startsWith(':')) { + const parts = str.slice(1).split('<'); + const name = parts[0] as FuncName; + const arg = parseFloat(parts[1]); + const value = parts[2].startsWith('@') ? parts[2].slice(1) : ''; + return { type: 'func', name, arg, value }; + } else if (str.startsWith('@')) { + return { + type: 'refProp', + key: str.slice(1), + }; + } else if (str.startsWith('$')) { + return { + type: 'refConst', + key: str.slice(1), + }; + } else if (str.startsWith('"')) { + return { + type: 'css', + value: str.substr(1).trim(), + }; + } else { + return str; + } +}; + +export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => { + if (typeof value === 'string') return value; + switch (value.type) { + case 'func': return `:${value.name}<${value.arg}<@${value.value}`; + case 'refProp': return `@${value.key}`; + case 'refConst': return `$${value.key}`; + case 'css': return `" ${value.value}`; + } +}; + +export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => { + const props = { } as { [key: string]: string }; + for (const [key, value] of vm) { + if (value === null) continue; + props[key] = toThemeString(value); + } + + return { + id: uuid(), + name, desc, author, props, base, + }; +}; + +export const convertToViewModel = (theme: Theme): ThemeViewModel => { + const vm: ThemeViewModel = []; + // プロパティの登録 + vm.push(...themeProps.map(key => [key, fromThemeString(theme.props[key])] as [ string, ThemeValue ])); + + // 定数の登録 + const consts = Object + .keys(theme.props) + .filter(k => k.startsWith('$')) + .map(k => [k, fromThemeString(theme.props[k])] as [ string, ThemeValue ]); + + vm.push(...consts); + return vm; +}; diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts new file mode 100644 index 0000000000..62a2b9459a --- /dev/null +++ b/packages/frontend/src/scripts/theme.ts @@ -0,0 +1,148 @@ +import { ref } from 'vue'; +import tinycolor from 'tinycolor2'; +import { globalEvents } from '@/events'; + +export type Theme = { + id: string; + name: string; + author: string; + desc?: string; + base?: 'dark' | 'light'; + props: Record<string, string>; +}; + +import lightTheme from '@/themes/_light.json5'; +import darkTheme from '@/themes/_dark.json5'; +import { deepClone } from './clone'; + +export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X')); + +export const getBuiltinThemes = () => Promise.all( + [ + 'l-light', + 'l-coffee', + 'l-apricot', + 'l-rainy', + 'l-vivid', + 'l-cherry', + 'l-sushi', + 'l-u0', + + 'd-dark', + 'd-persimmon', + 'd-astro', + 'd-future', + 'd-botanical', + 'd-green-lime', + 'd-green-orange', + 'd-cherry', + 'd-ice', + 'd-u0', + ].map(name => import(`../themes/${name}.json5`).then(({ default: _default }): Theme => _default)), +); + +export const getBuiltinThemesRef = () => { + const builtinThemes = ref<Theme[]>([]); + getBuiltinThemes().then(themes => builtinThemes.value = themes); + return builtinThemes; +}; + +let timeout = null; + +export function applyTheme(theme: Theme, persist = true) { + if (timeout) window.clearTimeout(timeout); + + document.documentElement.classList.add('_themeChanging_'); + + timeout = window.setTimeout(() => { + document.documentElement.classList.remove('_themeChanging_'); + }, 1000); + + const colorSchema = theme.base === 'dark' ? 'dark' : 'light'; + + // Deep copy + const _theme = deepClone(theme); + + if (_theme.base) { + const base = [lightTheme, darkTheme].find(x => x.id === _theme.base); + if (base) _theme.props = Object.assign({}, base.props, _theme.props); + } + + const props = compile(_theme); + + for (const tag of document.head.children) { + if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { + tag.setAttribute('content', props['htmlThemeColor']); + break; + } + } + + for (const [k, v] of Object.entries(props)) { + document.documentElement.style.setProperty(`--${k}`, v.toString()); + } + + document.documentElement.style.setProperty('color-schema', colorSchema); + + if (persist) { + localStorage.setItem('theme', JSON.stringify(props)); + localStorage.setItem('colorSchema', colorSchema); + } + + // 色計算など再度行えるようにクライアント全体に通知 + globalEvents.emit('themeChanged'); +} + +function compile(theme: Theme): Record<string, string> { + function getColor(val: string): tinycolor.Instance { + // ref (prop) + if (val[0] === '@') { + return getColor(theme.props[val.substr(1)]); + } + + // ref (const) + else if (val[0] === '$') { + return getColor(theme.props[val]); + } + + // func + else if (val[0] === ':') { + const parts = val.split('<'); + const func = parts.shift().substr(1); + const arg = parseFloat(parts.shift()); + const color = getColor(parts.join('<')); + + switch (func) { + case 'darken': return color.darken(arg); + case 'lighten': return color.lighten(arg); + case 'alpha': return color.setAlpha(arg); + case 'hue': return color.spin(arg); + case 'saturate': return color.saturate(arg); + } + } + + // other case + return tinycolor(val); + } + + const props = {}; + + for (const [k, v] of Object.entries(theme.props)) { + if (k.startsWith('$')) continue; // ignore const + + props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v)); + } + + return props; +} + +function genValue(c: tinycolor.Instance): string { + return c.toRgbString(); +} + +export function validateTheme(theme: Record<string, any>): boolean { + if (theme.id == null || typeof theme.id !== 'string') return false; + if (theme.name == null || typeof theme.name !== 'string') return false; + if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false; + if (theme.props == null || typeof theme.props !== 'object') return false; + return true; +} diff --git a/packages/frontend/src/scripts/time.ts b/packages/frontend/src/scripts/time.ts new file mode 100644 index 0000000000..34e8b6b17c --- /dev/null +++ b/packages/frontend/src/scripts/time.ts @@ -0,0 +1,39 @@ +const dateTimeIntervals = { + 'day': 86400000, + 'hour': 3600000, + 'ms': 1, +}; + +export function dateUTC(time: number[]): Date { + const d = time.length === 2 ? Date.UTC(time[0], time[1]) + : time.length === 3 ? Date.UTC(time[0], time[1], time[2]) + : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3]) + : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4]) + : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5]) + : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6]) + : null; + + if (!d) throw 'wrong number of arguments'; + + return new Date(d); +} + +export function isTimeSame(a: Date, b: Date): boolean { + return a.getTime() === b.getTime(); +} + +export function isTimeBefore(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) < 0; +} + +export function isTimeAfter(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) > 0; +} + +export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() + (value * dateTimeIntervals[span])); +} + +export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() - (value * dateTimeIntervals[span])); +} diff --git a/packages/frontend/src/scripts/timezones.ts b/packages/frontend/src/scripts/timezones.ts new file mode 100644 index 0000000000..8ce07323f6 --- /dev/null +++ b/packages/frontend/src/scripts/timezones.ts @@ -0,0 +1,49 @@ +export const timezones = [{ + name: 'UTC', + abbrev: 'UTC', + offset: 0, +}, { + name: 'Europe/Berlin', + abbrev: 'CET', + offset: 60, +}, { + name: 'Asia/Tokyo', + abbrev: 'JST', + offset: 540, +}, { + name: 'Asia/Seoul', + abbrev: 'KST', + offset: 540, +}, { + name: 'Asia/Shanghai', + abbrev: 'CST', + offset: 480, +}, { + name: 'Australia/Sydney', + abbrev: 'AEST', + offset: 600, +}, { + name: 'Australia/Darwin', + abbrev: 'ACST', + offset: 570, +}, { + name: 'Australia/Perth', + abbrev: 'AWST', + offset: 480, +}, { + name: 'America/New_York', + abbrev: 'EST', + offset: -300, +}, { + name: 'America/Mexico_City', + abbrev: 'CST', + offset: -360, +}, { + name: 'America/Phoenix', + abbrev: 'MST', + offset: -420, +}, { + name: 'America/Los_Angeles', + abbrev: 'PST', + offset: -480, +}]; diff --git a/packages/frontend/src/scripts/touch.ts b/packages/frontend/src/scripts/touch.ts new file mode 100644 index 0000000000..5251bc2e27 --- /dev/null +++ b/packages/frontend/src/scripts/touch.ts @@ -0,0 +1,23 @@ +const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0; + +export let isTouchUsing = false; + +export let isScreenTouching = false; + +if (isTouchSupported) { + window.addEventListener('touchstart', () => { + // maxTouchPointsなどでの判定だけだと、「タッチ機能付きディスプレイを使っているがマウスでしか操作しない」場合にも + // タッチで使っていると判定されてしまうため、実際に一度でもタッチされたらtrueにする + isTouchUsing = true; + + isScreenTouching = true; + }, { passive: true }); + + window.addEventListener('touchend', () => { + // 子要素のtouchstartイベントでstopPropagation()が呼ばれると親要素に伝搬されずタッチされたと判定されないため、 + // touchendイベントでもtouchstartイベントと同様にtrueにする + isTouchUsing = true; + + isScreenTouching = false; + }, { passive: true }); +} diff --git a/packages/frontend/src/scripts/unison-reload.ts b/packages/frontend/src/scripts/unison-reload.ts new file mode 100644 index 0000000000..59af584c1b --- /dev/null +++ b/packages/frontend/src/scripts/unison-reload.ts @@ -0,0 +1,15 @@ +// SafariがBroadcastChannel未実装なのでライブラリを使う +import { BroadcastChannel } from 'broadcast-channel'; + +export const reloadChannel = new BroadcastChannel<string | null>('reload'); + +// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。 +export function unisonReload(path?: string) { + if (path !== undefined) { + reloadChannel.postMessage(path); + location.href = path; + } else { + reloadChannel.postMessage(null); + location.reload(); + } +} diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts new file mode 100644 index 0000000000..9a39652ef5 --- /dev/null +++ b/packages/frontend/src/scripts/upload.ts @@ -0,0 +1,137 @@ +import { reactive, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { readAndCompressImage } from 'browser-image-resizer'; +import { getCompressionConfig } from './upload/compress-config'; +import { defaultStore } from '@/store'; +import { apiUrl } from '@/config'; +import { $i } from '@/account'; +import { alert } from '@/os'; +import { i18n } from '@/i18n'; + +type Uploading = { + id: string; + name: string; + progressMax: number | undefined; + progressValue: number | undefined; + img: string; +}; +export const uploads = ref<Uploading[]>([]); + +const mimeTypeMap = { + 'image/webp': 'webp', + 'image/jpeg': 'jpg', + 'image/png': 'png', +} as const; + +export function uploadFile( + file: File, + folder?: any, + name?: string, + keepOriginal: boolean = defaultStore.state.keepOriginalUploading, +): Promise<Misskey.entities.DriveFile> { + if ($i == null) throw new Error('Not logged in'); + + if (folder && typeof folder === 'object') folder = folder.id; + + return new Promise((resolve, reject) => { + const id = Math.random().toString(); + + const reader = new FileReader(); + reader.onload = async (): Promise<void> => { + const ctx = reactive<Uploading>({ + id: id, + name: name ?? file.name ?? 'untitled', + progressMax: undefined, + progressValue: undefined, + img: window.URL.createObjectURL(file), + }); + + uploads.value.push(ctx); + + const config = !keepOriginal ? await getCompressionConfig(file) : undefined; + let resizedImage: Blob | undefined; + if (config) { + try { + const resized = await readAndCompressImage(file, config); + if (resized.size < file.size || file.type === 'image/webp') { + // The compression may not always reduce the file size + // (and WebP is not browser safe yet) + resizedImage = resized; + } + if (_DEV_) { + const saved = ((1 - resized.size / file.size) * 100).toFixed(2); + console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`); + } + + ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name; + } catch (err) { + console.error('Failed to resize image', err); + } + } + + const formData = new FormData(); + formData.append('i', $i.token); + formData.append('force', 'true'); + formData.append('file', resizedImage ?? file); + formData.append('name', ctx.name); + if (folder) formData.append('folderId', folder); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => { + if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { + // TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい + uploads.value = uploads.value.filter(x => x.id !== id); + + if (ev.target?.response) { + const res = JSON.parse(ev.target.response); + if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseInappropriate, + }); + } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseNoFreeSpace, + }); + } else { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, + }); + } + } else { + alert({ + type: 'error', + title: 'Failed to upload', + text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, + }); + } + + reject(); + return; + } + + const driveFile = JSON.parse(ev.target.response); + + resolve(driveFile); + + uploads.value = uploads.value.filter(x => x.id !== id); + }) as (ev: ProgressEvent<EventTarget>) => any; + + xhr.upload.onprogress = ev => { + if (ev.lengthComputable) { + ctx.progressMax = ev.total; + ctx.progressValue = ev.loaded; + } + }; + + xhr.send(formData); + }; + reader.readAsArrayBuffer(file); + }); +} diff --git a/packages/frontend/src/scripts/upload/compress-config.ts b/packages/frontend/src/scripts/upload/compress-config.ts new file mode 100644 index 0000000000..793c78ad20 --- /dev/null +++ b/packages/frontend/src/scripts/upload/compress-config.ts @@ -0,0 +1,23 @@ +import isAnimated from 'is-file-animated'; +import type { BrowserImageResizerConfig } from 'browser-image-resizer'; + +const compressTypeMap = { + 'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' }, + 'image/png': { quality: 1, mimeType: 'image/png' }, + 'image/webp': { quality: 0.85, mimeType: 'image/jpeg' }, + 'image/svg+xml': { quality: 1, mimeType: 'image/png' }, +} as const; + +export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfig | undefined> { + const imgConfig = compressTypeMap[file.type]; + if (!imgConfig || await isAnimated(file)) { + return; + } + + return { + maxWidth: 2048, + maxHeight: 2048, + debug: true, + ...imgConfig, + }; +} diff --git a/packages/frontend/src/scripts/url.ts b/packages/frontend/src/scripts/url.ts new file mode 100644 index 0000000000..86735de9f0 --- /dev/null +++ b/packages/frontend/src/scripts/url.ts @@ -0,0 +1,13 @@ +export function query(obj: Record<string, any>): string { + const params = Object.entries(obj) + .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) + .reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>); + + return Object.entries(params) + .map((p) => `${p[0]}=${encodeURIComponent(p[1])}`) + .join('&'); +} + +export function appendQuery(url: string, query: string): string { + return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`; +} diff --git a/packages/frontend/src/scripts/use-chart-tooltip.ts b/packages/frontend/src/scripts/use-chart-tooltip.ts new file mode 100644 index 0000000000..881e5e9ad5 --- /dev/null +++ b/packages/frontend/src/scripts/use-chart-tooltip.ts @@ -0,0 +1,54 @@ +import { onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import MkChartTooltip from '@/components/MkChartTooltip.vue'; + +export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) { + const tooltipShowing = ref(false); + const tooltipX = ref(0); + const tooltipY = ref(0); + const tooltipTitle = ref(null); + const tooltipSeries = ref(null); + let disposeTooltipComponent; + + os.popup(MkChartTooltip, { + showing: tooltipShowing, + x: tooltipX, + y: tooltipY, + title: tooltipTitle, + series: tooltipSeries, + }, {}).then(({ dispose }) => { + disposeTooltipComponent = dispose; + }); + + onUnmounted(() => { + if (disposeTooltipComponent) disposeTooltipComponent(); + }); + + function handler(context) { + if (context.tooltip.opacity === 0) { + tooltipShowing.value = false; + return; + } + + tooltipTitle.value = context.tooltip.title[0]; + tooltipSeries.value = context.tooltip.body.map((b, i) => ({ + backgroundColor: context.tooltip.labelColors[i].backgroundColor, + borderColor: context.tooltip.labelColors[i].borderColor, + text: b.lines[0], + })); + + const rect = context.chart.canvas.getBoundingClientRect(); + + tooltipShowing.value = true; + tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX; + if (opts.position === 'top') { + tooltipY.value = rect.top + window.pageYOffset; + } else if (opts.position === 'middle') { + tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY; + } + } + + return { + handler, + }; +} diff --git a/packages/frontend/src/scripts/use-interval.ts b/packages/frontend/src/scripts/use-interval.ts new file mode 100644 index 0000000000..201ba417ef --- /dev/null +++ b/packages/frontend/src/scripts/use-interval.ts @@ -0,0 +1,24 @@ +import { onMounted, onUnmounted } from 'vue'; + +export function useInterval(fn: () => void, interval: number, options: { + immediate: boolean; + afterMounted: boolean; +}): void { + if (Number.isNaN(interval)) return; + + let intervalId: number | null = null; + + if (options.afterMounted) { + onMounted(() => { + if (options.immediate) fn(); + intervalId = window.setInterval(fn, interval); + }); + } else { + if (options.immediate) fn(); + intervalId = window.setInterval(fn, interval); + } + + onUnmounted(() => { + if (intervalId) window.clearInterval(intervalId); + }); +} diff --git a/packages/frontend/src/scripts/use-leave-guard.ts b/packages/frontend/src/scripts/use-leave-guard.ts new file mode 100644 index 0000000000..a93b84d1fe --- /dev/null +++ b/packages/frontend/src/scripts/use-leave-guard.ts @@ -0,0 +1,47 @@ +import { inject, onUnmounted, Ref } from 'vue'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +export function useLeaveGuard(enabled: Ref<boolean>) { + /* TODO + const setLeaveGuard = inject('setLeaveGuard'); + + if (setLeaveGuard) { + setLeaveGuard(async () => { + if (!enabled.value) return false; + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.leaveConfirm, + }); + + return canceled; + }); + } else { + onBeforeRouteLeave(async (to, from) => { + if (!enabled.value) return true; + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.leaveConfirm, + }); + + return !canceled; + }); + } + */ + + /* + function onBeforeLeave(ev: BeforeUnloadEvent) { + if (enabled.value) { + ev.preventDefault(); + ev.returnValue = ''; + } + } + + window.addEventListener('beforeunload', onBeforeLeave); + onUnmounted(() => { + window.removeEventListener('beforeunload', onBeforeLeave); + }); + */ +} diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts new file mode 100644 index 0000000000..e6bdb345c4 --- /dev/null +++ b/packages/frontend/src/scripts/use-note-capture.ts @@ -0,0 +1,110 @@ +import { onUnmounted, Ref } from 'vue'; +import * as misskey from 'misskey-js'; +import { stream } from '@/stream'; +import { $i } from '@/account'; + +export function useNoteCapture(props: { + rootEl: Ref<HTMLElement>; + note: Ref<misskey.entities.Note>; + isDeletedRef: Ref<boolean>; +}) { + const note = props.note; + const connection = $i ? stream : null; + + function onStreamNoteUpdated(noteData): void { + const { type, id, body } = noteData; + + if (id !== note.value.id) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + + if (body.emoji) { + const emojis = note.value.emojis || []; + if (!emojis.includes(body.emoji)) { + note.value.emojis = [...emojis, body.emoji]; + } + } + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (note.value.reactions || {})[reaction] || 0; + + note.value.reactions[reaction] = currentCount + 1; + + if ($i && (body.userId === $i.id)) { + note.value.myReaction = reaction; + } + break; + } + + case 'unreacted': { + const reaction = body.reaction; + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (note.value.reactions || {})[reaction] || 0; + + note.value.reactions[reaction] = Math.max(0, currentCount - 1); + + if ($i && (body.userId === $i.id)) { + note.value.myReaction = null; + } + break; + } + + case 'pollVoted': { + const choice = body.choice; + + const choices = [...note.value.poll.choices]; + choices[choice] = { + ...choices[choice], + votes: choices[choice].votes + 1, + ...($i && (body.userId === $i.id) ? { + isVoted: true, + } : {}), + }; + + note.value.poll.choices = choices; + break; + } + + case 'deleted': { + props.isDeletedRef.value = true; + break; + } + } + } + + function capture(withHandler = false): void { + if (connection) { + // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する + connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: note.value.id }); + if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); + } + } + + function decapture(withHandler = false): void { + if (connection) { + connection.send('un', { + id: note.value.id, + }); + if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated); + } + } + + function onStreamConnected() { + capture(false); + } + + capture(true); + if (connection) { + connection.on('_connected_', onStreamConnected); + } + + onUnmounted(() => { + decapture(true); + if (connection) { + connection.off('_connected_', onStreamConnected); + } + }); +} diff --git a/packages/frontend/src/scripts/use-tooltip.ts b/packages/frontend/src/scripts/use-tooltip.ts new file mode 100644 index 0000000000..1f6e0fb6ce --- /dev/null +++ b/packages/frontend/src/scripts/use-tooltip.ts @@ -0,0 +1,86 @@ +import { Ref, ref, watch, onUnmounted } from 'vue'; + +export function useTooltip( + elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>, + onShow: (showing: Ref<boolean>) => void, + delay = 300, +): void { + let isHovering = false; + + // iOS(Androidも?)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ + // 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる + // TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...? + let shouldIgnoreMouseover = false; + + let timeoutId: number; + + let changeShowingState: (() => void) | null; + + const open = () => { + close(); + if (!isHovering) return; + if (elRef.value == null) return; + const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el; + if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため + + const showing = ref(true); + onShow(showing); + changeShowingState = () => { + showing.value = false; + }; + }; + + const close = () => { + if (changeShowingState != null) { + changeShowingState(); + changeShowingState = null; + } + }; + + const onMouseover = () => { + if (isHovering) return; + if (shouldIgnoreMouseover) return; + isHovering = true; + timeoutId = window.setTimeout(open, delay); + }; + + const onMouseleave = () => { + if (!isHovering) return; + isHovering = false; + window.clearTimeout(timeoutId); + close(); + }; + + const onTouchstart = () => { + shouldIgnoreMouseover = true; + if (isHovering) return; + isHovering = true; + timeoutId = window.setTimeout(open, delay); + }; + + const onTouchend = () => { + if (!isHovering) return; + isHovering = false; + window.clearTimeout(timeoutId); + close(); + }; + + const stop = watch(elRef, () => { + if (elRef.value) { + stop(); + const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el; + el.addEventListener('mouseover', onMouseover, { passive: true }); + el.addEventListener('mouseleave', onMouseleave, { passive: true }); + el.addEventListener('touchstart', onTouchstart, { passive: true }); + el.addEventListener('touchend', onTouchend, { passive: true }); + el.addEventListener('click', close, { passive: true }); + } + }, { + immediate: true, + flush: 'post', + }); + + onUnmounted(() => { + close(); + }); +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts new file mode 100644 index 0000000000..1bedab5fad --- /dev/null +++ b/packages/frontend/src/store.ts @@ -0,0 +1,383 @@ +import { markRaw, ref } from 'vue'; +import { Storage } from './pizzax'; +import { Theme } from './scripts/theme'; + +interface PostFormAction { + title: string, + handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void; +} + +interface UserAction { + title: string, + handler: (user: UserDetailed) => void; +} + +interface NoteAction { + title: string, + handler: (note: Note) => void; +} + +interface NoteViewInterruptor { + handler: (note: Note) => unknown; +} + +interface NotePostInterruptor { + handler: (note: FIXME) => unknown; +} + +export const postFormActions: PostFormAction[] = []; +export const userActions: UserAction[] = []; +export const noteActions: NoteAction[] = []; +export const noteViewInterruptors: NoteViewInterruptor[] = []; +export const notePostInterruptors: NotePostInterruptor[] = []; + +// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう) +// あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない +export const defaultStore = markRaw(new Storage('base', { + tutorial: { + where: 'account', + default: 0, + }, + keepCw: { + where: 'account', + default: true, + }, + showFullAcct: { + where: 'account', + default: false, + }, + rememberNoteVisibility: { + where: 'account', + default: false, + }, + defaultNoteVisibility: { + where: 'account', + default: 'public', + }, + defaultNoteLocalOnly: { + where: 'account', + default: false, + }, + uploadFolder: { + where: 'account', + default: null as string | null, + }, + pastedFileName: { + where: 'account', + default: 'yyyy-MM-dd HH-mm-ss [{{number}}]', + }, + keepOriginalUploading: { + where: 'account', + default: false, + }, + memo: { + where: 'account', + default: null, + }, + reactions: { + where: 'account', + default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], + }, + mutedWords: { + where: 'account', + default: [], + }, + mutedAds: { + where: 'account', + default: [] as string[], + }, + + menu: { + where: 'deviceAccount', + default: [ + 'notifications', + 'favorites', + 'drive', + 'followRequests', + '-', + 'explore', + 'announcements', + 'search', + '-', + 'ui', + ], + }, + visibility: { + where: 'deviceAccount', + default: 'public' as 'public' | 'home' | 'followers' | 'specified', + }, + localOnly: { + where: 'deviceAccount', + default: false, + }, + statusbars: { + where: 'deviceAccount', + default: [] as { + name: string; + id: string; + type: string; + size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge'; + black: boolean; + props: Record<string, any>; + }[], + }, + widgets: { + where: 'deviceAccount', + default: [] as { + name: string; + id: string; + place: string | null; + data: Record<string, any>; + }[], + }, + tl: { + where: 'deviceAccount', + default: { + src: 'home' as 'home' | 'local' | 'social' | 'global', + arg: null, + }, + }, + + overridedDeviceKind: { + where: 'device', + default: null as null | 'smartphone' | 'tablet' | 'desktop', + }, + serverDisconnectedBehavior: { + where: 'device', + default: 'quiet' as 'quiet' | 'reload' | 'dialog', + }, + nsfw: { + where: 'device', + default: 'respect' as 'respect' | 'force' | 'ignore', + }, + animation: { + where: 'device', + default: true, + }, + animatedMfm: { + where: 'device', + default: false, + }, + loadRawImages: { + where: 'device', + default: false, + }, + imageNewTab: { + where: 'device', + default: false, + }, + disableShowingAnimatedImages: { + where: 'device', + default: false, + }, + disablePagesScript: { + where: 'device', + default: false, + }, + emojiStyle: { + where: 'device', + default: 'twemoji', // twemoji / fluentEmoji / native + }, + disableDrawer: { + where: 'device', + default: false, + }, + useBlurEffectForModal: { + where: 'device', + default: true, + }, + useBlurEffect: { + where: 'device', + default: true, + }, + showFixedPostForm: { + where: 'device', + default: false, + }, + enableInfiniteScroll: { + where: 'device', + default: true, + }, + useReactionPickerForContextMenu: { + where: 'device', + default: false, + }, + showGapBetweenNotesInTimeline: { + where: 'device', + default: false, + }, + darkMode: { + where: 'device', + default: false, + }, + instanceTicker: { + where: 'device', + default: 'remote' as 'none' | 'remote' | 'always', + }, + reactionPickerSize: { + where: 'device', + default: 1, + }, + reactionPickerWidth: { + where: 'device', + default: 1, + }, + reactionPickerHeight: { + where: 'device', + default: 2, + }, + reactionPickerUseDrawerForMobile: { + where: 'device', + default: true, + }, + recentlyUsedEmojis: { + where: 'device', + default: [] as string[], + }, + recentlyUsedUsers: { + where: 'device', + default: [] as string[], + }, + defaultSideView: { + where: 'device', + default: false, + }, + menuDisplay: { + where: 'device', + default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top', + }, + reportError: { + where: 'device', + default: false, + }, + squareAvatars: { + where: 'device', + default: false, + }, + postFormWithHashtags: { + where: 'device', + default: false, + }, + postFormHashtags: { + where: 'device', + default: '', + }, + themeInitial: { + where: 'device', + default: true, + }, + numberOfPageCache: { + where: 'device', + default: 5, + }, + aiChanMode: { + where: 'device', + default: false, + }, +})); + +// TODO: 他のタブと永続化されたstateを同期 + +const PREFIX = 'miux:'; + +type Plugin = { + id: string; + name: string; + active: boolean; + configData: Record<string, any>; + token: string; + ast: any[]; +}; + +interface Watcher { + key: string; + callback: (value: unknown) => void; +} + +/** + * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ) + */ +import lightTheme from '@/themes/l-light.json5'; +import darkTheme from '@/themes/d-green-lime.json5'; +import { Note, UserDetailed } from 'misskey-js/built/entities'; + +export class ColdDeviceStorage { + public static default = { + lightTheme, + darkTheme, + syncDeviceDarkMode: true, + plugins: [] as Plugin[], + mediaVolume: 0.5, + sound_masterVolume: 0.3, + sound_note: { type: 'syuilo/down', volume: 1 }, + sound_noteMy: { type: 'syuilo/up', volume: 1 }, + sound_notification: { type: 'syuilo/pope2', volume: 1 }, + sound_chat: { type: 'syuilo/pope1', volume: 1 }, + sound_chatBg: { type: 'syuilo/waon', volume: 1 }, + sound_antenna: { type: 'syuilo/triple', volume: 1 }, + sound_channel: { type: 'syuilo/square-pico', volume: 1 }, + }; + + public static watchers: Watcher[] = []; + + public static get<T extends keyof typeof ColdDeviceStorage.default>(key: T): typeof ColdDeviceStorage.default[T] { + // TODO: indexedDBにする + // ただしその際はnullチェックではなくキー存在チェックにしないとダメ + // (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある) + const value = localStorage.getItem(PREFIX + key); + if (value == null) { + return ColdDeviceStorage.default[key]; + } else { + return JSON.parse(value); + } + } + + public static set<T extends keyof typeof ColdDeviceStorage.default>(key: T, value: typeof ColdDeviceStorage.default[T]): void { + // 呼び出し側のバグ等で undefined が来ることがある + // undefined を文字列として localStorage に入れると参照する際の JSON.parse でコケて不具合の元になるため無視 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (value === undefined) { + console.error(`attempt to store undefined value for key '${key}'`); + return; + } + + localStorage.setItem(PREFIX + key, JSON.stringify(value)); + + for (const watcher of this.watchers) { + if (watcher.key === key) watcher.callback(value); + } + } + + public static watch(key, callback) { + this.watchers.push({ key, callback }); + } + + // TODO: VueのcustomRef使うと良い感じになるかも + public static ref<T extends keyof typeof ColdDeviceStorage.default>(key: T) { + const v = ColdDeviceStorage.get(key); + const r = ref(v); + // TODO: このままではwatcherがリークするので開放する方法を考える + this.watch(key, v => { + r.value = v; + }); + return r; + } + + /** + * 特定のキーの、簡易的なgetter/setterを作ります + * 主にvue場で設定コントロールのmodelとして使う用 + */ + public static makeGetterSetter<K extends keyof typeof ColdDeviceStorage.default>(key: K) { + // TODO: VueのcustomRef使うと良い感じになるかも + const valueRef = ColdDeviceStorage.ref(key); + return { + get: () => { + return valueRef.value; + }, + set: (value: unknown) => { + const val = value; + ColdDeviceStorage.set(key, val); + }, + }; + } +} diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts new file mode 100644 index 0000000000..dea3459b86 --- /dev/null +++ b/packages/frontend/src/stream.ts @@ -0,0 +1,8 @@ +import * as Misskey from 'misskey-js'; +import { markRaw } from 'vue'; +import { $i } from '@/account'; +import { url } from '@/config'; + +export const stream = markRaw(new Misskey.Stream(url, $i ? { + token: $i.token, +} : null)); diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss new file mode 100644 index 0000000000..8b7a846863 --- /dev/null +++ b/packages/frontend/src/style.scss @@ -0,0 +1,584 @@ +@charset "utf-8"; + +:root { + --radius: 12px; + --marginFull: 16px; + --marginHalf: 10px; + + --margin: var(--marginFull); + + @media (max-width: 500px) { + --margin: var(--marginHalf); + } + + //--ad: rgb(255 169 0 / 10%); +} + +::selection { + color: #fff; + background-color: var(--accent); +} + +html { + touch-action: manipulation; + background-color: var(--bg); + background-attachment: fixed; + background-size: cover; + background-position: center; + color: var(--fg); + accent-color: var(--accent); + overflow: auto; + overflow-wrap: break-word; + font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; + font-size: 14px; + line-height: 1.35; + text-size-adjust: 100%; + tab-size: 2; + + &, * { + scrollbar-color: var(--scrollbarHandle) inherit; + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + background: inherit; + } + + &::-webkit-scrollbar-thumb { + background: var(--scrollbarHandle); + + &:hover { + background: var(--scrollbarHandleHover); + } + + &:active { + background: var(--accent); + } + } + } + + &.f-1 { + font-size: 15px; + } + + &.f-2 { + font-size: 16px; + } + + &.f-3 { + font-size: 17px; + } + + &.useSystemFont { + font-family: 'Hiragino Maru Gothic Pro', sans-serif; + } +} + +html._themeChanging_ { + &, * { + transition: background 1s ease, border 1s ease !important; + } +} + +html, body { + margin: 0; + padding: 0; + scroll-behavior: smooth; +} + +a { + text-decoration: none; + cursor: pointer; + color: inherit; + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; + + &:hover { + text-decoration: underline; + } +} + +textarea, input { + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; +} + +optgroup, option { + background: var(--panel); + color: var(--fg); +} + +hr { + margin: var(--margin) 0 var(--margin) 0; + border: none; + height: 1px; + background: var(--divider); +} + +.ti { + vertical-align: -10%; + line-height: 0.9em; + + &:before { + font-size: 130%; + } +} + +.ti-fw { + display: inline-block; + text-align: center; +} + +._indicatorCircle { + display: inline-block; + width: 1em; + height: 1em; + border-radius: 100%; + background: currentColor; +} + +._noSelect { + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} + +._ghost { + &, * { + @extend ._noSelect; + pointer-events: none; + } +} + +._modalBg { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--modalBg); + -webkit-backdrop-filter: var(--modalBgFilter); + backdrop-filter: var(--modalBgFilter); +} + +._shadow { + box-shadow: 0px 4px 32px var(--shadow) !important; +} + +._button { + appearance: none; + display: inline-block; + padding: 0; + margin: 0; // for Safari + background: none; + border: none; + cursor: pointer; + color: inherit; + touch-action: manipulation; + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; + font-size: 1em; + font-family: inherit; + line-height: inherit; + max-width: 100%; + + &, * { + @extend ._noSelect; + } + + * { + pointer-events: none; + } + + &:focus-visible { + outline: none; + } + + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +._buttonPrimary { + @extend ._button; + color: var(--fgOnAccent); + background: var(--accent); + + &:not(:disabled):hover { + background: var(--X8); + } + + &:not(:disabled):active { + background: var(--X9); + } +} + +._buttonGradate { + @extend ._buttonPrimary; + color: var(--fgOnAccent); + 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)); + } +} + +._help { + color: var(--accent); + cursor: help +} + +._textButton { + @extend ._button; + color: var(--accent); + + &:not(:disabled):hover { + text-decoration: underline; + } +} + +._inputs { + display: flex; + margin: 32px 0; + + &:first-child { + margin-top: 8px; + } + + &:last-child { + margin-bottom: 8px; + } + + > * { + flex: 1; + margin: 0 !important; + + &:not(:first-child) { + margin-left: 8px !important; + } + + &:not(:last-child) { + margin-right: 8px !important; + } + } +} + +._panel { + background: var(--panel); + border-radius: var(--radius); + overflow: clip; +} + +._block { + @extend ._panel; + + & + ._block { + margin-top: var(--margin); + } +} + +._gap { + margin: var(--margin) 0; +} + +// TODO: 廃止 +._card { + @extend ._panel; + + // TODO: _cardTitle に + > ._title { + margin: 0; + padding: 22px 32px; + font-size: 1em; + border-bottom: solid 1px var(--panelHeaderDivider); + font-weight: bold; + background: var(--panelHeaderBg); + color: var(--panelHeaderFg); + + @media (max-width: 500px) { + padding: 16px; + font-size: 1em; + } + } + + // TODO: _cardContent に + > ._content { + padding: 32px; + + @media (max-width: 500px) { + padding: 16px; + } + + &._noPad { + padding: 0 !important; + } + + & + ._content { + border-top: solid 0.5px var(--divider); + } + } + + // TODO: _cardFooter に + > ._footer { + border-top: solid 0.5px var(--divider); + padding: 24px 32px; + + @media (max-width: 500px) { + padding: 16px; + } + } +} + +._borderButton { + @extend ._button; + display: block; + width: 100%; + padding: 10px; + box-sizing: border-box; + text-align: center; + border: solid 0.5px var(--divider); + border-radius: var(--radius); + + &:active { + border-color: var(--accent); + } +} + +._popup { + background: var(--popup); + border-radius: var(--radius); + contain: content; +} + +// TODO: 廃止 +._monolithic_ { + ._section:not(:empty) { + box-sizing: border-box; + padding: var(--root-margin, 32px); + + @media (max-width: 500px) { + --root-margin: 10px; + } + + & + ._section:not(:empty) { + border-top: solid 0.5px var(--divider); + } + } +} + +._narrow_ ._card { + > ._title { + padding: 16px; + font-size: 1em; + } + + > ._content { + padding: 16px; + } + + > ._footer { + padding: 16px; + } +} + +._acrylic { + background: var(--acrylicPanel); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); +} + +._formBlock { + margin: 1.5em 0; +} + +._formRoot { + > ._formBlock:first-child { + margin-top: 0; + } + + > ._formBlock:last-child { + margin-bottom: 0; + } +} + +._formLinksGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-gap: 12px; +} + +._formLinks { + > *:not(:last-child) { + margin-bottom: 8px; + } +} + +._beta { + margin-left: 0.7em; + font-size: 65%; + padding: 2px 3px; + color: var(--accent); + border: solid 1px var(--accent); + border-radius: 4px; + vertical-align: top; +} + +._table { + > ._row { + display: flex; + + &:not(:last-child) { + margin-bottom: 16px; + + @media (max-width: 500px) { + margin-bottom: 8px; + } + } + + > ._cell { + flex: 1; + + > ._label { + font-size: 80%; + opacity: 0.7; + + > ._icon { + margin-right: 4px; + display: none; + } + } + } + } +} + +._fullinfo { + padding: 64px 32px; + text-align: center; + + > img { + vertical-align: bottom; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; + } +} + +._keyValue { + display: flex; + + > * { + flex: 1; + } +} + +._link { + color: var(--link); +} + +._caption { + font-size: 0.8em; + opacity: 0.7; +} + +._monospace { + font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important; +} + +._code { + @extend ._monospace; + background: #2d2d2d; + color: #ccc; + font-size: 14px; + line-height: 1.5; + padding: 5px; +} + +.prism-editor__textarea:focus { + outline: none; +} + +._zoom { + transition-duration: 0.5s, 0.5s; + transition-property: opacity, transform; + transition-timing-function: cubic-bezier(0,.5,.5,1); +} + +.zoom-enter-active, .zoom-leave-active { + transition: opacity 0.5s, transform 0.5s !important; +} +.zoom-enter-from, .zoom-leave-to { + opacity: 0; + transform: scale(0.9); +} + +@keyframes blink { + 0% { opacity: 1; transform: scale(1); } + 30% { opacity: 1; transform: scale(1); } + 90% { opacity: 0; transform: scale(0.5); } +} + +@keyframes tada { + from { + transform: scale3d(1, 1, 1); + } + + 10%, + 20% { + transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); + } + + 30%, + 50%, + 70%, + 90% { + transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); + } + + 40%, + 60%, + 80% { + transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); + } + + to { + transform: scale3d(1, 1, 1); + } +} + +._anime_bounce { + will-change: transform; + animation: bounce ease 0.7s; + animation-iteration-count: 1; + transform-origin: 50% 50%; +} +._anime_bounce_ready { + will-change: transform; + transform: scaleX(0.90) scaleY(0.90) ; +} +._anime_bounce_standBy { + transition: transform 0.1s ease; +} + +@keyframes bounce{ + 0% { + transform: scaleX(0.90) scaleY(0.90) ; + } + 19% { + transform: scaleX(1.10) scaleY(1.10) ; + } + 48% { + transform: scaleX(0.95) scaleY(0.95) ; + } + 100% { + transform: scaleX(1.00) scaleY(1.00) ; + } +} diff --git a/packages/frontend/src/theme-store.ts b/packages/frontend/src/theme-store.ts new file mode 100644 index 0000000000..fdc92ed793 --- /dev/null +++ b/packages/frontend/src/theme-store.ts @@ -0,0 +1,34 @@ +import { api } from '@/os'; +import { $i } from '@/account'; +import { Theme } from './scripts/theme'; + +const lsCacheKey = $i ? `themes:${$i.id}` : ''; + +export function getThemes(): Theme[] { + return JSON.parse(localStorage.getItem(lsCacheKey) || '[]'); +} + +export async function fetchThemes(): Promise<void> { + if ($i == null) return; + + try { + const themes = await api('i/registry/get', { scope: ['client'], key: 'themes' }); + localStorage.setItem(lsCacheKey, JSON.stringify(themes)); + } catch (err) { + if (err.code === 'NO_SUCH_KEY') return; + throw err; + } +} + +export async function addTheme(theme: Theme): Promise<void> { + await fetchThemes(); + const themes = getThemes().concat(theme); + await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); + localStorage.setItem(lsCacheKey, JSON.stringify(themes)); +} + +export async function removeTheme(theme: Theme): Promise<void> { + const themes = getThemes().filter(t => t.id !== theme.id); + await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); + localStorage.setItem(lsCacheKey, JSON.stringify(themes)); +} diff --git a/packages/frontend/src/themes/_dark.json5 b/packages/frontend/src/themes/_dark.json5 new file mode 100644 index 0000000000..88ec8a5459 --- /dev/null +++ b/packages/frontend/src/themes/_dark.json5 @@ -0,0 +1,99 @@ +// ダークテーマのベーステーマ +// このテーマが直接使われることは無い +{ + id: 'dark', + + name: 'Dark', + author: 'syuilo', + desc: 'Default dark theme', + kind: 'dark', + + props: { + accent: '#86b300', + accentDarken: ':darken<10<@accent', + accentLighten: ':lighten<10<@accent', + accentedBg: ':alpha<0.15<@accent', + focus: ':alpha<0.3<@accent', + bg: '#000', + acrylicBg: ':alpha<0.5<@bg', + fg: '#dadada', + fgTransparentWeak: ':alpha<0.75<@fg', + fgTransparent: ':alpha<0.5<@fg', + fgHighlighted: ':lighten<3<@fg', + fgOnAccent: '#fff', + divider: 'rgba(255, 255, 255, 0.1)', + indicator: '@accent', + panel: ':lighten<3<@bg', + panelHighlight: ':lighten<3<@panel', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + panelBorder: '" solid 1px var(--divider)', + acrylicPanel: ':alpha<0.5<@panel', + windowHeader: ':alpha<0.85<@panel', + popup: ':lighten<3<@panel', + shadow: 'rgba(0, 0, 0, 0.3)', + header: ':alpha<0.7<@panel', + navBg: '@panel', + navFg: '@fg', + navHoverFg: ':lighten<17<@fg', + navActive: '@accent', + navIndicator: '@indicator', + link: '#44a4c1', + hashtag: '#ff9156', + mention: '@accent', + mentionMe: '@mention', + renote: '#229e82', + modalBg: 'rgba(0, 0, 0, 0.5)', + scrollbarHandle: 'rgba(255, 255, 255, 0.2)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + dateLabelFg: '@fg', + infoBg: '#253142', + infoFg: '#fff', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + switchBg: 'rgba(255, 255, 255, 0.15)', + cwBg: '#687390', + cwFg: '#393f4f', + cwHoverBg: '#707b97', + buttonBg: 'rgba(255, 255, 255, 0.05)', + buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + buttonGradateA: '@accent', + buttonGradateB: ':hue<20<@accent', + swutchOffBg: 'rgba(255, 255, 255, 0.1)', + swutchOffFg: '@fg', + swutchOnBg: '@accentedBg', + swutchOnFg: '@accent', + inputBorder: 'rgba(255, 255, 255, 0.1)', + inputBorderHover: 'rgba(255, 255, 255, 0.2)', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + driveFolderBg: ':alpha<0.3<@accent', + wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', + badge: '#31b1ce', + messageBg: '@bg', + success: '#86b300', + error: '#ec4137', + warn: '#ecb637', + codeString: '#ffb675', + codeNumber: '#cfff9e', + codeBoolean: '#c59eff', + deckDivider: '#000', + htmlThemeColor: '@bg', + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: 'rgba(255, 255, 255, 0.05)', + X6: 'rgba(255, 255, 255, 0.15)', + X7: 'rgba(255, 255, 255, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: 'rgba(255, 255, 255, 0.1)', + X13: 'rgba(255, 255, 255, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + }, +} diff --git a/packages/frontend/src/themes/_light.json5 b/packages/frontend/src/themes/_light.json5 new file mode 100644 index 0000000000..bad1291c83 --- /dev/null +++ b/packages/frontend/src/themes/_light.json5 @@ -0,0 +1,99 @@ +// ライトテーマのベーステーマ +// このテーマが直接使われることは無い +{ + id: 'light', + + name: 'Light', + author: 'syuilo', + desc: 'Default light theme', + kind: 'light', + + props: { + accent: '#86b300', + accentDarken: ':darken<10<@accent', + accentLighten: ':lighten<10<@accent', + accentedBg: ':alpha<0.15<@accent', + focus: ':alpha<0.3<@accent', + bg: '#fff', + acrylicBg: ':alpha<0.5<@bg', + fg: '#5f5f5f', + fgTransparentWeak: ':alpha<0.75<@fg', + fgTransparent: ':alpha<0.5<@fg', + fgHighlighted: ':darken<3<@fg', + fgOnAccent: '#fff', + divider: 'rgba(0, 0, 0, 0.1)', + indicator: '@accent', + panel: ':lighten<3<@bg', + panelHighlight: ':darken<3<@panel', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + panelBorder: '" solid 1px var(--divider)', + acrylicPanel: ':alpha<0.5<@panel', + windowHeader: ':alpha<0.85<@panel', + popup: ':lighten<3<@panel', + shadow: 'rgba(0, 0, 0, 0.1)', + header: ':alpha<0.7<@panel', + navBg: '@panel', + navFg: '@fg', + navHoverFg: ':darken<17<@fg', + navActive: '@accent', + navIndicator: '@indicator', + link: '#44a4c1', + hashtag: '#ff9156', + mention: '@accent', + mentionMe: '@mention', + renote: '#229e82', + modalBg: 'rgba(0, 0, 0, 0.3)', + scrollbarHandle: 'rgba(0, 0, 0, 0.2)', + scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)', + dateLabelFg: '@fg', + infoBg: '#e5f5ff', + infoFg: '#72818a', + infoWarnBg: '#fff0db', + infoWarnFg: '#8f6e31', + switchBg: 'rgba(0, 0, 0, 0.15)', + cwBg: '#b1b9c1', + cwFg: '#fff', + cwHoverBg: '#bbc4ce', + buttonBg: 'rgba(0, 0, 0, 0.05)', + buttonHoverBg: 'rgba(0, 0, 0, 0.1)', + buttonGradateA: '@accent', + buttonGradateB: ':hue<20<@accent', + swutchOffBg: 'rgba(0, 0, 0, 0.1)', + swutchOffFg: '@panel', + swutchOnBg: '@accent', + swutchOnFg: '@fgOnAccent', + inputBorder: 'rgba(0, 0, 0, 0.1)', + inputBorderHover: 'rgba(0, 0, 0, 0.2)', + listItemHoverBg: 'rgba(0, 0, 0, 0.03)', + driveFolderBg: ':alpha<0.3<@accent', + wallpaperOverlay: 'rgba(255, 255, 255, 0.5)', + badge: '#31b1ce', + messageBg: '@bg', + success: '#86b300', + error: '#ec4137', + warn: '#ecb637', + codeString: '#b98710', + codeNumber: '#0fbbbb', + codeBoolean: '#62b70c', + deckDivider: ':darken<3<@bg', + htmlThemeColor: '@bg', + X2: ':darken<2<@panel', + X3: 'rgba(0, 0, 0, 0.05)', + X4: 'rgba(0, 0, 0, 0.1)', + X5: 'rgba(0, 0, 0, 0.05)', + X6: 'rgba(0, 0, 0, 0.25)', + X7: 'rgba(0, 0, 0, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.1)', + X12: 'rgba(0, 0, 0, 0.1)', + X13: 'rgba(0, 0, 0, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + }, +} diff --git a/packages/frontend/src/themes/d-astro.json5 b/packages/frontend/src/themes/d-astro.json5 new file mode 100644 index 0000000000..c6a927ec3a --- /dev/null +++ b/packages/frontend/src/themes/d-astro.json5 @@ -0,0 +1,78 @@ +{ + id: '080a01c5-377d-4fbb-88cc-6bb5d04977ea', + base: 'dark', + name: 'Mi Astro Dark', + author: 'syuilo', + props: { + bg: '#232125', + fg: '#efdab9', + cwBg: '#687390', + cwFg: '#393f4f', + link: '#78b0a0', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: '#2a272b', + accent: '#81c08b', + header: ':alpha<0.7<@bg', + infoBg: '#253142', + infoFg: '#fff', + renote: '#659CC8', + shadow: 'rgba(0, 0, 0, 0.3)', + divider: 'rgba(255, 255, 255, 0.1)', + hashtag: '#ff9156', + mention: '#ffd152', + modalBg: 'rgba(0, 0, 0, 0.5)', + success: '#86b300', + buttonBg: 'rgba(255, 255, 255, 0.05)', + acrylicBg: ':alpha<0.5<@bg', + cwHoverBg: '#707b97', + indicator: '@accent', + mentionMe: '#fb5d38', + messageBg: '@bg', + navActive: '@accent', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + navHoverFg: ':lighten<17<@fg', + dateLabelFg: '@fg', + inputBorder: 'rgba(255, 255, 255, 0.1)', + inputBorderHover: 'rgba(255, 255, 255, 0.2)', + panelBorder: '" solid 1px var(--divider)', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@accent', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + buttonGradateA: '@accent', + buttonGradateB: ':hue<-20<@accent', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':lighten<3<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + htmlThemeColor: '@bg', + panelHighlight: ':lighten<3<@panel', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + scrollbarHandle: 'rgba(255, 255, 255, 0.2)', + wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: 'rgba(255, 255, 255, 0.05)', + X6: 'rgba(255, 255, 255, 0.15)', + X7: 'rgba(255, 255, 255, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: 'rgba(255, 255, 255, 0.1)', + X13: 'rgba(255, 255, 255, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + }, +} diff --git a/packages/frontend/src/themes/d-botanical.json5 b/packages/frontend/src/themes/d-botanical.json5 new file mode 100644 index 0000000000..c03b95e2d7 --- /dev/null +++ b/packages/frontend/src/themes/d-botanical.json5 @@ -0,0 +1,26 @@ +{ + id: '504debaf-4912-6a4c-5059-1db08a76b737', + + name: 'Mi Botanical Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: 'rgb(148, 179, 0)', + bg: 'rgb(37, 38, 36)', + fg: 'rgb(216, 212, 199)', + fgHighlighted: '#fff', + divider: 'rgba(255, 255, 255, 0.14)', + panel: 'rgb(47, 47, 44)', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + header: ':alpha<0.7<@panel', + navBg: '#363636', + renote: '@accent', + mention: 'rgb(212, 153, 76)', + mentionMe: 'rgb(212, 210, 76)', + hashtag: '#5bcbb0', + link: '@accent', + }, +} diff --git a/packages/frontend/src/themes/d-cherry.json5 b/packages/frontend/src/themes/d-cherry.json5 new file mode 100644 index 0000000000..a7e1ad1c80 --- /dev/null +++ b/packages/frontend/src/themes/d-cherry.json5 @@ -0,0 +1,20 @@ +{ + id: '679b3b87-a4e9-4789-8696-b56c15cc33b0', + + name: 'Mi Cherry Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: 'rgb(255, 89, 117)', + bg: 'rgb(28, 28, 37)', + fg: 'rgb(236, 239, 244)', + panel: 'rgb(35, 35, 47)', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '@accent', + divider: 'rgb(63, 63, 80)', + }, +} diff --git a/packages/frontend/src/themes/d-dark.json5 b/packages/frontend/src/themes/d-dark.json5 new file mode 100644 index 0000000000..d24ce4df69 --- /dev/null +++ b/packages/frontend/src/themes/d-dark.json5 @@ -0,0 +1,26 @@ +{ + id: '8050783a-7f63-445a-b270-36d0f6ba1677', + + name: 'Mi Dark', + author: 'syuilo', + desc: 'Default light theme', + + base: 'dark', + + props: { + bg: '#232323', + fg: 'rgb(199, 209, 216)', + fgHighlighted: '#fff', + divider: 'rgba(255, 255, 255, 0.14)', + panel: '#2d2d2d', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + header: ':alpha<0.7<@panel', + navBg: '#363636', + renote: '@accent', + mention: '#da6d35', + mentionMe: '#d44c4c', + hashtag: '#4cb8d4', + link: '@accent', + }, +} diff --git a/packages/frontend/src/themes/d-future.json5 b/packages/frontend/src/themes/d-future.json5 new file mode 100644 index 0000000000..b6fa1ab0c1 --- /dev/null +++ b/packages/frontend/src/themes/d-future.json5 @@ -0,0 +1,27 @@ +{ + id: '32a637ef-b47a-4775-bb7b-bacbb823f865', + + name: 'Mi Future Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: '#63e2b7', + bg: '#101014', + fg: '#D5D5D6', + fgHighlighted: '#fff', + fgOnAccent: '#000', + divider: 'rgba(255, 255, 255, 0.1)', + panel: '#18181c', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + renote: '@accent', + mention: '#f2c97d', + mentionMe: '@accent', + hashtag: '#70c0e8', + link: '#e88080', + buttonGradateA: '@accent', + buttonGradateB: ':saturate<30<:hue<30<@accent', + }, +} diff --git a/packages/frontend/src/themes/d-green-lime.json5 b/packages/frontend/src/themes/d-green-lime.json5 new file mode 100644 index 0000000000..a6983b9ac2 --- /dev/null +++ b/packages/frontend/src/themes/d-green-lime.json5 @@ -0,0 +1,24 @@ +{ + id: '02816013-8107-440f-877e-865083ffe194', + + name: 'Mi Green+Lime Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: '#b4e900', + bg: '#0C1210', + fg: '#dee7e4', + fgHighlighted: '#fff', + fgOnAccent: '#192320', + divider: '#e7fffb24', + panel: '#192320', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + popup: '#293330', + renote: '@accent', + mentionMe: '#ffaa00', + link: '#24d7ce', + }, +} diff --git a/packages/frontend/src/themes/d-green-orange.json5 b/packages/frontend/src/themes/d-green-orange.json5 new file mode 100644 index 0000000000..62adc39e29 --- /dev/null +++ b/packages/frontend/src/themes/d-green-orange.json5 @@ -0,0 +1,24 @@ +{ + id: 'dc489603-27b5-424a-9b25-1ff6aec9824a', + + name: 'Mi Green+Orange Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: '#e97f00', + bg: '#0C1210', + fg: '#dee7e4', + fgHighlighted: '#fff', + fgOnAccent: '#192320', + divider: '#e7fffb24', + panel: '#192320', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + popup: '#293330', + renote: '@accent', + mentionMe: '#b4e900', + link: '#24d7ce', + }, +} diff --git a/packages/frontend/src/themes/d-ice.json5 b/packages/frontend/src/themes/d-ice.json5 new file mode 100644 index 0000000000..179b060dcf --- /dev/null +++ b/packages/frontend/src/themes/d-ice.json5 @@ -0,0 +1,13 @@ +{ + id: '66e7e5a9-cd43-42cd-837d-12f47841fa34', + + name: 'Mi Ice Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: '#47BFE8', + bg: '#212526', + }, +} diff --git a/packages/frontend/src/themes/d-persimmon.json5 b/packages/frontend/src/themes/d-persimmon.json5 new file mode 100644 index 0000000000..e36265ff10 --- /dev/null +++ b/packages/frontend/src/themes/d-persimmon.json5 @@ -0,0 +1,25 @@ +{ + id: 'c503d768-7c70-4db2-a4e6-08264304bc8d', + + name: 'Mi Persimmon Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: 'rgb(206, 102, 65)', + bg: 'rgb(31, 33, 31)', + fg: '#cdd8c7', + fgHighlighted: '#fff', + divider: 'rgba(255, 255, 255, 0.14)', + panel: 'rgb(41, 43, 41)', + infoFg: '@fg', + infoBg: '#333c3b', + navBg: '#141714', + renote: '@accent', + mention: '@accent', + mentionMe: '#de6161', + hashtag: '#68bad0', + link: '#a1c758', + }, +} diff --git a/packages/frontend/src/themes/d-u0.json5 b/packages/frontend/src/themes/d-u0.json5 new file mode 100644 index 0000000000..b270f809ac --- /dev/null +++ b/packages/frontend/src/themes/d-u0.json5 @@ -0,0 +1,88 @@ +{ + id: '7a5bc13b-df8f-4d44-8e94-4452f0c634bb', + base: 'dark', + name: 'Mi U0 Dark', + props: { + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: 'rgba(255, 255, 255, 0.05)', + X6: 'rgba(255, 255, 255, 0.15)', + X7: 'rgba(255, 255, 255, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + bg: '#172426', + fg: '#dadada', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: 'rgba(255, 255, 255, 0.1)', + X13: 'rgba(255, 255, 255, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + cwBg: '#687390', + cwFg: '#393f4f', + link: '@accent', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: ':lighten<3<@bg', + popup: ':lighten<3<@panel', + accent: '#00a497', + header: ':alpha<0.7<@panel', + infoBg: '#253142', + infoFg: '#fff', + renote: '@accent', + shadow: 'rgba(0, 0, 0, 0.3)', + divider: 'rgba(255, 255, 255, 0.1)', + hashtag: '#e6b422', + mention: '@accent', + modalBg: 'rgba(0, 0, 0, 0.5)', + success: '#86b300', + buttonBg: 'rgba(255, 255, 255, 0.05)', + switchBg: 'rgba(255, 255, 255, 0.15)', + acrylicBg: ':alpha<0.5<@bg', + cwHoverBg: '#707b97', + indicator: '@accent', + mentionMe: '@mention', + messageBg: '@bg', + navActive: '@accent', + accentedBg: ':alpha<0.15<@accent', + codeNumber: '#cfff9e', + codeString: '#ffb675', + fgOnAccent: '#fff', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + navHoverFg: ':lighten<17<@fg', + codeBoolean: '#c59eff', + dateLabelFg: '@fg', + inputBorder: 'rgba(255, 255, 255, 0.1)', + panelBorder: '" solid 1px var(--divider)', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@indicator', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':lighten<3<@fg', + fgTransparent: ':alpha<0.5<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + buttonGradateA: '@accent', + buttonGradateB: ':hue<20<@accent', + htmlThemeColor: '@bg', + panelHighlight: ':lighten<3<@panel', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + scrollbarHandle: 'rgba(255, 255, 255, 0.2)', + inputBorderHover: 'rgba(255, 255, 255, 0.2)', + wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', + fgTransparentWeak: ':alpha<0.75<@fg', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + deckDivider: '#142022', + }, +} diff --git a/packages/frontend/src/themes/l-apricot.json5 b/packages/frontend/src/themes/l-apricot.json5 new file mode 100644 index 0000000000..1ed5525575 --- /dev/null +++ b/packages/frontend/src/themes/l-apricot.json5 @@ -0,0 +1,22 @@ +{ + id: '0ff48d43-aab3-46e7-ab12-8492110d2e2b', + + name: 'Mi Apricot Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: 'rgb(234, 154, 82)', + bg: '#e6e5e2', + fg: 'rgb(149, 143, 139)', + panel: '#EEECE8', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '@accent', + inputBorder: 'rgba(0, 0, 0, 0.1)', + inputBorderHover: 'rgba(0, 0, 0, 0.2)', + infoBg: 'rgb(226, 235, 241)', + }, +} diff --git a/packages/frontend/src/themes/l-cherry.json5 b/packages/frontend/src/themes/l-cherry.json5 new file mode 100644 index 0000000000..5ad240241e --- /dev/null +++ b/packages/frontend/src/themes/l-cherry.json5 @@ -0,0 +1,21 @@ +{ + id: 'ac168876-f737-4074-a3fc-a370c732ef48', + + name: 'Mi Cherry Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: 'rgb(219, 96, 114)', + bg: 'rgb(254, 248, 249)', + fg: 'rgb(152, 13, 26)', + panel: 'rgb(255, 255, 255)', + renote: '@accent', + link: 'rgb(156, 187, 5)', + mention: '@accent', + hashtag: '@accent', + divider: 'rgba(134, 51, 51, 0.1)', + inputBorderHover: 'rgb(238, 221, 222)', + }, +} diff --git a/packages/frontend/src/themes/l-coffee.json5 b/packages/frontend/src/themes/l-coffee.json5 new file mode 100644 index 0000000000..fbcd4fa9ef --- /dev/null +++ b/packages/frontend/src/themes/l-coffee.json5 @@ -0,0 +1,21 @@ +{ + id: '6ed80faa-74f0-42c2-98e4-a64d9e138eab', + + name: 'Mi Coffee Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: '#9f8989', + bg: '#f5f3f3', + fg: '#7f6666', + panel: '#fff', + divider: 'rgba(87, 68, 68, 0.1)', + renote: 'rgb(160, 172, 125)', + link: 'rgb(137, 151, 159)', + mention: '@accent', + mentionMe: 'rgb(170, 149, 98)', + hashtag: '@accent', + }, +} diff --git a/packages/frontend/src/themes/l-light.json5 b/packages/frontend/src/themes/l-light.json5 new file mode 100644 index 0000000000..248355c945 --- /dev/null +++ b/packages/frontend/src/themes/l-light.json5 @@ -0,0 +1,20 @@ +{ + id: '4eea646f-7afa-4645-83e9-83af0333cd37', + + name: 'Mi Light', + author: 'syuilo', + desc: 'Default light theme', + + base: 'light', + + props: { + bg: '#f9f9f9', + fg: '#676767', + divider: '#e8e8e8', + header: ':alpha<0.7<@panel', + navBg: '#fff', + panel: '#fff', + panelHeaderDivider: '@divider', + mentionMe: 'rgb(0, 179, 70)', + }, +} diff --git a/packages/frontend/src/themes/l-rainy.json5 b/packages/frontend/src/themes/l-rainy.json5 new file mode 100644 index 0000000000..283dd74c6c --- /dev/null +++ b/packages/frontend/src/themes/l-rainy.json5 @@ -0,0 +1,21 @@ +{ + id: 'a58a0abb-ff8c-476a-8dec-0ad7837e7e96', + + name: 'Mi Rainy Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: '#5db0da', + bg: 'rgb(246 248 249)', + fg: '#636b71', + panel: '#fff', + divider: 'rgb(230 233 234)', + panelHeaderDivider: '@divider', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '@accent', + }, +} diff --git a/packages/frontend/src/themes/l-sushi.json5 b/packages/frontend/src/themes/l-sushi.json5 new file mode 100644 index 0000000000..5846927d65 --- /dev/null +++ b/packages/frontend/src/themes/l-sushi.json5 @@ -0,0 +1,18 @@ +{ + id: '213273e5-7d20-d5f0-6e36-1b6a4f67115c', + + name: 'Mi Sushi Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: '#e36749', + bg: '#f0eee9', + fg: '#5f5f5f', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '#229e82', + }, +} diff --git a/packages/frontend/src/themes/l-u0.json5 b/packages/frontend/src/themes/l-u0.json5 new file mode 100644 index 0000000000..03b114ba39 --- /dev/null +++ b/packages/frontend/src/themes/l-u0.json5 @@ -0,0 +1,87 @@ +{ + id: 'e2c940b5-6e9a-4c03-b738-261c720c426d', + base: 'light', + name: 'Mi U0 Light', + props: { + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: 'rgba(255, 255, 255, 0.05)', + X6: 'rgba(255, 255, 255, 0.15)', + X7: 'rgba(255, 255, 255, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + bg: '#e7e7eb', + fg: '#5f5f5f', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: 'rgba(255, 255, 255, 0.1)', + X13: 'rgba(255, 255, 255, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + cwBg: '#687390', + cwFg: '#393f4f', + link: '@accent', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: ':lighten<3<@bg', + popup: ':lighten<3<@panel', + accent: '#478384', + header: ':alpha<0.7<@panel', + infoBg: '#253142', + infoFg: '#fff', + renote: '@accent', + shadow: 'rgba(0, 0, 0, 0.3)', + divider: '#4646461a', + hashtag: '#1f3134', + mention: '@accent', + modalBg: 'rgba(0, 0, 0, 0.5)', + success: '#86b300', + buttonBg: '#0000000d', + switchBg: 'rgba(255, 255, 255, 0.15)', + acrylicBg: ':alpha<0.5<@bg', + cwHoverBg: '#707b97', + indicator: '@accent', + mentionMe: '@mention', + messageBg: '@bg', + navActive: '@accent', + accentedBg: ':alpha<0.15<@accent', + codeNumber: '#cfff9e', + codeString: '#ffb675', + fgOnAccent: '#fff', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + navHoverFg: ':lighten<17<@fg', + codeBoolean: '#c59eff', + dateLabelFg: '@fg', + inputBorder: 'rgba(255, 255, 255, 0.1)', + panelBorder: '" solid 1px var(--divider)', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@indicator', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: '#0000001a', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':lighten<3<@fg', + fgTransparent: ':alpha<0.5<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + buttonGradateA: '@accent', + buttonGradateB: ':hue<20<@accent', + htmlThemeColor: '@bg', + panelHighlight: ':lighten<3<@panel', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + scrollbarHandle: '#74747433', + inputBorderHover: 'rgba(255, 255, 255, 0.2)', + wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', + fgTransparentWeak: ':alpha<0.75<@fg', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + }, +} diff --git a/packages/frontend/src/themes/l-vivid.json5 b/packages/frontend/src/themes/l-vivid.json5 new file mode 100644 index 0000000000..b3c08f38ae --- /dev/null +++ b/packages/frontend/src/themes/l-vivid.json5 @@ -0,0 +1,82 @@ +{ + id: '6128c2a9-5c54-43fe-a47d-17942356470b', + + name: 'Mi Vivid Light', + author: 'syuilo', + + base: 'light', + + props: { + bg: '#fafafa', + fg: '#444', + cwBg: '#b1b9c1', + cwFg: '#fff', + link: '#ff9400', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: '#fff', + accent: '#008cff', + header: ':alpha<0.7<@panel', + infoBg: '#e5f5ff', + infoFg: '#72818a', + renote: '@accent', + shadow: 'rgba(0, 0, 0, 0.1)', + divider: 'rgba(0, 0, 0, 0.08)', + hashtag: '#92d400', + mention: '@accent', + modalBg: 'rgba(0, 0, 0, 0.3)', + success: '#86b300', + buttonBg: 'rgba(0, 0, 0, 0.05)', + acrylicBg: ':alpha<0.5<@bg', + cwHoverBg: '#bbc4ce', + indicator: '@accent', + mentionMe: '@mention', + messageBg: '@bg', + navActive: '@accent', + infoWarnBg: '#fff0db', + infoWarnFg: '#8f6e31', + navHoverFg: ':darken<17<@fg', + dateLabelFg: '@fg', + inputBorder: 'rgba(0, 0, 0, 0.1)', + inputBorderHover: 'rgba(0, 0, 0, 0.2)', + panelBorder: '" solid 1px var(--divider)', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@accent', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: 'rgba(0, 0, 0, 0.1)', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':darken<3<@fg', + fgTransparent: ':alpha<0.5<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + htmlThemeColor: '@bg', + panelHighlight: ':darken<3<@panel', + listItemHoverBg: 'rgba(0, 0, 0, 0.03)', + scrollbarHandle: 'rgba(0, 0, 0, 0.2)', + wallpaperOverlay: 'rgba(255, 255, 255, 0.5)', + fgTransparentWeak: ':alpha<0.75<@fg', + panelHeaderDivider: '@divider', + scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)', + X2: ':darken<2<@panel', + X3: 'rgba(0, 0, 0, 0.05)', + X4: 'rgba(0, 0, 0, 0.1)', + X5: 'rgba(0, 0, 0, 0.05)', + X6: 'rgba(0, 0, 0, 0.25)', + X7: 'rgba(0, 0, 0, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.1)', + X12: 'rgba(0, 0, 0, 0.1)', + X13: 'rgba(0, 0, 0, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + }, +} diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts new file mode 100644 index 0000000000..972f6db214 --- /dev/null +++ b/packages/frontend/src/types/menu.ts @@ -0,0 +1,21 @@ +import * as Misskey from 'misskey-js'; +import { Ref } from 'vue'; + +export type MenuAction = (ev: MouseEvent) => void; + +export type MenuDivider = null; +export type MenuNull = undefined; +export type MenuLabel = { type: 'label', text: string }; +export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User }; +export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean }; +export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction }; +export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean }; +export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean, avatar?: Misskey.entities.User; action: MenuAction }; +export type MenuParent = { type: 'parent', text: string, icon?: string, children: OuterMenuItem[] }; + +export type MenuPending = { type: 'pending' }; + +type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent; +type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent>; +export type MenuItem = OuterMenuItem | OuterPromiseMenuItem; +export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent; diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue new file mode 100644 index 0000000000..7f3fc0e4af --- /dev/null +++ b/packages/frontend/src/ui/_common_/common.vue @@ -0,0 +1,139 @@ +<template> +<component + :is="popup.component" + v-for="popup in popups" + :key="popup.id" + v-bind="popup.props" + v-on="popup.events" +/> + +<XUpload v-if="uploads.length > 0"/> + +<XStreamIndicator/> + +<div v-if="pendingApiRequestsCount > 0" id="wait"></div> + +<div v-if="dev" id="devTicker"><span>DEV BUILD</span></div> + +<div v-if="$i && $i.isBot" id="botWarn"><span>{{ i18n.ts.loggedInAsBot }}</span></div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import { swInject } from './sw-inject'; +import { popup, popups, pendingApiRequestsCount } from '@/os'; +import { uploads } from '@/scripts/upload'; +import * as sound from '@/scripts/sound'; +import { $i } from '@/account'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; + +const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); +const XUpload = defineAsyncComponent(() => import('./upload.vue')); + +const dev = _DEV_; + +const onNotification = notification => { + if ($i.mutingNotificationTypes.includes(notification.type)) return; + + if (document.visibilityState === 'visible') { + stream.send('readNotification', { + id: notification.id, + }); + + popup(defineAsyncComponent(() => import('@/components/MkNotificationToast.vue')), { + notification, + }, {}, 'closed'); + } + + sound.play('notification'); +}; + +if ($i) { + const connection = stream.useChannel('main', null, 'UI'); + connection.on('notification', onNotification); + + //#region Listen message from SW + if ('serviceWorker' in navigator) { + swInject(); + } +} +</script> + +<style lang="scss"> +@keyframes dev-ticker-blink { + 0% { opacity: 1; } + 50% { opacity: 0; } + 100% { opacity: 1; } +} + +@keyframes progress-spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +#wait { + display: block; + position: fixed; + z-index: 4000000; + top: 15px; + right: 15px; + + &:before { + content: ""; + display: block; + width: 18px; + height: 18px; + box-sizing: border-box; + border: solid 2px transparent; + border-top-color: var(--accent); + border-left-color: var(--accent); + border-radius: 50%; + animation: progress-spinner 400ms linear infinite; + } +} + +#botWarn { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 100%; + height: max-content; + text-align: center; + z-index: 2147483647; + color: #ff0; + background: rgba(0, 0, 0, 0.5); + padding: 4px 7px; + font-size: 14px; + pointer-events: none; + user-select: none; + + > span { + animation: dev-ticker-blink 2s infinite; + } +} + +#devTicker { + position: fixed; + top: 0; + left: 0; + z-index: 2147483647; + color: #ff0; + background: rgba(0, 0, 0, 0.5); + padding: 4px 5px; + font-size: 14px; + pointer-events: none; + user-select: none; + + > span { + animation: dev-ticker-blink 2s infinite; + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue new file mode 100644 index 0000000000..50b28de063 --- /dev/null +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -0,0 +1,314 @@ +<template> +<div class="kmwsukvl"> + <div class="body"> + <div class="top"> + <div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div> + <button v-click-anime class="item _button instance" @click="openInstanceMenu"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + </button> + </div> + <div class="middle"> + <MkA v-click-anime class="item index" active-class="active" to="/" exact> + <i class="icon ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span> + </MkA> + <template v-for="item in menu"> + <div v-if="item === '-'" class="divider"></div> + <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: navbarItemDef[item].active }]" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> + <i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span> + <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span> + </component> + </template> + <div class="divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin"> + <i class="icon ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span> + </MkA> + <button v-click-anime class="item _button" @click="more"> + <i class="icon ti ti-grid-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span> + <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon _indicatorCircle"></i></span> + </button> + <MkA v-click-anime class="item" active-class="active" to="/settings"> + <i class="icon ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span> + </MkA> + </div> + <div class="bottom"> + <button class="item _button post" data-cy-open-post-form @click="os.post"> + <i class="icon ti ti-pencil ti-fw"></i><span class="text">{{ i18n.ts.note }}</span> + </button> + <button v-click-anime class="item _button account" @click="openAccountMenu"> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> + </button> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, defineComponent, ref, toRef, watch } from 'vue'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { openAccountMenu as openAccountMenu_ } from '@/account'; +import { defaultStore } from '@/store'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; + +const menu = toRef(defaultStore.state, 'menu'); +const otherMenuItemIndicated = computed(() => { + for (const def in navbarItemDef) { + if (menu.value.includes(def)) continue; + if (navbarItemDef[def].indicated) return true; + } + return false; +}); + +function openAccountMenu(ev: MouseEvent) { + openAccountMenu_({ + withExtraOperation: true, + }, ev); +} + +function openInstanceMenu(ev: MouseEvent) { + os.popupMenu([{ + text: instance.name ?? host, + type: 'label', + }, { + type: 'link', + text: i18n.ts.instanceInfo, + icon: 'ti ti-info-circle', + to: '/about', + }, { + type: 'link', + text: i18n.ts.customEmojis, + icon: 'ti ti-mood-happy', + to: '/about#emojis', + }, { + type: 'link', + text: i18n.ts.federation, + icon: 'ti ti-whirl', + to: '/about#federation', + }, null, { + type: 'parent', + text: i18n.ts.help, + icon: 'ti ti-question-circle', + children: [{ + type: 'link', + to: '/mfm-cheat-sheet', + text: i18n.ts._mfm.cheatSheet, + icon: 'ti ti-code', + }, { + type: 'link', + to: '/scratchpad', + text: i18n.ts.scratchpad, + icon: 'ti ti-terminal-2', + }, { + type: 'link', + to: '/api-console', + text: 'API Console', + icon: 'ti ti-terminal-2', + }, null, { + text: i18n.ts.document, + icon: 'ti ti-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.html', '_blank'); + }, + }], + }, { + type: 'link', + text: i18n.ts.aboutMisskey, + to: '/about-misskey', + }], ev.currentTarget ?? ev.target, { + align: 'left', + }); +} + +function more() { + os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {}, { + }, 'closed'); +} +</script> + +<style lang="scss" scoped> +.kmwsukvl { + > .body { + display: flex; + flex-direction: column; + + > .top { + position: sticky; + top: 0; + z-index: 1; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .banner { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center center; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + } + + > .instance { + position: relative; + display: block; + text-align: center; + width: 100%; + + > .icon { + display: inline-block; + width: 38px; + aspect-ratio: 1; + } + } + } + + > .bottom { + position: sticky; + bottom: 0; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .post { + position: relative; + display: block; + width: 100%; + height: 40px; + color: var(--fgOnAccent); + font-weight: bold; + text-align: left; + + &:before { + content: ""; + display: block; + width: calc(100% - 38px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + } + + &:hover, &.active { + &:before { + background: var(--accentLighten); + } + } + + > .icon { + position: relative; + margin-left: 30px; + margin-right: 8px; + width: 32px; + } + + > .text { + position: relative; + } + } + + > .account { + position: relative; + display: flex; + align-items: center; + padding-left: 30px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + margin-top: 16px; + + > .avatar { + position: relative; + width: 32px; + aspect-ratio: 1; + margin-right: 8px; + } + } + } + + > .middle { + flex: 1; + + > .divider { + margin: 16px 16px; + border-top: solid 0.5px var(--divider); + } + + > .item { + position: relative; + display: block; + padding-left: 24px; + line-height: 2.85rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + color: var(--navFg); + + > .icon { + position: relative; + width: 32px; + margin-right: 8px; + } + + > .indicator { + position: absolute; + top: 0; + left: 20px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + > .text { + position: relative; + font-size: 0.9em; + } + + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } + + &.active { + color: var(--navActive); + } + + &:hover, &.active { + &:before { + content: ""; + display: block; + width: calc(100% - 24px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: var(--accentedBg); + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue new file mode 100644 index 0000000000..b82da15f13 --- /dev/null +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -0,0 +1,521 @@ +<template> +<div class="mvcprjjd" :class="{ iconOnly }"> + <div class="body"> + <div class="top"> + <div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div> + <button v-click-anime v-tooltip.noDelay.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + </button> + </div> + <div class="middle"> + <MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.timeline" class="item index" active-class="active" to="/" exact> + <i class="icon ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span> + </MkA> + <template v-for="item in menu"> + <div v-if="item === '-'" class="divider"></div> + <component + :is="navbarItemDef[item].to ? 'MkA' : 'button'" + v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" + v-click-anime + v-tooltip.noDelay.right="i18n.ts[navbarItemDef[item].title]" + class="item _button" + :class="[item, { active: navbarItemDef[item].active }]" + active-class="active" + :to="navbarItemDef[item].to" + v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}" + > + <i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span> + <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span> + </component> + </template> + <div class="divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip.noDelay.right="i18n.ts.controlPanel" class="item" active-class="active" to="/admin"> + <i class="icon ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span> + </MkA> + <button v-click-anime class="item _button" @click="more"> + <i class="icon ti ti-grid-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span> + <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon _indicatorCircle"></i></span> + </button> + <MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.settings" class="item" active-class="active" to="/settings"> + <i class="icon ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span> + </MkA> + </div> + <div class="bottom"> + <button v-tooltip.noDelay.right="i18n.ts.note" class="item _button post" data-cy-open-post-form @click="os.post"> + <i class="icon ti ti-pencil ti-fw"></i><span class="text">{{ i18n.ts.note }}</span> + </button> + <button v-click-anime v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="item _button account" @click="openAccountMenu"> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> + </button> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, watch } from 'vue'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { $i, openAccountMenu as openAccountMenu_ } from '@/account'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import { host } from '@/config'; + +const iconOnly = ref(false); + +const menu = computed(() => defaultStore.state.menu); +const otherMenuItemIndicated = computed(() => { + for (const def in navbarItemDef) { + if (menu.value.includes(def)) continue; + if (navbarItemDef[def].indicated) return true; + } + return false; +}); + +const calcViewState = () => { + iconOnly.value = (window.innerWidth <= 1279) || (defaultStore.state.menuDisplay === 'sideIcon'); +}; + +calcViewState(); + +window.addEventListener('resize', calcViewState); + +watch(defaultStore.reactiveState.menuDisplay, () => { + calcViewState(); +}); + +function openAccountMenu(ev: MouseEvent) { + openAccountMenu_({ + withExtraOperation: true, + }, ev); +} + +function openInstanceMenu(ev: MouseEvent) { + os.popupMenu([{ + text: instance.name ?? host, + type: 'label', + }, { + type: 'link', + text: i18n.ts.instanceInfo, + icon: 'ti ti-info-circle', + to: '/about', + }, { + type: 'link', + text: i18n.ts.customEmojis, + icon: 'ti ti-mood-happy', + to: '/about#emojis', + }, { + type: 'link', + text: i18n.ts.federation, + icon: 'ti ti-whirl', + to: '/about#federation', + }, null, { + type: 'parent', + text: i18n.ts.help, + icon: 'ti ti-question-circle', + children: [{ + type: 'link', + to: '/mfm-cheat-sheet', + text: i18n.ts._mfm.cheatSheet, + icon: 'ti ti-code', + }, { + type: 'link', + to: '/scratchpad', + text: i18n.ts.scratchpad, + icon: 'ti ti-terminal-2', + }, { + type: 'link', + to: '/api-console', + text: 'API Console', + icon: 'ti ti-terminal-2', + }, null, { + text: i18n.ts.document, + icon: 'ti ti-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.html', '_blank'); + }, + }], + }, { + type: 'link', + text: i18n.ts.aboutMisskey, + to: '/about-misskey', + }], ev.currentTarget ?? ev.target, { + align: 'left', + }); +} + +function more(ev: MouseEvent) { + os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { + src: ev.currentTarget ?? ev.target, + }, { + }, 'closed'); +} +</script> + +<style lang="scss" scoped> +.mvcprjjd { + $nav-width: 250px; + $nav-icon-only-width: 80px; + + flex: 0 0 $nav-width; + width: $nav-width; + box-sizing: border-box; + + > .body { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + width: $nav-icon-only-width; + height: 100dvh; + box-sizing: border-box; + overflow: auto; + overflow-x: clip; + background: var(--navBg); + contain: strict; + display: flex; + flex-direction: column; + } + + &:not(.iconOnly) { + > .body { + width: $nav-width; + + > .top { + position: sticky; + top: 0; + z-index: 1; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .banner { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center center; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + } + + > .instance { + position: relative; + display: block; + text-align: center; + width: 100%; + + > .icon { + display: inline-block; + width: 38px; + aspect-ratio: 1; + } + } + } + + > .bottom { + position: sticky; + bottom: 0; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .post { + position: relative; + display: block; + width: 100%; + height: 40px; + color: var(--fgOnAccent); + font-weight: bold; + text-align: left; + + &:before { + content: ""; + display: block; + width: calc(100% - 38px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + } + + &:hover, &.active { + &:before { + background: var(--accentLighten); + } + } + + > .icon { + position: relative; + margin-left: 30px; + margin-right: 8px; + width: 32px; + } + + > .text { + position: relative; + } + } + + > .account { + position: relative; + display: flex; + align-items: center; + padding-left: 30px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + margin-top: 16px; + + > .avatar { + position: relative; + width: 32px; + aspect-ratio: 1; + margin-right: 8px; + } + } + } + + > .middle { + flex: 1; + + > .divider { + margin: 16px 16px; + border-top: solid 0.5px var(--divider); + } + + > .item { + position: relative; + display: block; + padding-left: 30px; + line-height: 2.85rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + color: var(--navFg); + + > .icon { + position: relative; + width: 32px; + margin-right: 8px; + } + + > .indicator { + position: absolute; + top: 0; + left: 20px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + > .text { + position: relative; + font-size: 0.9em; + } + + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } + + &.active { + color: var(--navActive); + } + + &:hover, &.active { + color: var(--accent); + + &:before { + content: ""; + display: block; + width: calc(100% - 34px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: var(--accentedBg); + } + } + } + } + } + } + + &.iconOnly { + flex: 0 0 $nav-icon-only-width; + width: $nav-icon-only-width; + + > .body { + width: $nav-icon-only-width; + + > .top { + position: sticky; + top: 0; + z-index: 1; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .instance { + display: block; + text-align: center; + width: 100%; + + > .icon { + display: inline-block; + width: 30px; + aspect-ratio: 1; + } + } + } + + > .bottom { + position: sticky; + bottom: 0; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .post { + display: block; + position: relative; + width: 100%; + height: 52px; + margin-bottom: 16px; + text-align: center; + + &:before { + content: ""; + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 52px; + aspect-ratio: 1/1; + border-radius: 100%; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + } + + &:hover, &.active { + &:before { + background: var(--accentLighten); + } + } + + > .icon { + position: relative; + color: var(--fgOnAccent); + } + + > .text { + display: none; + } + } + + > .account { + display: block; + text-align: center; + width: 100%; + + > .avatar { + display: inline-block; + width: 38px; + aspect-ratio: 1; + } + + > .text { + display: none; + } + } + } + + > .middle { + flex: 1; + + > .divider { + margin: 8px auto; + width: calc(100% - 32px); + border-top: solid 0.5px var(--divider); + } + + > .item { + display: block; + position: relative; + padding: 18px 0; + width: 100%; + text-align: center; + + > .icon { + display: block; + margin: 0 auto; + opacity: 0.7; + } + + > .text { + display: none; + } + + > .indicator { + position: absolute; + top: 6px; + left: 24px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + &:hover, &.active { + text-decoration: none; + color: var(--accent); + + &:before { + content: ""; + display: block; + height: 100%; + aspect-ratio: 1; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: var(--accentedBg); + } + + > .icon, > .text { + opacity: 1; + } + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue new file mode 100644 index 0000000000..24fc4f6f6d --- /dev/null +++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue @@ -0,0 +1,108 @@ +<template> +<span v-if="!fetching" class="nmidsaqw"> + <template v-if="display === 'marquee'"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <span v-for="instance in instances" :key="instance.id" class="item" :class="{ colored }" :style="{ background: colored ? instance.themeColor : null }"> + <img class="icon" :src="getInstanceIcon(instance)" alt=""/> + <MkA :to="`/instance-info/${instance.host}`" class="host _monospace"> + {{ instance.host }} + </MkA> + <span class="divider"></span> + </span> + </MarqueeText> + </transition> + </template> + <template v-else-if="display === 'oneByOne'"> + <!-- TODO --> + </template> +</span> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MarqueeText from '@/components/MkMarquee.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { getNoteSummary } from '@/scripts/get-note-summary'; +import { notePage } from '@/filters/note'; +import { getProxiedImageUrlNullable } from '@/scripts/media-proxy'; + +const props = defineProps<{ + display?: 'marquee' | 'oneByOne'; + colored?: boolean; + marqueeDuration?: number; + marqueeReverse?: boolean; + oneByOneInterval?: number; + refreshIntervalSec?: number; +}>(); + +const instances = ref<misskey.entities.Instance[]>([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 30, + }).then(res => { + instances.value = res; + fetching.value = false; + key++; + }); +}; + +useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); + +function getInstanceIcon(instance): string { + return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png'; +} +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.nmidsaqw { + display: inline-block; + position: relative; + + ::v-deep(.item) { + display: inline-block; + vertical-align: bottom; + margin-right: 5em; + + > .icon { + display: inline-block; + height: var(--height); + aspect-ratio: 1; + vertical-align: bottom; + margin-right: 1em; + } + + > .host { + vertical-align: bottom; + } + + &.colored { + padding-right: 1em; + color: #fff; + } + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue new file mode 100644 index 0000000000..e7f88e4984 --- /dev/null +++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue @@ -0,0 +1,93 @@ +<template> +<span v-if="!fetching" class="xbhtxfms"> + <template v-if="display === 'marquee'"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <span v-for="item in items" class="item"> + <a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span> + </span> + </MarqueeText> + </transition> + </template> + <template v-else-if="display === 'oneByOne'"> + <!-- TODO --> + </template> +</span> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import MarqueeText from '@/components/MkMarquee.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { shuffle } from '@/scripts/shuffle'; + +const props = defineProps<{ + url?: string; + shuffle?: boolean; + display?: 'marquee' | 'oneByOne'; + marqueeDuration?: number; + marqueeReverse?: boolean; + oneByOneInterval?: number; + refreshIntervalSec?: number; +}>(); + +const items = ref([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + window.fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => { + res.json().then(feed => { + if (props.shuffle) { + shuffle(feed.items); + } + items.value = feed.items; + fetching.value = false; + key++; + }); + }); +}; + +useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.xbhtxfms { + display: inline-block; + position: relative; + + ::v-deep(.item) { + display: inline-flex; + align-items: center; + vertical-align: bottom; + margin: 0; + + > .divider { + display: inline-block; + width: 0.5px; + height: var(--height); + margin: 0 3em; + background: currentColor; + opacity: 0.3; + } + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue new file mode 100644 index 0000000000..f4d989c387 --- /dev/null +++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue @@ -0,0 +1,113 @@ +<template> +<span v-if="!fetching" class="osdsvwzy"> + <template v-if="display === 'marquee'"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <span v-for="note in notes" :key="note.id" class="item"> + <img class="avatar" :src="note.user.avatarUrl" decoding="async"/> + <MkA class="text" :to="notePage(note)"> + <Mfm class="text" :text="getNoteSummary(note)" :plain="true" :nowrap="true" :custom-emojis="note.emojis"/> + </MkA> + <span class="divider"></span> + </span> + </MarqueeText> + </transition> + </template> + <template v-else-if="display === 'oneByOne'"> + <!-- TODO --> + </template> +</span> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MarqueeText from '@/components/MkMarquee.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { getNoteSummary } from '@/scripts/get-note-summary'; +import { notePage } from '@/filters/note'; + +const props = defineProps<{ + userListId?: string; + display?: 'marquee' | 'oneByOne'; + marqueeDuration?: number; + marqueeReverse?: boolean; + oneByOneInterval?: number; + refreshIntervalSec?: number; +}>(); + +const notes = ref<misskey.entities.Note[]>([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + if (props.userListId == null) return; + os.api('notes/user-list-timeline', { + listId: props.userListId, + }).then(res => { + notes.value = res; + fetching.value = false; + key++; + }); +}; + +watch(() => props.userListId, tick); + +useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.osdsvwzy { + display: inline-block; + position: relative; + + ::v-deep(.item) { + display: inline-flex; + align-items: center; + vertical-align: bottom; + margin: 0; + + > .avatar { + display: inline-block; + height: var(--height); + aspect-ratio: 1; + vertical-align: bottom; + margin-right: 8px; + } + + > .text { + > .text { + display: inline-block; + vertical-align: bottom; + } + } + + > .divider { + display: inline-block; + width: 0.5px; + height: 16px; + margin: 0 3em; + background: currentColor; + opacity: 0; + } + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/statusbars.vue b/packages/frontend/src/ui/_common_/statusbars.vue new file mode 100644 index 0000000000..114ca5be8c --- /dev/null +++ b/packages/frontend/src/ui/_common_/statusbars.vue @@ -0,0 +1,92 @@ +<template> +<div class="dlrsnxqu"> + <div + v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" class="item" :class="[{ black: x.black }, { + verySmall: x.size === 'verySmall', + small: x.size === 'small', + medium: x.size === 'medium', + large: x.size === 'large', + veryLarge: x.size === 'veryLarge', + }]" + > + <span class="name">{{ x.name }}</span> + <XRss v-if="x.type === 'rss'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :url="x.props.url" :shuffle="x.props.shuffle"/> + <XFederation v-else-if="x.type === 'federation'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :colored="x.props.colored"/> + <XUserList v-else-if="x.type === 'userList'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :user-list-id="x.props.userListId"/> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +const XRss = defineAsyncComponent(() => import('./statusbar-rss.vue')); +const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vue')); +const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue')); +</script> + +<style lang="scss" scoped> +.dlrsnxqu { + font-size: 15px; + background: var(--panel); + + > .item { + --height: 24px; + --nameMargin: 10px; + font-size: 0.85em; + + &.verySmall { + --nameMargin: 7px; + --height: 16px; + font-size: 0.75em; + } + + &.small { + --nameMargin: 8px; + --height: 20px; + font-size: 0.8em; + } + + &.large { + --nameMargin: 12px; + --height: 26px; + font-size: 0.875em; + } + + &.veryLarge { + --nameMargin: 14px; + --height: 30px; + font-size: 0.9em; + } + + display: flex; + vertical-align: bottom; + width: 100%; + line-height: var(--height); + height: var(--height); + overflow: clip; + contain: strict; + + > .name { + padding: 0 var(--nameMargin); + font-weight: bold; + color: var(--accent); + + &:empty { + display: none; + } + } + + > .body { + min-width: 0; + flex: 1; + } + + &.black { + background: #000; + color: #fff; + } + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue new file mode 100644 index 0000000000..a855de8ab9 --- /dev/null +++ b/packages/frontend/src/ui/_common_/stream-indicator.vue @@ -0,0 +1,61 @@ +<template> +<div v-if="hasDisconnected && $store.state.serverDisconnectedBehavior === 'quiet'" class="nsbbhtug" @click="resetDisconnected"> + <div>{{ i18n.ts.disconnectedFromServer }}</div> + <div class="command"> + <button class="_textButton" @click="reload">{{ i18n.ts.reload }}</button> + <button class="_textButton">{{ i18n.ts.doNothing }}</button> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onUnmounted } from 'vue'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; + +let hasDisconnected = $ref(false); + +function onDisconnected() { + hasDisconnected = true; +} + +function resetDisconnected() { + hasDisconnected = false; +} + +function reload() { + location.reload(); +} + +stream.on('_disconnected_', onDisconnected); + +onUnmounted(() => { + stream.off('_disconnected_', onDisconnected); +}); +</script> + +<style lang="scss" scoped> +.nsbbhtug { + position: fixed; + z-index: 16385; + bottom: 8px; + right: 8px; + margin: 0; + padding: 6px 12px; + font-size: 0.9em; + color: #fff; + background: #000; + opacity: 0.8; + border-radius: 4px; + max-width: 320px; + + > .command { + display: flex; + justify-content: space-around; + + > button { + padding: 0.7em; + } + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts new file mode 100644 index 0000000000..8676d2d48d --- /dev/null +++ b/packages/frontend/src/ui/_common_/sw-inject.ts @@ -0,0 +1,35 @@ +import { inject } from 'vue'; +import { post } from '@/os'; +import { $i, login } from '@/account'; +import { defaultStore } from '@/store'; +import { getAccountFromId } from '@/scripts/get-account-from-id'; +import { mainRouter } from '@/router'; + +export function swInject() { + navigator.serviceWorker.addEventListener('message', ev => { + if (_DEV_) { + console.log('sw msg', ev.data); + } + + if (ev.data.type !== 'order') return; + + if (ev.data.loginId !== $i?.id) { + return getAccountFromId(ev.data.loginId).then(account => { + if (!account) return; + return login(account.token, ev.data.url); + }); + } + + switch (ev.data.order) { + case 'post': + return post(ev.data.options); + case 'push': + if (mainRouter.currentRoute.value.path === ev.data.url) { + return window.scroll({ top: 0, behavior: 'smooth' }); + } + return mainRouter.push(ev.data.url); + default: + return; + } + }); +} diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue new file mode 100644 index 0000000000..70882bd251 --- /dev/null +++ b/packages/frontend/src/ui/_common_/upload.vue @@ -0,0 +1,129 @@ +<template> +<div class="mk-uploader _acrylic" :style="{ zIndex }"> + <ol v-if="uploads.length > 0"> + <li v-for="ctx in uploads" :key="ctx.id"> + <div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div> + <div class="top"> + <p class="name"><MkLoading :em="true"/>{{ ctx.name }}</p> + <p class="status"> + <span v-if="ctx.progressValue === undefined" class="initing">{{ i18n.ts.waiting }}<MkEllipsis/></span> + <span v-if="ctx.progressValue !== undefined" class="kb">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> + <span v-if="ctx.progressValue !== undefined" class="percentage">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span> + </p> + </div> + <progress :value="ctx.progressValue || 0" :max="ctx.progressMax || 0" :class="{ initing: ctx.progressValue === undefined, waiting: ctx.progressValue !== undefined && ctx.progressValue === ctx.progressMax }"></progress> + </li> + </ol> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as os from '@/os'; +import { uploads } from '@/scripts/upload'; +import { i18n } from '@/i18n'; + +const zIndex = os.claimZIndex('high'); +</script> + +<style lang="scss" scoped> +.mk-uploader { + position: fixed; + right: 16px; + width: 260px; + top: 32px; + padding: 16px 20px; + pointer-events: none; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + border-radius: 8px; +} +.mk-uploader:empty { + display: none; +} +.mk-uploader > ol { + display: block; + margin: 0; + padding: 0; + list-style: none; +} +.mk-uploader > ol > li { + display: grid; + margin: 8px 0 0 0; + padding: 0; + height: 36px; + width: 100%; + border-top: solid 8px transparent; + grid-template-columns: 36px calc(100% - 44px); + grid-template-rows: 1fr 8px; + column-gap: 8px; + box-sizing: content-box; +} +.mk-uploader > ol > li:first-child { + margin: 0; + box-shadow: none; + border-top: none; +} +.mk-uploader > ol > li > .img { + display: block; + background-size: cover; + background-position: center center; + grid-column: 1/2; + grid-row: 1/3; +} +.mk-uploader > ol > li > .top { + display: flex; + grid-column: 2/3; + grid-row: 1/2; +} +.mk-uploader > ol > li > .top > .name { + display: block; + padding: 0 8px 0 0; + margin: 0; + font-size: 0.8em; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + flex-shrink: 1; +} +.mk-uploader > ol > li > .top > .name > i { + margin-right: 4px; +} +.mk-uploader > ol > li > .top > .status { + display: block; + margin: 0 0 0 auto; + padding: 0; + font-size: 0.8em; + flex-shrink: 0; +} +.mk-uploader > ol > li > .top > .status > .initing { +} +.mk-uploader > ol > li > .top > .status > .kb { +} +.mk-uploader > ol > li > .top > .status > .percentage { + display: inline-block; + width: 48px; + text-align: right; +} +.mk-uploader > ol > li > .top > .status > .percentage:after { + content: '%'; +} +.mk-uploader > ol > li > progress { + display: block; + background: transparent; + border: none; + border-radius: 4px; + overflow: hidden; + grid-column: 2/3; + grid-row: 2/3; + z-index: 2; + width: 100%; + height: 8px; +} +.mk-uploader > ol > li > progress::-webkit-progress-value { + background: var(--accent); +} +.mk-uploader > ol > li > progress::-webkit-progress-bar { + //background: var(--accentAlpha01); + background: transparent; +} +</style> diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue new file mode 100644 index 0000000000..46d79e6355 --- /dev/null +++ b/packages/frontend/src/ui/classic.header.vue @@ -0,0 +1,217 @@ +<template> +<div class="azykntjl"> + <div class="body"> + <div class="left"> + <MkA v-click-anime v-tooltip="$ts.timeline" class="item index" active-class="active" to="/" exact> + <i class="ti ti-home ti-fw"></i> + </MkA> + <template v-for="item in menu"> + <div v-if="item === '-'" class="divider"></div> + <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="$ts[navbarItemDef[item].title]" class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> + <i class="ti-fw" :class="navbarItemDef[item].icon"></i> + <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span> + </component> + </template> + <div class="divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip="$ts.controlPanel" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null"> + <i class="ti ti-dashboard ti-fw"></i> + </MkA> + <button v-click-anime class="item _button" @click="more"> + <i class="ti ti-dots ti-fw"></i> + <span v-if="otherNavItemIndicated" class="indicator"><i class="_indicatorCircle"></i></span> + </button> + </div> + <div class="right"> + <MkA v-click-anime v-tooltip="$ts.settings" class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null"> + <i class="ti ti-settings ti-fw"></i> + </MkA> + <button v-click-anime class="item _button account" @click="openAccountMenu"> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="acct" :user="$i"/> + </button> + <div class="post" @click="post"> + <MkButton class="button" gradate full rounded> + <i class="ti ti-pencil ti-fw"></i> + </MkButton> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { openAccountMenu } from '@/account'; +import MkButton from '@/components/MkButton.vue'; + +export default defineComponent({ + components: { + MkButton, + }, + + data() { + return { + host: host, + accounts: [], + connection: null, + navbarItemDef: navbarItemDef, + settingsWindowed: false, + }; + }, + + computed: { + menu(): string[] { + return this.$store.state.menu; + }, + + otherNavItemIndicated(): boolean { + for (const def in this.navbarItemDef) { + if (this.menu.includes(def)) continue; + if (this.navbarItemDef[def].indicated) return true; + } + return false; + }, + }, + + watch: { + '$store.reactiveState.menuDisplay.value'() { + this.calcViewState(); + }, + }, + + created() { + window.addEventListener('resize', this.calcViewState); + this.calcViewState(); + }, + + methods: { + calcViewState() { + this.settingsWindowed = (window.innerWidth > 1400); + }, + + post() { + os.post(); + }, + + search() { + search(); + }, + + more(ev) { + os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { + src: ev.currentTarget ?? ev.target, + anchor: { x: 'center', y: 'bottom' }, + }, { + }, 'closed'); + }, + + openAccountMenu: (ev) => { + openAccountMenu({ + withExtraOperation: true, + }, ev); + }, + }, +}); +</script> + +<style lang="scss" scoped> +.azykntjl { + $height: 60px; + $avatar-size: 32px; + $avatar-margin: 8px; + + position: sticky; + top: 0; + z-index: 1000; + width: 100%; + height: $height; + background-color: var(--bg); + + > .body { + max-width: 1380px; + margin: 0 auto; + display: flex; + + > .right, + > .left { + + > .item { + position: relative; + font-size: 0.9em; + display: inline-block; + padding: 0 12px; + line-height: $height; + + > i, + > .avatar { + margin-right: 0; + } + + > i { + left: 10px; + } + + > .avatar { + width: $avatar-size; + height: $avatar-size; + vertical-align: middle; + } + + > .indicator { + position: absolute; + top: 0; + left: 0; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } + + &.active { + color: var(--navActive); + } + } + + > .divider { + display: inline-block; + height: 16px; + margin: 0 10px; + border-right: solid 0.5px var(--divider); + } + + > .post { + display: inline-block; + + > .button { + width: 40px; + height: 40px; + padding: 0; + min-width: 0; + } + } + + > .account { + display: inline-flex; + align-items: center; + vertical-align: top; + margin-right: 8px; + + > .acct { + margin-left: 8px; + } + } + } + + > .right { + margin-left: auto; + } + } +} +</style> diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue new file mode 100644 index 0000000000..dac09ea703 --- /dev/null +++ b/packages/frontend/src/ui/classic.sidebar.vue @@ -0,0 +1,268 @@ +<template> +<div class="npcljfve" :class="{ iconOnly }"> + <button v-click-anime class="item _button account" @click="openAccountMenu"> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> + </button> + <div class="post" data-cy-open-post-form @click="post"> + <MkButton class="button" gradate full rounded> + <i class="ti ti-pencil ti-fw"></i><span v-if="!iconOnly" class="text">{{ $ts.note }}</span> + </MkButton> + </div> + <div class="divider"></div> + <MkA v-click-anime class="item index" active-class="active" to="/" exact> + <i class="ti ti-home ti-fw"></i><span class="text">{{ $ts.timeline }}</span> + </MkA> + <template v-for="item in menu"> + <div v-if="item === '-'" class="divider"></div> + <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> + <i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ $ts[navbarItemDef[item].title] }}</span> + <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span> + </component> + </template> + <div class="divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null"> + <i class="ti ti-dashboard ti-fw"></i><span class="text">{{ $ts.controlPanel }}</span> + </MkA> + <button v-click-anime class="item _button" @click="more"> + <i class="ti ti-dots ti-fw"></i><span class="text">{{ $ts.more }}</span> + <span v-if="otherNavItemIndicated" class="indicator"><i class="_indicatorCircle"></i></span> + </button> + <MkA v-click-anime class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null"> + <i class="ti ti-settings ti-fw"></i><span class="text">{{ $ts.settings }}</span> + </MkA> + <div class="divider"></div> + <div class="about"> + <MkA v-click-anime class="link" to="/about"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" class="_ghost"/> + </MkA> + </div> + <!--<MisskeyLogo class="misskey"/>--> +</div> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { openAccountMenu } from '@/account'; +import MkButton from '@/components/MkButton.vue'; +import { StickySidebar } from '@/scripts/sticky-sidebar'; +//import MisskeyLogo from '@assets/client/misskey.svg'; + +export default defineComponent({ + components: { + MkButton, + //MisskeyLogo, + }, + + data() { + return { + host: host, + accounts: [], + connection: null, + navbarItemDef: navbarItemDef, + iconOnly: false, + settingsWindowed: false, + }; + }, + + computed: { + menu(): string[] { + return this.$store.state.menu; + }, + + otherNavItemIndicated(): boolean { + for (const def in this.navbarItemDef) { + if (this.menu.includes(def)) continue; + if (this.navbarItemDef[def].indicated) return true; + } + return false; + }, + }, + + watch: { + '$store.reactiveState.menuDisplay.value'() { + this.calcViewState(); + }, + + iconOnly() { + this.$nextTick(() => { + this.$emit('change-view-mode'); + }); + }, + }, + + created() { + window.addEventListener('resize', this.calcViewState); + this.calcViewState(); + }, + + mounted() { + const sticky = new StickySidebar(this.$el.parentElement, 16); + window.addEventListener('scroll', () => { + sticky.calc(window.scrollY); + }, { passive: true }); + }, + + methods: { + calcViewState() { + this.iconOnly = (window.innerWidth <= 1400) || (this.$store.state.menuDisplay === 'sideIcon'); + this.settingsWindowed = (window.innerWidth > 1400); + }, + + post() { + os.post(); + }, + + search() { + search(); + }, + + more(ev) { + os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { + src: ev.currentTarget ?? ev.target, + }, {}, 'closed'); + }, + + openAccountMenu: (ev) => { + openAccountMenu({ + withExtraOperation: true, + }, ev); + }, + }, +}); +</script> + +<style lang="scss" scoped> +.npcljfve { + $ui-font-size: 1em; // TODO: どこかに集約したい + $nav-icon-only-width: 78px; // TODO: どこかに集約したい + $avatar-size: 32px; + $avatar-margin: 8px; + + padding: 0 16px; + box-sizing: border-box; + width: 260px; + + &.iconOnly { + flex: 0 0 $nav-icon-only-width; + width: $nav-icon-only-width !important; + + > .divider { + margin: 8px auto; + width: calc(100% - 32px); + } + + > .post { + > .button { + width: 46px; + height: 46px; + padding: 0; + } + } + + > .item { + padding-left: 0; + width: 100%; + text-align: center; + font-size: $ui-font-size * 1.1; + line-height: 3.7rem; + + > i, + > .avatar { + margin-right: 0; + } + + > i { + left: 10px; + } + + > .text { + display: none; + } + } + } + + > .divider { + margin: 10px 0; + border-top: solid 0.5px var(--divider); + } + + > .post { + position: sticky; + top: 0; + z-index: 1; + padding: 16px 0; + background: var(--bg); + + > .button { + min-width: 0; + } + } + + > .about { + fill: currentColor; + padding: 8px 0 16px 0; + text-align: center; + + > .link { + display: block; + width: 32px; + margin: 0 auto; + + img { + display: block; + width: 100%; + } + } + } + + > .item { + position: relative; + display: block; + font-size: $ui-font-size; + line-height: 2.6rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + + > i { + width: 32px; + } + + > i, + > .avatar { + margin-right: $avatar-margin; + } + + > .avatar { + width: $avatar-size; + height: $avatar-size; + vertical-align: middle; + } + + > .indicator { + position: absolute; + top: 0; + left: 0; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } + + &.active { + color: var(--navActive); + } + } +} +</style> diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue new file mode 100644 index 0000000000..0e726c11ed --- /dev/null +++ b/packages/frontend/src/ui/classic.vue @@ -0,0 +1,320 @@ +<template> +<div class="gbhvwtnk" :class="{ wallpaper }" :style="`--globalHeaderHeight:${globalHeaderHeight}px`"> + <XHeaderMenu v-if="showMenuOnTop" v-get-size="(w, h) => globalHeaderHeight = h"/> + + <div class="columns" :class="{ fullView, withGlobalHeader: showMenuOnTop }"> + <div v-if="!showMenuOnTop" class="sidebar"> + <XSidebar/> + </div> + <div v-else ref="widgetsLeft" class="widgets left"> + <XWidgets :place="'left'" @mounted="attachSticky(widgetsLeft)"/> + </div> + + <main class="main" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> + <div class="content"> + <RouterView/> + </div> + </main> + + <div v-if="isDesktop" ref="widgetsRight" class="widgets right"> + <XWidgets :place="null" @mounted="attachSticky(widgetsRight)"/> + </div> + </div> + + <transition :name="$store.state.animation ? 'tray-back' : ''"> + <div + v-if="widgetsShowing" + class="tray-back _modalBg" + @click="widgetsShowing = false" + @touchstart.passive="widgetsShowing = false" + ></div> + </transition> + + <transition :name="$store.state.animation ? 'tray' : ''"> + <XWidgets v-if="widgetsShowing" class="tray"/> + </transition> + + <iframe v-if="$store.state.aiChanMode" ref="live2d" class="ivnzpscs" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe> + + <XCommon/> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, markRaw, ComputedRef, ref, onMounted, provide } from 'vue'; +import XSidebar from './classic.sidebar.vue'; +import XCommon from './_common_/common.vue'; +import { instanceName } from '@/config'; +import { StickySidebar } from '@/scripts/sticky-sidebar'; +import * as os from '@/os'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue')); +const XWidgets = defineAsyncComponent(() => import('./classic.widgets.vue')); + +const DESKTOP_THRESHOLD = 1100; + +let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD); + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); +let widgetsShowing = $ref(false); +let fullView = $ref(false); +let globalHeaderHeight = $ref(0); +const wallpaper = localStorage.getItem('wallpaper') != null; +const showMenuOnTop = $computed(() => defaultStore.state.menuDisplay === 'top'); +let live2d = $ref<HTMLIFrameElement>(); +let widgetsLeft = $ref(); +let widgetsRight = $ref(); + +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; + if (pageMetadata.value) { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } +}); +provide('shouldHeaderThin', showMenuOnTop); +provide('shouldSpacerMin', true); + +function attachSticky(el) { + const sticky = new StickySidebar(el, defaultStore.state.menuDisplay === 'top' ? 0 : 16, defaultStore.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す + window.addEventListener('scroll', () => { + sticky.calc(window.scrollY); + }, { passive: true }); +} + +function top() { + window.scroll({ top: 0, behavior: 'smooth' }); +} + +function onContextmenu(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; + const path = mainRouter.getCurrentPath(); + os.contextMenu([{ + type: 'label', + text: path, + }, { + icon: fullView ? 'fas fa-compress' : 'fas fa-expand', + text: fullView ? i18n.ts.quitFullView : i18n.ts.fullView, + action: () => { + fullView = !fullView; + }, + }, { + icon: 'ti ti-window-maximize', + text: i18n.ts.openInWindow, + action: () => { + os.pageWindow(path); + }, + }], ev); +} + +function onAiClick(ev) { + //if (this.live2d) this.live2d.click(ev); +} + +if (window.innerWidth < 1024) { + localStorage.setItem('ui', 'default'); + location.reload(); +} + +document.documentElement.style.overflowY = 'scroll'; + +if (defaultStore.state.widgets.length === 0) { + defaultStore.set('widgets', [{ + name: 'calendar', + id: 'a', place: null, data: {}, + }, { + name: 'notifications', + id: 'b', place: null, data: {}, + }, { + name: 'trends', + id: 'c', place: null, data: {}, + }]); +} + +onMounted(() => { + window.addEventListener('resize', () => { + isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD); + }, { passive: true }); + + if (defaultStore.state.aiChanMode) { + const iframeRect = live2d.getBoundingClientRect(); + window.addEventListener('mousemove', ev => { + live2d.contentWindow.postMessage({ + type: 'moveCursor', + body: { + x: ev.clientX - iframeRect.left, + y: ev.clientY - iframeRect.top, + }, + }, '*'); + }, { passive: true }); + window.addEventListener('touchmove', ev => { + live2d.contentWindow.postMessage({ + type: 'moveCursor', + body: { + x: ev.touches[0].clientX - iframeRect.left, + y: ev.touches[0].clientY - iframeRect.top, + }, + }, '*'); + }, { passive: true }); + } +}); +</script> + +<style lang="scss" scoped> +.tray-enter-active, +.tray-leave-active { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-enter-from, +.tray-leave-active { + opacity: 0; + transform: translateX(240px); +} + +.tray-back-enter-active, +.tray-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-back-enter-from, +.tray-back-leave-active { + opacity: 0; +} + +.gbhvwtnk { + $ui-font-size: 1em; + $widgets-hide-threshold: 1200px; + + min-height: 100dvh; + box-sizing: border-box; + + &.wallpaper { + background: var(--wallpaperOverlay); + //backdrop-filter: var(--blur, blur(4px)); + } + + > .columns { + display: flex; + justify-content: center; + max-width: 100%; + //margin: 32px 0; + + &.fullView { + margin: 0; + + > .sidebar { + display: none; + } + + > .widgets { + display: none; + } + + > .main { + margin: 0; + border-radius: 0; + box-shadow: none; + width: 100%; + } + } + + > .main { + min-width: 0; + width: 750px; + margin: 0 16px 0 0; + background: var(--panel); + border-left: solid 1px var(--divider); + border-right: solid 1px var(--divider); + border-radius: 0; + overflow: clip; + --margin: 12px; + } + + > .widgets { + //--panelBorder: none; + width: 300px; + margin-top: 16px; + + @media (max-width: $widgets-hide-threshold) { + display: none; + } + + &.left { + margin-right: 16px; + } + } + + > .sidebar { + margin-top: 16px; + } + + &.withGlobalHeader { + > .main { + margin-top: 0; + border: solid 1px var(--divider); + border-radius: var(--radius); + --stickyTop: var(--globalHeaderHeight); + } + + > .widgets { + --stickyTop: var(--globalHeaderHeight); + margin-top: 0; + } + } + + @media (max-width: 850px) { + margin: 0; + + > .sidebar { + border-right: solid 0.5px var(--divider); + } + + > .main { + margin: 0; + border-radius: 0; + box-shadow: none; + width: 100%; + } + } + } + + > .tray-back { + z-index: 1001; + } + + > .tray { + position: fixed; + top: 0; + right: 0; + z-index: 1001; + height: 100dvh; + padding: var(--margin); + box-sizing: border-box; + overflow: auto; + background: var(--bg); + } + + > .ivnzpscs { + position: fixed; + bottom: 0; + right: 0; + width: 300px; + height: 600px; + border: none; + pointer-events: none; + } +} +</style> diff --git a/packages/frontend/src/ui/classic.widgets.vue b/packages/frontend/src/ui/classic.widgets.vue new file mode 100644 index 0000000000..163ec982ce --- /dev/null +++ b/packages/frontend/src/ui/classic.widgets.vue @@ -0,0 +1,84 @@ +<template> +<div class="ddiqwdnk"> + <XWidgets class="widgets" :edit="editMode" :widgets="$store.reactiveState.widgets.value.filter(w => w.place === place)" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> + <MkAd class="a" :prefer="['square']"/> + + <button v-if="editMode" class="_textButton edit" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ $ts.editWidgetsExit }}</button> + <button v-else class="_textButton edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ $ts.editWidgets }}</button> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import XWidgets from '@/components/MkWidgets.vue'; + +export default defineComponent({ + components: { + XWidgets, + }, + + props: { + place: { + type: String, + }, + }, + + emits: ['mounted'], + + data() { + return { + editMode: false, + }; + }, + + mounted() { + this.$emit('mounted', this.$el); + }, + + methods: { + addWidget(widget) { + this.$store.set('widgets', [{ + ...widget, + place: this.place, + }, ...this.$store.state.widgets]); + }, + + removeWidget(widget) { + this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id !== widget.id)); + }, + + updateWidget({ id, data }) { + this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? { + ...w, + data, + } : w)); + }, + + updateWidgets(widgets) { + this.$store.set('widgets', [ + ...this.$store.state.widgets.filter(w => w.place !== this.place), + ...widgets, + ]); + }, + }, +}); +</script> + +<style lang="scss" scoped> +.ddiqwdnk { + position: sticky; + height: min-content; + box-sizing: border-box; + padding-bottom: 8px; + + > .widgets, + > .a { + width: 300px; + } + + > .edit { + display: block; + margin: 16px auto; + } +} +</style> diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue new file mode 100644 index 0000000000..f3415cfd09 --- /dev/null +++ b/packages/frontend/src/ui/deck.vue @@ -0,0 +1,435 @@ +<template> +<div + class="mk-deck" :class="[{ isMobile }]" +> + <XSidebar v-if="!isMobile"/> + + <div class="main"> + <XStatusBars class="statusbars"/> + <div ref="columnsEl" class="columns" :class="deckStore.reactiveState.columnAlign.value" @contextmenu.self.prevent="onContextmenu"> + <template v-for="ids in layout"> + <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> + <section + v-if="ids.length > 1" + class="folder column" + :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" + > + <DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/> + </section> + <DeckColumnCore + v-else + :ref="ids[0]" + :key="ids[0]" + class="column" + :column="columns.find(c => c.id === ids[0])" + :is-stacked="false" + :style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }" + @parent-focus="moveFocus(ids[0], $event)" + /> + </template> + <div v-if="layout.length === 0" class="intro _panel"> + <div>{{ i18n.ts._deck.introduction }}</div> + <MkButton primary class="add" @click="addColumn">{{ i18n.ts._deck.addColumn }}</MkButton> + <div>{{ i18n.ts._deck.introduction2 }}</div> + </div> + <div class="sideMenu"> + <div class="top"> + <button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${deckStore.state.profile}`" class="_button button" @click="changeProfile"><i class="ti ti-caret-down"></i></button> + <button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" class="_button button" @click="deleteProfile"><i class="ti ti-trash"></i></button> + </div> + <div class="middle"> + <button v-tooltip.noDelay.left="i18n.ts._deck.addColumn" class="_button button" @click="addColumn"><i class="ti ti-plus"></i></button> + </div> + <div class="bottom"> + <button v-tooltip.noDelay.left="i18n.ts.settings" class="_button button settings" @click="showSettings"><i class="ti ti-settings"></i></button> + </div> + </div> + </div> + </div> + + <div v-if="isMobile" class="buttons"> + <button class="button nav _button" @click="drawerMenuShowing = true"><i class="ti ti-menu-2"></i><span v-if="menuIndicated" class="indicator"><i class="_indicatorCircle"></i></span></button> + <button class="button home _button" @click="mainRouter.push('/')"><i class="ti ti-home"></i></button> + <button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="ti ti-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="_indicatorCircle"></i></span></button> + <button class="button post _button" @click="os.post()"><i class="ti ti-pencil"></i></button> + </div> + + <transition :name="$store.state.animation ? 'menu-back' : ''"> + <div + v-if="drawerMenuShowing" + class="menu-back _modalBg" + @click="drawerMenuShowing = false" + @touchstart.passive="drawerMenuShowing = false" + ></div> + </transition> + + <transition :name="$store.state.animation ? 'menu' : ''"> + <XDrawerMenu v-if="drawerMenuShowing" class="menu"/> + </transition> + + <XCommon/> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, onMounted, provide, ref, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XCommon from './_common_/common.vue'; +import { deckStore, addColumn as addColumnToStore, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store'; +import DeckColumnCore from '@/ui/deck/column-core.vue'; +import XSidebar from '@/ui/_common_/navbar.vue'; +import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; +import MkButton from '@/components/MkButton.vue'; +import { getScrollContainer } from '@/scripts/scroll'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { mainRouter } from '@/router'; +import { unisonReload } from '@/scripts/unison-reload'; +const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); + +mainRouter.navHook = (path, flag): boolean => { + if (flag === 'forcePage') return false; + const noMainColumn = !deckStore.state.columns.some(x => x.type === 'main'); + if (deckStore.state.navWindow || noMainColumn) { + os.pageWindow(path); + return true; + } + return false; +}; + +const isMobile = ref(window.innerWidth <= 500); +window.addEventListener('resize', () => { + isMobile.value = window.innerWidth <= 500; +}); + +const drawerMenuShowing = ref(false); + +const route = 'TODO'; +watch(route, () => { + drawerMenuShowing.value = false; +}); + +const columns = deckStore.reactiveState.columns; +const layout = deckStore.reactiveState.layout; +const menuIndicated = computed(() => { + if ($i == null) return false; + for (const def in navbarItemDef) { + if (navbarItemDef[def].indicated) return true; + } + return false; +}); + +function showSettings() { + os.pageWindow('/settings/deck'); +} + +let columnsEl = $ref<HTMLElement>(); + +const addColumn = async (ev) => { + const columns = [ + 'main', + 'widgets', + 'notifications', + 'tl', + 'antenna', + 'list', + 'mentions', + 'direct', + ]; + + const { canceled, result: column } = await os.select({ + title: i18n.ts._deck.addColumn, + items: columns.map(column => ({ + value: column, text: i18n.t('_deck._columns.' + column), + })), + }); + if (canceled) return; + + addColumnToStore({ + type: column, + id: uuid(), + name: i18n.t('_deck._columns.' + column), + width: 330, + }); +}; + +const onContextmenu = (ev) => { + os.contextMenu([{ + text: i18n.ts._deck.addColumn, + action: addColumn, + }], ev); +}; + +document.documentElement.style.overflowY = 'hidden'; +document.documentElement.style.scrollBehavior = 'auto'; +window.addEventListener('wheel', (ev) => { + if (ev.target === columnsEl && ev.deltaX === 0) { + columnsEl.scrollLeft += ev.deltaY; + } else if (getScrollContainer(ev.target as HTMLElement) == null && ev.deltaX === 0) { + columnsEl.scrollLeft += ev.deltaY; + } +}); +loadDeck(); + +function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') { + // TODO?? +} + +function changeProfile(ev: MouseEvent) { + const items = ref([{ + text: deckStore.state.profile, + active: true.valueOf, + }]); + getProfiles().then(profiles => { + items.value = [{ + text: deckStore.state.profile, + active: true.valueOf, + }, ...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({ + text: k, + action: () => { + deckStore.set('profile', k); + unisonReload(); + }, + }))), null, { + text: i18n.ts._deck.newProfile, + icon: 'ti ti-plus', + action: async () => { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts._deck.profile, + allowEmpty: false, + }); + if (canceled) return; + + deckStore.set('profile', name); + unisonReload(); + }, + }]; + }); + os.popupMenu(items, ev.currentTarget ?? ev.target); +} + +async function deleteProfile() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('deleteAreYouSure', { x: deckStore.state.profile }), + }); + if (canceled) return; + + deleteProfile_(deckStore.state.profile); + deckStore.set('profile', 'default'); + unisonReload(); +} +</script> + +<style lang="scss" scoped> +.menu-enter-active, +.menu-leave-active { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.menu-enter-from, +.menu-leave-active { + opacity: 0; + transform: translateX(-240px); +} + +.menu-back-enter-active, +.menu-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.menu-back-enter-from, +.menu-back-leave-active { + opacity: 0; +} + +.mk-deck { + $nav-hide-threshold: 650px; // TODO: どこかに集約したい + + // TODO: ここではなくて、各カラムで自身の幅に応じて上書きするようにしたい + --margin: var(--marginHalf); + + --deckDividerThickness: 5px; + + display: flex; + height: 100dvh; + box-sizing: border-box; + flex: 1; + + &.isMobile { + padding-bottom: 100px; + } + + > .main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + + > .columns { + flex: 1; + display: flex; + overflow-x: auto; + overflow-y: clip; + + &.center { + > .column:first-of-type { + margin-left: auto; + } + + > .column:last-of-type { + margin-right: auto; + } + } + + > .column { + flex-shrink: 0; + border-right: solid var(--deckDividerThickness) var(--deckDivider); + + &:first-of-type { + border-left: solid var(--deckDividerThickness) var(--deckDivider); + } + + &.folder { + display: flex; + flex-direction: column; + + > *:not(:last-of-type) { + border-bottom: solid var(--deckDividerThickness) var(--deckDivider); + } + } + } + + > .intro { + padding: 32px; + height: min-content; + text-align: center; + margin: auto; + + > .add { + margin: 1em auto; + } + } + + > .sideMenu { + flex-shrink: 0; + margin-right: 0; + margin-left: auto; + display: flex; + flex-direction: column; + justify-content: center; + width: 32px; + + > .top, > .middle, > .bottom { + > .button { + display: block; + width: 100%; + aspect-ratio: 1; + } + } + + > .top { + margin-bottom: auto; + } + + > .middle { + margin-top: auto; + margin-bottom: auto; + } + + > .bottom { + margin-top: auto; + } + } + } + } + + > .buttons { + position: fixed; + z-index: 1000; + bottom: 0; + left: 0; + padding: 16px; + display: flex; + width: 100%; + box-sizing: border-box; + + > .button { + position: relative; + flex: 1; + padding: 0; + margin: auto; + height: 64px; + border-radius: 8px; + background: var(--panel); + color: var(--fg); + + &:not(:last-child) { + margin-right: 12px; + } + + @media (max-width: 400px) { + height: 60px; + + &:not(:last-child) { + margin-right: 8px; + } + } + + &:hover { + background: var(--X2); + } + + > .indicator { + position: absolute; + top: 0; + left: 0; + color: var(--indicator); + font-size: 16px; + animation: blink 1s infinite; + } + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + + > * { + font-size: 20px; + } + + &:disabled { + cursor: default; + + > * { + opacity: 0.5; + } + } + } + } + + > .menu-back { + z-index: 1001; + } + + > .menu { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + height: 100dvh; + width: 240px; + box-sizing: border-box; + contain: strict; + overflow: auto; + overscroll-behavior: contain; + background: var(--navBg); + } +} +</style> diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue new file mode 100644 index 0000000000..ba14530662 --- /dev/null +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -0,0 +1,70 @@ +<template> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header> + <i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name }}</span> + </template> + + <XTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => emit('loaded')"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import XColumn from './column.vue'; +import { updateColumn, Column } from './deck-store'; +import XTimeline from '@/components/MkTimeline.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'loaded'): void; + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let timeline = $ref<InstanceType<typeof XTimeline>>(); + +onMounted(() => { + if (props.column.antennaId == null) { + setAntenna(); + } +}); + +async function setAntenna() { + const antennas = await os.api('antennas/list'); + const { canceled, result: antenna } = await os.select({ + title: i18n.ts.selectAntenna, + items: antennas.map(x => ({ + value: x, text: x.name, + })), + default: props.column.antennaId, + }); + if (canceled) return; + updateColumn(props.column.id, { + antennaId: antenna.id, + }); +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.selectAntenna, + action: setAntenna, +}]; + +/* +function focus() { + timeline.focus(); +} + +defineExpose({ + focus, +}); +*/ +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/ui/deck/column-core.vue b/packages/frontend/src/ui/deck/column-core.vue new file mode 100644 index 0000000000..30c0dc5e1c --- /dev/null +++ b/packages/frontend/src/ui/deck/column-core.vue @@ -0,0 +1,34 @@ +<template> +<!-- TODO: リファクタの余地がありそう --> +<div v-if="!column">たぶん見えちゃいけないやつ</div> +<XMainColumn v-else-if="column.type === 'main'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XWidgetsColumn v-else-if="column.type === 'widgets'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XNotificationsColumn v-else-if="column.type === 'notifications'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XTlColumn v-else-if="column.type === 'tl'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XListColumn v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XMainColumn from './main-column.vue'; +import XTlColumn from './tl-column.vue'; +import XAntennaColumn from './antenna-column.vue'; +import XListColumn from './list-column.vue'; +import XNotificationsColumn from './notifications-column.vue'; +import XWidgetsColumn from './widgets-column.vue'; +import XMentionsColumn from './mentions-column.vue'; +import XDirectColumn from './direct-column.vue'; +import { Column } from './deck-store'; + +defineProps<{ + column?: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); +</script> diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue new file mode 100644 index 0000000000..2a99b621e6 --- /dev/null +++ b/packages/frontend/src/ui/deck/column.vue @@ -0,0 +1,398 @@ +<template> +<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> +<section + v-hotkey="keymap" class="dnpfarvg _narrow_" + :class="{ paged: isMainColumn, naked, active, isStacked, draghover, dragging, dropready }" + @dragover.prevent.stop="onDragover" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" +> + <header + :class="{ indicated }" + draggable="true" + @click="goTop" + @dragstart="onDragstart" + @dragend="onDragend" + @contextmenu.prevent.stop="onContextmenu" + > + <button v-if="isStacked && !isMainColumn" class="toggleActive _button" @click="toggleActive"> + <template v-if="active"><i class="ti ti-chevron-up"></i></template> + <template v-else><i class="ti ti-chevron-down"></i></template> + </button> + <div class="action"> + <slot name="action"></slot> + </div> + <span class="header"><slot name="header"></slot></span> + <button v-tooltip="i18n.ts.settings" class="menu _button" @click.stop="showSettingsMenu"><i class="ti ti-dots"></i></button> + </header> + <div v-show="active" ref="body"> + <slot></slot> + </div> +</section> +</template> + +<script lang="ts" setup> +import { onBeforeUnmount, onMounted, provide, Ref, watch } from 'vue'; +import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn, Column, deckStore } from './deck-store'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { MenuItem } from '@/types/menu'; + +provide('shouldHeaderThin', true); +provide('shouldOmitHeaderTitle', true); +provide('shouldSpacerMin', true); + +const props = withDefaults(defineProps<{ + column: Column; + isStacked?: boolean; + naked?: boolean; + indicated?: boolean; + menu?: MenuItem[]; +}>(), { + isStacked: false, + naked: false, + indicated: false, +}); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; + (ev: 'change-active-state', v: boolean): void; +}>(); + +let body = $ref<HTMLDivElement>(); + +let dragging = $ref(false); +watch($$(dragging), v => os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd')); + +let draghover = $ref(false); +let dropready = $ref(false); + +const isMainColumn = $computed(() => props.column.type === 'main'); +const active = $computed(() => props.column.active !== false); +watch($$(active), v => emit('change-active-state', v)); + +const keymap = $computed(() => ({ + 'shift+up': () => emit('parent-focus', 'up'), + 'shift+down': () => emit('parent-focus', 'down'), + 'shift+left': () => emit('parent-focus', 'left'), + 'shift+right': () => emit('parent-focus', 'right'), +})); + +onMounted(() => { + os.deckGlobalEvents.on('column.dragStart', onOtherDragStart); + os.deckGlobalEvents.on('column.dragEnd', onOtherDragEnd); +}); + +onBeforeUnmount(() => { + os.deckGlobalEvents.off('column.dragStart', onOtherDragStart); + os.deckGlobalEvents.off('column.dragEnd', onOtherDragEnd); +}); + +function onOtherDragStart() { + dropready = true; +} + +function onOtherDragEnd() { + dropready = false; +} + +function toggleActive() { + if (!props.isStacked) return; + updateColumn(props.column.id, { + active: !props.column.active, + }); +} + +function getMenu() { + let items = [{ + icon: 'ti ti-settings', + text: i18n.ts._deck.configureColumn, + action: async () => { + const { canceled, result } = await os.form(props.column.name, { + name: { + type: 'string', + label: i18n.ts.name, + default: props.column.name, + }, + width: { + type: 'number', + label: i18n.ts.width, + default: props.column.width, + }, + flexible: { + type: 'boolean', + label: i18n.ts.flexible, + default: props.column.flexible, + }, + }); + if (canceled) return; + updateColumn(props.column.id, result); + }, + }, { + type: 'parent', + text: i18n.ts.move + '...', + icon: 'ti ti-arrows-move', + children: [{ + icon: 'ti ti-arrow-left', + text: i18n.ts._deck.swapLeft, + action: () => { + swapLeftColumn(props.column.id); + }, + }, { + icon: 'ti ti-arrow-right', + text: i18n.ts._deck.swapRight, + action: () => { + swapRightColumn(props.column.id); + }, + }, props.isStacked ? { + icon: 'ti ti-arrow-up', + text: i18n.ts._deck.swapUp, + action: () => { + swapUpColumn(props.column.id); + }, + } : undefined, props.isStacked ? { + icon: 'ti ti-arrow-down', + text: i18n.ts._deck.swapDown, + action: () => { + swapDownColumn(props.column.id); + }, + } : undefined], + }, { + icon: 'ti ti-stack-2', + text: i18n.ts._deck.stackLeft, + action: () => { + stackLeftColumn(props.column.id); + }, + }, props.isStacked ? { + icon: 'ti ti-window-maximize', + text: i18n.ts._deck.popRight, + action: () => { + popRightColumn(props.column.id); + }, + } : undefined, null, { + icon: 'ti ti-trash', + text: i18n.ts.remove, + danger: true, + action: () => { + removeColumn(props.column.id); + }, + }]; + + if (props.menu) { + items.unshift(null); + items = props.menu.concat(items); + } + + return items; +} + +function showSettingsMenu(ev: MouseEvent) { + os.popupMenu(getMenu(), ev.currentTarget ?? ev.target); +} + +function onContextmenu(ev: MouseEvent) { + os.contextMenu(getMenu(), ev); +} + +function goTop() { + body.scrollTo({ + top: 0, + behavior: 'smooth', + }); +} + +function onDragstart(ev) { + ev.dataTransfer.effectAllowed = 'move'; + ev.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, props.column.id); + + // Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう + // SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately + window.setTimeout(() => { + dragging = true; + }, 10); +} + +function onDragend(ev) { + dragging = false; +} + +function onDragover(ev) { + // 自分自身がドラッグされている場合 + if (dragging) { + // 自分自身にはドロップさせない + ev.dataTransfer.dropEffect = 'none'; + } else { + const isDeckColumn = ev.dataTransfer.types[0] === _DATA_TRANSFER_DECK_COLUMN_; + + ev.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none'; + + if (isDeckColumn) draghover = true; + } +} + +function onDragleave() { + draghover = false; +} + +function onDrop(ev) { + draghover = false; + os.deckGlobalEvents.emit('column.dragEnd'); + + const id = ev.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_); + if (id != null && id !== '') { + swapColumn(props.column.id, id); + } +} +</script> + +<style lang="scss" scoped> +.dnpfarvg { + --root-margin: 10px; + --deckColumnHeaderHeight: 40px; + + height: 100%; + overflow: hidden; + contain: strict; + + &.draghover { + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1000; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--focus); + } + } + + &.dragging { + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1000; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--focus); + opacity: 0.5; + } + } + + &.dropready { + * { + pointer-events: none; + } + } + + &:not(.active) { + flex-basis: var(--deckColumnHeaderHeight); + min-height: var(--deckColumnHeaderHeight); + + > header.indicated { + box-shadow: 4px 0px var(--accent) inset; + } + } + + &.naked { + background: var(--acrylicBg) !important; + -webkit-backdrop-filter: var(--blur, blur(10px)); + backdrop-filter: var(--blur, blur(10px)); + + > header { + background: transparent; + box-shadow: none; + + > button { + color: var(--fg); + } + } + } + + &.paged { + background: var(--bg) !important; + } + + > header { + position: relative; + display: flex; + z-index: 2; + line-height: var(--deckColumnHeaderHeight); + height: var(--deckColumnHeaderHeight); + padding: 0 16px; + font-size: 0.9em; + color: var(--panelHeaderFg); + background: var(--panelHeaderBg); + box-shadow: 0 1px 0 0 var(--panelHeaderDivider); + cursor: pointer; + + &, * { + user-select: none; + } + + &.indicated { + box-shadow: 0 3px 0 0 var(--accent); + } + + > .header { + display: inline-block; + align-items: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + > span:only-of-type { + width: 100%; + } + + > .toggleActive, + > .action > ::v-deep(*), + > .menu { + z-index: 1; + width: var(--deckColumnHeaderHeight); + line-height: var(--deckColumnHeaderHeight); + color: var(--faceTextButton); + + &:hover { + color: var(--faceTextButtonHover); + } + + &:active { + color: var(--faceTextButtonActive); + } + } + + > .toggleActive, > .action { + margin-left: -16px; + } + + > .action { + z-index: 1; + } + + > .action:empty { + display: none; + } + + > .menu { + margin-left: auto; + margin-right: -16px; + } + } + + > div { + height: calc(100% - var(--deckColumnHeaderHeight)); + overflow-y: auto; + overflow-x: hidden; // Safari does not supports clip + overflow-x: clip; + -webkit-overflow-scrolling: touch; + box-sizing: border-box; + background-color: var(--bg); + } +} +</style> diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts new file mode 100644 index 0000000000..56db7398e5 --- /dev/null +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -0,0 +1,296 @@ +import { throttle } from 'throttle-debounce'; +import { markRaw } from 'vue'; +import { notificationTypes } from 'misskey-js'; +import { Storage } from '../../pizzax'; +import { i18n } from '@/i18n'; +import { api } from '@/os'; +import { deepClone } from '@/scripts/clone'; + +type ColumnWidget = { + name: string; + id: string; + data: Record<string, any>; +}; + +export type Column = { + id: string; + type: 'main' | 'widgets' | 'notifications' | 'tl' | 'antenna' | 'list' | 'mentions' | 'direct'; + name: string | null; + width: number; + widgets?: ColumnWidget[]; + active?: boolean; + flexible?: boolean; + antennaId?: string; + listId?: string; + includingTypes?: typeof notificationTypes[number][]; + tl?: 'home' | 'local' | 'social' | 'global'; +}; + +export const deckStore = markRaw(new Storage('deck', { + profile: { + where: 'deviceAccount', + default: 'default', + }, + columns: { + where: 'deviceAccount', + default: [] as Column[], + }, + layout: { + where: 'deviceAccount', + default: [] as Column['id'][][], + }, + columnAlign: { + where: 'deviceAccount', + default: 'left' as 'left' | 'right' | 'center', + }, + alwaysShowMainColumn: { + where: 'deviceAccount', + default: true, + }, + navWindow: { + where: 'deviceAccount', + default: true, + }, +})); + +export const loadDeck = async () => { + let deck; + + try { + deck = await api('i/registry/get', { + scope: ['client', 'deck', 'profiles'], + key: deckStore.state.profile, + }); + } catch (err) { + if (err.code === 'NO_SUCH_KEY') { + // 後方互換性のため + if (deckStore.state.profile === 'default') { + saveDeck(); + return; + } + + deckStore.set('columns', []); + deckStore.set('layout', []); + return; + } + throw err; + } + + deckStore.set('columns', deck.columns); + deckStore.set('layout', deck.layout); +}; + +// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する +export const saveDeck = throttle(1000, () => { + api('i/registry/set', { + scope: ['client', 'deck', 'profiles'], + key: deckStore.state.profile, + value: { + columns: deckStore.reactiveState.columns.value, + layout: deckStore.reactiveState.layout.value, + }, + }); +}); + +export async function getProfiles(): Promise<string[]> { + return await api('i/registry/keys', { + scope: ['client', 'deck', 'profiles'], + }); +} + +export async function deleteProfile(key: string): Promise<void> { + return await api('i/registry/remove', { + scope: ['client', 'deck', 'profiles'], + key: key, + }); +} + +export function addColumn(column: Column) { + if (column.name === undefined) column.name = null; + deckStore.push('columns', column); + deckStore.push('layout', [column.id]); + saveDeck(); +} + +export function removeColumn(id: Column['id']) { + deckStore.set('columns', deckStore.state.columns.filter(c => c.id !== id)); + deckStore.set('layout', deckStore.state.layout + .map(ids => ids.filter(_id => _id !== id)) + .filter(ids => ids.length > 0)); + saveDeck(); +} + +export function swapColumn(a: Column['id'], b: Column['id']) { + const aX = deckStore.state.layout.findIndex(ids => ids.indexOf(a) !== -1); + const aY = deckStore.state.layout[aX].findIndex(id => id === a); + const bX = deckStore.state.layout.findIndex(ids => ids.indexOf(b) !== -1); + const bY = deckStore.state.layout[bX].findIndex(id => id === b); + const layout = deepClone(deckStore.state.layout); + layout[aX][aY] = b; + layout[bX][bY] = a; + deckStore.set('layout', layout); + saveDeck(); +} + +export function swapLeftColumn(id: Column['id']) { + const layout = deepClone(deckStore.state.layout); + deckStore.state.layout.some((ids, i) => { + if (ids.includes(id)) { + const left = deckStore.state.layout[i - 1]; + if (left) { + layout[i - 1] = deckStore.state.layout[i]; + layout[i] = left; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function swapRightColumn(id: Column['id']) { + const layout = deepClone(deckStore.state.layout); + deckStore.state.layout.some((ids, i) => { + if (ids.includes(id)) { + const right = deckStore.state.layout[i + 1]; + if (right) { + layout[i + 1] = deckStore.state.layout[i]; + layout[i] = right; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function swapUpColumn(id: Column['id']) { + const layout = deepClone(deckStore.state.layout); + const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const ids = deepClone(deckStore.state.layout[idsIndex]); + ids.some((x, i) => { + if (x === id) { + const up = ids[i - 1]; + if (up) { + ids[i - 1] = id; + ids[i] = up; + + layout[idsIndex] = ids; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function swapDownColumn(id: Column['id']) { + const layout = deepClone(deckStore.state.layout); + const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const ids = deepClone(deckStore.state.layout[idsIndex]); + ids.some((x, i) => { + if (x === id) { + const down = ids[i + 1]; + if (down) { + ids[i + 1] = id; + ids[i] = down; + + layout[idsIndex] = ids; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function stackLeftColumn(id: Column['id']) { + let layout = deepClone(deckStore.state.layout); + const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); + layout = layout.map(ids => ids.filter(_id => _id !== id)); + layout[i - 1].push(id); + layout = layout.filter(ids => ids.length > 0); + deckStore.set('layout', layout); + saveDeck(); +} + +export function popRightColumn(id: Column['id']) { + let layout = deepClone(deckStore.state.layout); + const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const affected = layout[i]; + layout = layout.map(ids => ids.filter(_id => _id !== id)); + layout.splice(i + 1, 0, [id]); + layout = layout.filter(ids => ids.length > 0); + deckStore.set('layout', layout); + + const columns = deepClone(deckStore.state.columns); + for (const column of columns) { + if (affected.includes(column.id)) { + column.active = true; + } + } + deckStore.set('columns', columns); + + saveDeck(); +} + +export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(deckStore.state.columns[columnIndex]); + if (column == null) return; + if (column.widgets == null) column.widgets = []; + column.widgets.unshift(widget); + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(deckStore.state.columns[columnIndex]); + if (column == null) return; + column.widgets = column.widgets.filter(w => w.id !== widget.id); + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(deckStore.state.columns[columnIndex]); + if (column == null) return; + column.widgets = widgets; + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(deckStore.state.columns[columnIndex]); + if (column == null) return; + column.widgets = column.widgets.map(w => w.id === widgetId ? { + ...w, + data: widgetData, + } : w); + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function updateColumn(id: Column['id'], column: Partial<Column>) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const currentColumn = deepClone(deckStore.state.columns[columnIndex]); + if (currentColumn == null) return; + for (const [k, v] of Object.entries(column)) { + currentColumn[k] = v; + } + columns[columnIndex] = currentColumn; + deckStore.set('columns', columns); + saveDeck(); +} diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue new file mode 100644 index 0000000000..75b018cacd --- /dev/null +++ b/packages/frontend/src/ui/deck/direct-column.vue @@ -0,0 +1,31 @@ +<template> +<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <XNotes :pagination="pagination"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XColumn from './column.vue'; +import XNotes from '@/components/MkNotes.vue'; +import { Column } from './deck-store'; + +defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +const pagination = { + endpoint: 'notes/mentions' as const, + limit: 10, + params: { + visibility: 'specified', + }, +}; +</script> diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue new file mode 100644 index 0000000000..d9f3f7b4e7 --- /dev/null +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -0,0 +1,58 @@ +<template> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header> + <i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name }}</span> + </template> + + <XTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => emit('loaded')"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XColumn from './column.vue'; +import { updateColumn, Column } from './deck-store'; +import XTimeline from '@/components/MkTimeline.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'loaded'): void; + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let timeline = $ref<InstanceType<typeof XTimeline>>(); + +if (props.column.listId == null) { + setList(); +} + +async function setList() { + const lists = await os.api('users/lists/list'); + const { canceled, result: list } = await os.select({ + title: i18n.ts.selectList, + items: lists.map(x => ({ + value: x, text: x.name, + })), + default: props.column.listId, + }); + if (canceled) return; + updateColumn(props.column.id, { + listId: list.id, + }); +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.selectList, + action: setList, +}]; +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue new file mode 100644 index 0000000000..0c66172397 --- /dev/null +++ b/packages/frontend/src/ui/deck/main-column.vue @@ -0,0 +1,68 @@ +<template> +<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header> + <template v-if="pageMetadata?.value"> + <i :class="pageMetadata?.value.icon"></i> + {{ pageMetadata?.value.title }} + </template> + </template> + + <RouterView @contextmenu.stop="onContextmenu"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { ComputedRef, provide } from 'vue'; +import XColumn from './column.vue'; +import { deckStore, Column } from '@/ui/deck/deck-store'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; + +defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); + +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; +}); + +/* +function back() { + history.back(); +} +*/ +function onContextmenu(ev: MouseEvent) { + if (!ev.target) return; + + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(ev.target as HTMLElement)) return; + if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes((ev.target as HTMLElement).tagName) || (ev.target as HTMLElement).attributes['contenteditable']) return; + if (window.getSelection()?.toString() !== '') return; + const path = mainRouter.currentRoute.value.path; + os.contextMenu([{ + type: 'label', + text: path, + }, { + icon: 'ti ti-window-maximize', + text: i18n.ts.openInWindow, + action: () => { + os.pageWindow(path); + }, + }], ev); +} +</script> diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue new file mode 100644 index 0000000000..16962956a0 --- /dev/null +++ b/packages/frontend/src/ui/deck/mentions-column.vue @@ -0,0 +1,28 @@ +<template> +<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <XNotes :pagination="pagination"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XColumn from './column.vue'; +import XNotes from '@/components/MkNotes.vue'; +import { Column } from './deck-store'; + +defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +const pagination = { + endpoint: 'notes/mentions' as const, + limit: 10, +}; +</script> diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue new file mode 100644 index 0000000000..9d133035fe --- /dev/null +++ b/packages/frontend/src/ui/deck/notifications-column.vue @@ -0,0 +1,44 @@ +<template> +<XColumn :column="column" :is-stacked="isStacked" :menu="menu" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <XNotifications :include-types="column.includingTypes"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import XColumn from './column.vue'; +import { updateColumn, Column } from './deck-store'; +import XNotifications from '@/components/MkNotifications.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +function func() { + os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { + includingTypes: props.column.includingTypes, + }, { + done: async (res) => { + const { includingTypes } = res; + updateColumn(props.column.id, { + includingTypes: includingTypes, + }); + }, + }, 'closed'); +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.notificationSetting, + action: func, +}]; +</script> diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue new file mode 100644 index 0000000000..49b29145ff --- /dev/null +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -0,0 +1,119 @@ +<template> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header> + <i v-if="column.tl === 'home'" class="ti ti-home"></i> + <i v-else-if="column.tl === 'local'" class="ti ti-messages"></i> + <i v-else-if="column.tl === 'social'" class="ti ti-share"></i> + <i v-else-if="column.tl === 'global'" class="ti ti-world"></i> + <span style="margin-left: 8px;">{{ column.name }}</span> + </template> + + <div v-if="disabled" class="iwaalbte"> + <p> + <i class="ti ti-minus-circle"></i> + {{ $t('disabled-timeline.title') }} + </p> + <p class="desc">{{ $t('disabled-timeline.description') }}</p> + </div> + <XTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl" @after="() => emit('loaded')" @queue="queueUpdated" @note="onNote"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import XColumn from './column.vue'; +import { removeColumn, updateColumn, Column } from './deck-store'; +import XTimeline from '@/components/MkTimeline.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'loaded'): void; + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let disabled = $ref(false); +let indicated = $ref(false); +let columnActive = $ref(true); + +onMounted(() => { + if (props.column.tl == null) { + setType(); + } else if ($i) { + disabled = !$i.isModerator && !$i.isAdmin && ( + instance.disableLocalTimeline && ['local', 'social'].includes(props.column.tl) || + instance.disableGlobalTimeline && ['global'].includes(props.column.tl)); + } +}); + +async function setType() { + const { canceled, result: src } = await os.select({ + title: i18n.ts.timeline, + items: [{ + value: 'home' as const, text: i18n.ts._timelines.home, + }, { + value: 'local' as const, text: i18n.ts._timelines.local, + }, { + value: 'social' as const, text: i18n.ts._timelines.social, + }, { + value: 'global' as const, text: i18n.ts._timelines.global, + }], + }); + if (canceled) { + if (props.column.tl == null) { + removeColumn(props.column.id); + } + return; + } + updateColumn(props.column.id, { + tl: src, + }); +} + +function queueUpdated(q) { + if (columnActive) { + indicated = q !== 0; + } +} + +function onNote() { + if (!columnActive) { + indicated = true; + } +} + +function onChangeActiveState(state) { + columnActive = state; + + if (columnActive) { + indicated = false; + } +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.timeline, + action: setType, +}]; +</script> + +<style lang="scss" scoped> +.iwaalbte { + text-align: center; + + > p { + margin: 16px; + + &.desc { + font-size: 14px; + } + } +} +</style> diff --git a/packages/frontend/src/ui/deck/widgets-column.vue b/packages/frontend/src/ui/deck/widgets-column.vue new file mode 100644 index 0000000000..fc61d18ff6 --- /dev/null +++ b/packages/frontend/src/ui/deck/widgets-column.vue @@ -0,0 +1,69 @@ +<template> +<XColumn :menu="menu" :naked="true" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header><i class="ti ti-apps" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <div class="wtdtxvec"> + <div v-if="!(column.widgets && column.widgets.length > 0) && !edit" class="intro">{{ i18n.ts._deck.widgetsIntroduction }}</div> + <XWidgets :edit="edit" :widgets="column.widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> + </div> +</XColumn> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XColumn from './column.vue'; +import { addColumnWidget, Column, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store'; +import XWidgets from '@/components/MkWidgets.vue'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let edit = $ref(false); + +function addWidget(widget) { + addColumnWidget(props.column.id, widget); +} + +function removeWidget(widget) { + removeColumnWidget(props.column.id, widget); +} + +function updateWidget({ id, data }) { + updateColumnWidget(props.column.id, id, data); +} + +function updateWidgets(widgets) { + setColumnWidgets(props.column.id, widgets); +} + +function func() { + edit = !edit; +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.editWidgets, + action: func, +}]; +</script> + +<style lang="scss" scoped> +.wtdtxvec { + --margin: 8px; + --panelBorder: none; + + padding: 0 var(--margin); + + > .intro { + padding: 16px; + text-align: center; + } +} +</style> diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue new file mode 100644 index 0000000000..b91bf476e8 --- /dev/null +++ b/packages/frontend/src/ui/universal.vue @@ -0,0 +1,390 @@ +<template> +<div class="dkgtipfy" :class="{ wallpaper }"> + <XSidebar v-if="!isMobile" class="sidebar"/> + + <MkStickyContainer class="contents"> + <template #header><XStatusBars :class="$style.statusbars"/></template> + <main style="min-width: 0;" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> + <div :class="$style.content"> + <RouterView/> + </div> + <div :class="$style.spacer"></div> + </main> + </MkStickyContainer> + + <div v-if="isDesktop" ref="widgetsEl" class="widgets"> + <XWidgets @mounted="attachSticky"/> + </div> + + <button v-if="!isDesktop && !isMobile" class="widgetButton _button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button> + + <div v-if="isMobile" class="buttons"> + <button class="button nav _button" @click="drawerMenuShowing = true"><i class="ti ti-menu-2"></i><span v-if="menuIndicated" class="indicator"><i class="_indicatorCircle"></i></span></button> + <button class="button home _button" @click="mainRouter.currentRoute.value.name === 'index' ? top() : mainRouter.push('/')"><i class="ti ti-home"></i></button> + <button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="ti ti-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="_indicatorCircle"></i></span></button> + <button class="button widget _button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button> + <button class="button post _button" @click="os.post()"><i class="ti ti-pencil"></i></button> + </div> + + <transition :name="$store.state.animation ? 'menuDrawer-back' : ''"> + <div + v-if="drawerMenuShowing" + class="menuDrawer-back _modalBg" + @click="drawerMenuShowing = false" + @touchstart.passive="drawerMenuShowing = false" + ></div> + </transition> + + <transition :name="$store.state.animation ? 'menuDrawer' : ''"> + <XDrawerMenu v-if="drawerMenuShowing" class="menuDrawer"/> + </transition> + + <transition :name="$store.state.animation ? 'widgetsDrawer-back' : ''"> + <div + v-if="widgetsShowing" + class="widgetsDrawer-back _modalBg" + @click="widgetsShowing = false" + @touchstart.passive="widgetsShowing = false" + ></div> + </transition> + + <transition :name="$store.state.animation ? 'widgetsDrawer' : ''"> + <XWidgets v-if="widgetsShowing" class="widgetsDrawer"/> + </transition> + + <XCommon/> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, provide, onMounted, computed, ref, watch, ComputedRef } from 'vue'; +import XCommon from './_common_/common.vue'; +import { instanceName } from '@/config'; +import { StickySidebar } from '@/scripts/sticky-sidebar'; +import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { navbarItemDef } from '@/navbar'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { Router } from '@/nirax'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import { deviceKind } from '@/scripts/device-kind'; +const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); +const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); +const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); + +const DESKTOP_THRESHOLD = 1100; +const MOBILE_THRESHOLD = 500; + +// デスクトップでウィンドウを狭くしたときモバイルUIが表示されて欲しいことはあるので deviceKind === 'desktop' の判定は行わない +const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD); +const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD); +window.addEventListener('resize', () => { + isMobile.value = deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD; +}); + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); +const widgetsEl = $ref<HTMLElement>(); +const widgetsShowing = $ref(false); + +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; + if (pageMetadata.value) { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } +}); + +const menuIndicated = computed(() => { + for (const def in navbarItemDef) { + if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから + if (navbarItemDef[def].indicated) return true; + } + return false; +}); + +const drawerMenuShowing = ref(false); + +mainRouter.on('change', () => { + drawerMenuShowing.value = false; +}); + +document.documentElement.style.overflowY = 'scroll'; + +if (defaultStore.state.widgets.length === 0) { + defaultStore.set('widgets', [{ + name: 'calendar', + id: 'a', place: 'right', data: {}, + }, { + name: 'notifications', + id: 'b', place: 'right', data: {}, + }, { + name: 'trends', + id: 'c', place: 'right', data: {}, + }]); +} + +onMounted(() => { + if (!isDesktop.value) { + window.addEventListener('resize', () => { + if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop.value = true; + }, { passive: true }); + } +}); + +const onContextmenu = (ev) => { + 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; + const path = mainRouter.getCurrentPath(); + os.contextMenu([{ + type: 'label', + text: path, + }, { + icon: 'ti ti-window-maximize', + text: i18n.ts.openInWindow, + action: () => { + os.pageWindow(path); + }, + }], ev); +}; + +const attachSticky = (el) => { + const sticky = new StickySidebar(widgetsEl); + window.addEventListener('scroll', () => { + sticky.calc(window.scrollY); + }, { passive: true }); +}; + +function top() { + window.scroll({ top: 0, behavior: 'smooth' }); +} + +const wallpaper = localStorage.getItem('wallpaper') != null; +</script> + +<style lang="scss" scoped> +.widgetsDrawer-enter-active, +.widgetsDrawer-leave-active { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.widgetsDrawer-enter-from, +.widgetsDrawer-leave-active { + opacity: 0; + transform: translateX(240px); +} + +.widgetsDrawer-back-enter-active, +.widgetsDrawer-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.widgetsDrawer-back-enter-from, +.widgetsDrawer-back-leave-active { + opacity: 0; +} + +.menuDrawer-enter-active, +.menuDrawer-leave-active { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.menuDrawer-enter-from, +.menuDrawer-leave-active { + opacity: 0; + transform: translateX(-240px); +} + +.menuDrawer-back-enter-active, +.menuDrawer-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.menuDrawer-back-enter-from, +.menuDrawer-back-leave-active { + opacity: 0; +} + +.dkgtipfy { + $ui-font-size: 1em; // TODO: どこかに集約したい + $widgets-hide-threshold: 1090px; + + min-height: 100dvh; + box-sizing: border-box; + display: flex; + + &.wallpaper { + background: var(--wallpaperOverlay); + //backdrop-filter: var(--blur, blur(4px)); + } + + > .sidebar { + border-right: solid 0.5px var(--divider); + } + + > .contents { + width: 100%; + min-width: 0; + background: var(--bg); + } + + > .widgets { + padding: 0 var(--margin); + border-left: solid 0.5px var(--divider); + background: var(--bg); + + @media (max-width: $widgets-hide-threshold) { + display: none; + } + } + + > .widgetButton { + display: block; + position: fixed; + z-index: 1000; + bottom: 32px; + right: 32px; + width: 64px; + height: 64px; + border-radius: 100%; + box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); + font-size: 22px; + background: var(--panel); + } + + > .widgetsDrawer-back { + z-index: 1001; + } + + > .widgetsDrawer { + position: fixed; + top: 0; + right: 0; + z-index: 1001; + height: 100dvh; + padding: var(--margin); + box-sizing: border-box; + overflow: auto; + overscroll-behavior: contain; + background: var(--bg); + } + + > .buttons { + position: fixed; + z-index: 1000; + bottom: 0; + left: 0; + padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 16px) 16px; + display: flex; + width: 100%; + box-sizing: border-box; + -webkit-backdrop-filter: var(--blur, blur(32px)); + backdrop-filter: var(--blur, blur(32px)); + background-color: var(--header); + border-top: solid 0.5px var(--divider); + + > .button { + position: relative; + flex: 1; + padding: 0; + margin: auto; + height: 64px; + border-radius: 8px; + background: var(--panel); + color: var(--fg); + + &:not(:last-child) { + margin-right: 12px; + } + + @media (max-width: 400px) { + height: 60px; + + &:not(:last-child) { + margin-right: 8px; + } + } + + &:hover { + background: var(--X2); + } + + > .indicator { + position: absolute; + top: 0; + left: 0; + color: var(--indicator); + font-size: 16px; + animation: blink 1s infinite; + } + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + + > * { + font-size: 20px; + } + + &:disabled { + cursor: default; + + > * { + opacity: 0.5; + } + } + } + } + + > .menuDrawer-back { + z-index: 1001; + } + + > .menuDrawer { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + height: 100dvh; + width: 240px; + box-sizing: border-box; + contain: strict; + overflow: auto; + overscroll-behavior: contain; + background: var(--navBg); + } +} +</style> + +<style lang="scss" module> +.statusbars { + position: sticky; + top: 0; + left: 0; +} + +.spacer { + $widgets-hide-threshold: 1090px; + + height: calc(env(safe-area-inset-bottom, 0px) + 96px); + + @media (min-width: ($widgets-hide-threshold + 1px)) { + display: none; + } +} +</style> diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/universal.widgets.vue new file mode 100644 index 0000000000..33fb492836 --- /dev/null +++ b/packages/frontend/src/ui/universal.widgets.vue @@ -0,0 +1,71 @@ +<template> +<div class="efzpzdvf"> + <XWidgets :edit="editMode" :widgets="defaultStore.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> + + <button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button> + <button v-else class="_textButton mk-widget-edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button> +</div> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import XWidgets from '@/components/MkWidgets.vue'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; + +const emit = defineEmits<{ + (ev: 'mounted', el: Element): void; +}>(); + +let editMode = $ref(false); +let rootEl = $ref<HTMLDivElement>(); + +onMounted(() => { + emit('mounted', rootEl); +}); + +function addWidget(widget) { + defaultStore.set('widgets', [{ + ...widget, + place: null, + }, ...defaultStore.state.widgets]); +} + +function removeWidget(widget) { + defaultStore.set('widgets', defaultStore.state.widgets.filter(w => w.id !== widget.id)); +} + +function updateWidget({ id, data }) { + defaultStore.set('widgets', defaultStore.state.widgets.map(w => w.id === id ? { + ...w, + data, + } : w)); +} + +function updateWidgets(widgets) { + defaultStore.set('widgets', widgets); +} +</script> + +<style lang="scss" scoped> +.efzpzdvf { + position: sticky; + height: min-content; + min-height: 100vh; + padding: var(--margin) 0; + box-sizing: border-box; + + > * { + margin: var(--margin) 0; + width: 300px; + + &:first-child { + margin-top: 0; + } + } + + > .add { + margin: 0 auto; + } +} +</style> diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue new file mode 100644 index 0000000000..ec9150d346 --- /dev/null +++ b/packages/frontend/src/ui/visitor.vue @@ -0,0 +1,19 @@ +<template> +<DesignB/> +<XCommon/> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import DesignA from './visitor/a.vue'; +import DesignB from './visitor/b.vue'; +import XCommon from './_common_/common.vue'; + +export default defineComponent({ + components: { + XCommon, + DesignA, + DesignB, + }, +}); +</script> diff --git a/packages/frontend/src/ui/visitor/a.vue b/packages/frontend/src/ui/visitor/a.vue new file mode 100644 index 0000000000..f8db7a9d09 --- /dev/null +++ b/packages/frontend/src/ui/visitor/a.vue @@ -0,0 +1,259 @@ +<template> +<div class="mk-app"> + <div v-if="mainRouter.currentRoute?.name === 'index'" class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> + <div> + <h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> + <div v-if="meta" class="about"> + <!-- eslint-disable-next-line vue/no-v-html --> + <div class="desc" v-html="meta.description || $ts.introMisskey"></div> + </div> + <div class="action"> + <button class="_button primary" @click="signup()">{{ $ts.signup }}</button> + <button class="_button" @click="signin()">{{ $ts.login }}</button> + </div> + </div> + </div> + <div v-else class="banner-mini" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> + <div> + <h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> + </div> + </div> + + <div class="main"> + <div ref="contents" class="contents" :class="{ wallpaper }"> + <header v-show="mainRouter.currentRoute?.name !== 'index'" ref="header" class="header"> + <XHeader :info="pageInfo"/> + </header> + <main ref="main"> + <RouterView/> + </main> + <div class="powered-by"> + <b><MkA to="/">{{ host }}</MkA></b> + <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import XHeader from './header.vue'; +import { host, instanceName } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import { ColdDeviceStorage } from '@/store'; +import { mainRouter } from '@/router'; + +const DESKTOP_THRESHOLD = 1100; + +export default defineComponent({ + components: { + XHeader, + MkPagination, + MkButton, + }, + + data() { + return { + host, + instanceName, + pageInfo: null, + meta: null, + narrow: window.innerWidth < 1280, + announcements: { + endpoint: 'announcements', + limit: 10, + }, + mainRouter, + isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, + }; + }, + + computed: { + keymap(): any { + return { + 'd': () => { + if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; + this.$store.set('darkMode', !this.$store.state.darkMode); + }, + 's': search, + 'h|/': this.help, + }; + }, + }, + + created() { + document.documentElement.style.overflowY = 'scroll'; + + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + }, + + mounted() { + if (!this.isDesktop) { + window.addEventListener('resize', () => { + if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; + }, { passive: true }); + } + }, + + methods: { + // @ThatOneCalculator: Are these methods even used? + // I can't find references to them anywhere else in the code... + + // setParallax(el) { + // new simpleParallax(el); + // }, + + changePage(page) { + if (page == null) return; + // eslint-disable-next-line no-undef + if (page[symbols.PAGE_INFO]) { + // eslint-disable-next-line no-undef + this.pageInfo = page[symbols.PAGE_INFO]; + } + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + help() { + window.open('https://misskey-hub.net/docs/keyboard-shortcut.md', '_blank'); + }, + }, +}); +</script> + +<style lang="scss" scoped> +.mk-app { + min-height: 100vh; + + > .banner { + position: relative; + width: 100%; + text-align: center; + background-position: center; + background-size: cover; + + > div { + height: 100%; + background: rgba(0, 0, 0, 0.3); + + * { + color: #fff; + } + + > h1 { + margin: 0; + padding: 96px 32px 0 32px; + text-shadow: 0 0 8px black; + + > .logo { + vertical-align: bottom; + max-height: 150px; + } + } + + > .about { + padding: 32px; + max-width: 580px; + margin: 0 auto; + box-sizing: border-box; + text-shadow: 0 0 8px black; + } + + > .action { + padding-bottom: 64px; + + > button { + display: inline-block; + padding: 10px 20px; + box-sizing: border-box; + text-align: center; + border-radius: 999px; + background: var(--panel); + color: var(--fg); + + &.primary { + background: var(--accent); + color: #fff; + } + + &:first-child { + margin-right: 16px; + } + } + } + } + } + + > .banner-mini { + position: relative; + width: 100%; + text-align: center; + background-position: center; + background-size: cover; + + > div { + position: relative; + z-index: 1; + height: 100%; + background: rgba(0, 0, 0, 0.3); + + * { + color: #fff !important; + } + + > header { + + } + + > h1 { + margin: 0; + padding: 32px; + text-shadow: 0 0 8px black; + + > .logo { + vertical-align: bottom; + max-height: 100px; + } + } + } + } + + > .main { + > .contents { + position: relative; + z-index: 1; + + > .header { + position: sticky; + top: 0; + left: 0; + z-index: 1000; + } + + > .powered-by { + padding: 28px; + font-size: 14px; + text-align: center; + border-top: 1px solid var(--divider); + + > small { + display: block; + margin-top: 8px; + opacity: 0.5; + } + } + } + } +} +</style> + +<style lang="scss"> +</style> diff --git a/packages/frontend/src/ui/visitor/b.vue b/packages/frontend/src/ui/visitor/b.vue new file mode 100644 index 0000000000..275008a8f8 --- /dev/null +++ b/packages/frontend/src/ui/visitor/b.vue @@ -0,0 +1,248 @@ +<template> +<div class="mk-app"> + <a v-if="root" href="https://github.com/misskey-dev/misskey" target="_blank" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--panel); color:var(--fg); position: fixed; z-index: 10; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a> + + <div v-if="!narrow && !root" class="side"> + <XKanban class="kanban" full/> + </div> + + <div class="main"> + <XKanban v-if="narrow && !root" class="banner" :powered-by="root"/> + + <div class="contents"> + <XHeader v-if="!root" class="header" :info="pageInfo"/> + <main> + <RouterView/> + </main> + <div v-if="!root" class="powered-by"> + <b><MkA to="/">{{ host }}</MkA></b> + <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small> + </div> + </div> + </div> + + <transition :name="$store.state.animation ? 'tray-back' : ''"> + <div + v-if="showMenu" + class="menu-back _modalBg" + @click="showMenu = false" + @touchstart.passive="showMenu = false" + ></div> + </transition> + + <transition :name="$store.state.animation ? 'tray' : ''"> + <div v-if="showMenu" class="menu"> + <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ $ts.home }}</MkA> + <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ $ts.explore }}</MkA> + <MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA> + <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ $ts.channel }}</MkA> + <div class="action"> + <button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button> + <button class="_button" @click="signin()">{{ $ts.login }}</button> + </div> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { ComputedRef, onMounted, provide } from 'vue'; +import XHeader from './header.vue'; +import XKanban from './kanban.vue'; +import { host, instanceName } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import MkPagination from '@/components/MkPagination.vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; +import { ColdDeviceStorage, defaultStore } from '@/store'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; + +const DESKTOP_THRESHOLD = 1100; + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); + +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; + if (pageMetadata.value) { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } +}); + +const announcements = { + endpoint: 'announcements', + limit: 10, +}; +let showMenu = $ref(false); +let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD); +let narrow = $ref(window.innerWidth < 1280); +let meta = $ref(); + +const keymap = $computed(() => { + return { + 'd': () => { + if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; + defaultStore.set('darkMode', !defaultStore.state.darkMode); + }, + 's': search, + }; +}); + +const root = $computed(() => mainRouter.currentRoute.value.name === 'index'); + +os.api('meta', { detail: true }).then(res => { + meta = res; +}); + +function signin() { + os.popup(XSigninDialog, { + autoSet: true, + }, {}, 'closed'); +} + +function signup() { + os.popup(XSignupDialog, { + autoSet: true, + }, {}, 'closed'); +} + +onMounted(() => { + if (!isDesktop) { + window.addEventListener('resize', () => { + if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop = true; + }, { passive: true }); + } +}); + +defineExpose({ + showMenu: $$(showMenu), +}); +</script> + +<style> +.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}} +</style> + +<style lang="scss" scoped> +.tray-enter-active, +.tray-leave-active { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-enter-from, +.tray-leave-active { + opacity: 0; + transform: translateX(-240px); +} + +.tray-back-enter-active, +.tray-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-back-enter-from, +.tray-back-leave-active { + opacity: 0; +} + +.mk-app { + display: flex; + min-height: 100vh; + background-position: center; + background-size: cover; + background-attachment: fixed; + + > .side { + width: 500px; + height: 100vh; + + > .kanban { + position: fixed; + top: 0; + left: 0; + width: 500px; + height: 100vh; + overflow: auto; + } + } + + > .main { + flex: 1; + min-width: 0; + + > .banner { + } + + > .contents { + position: relative; + z-index: 1; + + > .powered-by { + padding: 28px; + font-size: 14px; + text-align: center; + border-top: 1px solid var(--divider); + + > small { + display: block; + margin-top: 8px; + opacity: 0.5; + } + } + } + } + + > .menu-back { + position: fixed; + z-index: 1001; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + } + + > .menu { + position: fixed; + z-index: 1001; + top: 0; + left: 0; + width: 240px; + height: 100vh; + background: var(--panel); + + > .link { + display: block; + padding: 16px; + + > .icon { + margin-right: 1em; + } + } + + > .action { + padding: 16px; + + > button { + display: block; + width: 100%; + padding: 10px; + box-sizing: border-box; + text-align: center; + border-radius: 999px; + + &._button { + background: var(--panel); + } + + &:first-child { + margin-bottom: 16px; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/ui/visitor/header.vue b/packages/frontend/src/ui/visitor/header.vue new file mode 100644 index 0000000000..7300b12a75 --- /dev/null +++ b/packages/frontend/src/ui/visitor/header.vue @@ -0,0 +1,228 @@ +<template> +<div class="sqxihjet"> + <div v-if="narrow === false" class="wide"> + <div class="content"> + <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ $ts.home }}</MkA> + <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ $ts.explore }}</MkA> + <MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA> + <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ $ts.channel }}</MkA> + <div v-if="info" class="page active link"> + <div class="title"> + <i v-if="info.icon" class="icon" :class="info.icon"></i> + <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> + <span v-if="info.title" class="text">{{ info.title }}</span> + <MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/> + </div> + <button v-if="info.action" class="_button action" @click.stop="info.action.handler"><!-- TODO --></button> + </div> + <div class="right"> + <button class="_button search" @click="search()"><i class="ti ti-search icon"></i><span>{{ $ts.search }}</span></button> + <button class="_buttonPrimary signup" @click="signup()">{{ $ts.signup }}</button> + <button class="_button login" @click="signin()">{{ $ts.login }}</button> + </div> + </div> + </div> + <div v-else-if="narrow === true" class="narrow"> + <button class="menu _button" @click="$parent.showMenu = true"> + <i class="ti ti-menu-2 icon"></i> + </button> + <div v-if="info" class="title"> + <i v-if="info.icon" class="icon" :class="info.icon"></i> + <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> + <span v-if="info.title" class="text">{{ info.title }}</span> + <MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/> + </div> + <button v-if="info && info.action" class="action _button" @click.stop="info.action.handler"> + <!-- TODO --> + </button> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import * as os from '@/os'; +import { search } from '@/scripts/search'; + +export default defineComponent({ + props: { + info: { + required: true, + }, + }, + + data() { + return { + narrow: null, + showMenu: false, + }; + }, + + mounted() { + this.narrow = this.$el.clientWidth < 1300; + }, + + methods: { + signin() { + os.popup(XSigninDialog, { + autoSet: true, + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true, + }, {}, 'closed'); + }, + + search, + }, +}); +</script> + +<style lang="scss" scoped> +.sqxihjet { + $height: 60px; + position: sticky; + top: 0; + left: 0; + z-index: 1000; + line-height: $height; + -webkit-backdrop-filter: var(--blur, blur(32px)); + backdrop-filter: var(--blur, blur(32px)); + background-color: var(--X16); + + > .wide { + > .content { + max-width: 1400px; + margin: 0 auto; + display: flex; + align-items: center; + + > .link { + $line: 3px; + display: inline-block; + padding: 0 16px; + line-height: $height - ($line * 2); + border-top: solid $line transparent; + border-bottom: solid $line transparent; + + > .icon { + margin-right: 0.5em; + } + + &.page { + border-bottom-color: var(--accent); + } + } + + > .page { + > .title { + display: inline-block; + vertical-align: bottom; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + + > .icon + .text { + margin-left: 8px; + } + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: middle; + margin-right: 8px; + pointer-events: none; + } + + &._button { + &:hover { + color: var(--fgHighlighted); + } + } + + &.selected { + box-shadow: 0 -2px 0 0 var(--accent) inset; + color: var(--fgHighlighted); + } + } + + > .action { + padding: 0 0 0 16px; + } + } + + > .right { + margin-left: auto; + + > .search { + background: var(--bg); + border-radius: 999px; + width: 230px; + line-height: $height - 20px; + margin-right: 16px; + text-align: left; + + > * { + opacity: 0.7; + } + + > .icon { + padding: 0 16px; + } + } + + > .signup { + border-radius: 999px; + padding: 0 24px; + line-height: $height - 20px; + } + + > .login { + padding: 0 16px; + } + } + } + } + + > .narrow { + display: flex; + + > .menu, + > .action { + width: $height; + height: $height; + font-size: 20px; + } + + > .title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + text-align: center; + + > .icon + .text { + margin-left: 8px; + } + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: middle; + margin-right: 8px; + pointer-events: none; + } + } + } +} +</style> diff --git a/packages/frontend/src/ui/visitor/kanban.vue b/packages/frontend/src/ui/visitor/kanban.vue new file mode 100644 index 0000000000..51e47f277d --- /dev/null +++ b/packages/frontend/src/ui/visitor/kanban.vue @@ -0,0 +1,257 @@ +<!-- eslint-disable vue/no-v-html --> +<template> +<div class="rwqkcmrc" :style="{ backgroundImage: transparent ? 'none' : `url(${ $instance.backgroundImageUrl })` }"> + <div class="back" :class="{ transparent }"></div> + <div class="contents"> + <div class="wrapper"> + <h1 v-if="meta" :class="{ full }"> + <MkA to="/" class="link"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl" alt="logo"><span v-else class="text">{{ instanceName }}</span></MkA> + </h1> + <template v-if="full"> + <div v-if="meta" class="about"> + <div class="desc" v-html="meta.description || $ts.introMisskey"></div> + </div> + <div class="action"> + <button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button> + <button class="_button" @click="signin()">{{ $ts.login }}</button> + </div> + <div class="announcements panel"> + <header>{{ $ts.announcements }}</header> + <MkPagination v-slot="{items}" :pagination="announcements" class="list"> + <section v-for="announcement in items" :key="announcement.id" class="item"> + <div class="title">{{ announcement.title }}</div> + <div class="content"> + <Mfm :text="announcement.text"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl" alt="announcement image"/> + </div> + </section> + </MkPagination> + </div> + <div v-if="poweredBy" class="powered-by"> + <b><MkA to="/">{{ host }}</MkA></b> + <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small> + </div> + </template> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import MkPagination from '@/components/MkPagination.vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; + +export default defineComponent({ + components: { + MkPagination, + MkButton, + }, + + props: { + full: { + type: Boolean, + required: false, + default: false, + }, + transparent: { + type: Boolean, + required: false, + default: false, + }, + poweredBy: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + host, + instanceName, + pageInfo: null, + meta: null, + narrow: window.innerWidth < 1280, + announcements: { + endpoint: 'announcements', + limit: 10, + }, + }; + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + }, + + methods: { + signin() { + os.popup(XSigninDialog, { + autoSet: true, + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true, + }, {}, 'closed'); + }, + }, +}); +</script> + +<style lang="scss" scoped> +.rwqkcmrc { + position: relative; + text-align: center; + background-position: center; + background-size: cover; + // TODO: パララックスにしたい + + > .back { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.3); + + &.transparent { + -webkit-backdrop-filter: var(--blur, blur(12px)); + backdrop-filter: var(--blur, blur(12px)); + } + } + + > .contents { + position: relative; + z-index: 1; + height: inherit; + overflow: auto; + + > .wrapper { + max-width: 380px; + padding: 0 16px; + box-sizing: border-box; + margin: 0 auto; + + > .panel { + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + background: rgba(0, 0, 0, 0.5); + border-radius: var(--radius); + + &, * { + color: #fff !important; + } + } + + > h1 { + display: block; + margin: 0; + padding: 32px 0 32px 0; + color: #fff; + + &.full { + padding: 64px 0 0 0; + + > .link { + > ::v-deep(.logo) { + max-height: 130px; + } + } + } + + > .link { + display: block; + + > ::v-deep(.logo) { + vertical-align: bottom; + max-height: 100px; + } + } + } + + > .about { + display: block; + margin: 24px 0; + text-align: center; + box-sizing: border-box; + text-shadow: 0 0 8px black; + color: #fff; + } + + > .action { + > button { + display: block; + width: 100%; + padding: 10px; + box-sizing: border-box; + text-align: center; + border-radius: 999px; + + &._button { + background: var(--panel); + } + + &:first-child { + margin-bottom: 16px; + } + } + } + + > .announcements { + margin: 32px 0; + text-align: left; + + > header { + padding: 12px 16px; + border-bottom: solid 1px rgba(255, 255, 255, 0.5); + } + + > .list { + max-height: 300px; + overflow: auto; + + > .item { + padding: 12px 16px; + + & + .item { + border-top: solid 1px rgba(255, 255, 255, 0.5); + } + + > .title { + font-weight: bold; + } + + > .content { + > img { + max-width: 100%; + } + } + } + } + } + + > .powered-by { + padding: 28px; + font-size: 14px; + text-align: center; + border-top: 1px solid rgba(255, 255, 255, 0.5); + color: #fff; + + > small { + display: block; + margin-top: 8px; + opacity: 0.5; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue new file mode 100644 index 0000000000..84c96a1dae --- /dev/null +++ b/packages/frontend/src/ui/zen.vue @@ -0,0 +1,34 @@ +<template> +<div class="mk-app"> + <RouterView/> + + <XCommon/> +</div> +</template> + +<script lang="ts" setup> +import { provide, ComputedRef } from 'vue'; +import XCommon from './_common_/common.vue'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import { instanceName } from '@/config'; + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); + +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; + if (pageMetadata.value) { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } +}); + +document.documentElement.style.overflowY = 'scroll'; +</script> + +<style lang="scss" scoped> +.mk-app { + min-height: 100dvh; + box-sizing: border-box; +} +</style> diff --git a/packages/frontend/src/widgets/activity.calendar.vue b/packages/frontend/src/widgets/activity.calendar.vue new file mode 100644 index 0000000000..84f6af1c13 --- /dev/null +++ b/packages/frontend/src/widgets/activity.calendar.vue @@ -0,0 +1,81 @@ +<template> +<svg viewBox="0 0 21 7"> + <rect v-for="record in activity" class="day" + width="1" height="1" + :x="record.x" :y="record.date.weekday" + rx="1" ry="1" + fill="transparent"> + <title>{{ record.date.year }}/{{ record.date.month + 1 }}/{{ record.date.day }}</title> + </rect> + <rect v-for="record in activity" class="day" + :width="record.v" :height="record.v" + :x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)" + rx="1" ry="1" + :fill="record.color" + style="pointer-events: none;"/> + <rect class="today" + width="1" height="1" + :x="activity[0].x" :y="activity[0].date.weekday" + rx="1" ry="1" + fill="none" + stroke-width="0.1" + stroke="#f73520"/> +</svg> +</template> + +<script lang="ts" setup> +const props = defineProps<{ + activity: any[] +}>(); + +for (const d of props.activity) { + d.total = d.notes + d.replies + d.renotes; +} +const peak = Math.max(...props.activity.map(d => d.total)); + +const now = new Date(); +const year = now.getFullYear(); +const month = now.getMonth(); +const day = now.getDate(); + +let x = 20; +props.activity.slice().forEach((d, i) => { + d.x = x; + + const date = new Date(year, month, day - i); + d.date = { + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + weekday: date.getDay(), + }; + + d.v = peak === 0 ? 0 : d.total / (peak / 2); + if (d.v > 1) d.v = 1; + const ch = d.date.weekday === 0 || d.date.weekday === 6 ? 275 : 170; + const cs = d.v * 100; + const cl = 15 + ((1 - d.v) * 80); + d.color = `hsl(${ch}, ${cs}%, ${cl}%)`; + + if (d.date.weekday === 0) x--; +}); +</script> + +<style lang="scss" scoped> +svg { + display: block; + padding: 16px; + width: 100%; + box-sizing: border-box; + + > rect { + transform-origin: center; + + &.day { + &:hover { + fill: rgba(#000, 0.05); + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/activity.chart.vue b/packages/frontend/src/widgets/activity.chart.vue new file mode 100644 index 0000000000..b61e419f94 --- /dev/null +++ b/packages/frontend/src/widgets/activity.chart.vue @@ -0,0 +1,92 @@ +<template> +<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" @mousedown.prevent="onMousedown"> + <polyline + :points="pointsNote" + fill="none" + stroke-width="1" + stroke="#41ddde"/> + <polyline + :points="pointsReply" + fill="none" + stroke-width="1" + stroke="#f7796c"/> + <polyline + :points="pointsRenote" + fill="none" + stroke-width="1" + stroke="#a1de41"/> + <polyline + :points="pointsTotal" + fill="none" + stroke-width="1" + stroke="#555" + stroke-dasharray="2 2"/> +</svg> +</template> + +<script lang="ts" setup> +const props = defineProps<{ + activity: any[] +}>(); + +let viewBoxX: number = $ref(147); +let viewBoxY: number = $ref(60); +let zoom: number = $ref(1); +let pos: number = $ref(0); +let pointsNote: any = $ref(null); +let pointsReply: any = $ref(null); +let pointsRenote: any = $ref(null); +let pointsTotal: any = $ref(null); + +function dragListen(fn) { + window.addEventListener('mousemove', fn); + window.addEventListener('mouseleave', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); +} + +function dragClear(fn) { + window.removeEventListener('mousemove', fn); + window.removeEventListener('mouseleave', dragClear); + window.removeEventListener('mouseup', dragClear); +} + +function onMousedown(ev) { + const clickX = ev.clientX; + const clickY = ev.clientY; + const baseZoom = zoom; + const basePos = pos; + + // 動かした時 + dragListen(me => { + let moveLeft = me.clientX - clickX; + let moveTop = me.clientY - clickY; + + zoom = Math.max(1, baseZoom + (-moveTop / 20)); + pos = Math.min(0, basePos + moveLeft); + if (pos < -(((props.activity.length - 1) * zoom) - viewBoxX)) pos = -(((props.activity.length - 1) * zoom) - viewBoxX); + + render(); + }); +} + +function render() { + const peak = Math.max(...props.activity.map(d => d.total)); + if (peak !== 0) { + const activity = props.activity.slice().reverse(); + pointsNote = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.notes / peak)) * viewBoxY}`).join(' '); + pointsReply = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.replies / peak)) * viewBoxY}`).join(' '); + pointsRenote = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.renotes / peak)) * viewBoxY}`).join(' '); + pointsTotal = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.total / peak)) * viewBoxY}`).join(' '); + } +} +</script> + +<style lang="scss" scoped> +svg { + display: block; + padding: 16px; + width: 100%; + box-sizing: border-box; + cursor: all-scroll; +} +</style> diff --git a/packages/frontend/src/widgets/activity.vue b/packages/frontend/src/widgets/activity.vue new file mode 100644 index 0000000000..238a05ca09 --- /dev/null +++ b/packages/frontend/src/widgets/activity.vue @@ -0,0 +1,90 @@ +<template> +<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" class="mkw-activity"> + <template #header><i class="ti ti-chart-line"></i>{{ i18n.ts._widgets.activity }}</template> + <template #func><button class="_button" @click="toggleView()"><i class="ti ti-selector"></i></button></template> + + <div> + <MkLoading v-if="fetching"/> + <template v-else> + <XCalendar v-show="widgetProps.view === 0" :activity="[].concat(activity)"/> + <XChart v-show="widgetProps.view === 1" :activity="[].concat(activity)"/> + </template> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import XCalendar from './activity.calendar.vue'; +import XChart from './activity.chart.vue'; +import { GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; + +const name = 'activity'; + +const widgetPropsDef = { + showHeader: { + type: 'boolean' as const, + default: true, + }, + transparent: { + type: 'boolean' as const, + default: false, + }, + view: { + type: 'number' as const, + default: 0, + hidden: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure, save } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const activity = ref(null); +const fetching = ref(true); + +const toggleView = () => { + if (widgetProps.view === 1) { + widgetProps.view = 0; + } else { + widgetProps.view++; + } + save(); +}; + +os.apiGet('charts/user/notes', { + userId: $i.id, + span: 'day', + limit: 7 * 21, +}).then(res => { + activity.value = res.diffs.normal.map((_, i) => ({ + total: res.diffs.normal[i] + res.diffs.reply[i] + res.diffs.renote[i], + notes: res.diffs.normal[i], + replies: res.diffs.reply[i], + renotes: res.diffs.renote[i], + })); + fetching.value = false; +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> diff --git a/packages/frontend/src/widgets/aichan.vue b/packages/frontend/src/widgets/aichan.vue new file mode 100644 index 0000000000..828490fd9c --- /dev/null +++ b/packages/frontend/src/widgets/aichan.vue @@ -0,0 +1,74 @@ +<template> +<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-aichan"> + <iframe ref="live2d" class="dedjhjmo" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100" @click="touched"></iframe> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, reactive, ref } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; + +const name = 'ai'; + +const widgetPropsDef = { + transparent: { + type: 'boolean' as const, + default: false, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const live2d = ref<HTMLIFrameElement>(); + +const touched = () => { + //if (this.live2d) this.live2d.changeExpression('gurugurume'); +}; + +const onMousemove = (ev: MouseEvent) => { + const iframeRect = live2d.value.getBoundingClientRect(); + live2d.value.contentWindow.postMessage({ + type: 'moveCursor', + body: { + x: ev.clientX - iframeRect.left, + y: ev.clientY - iframeRect.top, + }, + }, '*'); +}; + +onMounted(() => { + window.addEventListener('mousemove', onMousemove, { passive: true }); +}); + +onUnmounted(() => { + window.removeEventListener('mousemove', onMousemove); +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.dedjhjmo { + width: 100%; + height: 350px; + border: none; + pointer-events: none; +} +</style> diff --git a/packages/frontend/src/widgets/aiscript.vue b/packages/frontend/src/widgets/aiscript.vue new file mode 100644 index 0000000000..4009edb8b8 --- /dev/null +++ b/packages/frontend/src/widgets/aiscript.vue @@ -0,0 +1,175 @@ +<template> +<MkContainer :show-header="widgetProps.showHeader" class="mkw-aiscript"> + <template #header><i class="ti ti-terminal-2"></i>{{ i18n.ts._widgets.aiscript }}</template> + + <div class="uylguesu _monospace"> + <textarea v-model="widgetProps.script" placeholder="(1 + 1)"></textarea> + <button class="_buttonPrimary" @click="run">RUN</button> + <div class="logs"> + <div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div> + </div> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref, watch } from 'vue'; +import { AiScript, parse, utils } from '@syuilo/aiscript'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; +import { createAiScriptEnv } from '@/scripts/aiscript/api'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; + +const name = 'aiscript'; + +const widgetPropsDef = { + showHeader: { + type: 'boolean' as const, + default: true, + }, + script: { + type: 'string' as const, + multiline: true, + default: '(1 + 1)', + hidden: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const logs = ref<{ + id: string; + text: string; + print: boolean; +}[]>([]); + +const run = async () => { + logs.value = []; + const aiscript = new AiScript(createAiScriptEnv({ + storageKey: 'widget', + token: $i?.token, + }), { + in: (q) => { + return new Promise(ok => { + os.inputText({ + title: q, + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + logs.value.push({ + id: Math.random().toString(), + text: value.type === 'str' ? value.value : utils.valToString(value), + print: true, + }); + }, + log: (type, params) => { + switch (type) { + case 'end': logs.value.push({ + id: Math.random().toString(), + text: utils.valToString(params.val, true), + print: false, + }); break; + default: break; + } + }, + }); + + let ast; + try { + ast = parse(widgetProps.script); + } catch (err) { + os.alert({ + type: 'error', + text: 'Syntax error :(', + }); + return; + } + try { + await aiscript.exec(ast); + } catch (err) { + os.alert({ + type: 'error', + text: err, + }); + } +}; + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.uylguesu { + text-align: right; + + > textarea { + display: block; + width: 100%; + max-width: 100%; + min-width: 100%; + padding: 16px; + color: var(--fg); + background: transparent; + border: none; + border-bottom: solid 0.5px var(--divider); + border-radius: 0; + box-sizing: border-box; + font: inherit; + + &:focus-visible { + outline: none; + } + } + + > button { + display: inline-block; + margin: 8px; + padding: 0 10px; + height: 28px; + outline: none; + border-radius: 4px; + + &:disabled { + opacity: 0.7; + cursor: default; + } + } + + > .logs { + border-top: solid 0.5px var(--divider); + text-align: left; + padding: 16px; + + &:empty { + display: none; + } + + > .log { + &:not(.print) { + opacity: 0.7; + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/button.vue b/packages/frontend/src/widgets/button.vue new file mode 100644 index 0000000000..f0148d7f4e --- /dev/null +++ b/packages/frontend/src/widgets/button.vue @@ -0,0 +1,103 @@ +<template> +<div class="mkw-button"> + <MkButton :primary="widgetProps.colored" full @click="run"> + {{ widgetProps.label }} + </MkButton> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref, watch } from 'vue'; +import { AiScript, parse, utils } from '@syuilo/aiscript'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import { createAiScriptEnv } from '@/scripts/aiscript/api'; +import { $i } from '@/account'; +import MkButton from '@/components/MkButton.vue'; + +const name = 'button'; + +const widgetPropsDef = { + label: { + type: 'string' as const, + default: 'BUTTON', + }, + colored: { + type: 'boolean' as const, + default: true, + }, + script: { + type: 'string' as const, + multiline: true, + default: 'Mk:dialog("hello" "world")', + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const run = async () => { + const aiscript = new AiScript(createAiScriptEnv({ + storageKey: 'widget', + token: $i?.token, + }), { + in: (q) => { + return new Promise(ok => { + os.inputText({ + title: q, + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + // nop + }, + log: (type, params) => { + // nop + }, + }); + + let ast; + try { + ast = parse(widgetProps.script); + } catch (err) { + os.alert({ + type: 'error', + text: 'Syntax error :(', + }); + return; + } + try { + await aiscript.exec(ast); + } catch (err) { + os.alert({ + type: 'error', + text: err, + }); + } +}; + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.mkw-button { +} +</style> diff --git a/packages/frontend/src/widgets/calendar.vue b/packages/frontend/src/widgets/calendar.vue new file mode 100644 index 0000000000..99bd36e2fc --- /dev/null +++ b/packages/frontend/src/widgets/calendar.vue @@ -0,0 +1,213 @@ +<template> +<div class="mkw-calendar" :class="{ _panel: !widgetProps.transparent }"> + <div class="calendar" :class="{ isHoliday }"> + <p class="month-and-year"> + <span class="year">{{ $t('yearX', { year }) }}</span> + <span class="month">{{ $t('monthX', { month }) }}</span> + </p> + <p v-if="month === 1 && day === 1" class="day">🎉{{ $t('dayX', { day }) }}<span style="display: inline-block; transform: scaleX(-1);">🎉</span></p> + <p v-else class="day">{{ $t('dayX', { day }) }}</p> + <p class="week-day">{{ weekDay }}</p> + </div> + <div class="info"> + <div> + <p>{{ i18n.ts.today }}: <b>{{ dayP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${dayP}%` }"></div> + </div> + </div> + <div> + <p>{{ i18n.ts.thisMonth }}: <b>{{ monthP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${monthP}%` }"></div> + </div> + </div> + <div> + <p>{{ i18n.ts.thisYear }}: <b>{{ yearP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${yearP}%` }"></div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onUnmounted, ref } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import { i18n } from '@/i18n'; +import { useInterval } from '@/scripts/use-interval'; + +const name = 'calendar'; + +const widgetPropsDef = { + transparent: { + type: 'boolean' as const, + default: false, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const year = ref(0); +const month = ref(0); +const day = ref(0); +const weekDay = ref(''); +const yearP = ref(0); +const monthP = ref(0); +const dayP = ref(0); +const isHoliday = ref(false); +const tick = () => { + const now = new Date(); + const nd = now.getDate(); + const nm = now.getMonth(); + const ny = now.getFullYear(); + + year.value = ny; + month.value = nm + 1; + day.value = nd; + weekDay.value = [ + i18n.ts._weekday.sunday, + i18n.ts._weekday.monday, + i18n.ts._weekday.tuesday, + i18n.ts._weekday.wednesday, + i18n.ts._weekday.thursday, + i18n.ts._weekday.friday, + i18n.ts._weekday.saturday, + ][now.getDay()]; + + const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime(); + const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/; + const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime(); + const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime(); + const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime(); + const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime(); + + dayP.value = dayNumer / dayDenom * 100; + monthP.value = monthNumer / monthDenom * 100; + yearP.value = yearNumer / yearDenom * 100; + + isHoliday.value = now.getDay() === 0 || now.getDay() === 6; +}; + +useInterval(tick, 1000, { + immediate: true, + afterMounted: false, +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.mkw-calendar { + padding: 16px 0; + + &:after { + content: ""; + display: block; + clear: both; + } + + > .calendar { + float: left; + width: 60%; + text-align: center; + + &.isHoliday { + > .day { + color: #ef95a0; + } + } + + > .month-and-year, > .week-day { + margin: 0; + line-height: 18px; + font-size: 0.9em; + + > .year, > .month { + margin: 0 4px; + } + } + + > .day { + margin: 10px 0; + line-height: 32px; + font-size: 1.75em; + } + } + + > .info { + display: block; + float: left; + width: 40%; + padding: 0 16px 0 0; + box-sizing: border-box; + + > div { + margin-bottom: 8px; + + &:last-child { + margin-bottom: 4px; + } + + > p { + margin: 0 0 2px 0; + font-size: 0.75em; + line-height: 18px; + opacity: 0.8; + + > b { + margin-left: 2px; + } + } + + > .meter { + width: 100%; + overflow: hidden; + background: var(--X11); + border-radius: 8px; + + > .val { + height: 4px; + transition: width .3s cubic-bezier(0.23, 1, 0.32, 1); + } + } + + &:nth-child(1) { + > .meter > .val { + background: #f7796c; + } + } + + &:nth-child(2) { + > .meter > .val { + background: #a1de41; + } + } + + &:nth-child(3) { + > .meter > .val { + background: #41ddde; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/clock.vue b/packages/frontend/src/widgets/clock.vue new file mode 100644 index 0000000000..dc99b6631e --- /dev/null +++ b/packages/frontend/src/widgets/clock.vue @@ -0,0 +1,203 @@ +<template> +<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-clock"> + <div class="vubelbmv" :class="widgetProps.size"> + <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label a abbrev">{{ tzAbbrev }}</div> + <MkAnalogClock + class="clock" + :thickness="widgetProps.thickness" + :offset="tzOffset" + :graduations="widgetProps.graduations" + :fade-graduations="widgetProps.fadeGraduations" + :twentyfour="widgetProps.twentyFour" + :s-animation="widgetProps.sAnimation" + /> + <MkDigitalClock v-if="widgetProps.label === 'time' || widgetProps.label === 'timeAndTz'" class="_monospace label c time" :show-s="false" :offset="tzOffset"/> + <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label d offset">{{ tzOffsetLabel }}</div> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import MkContainer from '@/components/MkContainer.vue'; +import MkAnalogClock from '@/components/MkAnalogClock.vue'; +import MkDigitalClock from '@/components/MkDigitalClock.vue'; +import { timezones } from '@/scripts/timezones'; +import { i18n } from '@/i18n'; + +const name = 'clock'; + +const widgetPropsDef = { + transparent: { + type: 'boolean' as const, + default: false, + }, + size: { + type: 'radio' as const, + default: 'medium', + options: [{ + value: 'small', label: i18n.ts.small, + }, { + value: 'medium', label: i18n.ts.medium, + }, { + value: 'large', label: i18n.ts.large, + }], + }, + thickness: { + type: 'radio' as const, + default: 0.2, + options: [{ + value: 0.1, label: 'thin', + }, { + value: 0.2, label: 'medium', + }, { + value: 0.3, label: 'thick', + }], + }, + graduations: { + type: 'radio' as const, + default: 'numbers', + options: [{ + value: 'none', label: 'None', + }, { + value: 'dots', label: 'Dots', + }, { + value: 'numbers', label: 'Numbers', + }], + }, + fadeGraduations: { + type: 'boolean' as const, + default: true, + }, + sAnimation: { + type: 'radio' as const, + default: 'elastic', + options: [{ + value: 'none', label: 'None', + }, { + value: 'elastic', label: 'Elastic', + }, { + value: 'easeOut', label: 'Ease out', + }], + }, + twentyFour: { + type: 'boolean' as const, + default: false, + }, + label: { + type: 'radio' as const, + default: 'none', + options: [{ + value: 'none', label: 'None', + }, { + value: 'time', label: 'Time', + }, { + value: 'tz', label: 'TZ', + }, { + value: 'timeAndTz', label: 'Time + TZ', + }], + }, + timezone: { + type: 'enum' as const, + default: null, + enum: [...timezones.map((tz) => ({ + label: tz.name, + value: tz.name.toLowerCase(), + })), { + label: '(auto)', + value: null, + }], + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const tzAbbrev = $computed(() => (widgetProps.timezone === null + ? timezones.find((tz) => tz.name.toLowerCase() === Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase())?.abbrev + : timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.abbrev) ?? '?'); + +const tzOffset = $computed(() => widgetProps.timezone === null + ? 0 - new Date().getTimezoneOffset() + : timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.offset ?? 0); + +const tzOffsetLabel = $computed(() => (tzOffset >= 0 ? '+' : '-') + Math.floor(tzOffset / 60).toString().padStart(2, '0') + ':' + (tzOffset % 60).toString().padStart(2, '0')); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.vubelbmv { + position: relative; + + > .label { + position: absolute; + opacity: 0.7; + + &.a { + top: 14px; + left: 14px; + } + + &.b { + top: 14px; + right: 14px; + } + + &.c { + bottom: 14px; + left: 14px; + } + + &.d { + bottom: 14px; + right: 14px; + } + } + + > .clock { + margin: auto; + } + + &.small { + padding: 12px; + + > .clock { + height: 100px; + } + } + + &.medium { + padding: 14px; + + > .clock { + height: 150px; + } + } + + &.large { + padding: 16px; + + > .clock { + height: 200px; + } + } +} +</style> diff --git a/packages/frontend/src/widgets/digital-clock.vue b/packages/frontend/src/widgets/digital-clock.vue new file mode 100644 index 0000000000..d2bfd523f3 --- /dev/null +++ b/packages/frontend/src/widgets/digital-clock.vue @@ -0,0 +1,92 @@ +<template> +<div class="mkw-digitalClock _monospace" :class="{ _panel: !widgetProps.transparent }" :style="{ fontSize: `${widgetProps.fontSize}em` }"> + <div v-if="widgetProps.showLabel" class="label">{{ tzAbbrev }}</div> + <div class="time"> + <MkDigitalClock :show-ms="widgetProps.showMs" :offset="tzOffset"/> + </div> + <div v-if="widgetProps.showLabel" class="label">{{ tzOffsetLabel }}</div> +</div> +</template> + +<script lang="ts" setup> +import { onUnmounted, ref, watch } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import { timezones } from '@/scripts/timezones'; +import MkDigitalClock from '@/components/MkDigitalClock.vue'; + +const name = 'digitalClock'; + +const widgetPropsDef = { + transparent: { + type: 'boolean' as const, + default: false, + }, + fontSize: { + type: 'number' as const, + default: 1.5, + step: 0.1, + }, + showMs: { + type: 'boolean' as const, + default: true, + }, + showLabel: { + type: 'boolean' as const, + default: true, + }, + timezone: { + type: 'enum' as const, + default: null, + enum: [...timezones.map((tz) => ({ + label: tz.name, + value: tz.name.toLowerCase(), + })), { + label: '(auto)', + value: null, + }], + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const tzAbbrev = $computed(() => (widgetProps.timezone === null + ? timezones.find((tz) => tz.name.toLowerCase() === Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase())?.abbrev + : timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.abbrev) ?? '?'); + +const tzOffset = $computed(() => widgetProps.timezone === null + ? 0 - new Date().getTimezoneOffset() + : timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.offset ?? 0); + +const tzOffsetLabel = $computed(() => (tzOffset >= 0 ? '+' : '-') + Math.floor(tzOffset / 60).toString().padStart(2, '0') + ':' + (tzOffset % 60).toString().padStart(2, '0')); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.mkw-digitalClock { + padding: 16px 0; + text-align: center; + + > .label { + font-size: 65%; + opacity: 0.7; + } +} +</style> diff --git a/packages/frontend/src/widgets/federation.vue b/packages/frontend/src/widgets/federation.vue new file mode 100644 index 0000000000..3374783b0c --- /dev/null +++ b/packages/frontend/src/widgets/federation.vue @@ -0,0 +1,147 @@ +<template> +<MkContainer :show-header="widgetProps.showHeader" :foldable="foldable" :scrollable="scrollable" class="mkw-federation"> + <template #header><i class="ti ti-whirl"></i>{{ i18n.ts._widgets.federation }}</template> + + <div class="wbrkwalb"> + <MkLoading v-if="fetching"/> + <transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="instances"> + <div v-for="(instance, i) in instances" :key="instance.id" class="instance"> + <img :src="getInstanceIcon(instance)" alt=""/> + <div class="body"> + <a class="a" :href="'https://' + instance.host" target="_blank" :title="instance.host">{{ instance.host }}</a> + <p>{{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</p> + </div> + <MkMiniChart class="chart" :src="charts[i].requests.received"/> + </div> + </transition-group> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import MkContainer from '@/components/MkContainer.vue'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { i18n } from '@/i18n'; +import { getProxiedImageUrlNullable } from '@/scripts/media-proxy'; + +const name = 'federation'; + +const widgetPropsDef = { + showHeader: { + type: 'boolean' as const, + default: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps> & { foldable?: boolean; scrollable?: boolean; }>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; foldable?: boolean; scrollable?: boolean; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const instances = ref([]); +const charts = ref([]); +const fetching = ref(true); + +const fetch = async () => { + const fetchedInstances = await os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 5, + }); + const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.apiGet('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); + instances.value = fetchedInstances; + charts.value = fetchedCharts; + fetching.value = false; +}; + +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, +}); + +function getInstanceIcon(instance): string { + return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png'; +} + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.wbrkwalb { + $bodyTitleHieght: 18px; + $bodyInfoHieght: 16px; + + height: (62px + 1px) + (62px + 1px) + (62px + 1px) + (62px + 1px) + 62px; + overflow: hidden; + + > .instances { + .chart-move { + transition: transform 1s ease; + } + + > .instance { + display: flex; + align-items: center; + padding: 14px 16px; + border-bottom: solid 0.5px var(--divider); + + > img { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + object-fit: cover; + border-radius: 4px; + margin-right: 8px; + } + + > .body { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; + + > .a { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $bodyTitleHieght; + } + + > p { + margin: 0; + font-size: 75%; + opacity: 0.7; + line-height: $bodyInfoHieght; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + > .chart { + height: 30px; + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts new file mode 100644 index 0000000000..39826f13c8 --- /dev/null +++ b/packages/frontend/src/widgets/index.ts @@ -0,0 +1,53 @@ +import { App, defineAsyncComponent } from 'vue'; + +export default function(app: App) { + app.component('MkwMemo', defineAsyncComponent(() => import('./memo.vue'))); + app.component('MkwNotifications', defineAsyncComponent(() => import('./notifications.vue'))); + app.component('MkwTimeline', defineAsyncComponent(() => import('./timeline.vue'))); + app.component('MkwCalendar', defineAsyncComponent(() => import('./calendar.vue'))); + app.component('MkwRss', defineAsyncComponent(() => import('./rss.vue'))); + app.component('MkwRssTicker', defineAsyncComponent(() => import('./rss-ticker.vue'))); + app.component('MkwTrends', defineAsyncComponent(() => import('./trends.vue'))); + app.component('MkwClock', defineAsyncComponent(() => import('./clock.vue'))); + app.component('MkwActivity', defineAsyncComponent(() => import('./activity.vue'))); + app.component('MkwPhotos', defineAsyncComponent(() => import('./photos.vue'))); + app.component('MkwDigitalClock', defineAsyncComponent(() => import('./digital-clock.vue'))); + app.component('MkwUnixClock', defineAsyncComponent(() => import('./unix-clock.vue'))); + app.component('MkwFederation', defineAsyncComponent(() => import('./federation.vue'))); + app.component('MkwPostForm', defineAsyncComponent(() => import('./post-form.vue'))); + app.component('MkwSlideshow', defineAsyncComponent(() => import('./slideshow.vue'))); + app.component('MkwServerMetric', defineAsyncComponent(() => import('./server-metric/index.vue'))); + app.component('MkwOnlineUsers', defineAsyncComponent(() => import('./online-users.vue'))); + app.component('MkwJobQueue', defineAsyncComponent(() => import('./job-queue.vue'))); + app.component('MkwInstanceCloud', defineAsyncComponent(() => import('./instance-cloud.vue'))); + app.component('MkwButton', defineAsyncComponent(() => import('./button.vue'))); + app.component('MkwAiscript', defineAsyncComponent(() => import('./aiscript.vue'))); + app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue'))); + app.component('MkwUserList', defineAsyncComponent(() => import('./user-list.vue'))); +} + +export const widgets = [ + 'memo', + 'notifications', + 'timeline', + 'calendar', + 'rss', + 'rssTicker', + 'trends', + 'clock', + 'activity', + 'photos', + 'digitalClock', + 'unixClock', + 'federation', + 'instanceCloud', + 'postForm', + 'slideshow', + 'serverMetric', + 'onlineUsers', + 'jobQueue', + 'button', + 'aiscript', + 'aichan', + 'userList', +]; diff --git a/packages/frontend/src/widgets/instance-cloud.vue b/packages/frontend/src/widgets/instance-cloud.vue new file mode 100644 index 0000000000..4965616995 --- /dev/null +++ b/packages/frontend/src/widgets/instance-cloud.vue @@ -0,0 +1,81 @@ +<template> +<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-instance-cloud"> + <div class=""> + <MkTagCloud v-if="activeInstances"> + <li v-for="instance in activeInstances" :key="instance.id"> + <a @click.prevent="onInstanceClick(instance)"> + <img style="width: 32px;" :src="getInstanceIcon(instance)"> + </a> + </li> + </MkTagCloud> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import MkContainer from '@/components/MkContainer.vue'; +import MkTagCloud from '@/components/MkTagCloud.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { getProxiedImageUrlNullable } from '@/scripts/media-proxy'; + +const name = 'instanceCloud'; + +const widgetPropsDef = { + transparent: { + type: 'boolean' as const, + default: false, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +let cloud = $ref<InstanceType<typeof MkTagCloud> | null>(); +let activeInstances = $shallowRef(null); + +function onInstanceClick(i) { + os.pageWindow(`/instance-info/${i.host}`); +} + +useInterval(() => { + os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 25, + }).then(res => { + activeInstances = res; + if (cloud) cloud.update(); + }); +}, 1000 * 60 * 3, { + immediate: true, + afterMounted: true, +}); + +function getInstanceIcon(instance): string { + return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png'; +} + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/widgets/job-queue.vue b/packages/frontend/src/widgets/job-queue.vue new file mode 100644 index 0000000000..9f19c51825 --- /dev/null +++ b/packages/frontend/src/widgets/job-queue.vue @@ -0,0 +1,197 @@ +<template> +<div class="mkw-jobQueue _monospace" :class="{ _panel: !widgetProps.transparent }"> + <div class="inbox"> + <div class="label">Inbox queue<i v-if="current.inbox.waiting > 0" class="ti ti-alert-triangle icon"></i></div> + <div class="values"> + <div> + <div>Process</div> + <div :class="{ inc: current.inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: current.inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }">{{ number(current.inbox.activeSincePrevTick) }}</div> + </div> + <div> + <div>Active</div> + <div :class="{ inc: current.inbox.active > prev.inbox.active, dec: current.inbox.active < prev.inbox.active }">{{ number(current.inbox.active) }}</div> + </div> + <div> + <div>Delayed</div> + <div :class="{ inc: current.inbox.delayed > prev.inbox.delayed, dec: current.inbox.delayed < prev.inbox.delayed }">{{ number(current.inbox.delayed) }}</div> + </div> + <div> + <div>Waiting</div> + <div :class="{ inc: current.inbox.waiting > prev.inbox.waiting, dec: current.inbox.waiting < prev.inbox.waiting }">{{ number(current.inbox.waiting) }}</div> + </div> + </div> + </div> + <div class="deliver"> + <div class="label">Deliver queue<i v-if="current.deliver.waiting > 0" class="ti ti-alert-triangle icon"></i></div> + <div class="values"> + <div> + <div>Process</div> + <div :class="{ inc: current.deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: current.deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }">{{ number(current.deliver.activeSincePrevTick) }}</div> + </div> + <div> + <div>Active</div> + <div :class="{ inc: current.deliver.active > prev.deliver.active, dec: current.deliver.active < prev.deliver.active }">{{ number(current.deliver.active) }}</div> + </div> + <div> + <div>Delayed</div> + <div :class="{ inc: current.deliver.delayed > prev.deliver.delayed, dec: current.deliver.delayed < prev.deliver.delayed }">{{ number(current.deliver.delayed) }}</div> + </div> + <div> + <div>Waiting</div> + <div :class="{ inc: current.deliver.waiting > prev.deliver.waiting, dec: current.deliver.waiting < prev.deliver.waiting }">{{ number(current.deliver.waiting) }}</div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, reactive, ref } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import { stream } from '@/stream'; +import number from '@/filters/number'; +import * as sound from '@/scripts/sound'; +import * as os from '@/os'; +import { deepClone } from '@/scripts/clone'; + +const name = 'jobQueue'; + +const widgetPropsDef = { + transparent: { + type: 'boolean' as const, + default: false, + }, + sound: { + type: 'boolean' as const, + default: false, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const connection = stream.useChannel('queueStats'); +const current = reactive({ + inbox: { + activeSincePrevTick: 0, + active: 0, + waiting: 0, + delayed: 0, + }, + deliver: { + activeSincePrevTick: 0, + active: 0, + waiting: 0, + delayed: 0, + }, +}); +const prev = reactive({} as typeof current); +const jammedSound = sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1); + +for (const domain of ['inbox', 'deliver']) { + prev[domain] = deepClone(current[domain]); +} + +const onStats = (stats) => { + for (const domain of ['inbox', 'deliver']) { + prev[domain] = deepClone(current[domain]); + current[domain].activeSincePrevTick = stats[domain].activeSincePrevTick; + current[domain].active = stats[domain].active; + current[domain].waiting = stats[domain].waiting; + current[domain].delayed = stats[domain].delayed; + + if (current[domain].waiting > 0 && widgetProps.sound && jammedSound.paused) { + jammedSound.play(); + } + } +}; + +const onStatsLog = (statsLog) => { + for (const stats of [...statsLog].reverse()) { + onStats(stats); + } +}; + +connection.on('stats', onStats); +connection.on('statsLog', onStatsLog); + +connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 1, +}); + +onUnmounted(() => { + connection.off('stats', onStats); + connection.off('statsLog', onStatsLog); + connection.dispose(); +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +@keyframes warnBlink { + 0% { opacity: 1; } + 50% { opacity: 0; } +} + +.mkw-jobQueue { + font-size: 0.9em; + + > div { + padding: 16px; + + &:not(:first-child) { + border-top: solid 0.5px var(--divider); + } + + > .label { + display: flex; + + > .icon { + color: var(--warn); + margin-left: auto; + animation: warnBlink 1s infinite; + } + } + + > .values { + display: flex; + + > div { + flex: 1; + + > div:first-child { + opacity: 0.7; + } + + > div:last-child { + &.inc { + color: var(--warn); + } + + &.dec { + color: var(--success); + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/memo.vue b/packages/frontend/src/widgets/memo.vue new file mode 100644 index 0000000000..1cc0e10bba --- /dev/null +++ b/packages/frontend/src/widgets/memo.vue @@ -0,0 +1,111 @@ +<template> +<MkContainer :show-header="widgetProps.showHeader" class="mkw-memo"> + <template #header><i class="ti ti-note"></i>{{ i18n.ts._widgets.memo }}</template> + + <div class="otgbylcu"> + <textarea v-model="text" :placeholder="i18n.ts.placeholder" @input="onChange"></textarea> + <button :disabled="!changed" class="_buttonPrimary" @click="saveMemo">{{ i18n.ts.save }}</button> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; + +const name = 'memo'; + +const widgetPropsDef = { + showHeader: { + type: 'boolean' as const, + default: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const text = ref<string | null>(defaultStore.state.memo); +const changed = ref(false); +let timeoutId; + +const saveMemo = () => { + defaultStore.set('memo', text.value); + changed.value = false; +}; + +const onChange = () => { + changed.value = true; + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(saveMemo, 1000); +}; + +watch(() => defaultStore.reactiveState.memo, newText => { + text.value = newText.value; +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.otgbylcu { + padding-bottom: 28px + 16px; + + > textarea { + display: block; + width: 100%; + max-width: 100%; + min-width: 100%; + padding: 16px; + color: var(--fg); + background: transparent; + border: none; + border-bottom: solid 0.5px var(--divider); + border-radius: 0; + box-sizing: border-box; + font: inherit; + font-size: 0.9em; + + &:focus-visible { + outline: none; + } + } + + > button { + display: block; + position: absolute; + bottom: 8px; + right: 8px; + margin: 0; + padding: 0 10px; + height: 28px; + outline: none; + border-radius: 4px; + + &:disabled { + opacity: 0.7; + cursor: default; + } + } +} +</style> diff --git a/packages/frontend/src/widgets/notifications.vue b/packages/frontend/src/widgets/notifications.vue new file mode 100644 index 0000000000..e697209444 --- /dev/null +++ b/packages/frontend/src/widgets/notifications.vue @@ -0,0 +1,70 @@ +<template> +<MkContainer :style="`height: ${widgetProps.height}px;`" :show-header="widgetProps.showHeader" :scrollable="true" class="mkw-notifications"> + <template #header><i class="ti ti-bell"></i>{{ i18n.ts.notifications }}</template> + <template #func><button class="_button" @click="configureNotification()"><i class="ti ti-settings"></i></button></template> + + <div> + <XNotifications :include-types="widgetProps.includingTypes"/> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import MkContainer from '@/components/MkContainer.vue'; +import XNotifications from '@/components/MkNotifications.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const name = 'notifications'; + +const widgetPropsDef = { + showHeader: { + type: 'boolean' as const, + default: true, + }, + height: { + type: 'number' as const, + default: 300, + }, + includingTypes: { + type: 'array' as const, + hidden: true, + default: null, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure, save } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const configureNotification = () => { + os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { + includingTypes: widgetProps.includingTypes, + }, { + done: async (res) => { + const { includingTypes } = res; + widgetProps.includingTypes = includingTypes; + save(); + }, + }, 'closed'); +}; + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> diff --git a/packages/frontend/src/widgets/online-users.vue b/packages/frontend/src/widgets/online-users.vue new file mode 100644 index 0000000000..e9ab79b111 --- /dev/null +++ b/packages/frontend/src/widgets/online-users.vue @@ -0,0 +1,78 @@ +<template> +<div class="mkw-onlineUsers" :class="{ _panel: !widgetProps.transparent, pad: !widgetProps.transparent }"> + <I18n v-if="onlineUsersCount" :src="i18n.ts.onlineUsersCount" text-tag="span" class="text"> + <template #n><b>{{ onlineUsersCount }}</b></template> + </I18n> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { i18n } from '@/i18n'; + +const name = 'onlineUsers'; + +const widgetPropsDef = { + transparent: { + type: 'boolean' as const, + default: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const onlineUsersCount = ref(0); + +const tick = () => { + os.api('get-online-users-count').then(res => { + onlineUsersCount.value = res.count; + }); +}; + +useInterval(tick, 1000 * 15, { + immediate: true, + afterMounted: true, +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.mkw-onlineUsers { + text-align: center; + + &.pad { + padding: 16px 0; + } + + > .text { + ::v-deep(b) { + color: #41b781; + } + + ::v-deep(span) { + opacity: 0.7; + } + } +} +</style> diff --git a/packages/frontend/src/widgets/photos.vue b/packages/frontend/src/widgets/photos.vue new file mode 100644 index 0000000000..4ad5324053 --- /dev/null +++ b/packages/frontend/src/widgets/photos.vue @@ -0,0 +1,123 @@ +<template> +<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" :class="$style.root" :data-transparent="widgetProps.transparent ? true : null" class="mkw-photos"> + <template #header><i class="ti ti-camera"></i>{{ i18n.ts._widgets.photos }}</template> + + <div class=""> + <MkLoading v-if="fetching"/> + <div v-else :class="$style.stream"> + <div + v-for="(image, i) in images" :key="i" + :class="$style.img" + :style="`background-image: url(${thumbnail(image)})`" + ></div> + </div> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, reactive, ref } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import { stream } from '@/stream'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; + +const name = 'photos'; + +const widgetPropsDef = { + showHeader: { + type: 'boolean' as const, + default: true, + }, + transparent: { + type: 'boolean' as const, + default: false, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const connection = stream.useChannel('main'); +const images = ref([]); +const fetching = ref(true); + +const onDriveFileCreated = (file) => { + if (/^image\/.+$/.test(file.type)) { + images.value.unshift(file); + if (images.value.length > 9) images.value.pop(); + } +}; + +const thumbnail = (image: any): string => { + return defaultStore.state.disableShowingAnimatedImages + ? getStaticImageUrl(image.thumbnailUrl) + : image.thumbnailUrl; +}; + +os.api('drive/stream', { + type: 'image/*', + limit: 9, +}).then(res => { + images.value = res; + fetching.value = false; +}); + +connection.on('driveFileCreated', onDriveFileCreated); +onUnmounted(() => { + connection.dispose(); +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" module> +.root[data-transparent] { + .stream { + padding: 0; + } + + .img { + border: solid 4px transparent; + border-radius: 8px; + } +} + +.stream { + display: flex; + justify-content: center; + flex-wrap: wrap; + padding: 8px; + + .img { + flex: 1 1 33%; + width: 33%; + height: 80px; + box-sizing: border-box; + background-position: center center; + background-size: cover; + background-clip: content-box; + border: solid 2px transparent; + border-radius: 4px; + } +} +</style> diff --git a/packages/frontend/src/widgets/post-form.vue b/packages/frontend/src/widgets/post-form.vue new file mode 100644 index 0000000000..f1708775ba --- /dev/null +++ b/packages/frontend/src/widgets/post-form.vue @@ -0,0 +1,35 @@ +<template> +<XPostForm class="_panel mkw-postForm" :fixed="true" :autofocus="false"/> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import { GetFormResultType } from '@/scripts/form'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import XPostForm from '@/components/MkPostForm.vue'; + +const name = 'postForm'; + +const widgetPropsDef = { +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> diff --git a/packages/frontend/src/widgets/rss-ticker.vue b/packages/frontend/src/widgets/rss-ticker.vue new file mode 100644 index 0000000000..44c21d1836 --- /dev/null +++ b/packages/frontend/src/widgets/rss-ticker.vue @@ -0,0 +1,152 @@ +<template> +<MkContainer :naked="widgetProps.transparent" :show-header="widgetProps.showHeader" class="mkw-rss-ticker"> + <template #header><i class="ti ti-rss"></i>RSS</template> + <template #func><button class="_button" @click="configure"><i class="ti ti-settings"></i></button></template> + + <div class="ekmkgxbk"> + <MkLoading v-if="fetching"/> + <div v-else class="feed"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="widgetProps.duration" :reverse="widgetProps.reverse"> + <span v-for="item in items" class="item"> + <a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span> + </span> + </MarqueeText> + </transition> + </div> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref, watch } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import MarqueeText from '@/components/MkMarquee.vue'; +import { GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; +import { useInterval } from '@/scripts/use-interval'; +import { shuffle } from '@/scripts/shuffle'; + +const name = 'rssTicker'; + +const widgetPropsDef = { + url: { + type: 'string' as const, + default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', + }, + shuffle: { + type: 'boolean' as const, + default: true, + }, + refreshIntervalSec: { + type: 'number' as const, + default: 60, + }, + duration: { + type: 'range' as const, + default: 70, + step: 1, + min: 5, + max: 200, + }, + reverse: { + type: 'boolean' as const, + default: false, + }, + showHeader: { + type: 'boolean' as const, + default: false, + }, + transparent: { + type: 'boolean' as const, + default: false, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const items = ref([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => { + res.json().then(feed => { + if (widgetProps.shuffle) { + shuffle(feed.items); + } + items.value = feed.items; + fetching.value = false; + key++; + }); + }); +}; + +watch(() => widgetProps.url, tick); + +useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.ekmkgxbk { + > .feed { + --height: 42px; + padding: 0; + font-size: 0.9em; + line-height: var(--height); + height: var(--height); + contain: strict; + + ::v-deep(.item) { + display: inline-flex; + align-items: center; + vertical-align: bottom; + color: var(--fg); + + > .divider { + display: inline-block; + width: 0.5px; + height: 16px; + margin: 0 1em; + background: var(--divider); + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/rss.vue b/packages/frontend/src/widgets/rss.vue new file mode 100644 index 0000000000..c0338c8e47 --- /dev/null +++ b/packages/frontend/src/widgets/rss.vue @@ -0,0 +1,96 @@ +<template> +<MkContainer :show-header="widgetProps.showHeader" class="mkw-rss"> + <template #header><i class="ti ti-rss"></i>RSS</template> + <template #func><button class="_button" @click="configure"><i class="ti ti-settings"></i></button></template> + + <div class="ekmkgxbj"> + <MkLoading v-if="fetching"/> + <div v-else class="feed"> + <a v-for="item in items" class="item" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> + </div> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref, watch } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; +import { useInterval } from '@/scripts/use-interval'; + +const name = 'rss'; + +const widgetPropsDef = { + url: { + type: 'string' as const, + default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', + }, + showHeader: { + type: 'boolean' as const, + default: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const items = ref([]); +const fetching = ref(true); + +const tick = () => { + window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => { + res.json().then(feed => { + items.value = feed.items; + fetching.value = false; + }); + }); +}; + +watch(() => widgetProps.url, tick); + +useInterval(tick, 60000, { + immediate: true, + afterMounted: true, +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.ekmkgxbj { + > .feed { + padding: 0; + font-size: 0.9em; + + > .item { + display: block; + padding: 8px 16px; + color: var(--fg); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + &:nth-child(even) { + background: rgba(#000, 0.05); + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/server-metric/cpu-mem.vue b/packages/frontend/src/widgets/server-metric/cpu-mem.vue new file mode 100644 index 0000000000..80a8e427e1 --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/cpu-mem.vue @@ -0,0 +1,167 @@ +<template> +<div class="lcfyofjk"> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`"> + <defs> + <linearGradient :id="cpuGradientId" x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop> + <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> + </linearGradient> + <mask :id="cpuMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> + <polygon + :points="cpuPolygonPoints" + fill="#fff" + fill-opacity="0.5" + /> + <polyline + :points="cpuPolylinePoints" + fill="none" + stroke="#fff" + stroke-width="1" + /> + <circle + :cx="cpuHeadX" + :cy="cpuHeadY" + r="1.5" + fill="#fff" + /> + </mask> + </defs> + <rect + x="-2" y="-2" + :width="viewBoxX + 4" :height="viewBoxY + 4" + :style="`stroke: none; fill: url(#${ cpuGradientId }); mask: url(#${ cpuMaskId })`" + /> + <text x="1" y="5">CPU <tspan>{{ cpuP }}%</tspan></text> + </svg> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`"> + <defs> + <linearGradient :id="memGradientId" x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop> + <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> + </linearGradient> + <mask :id="memMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> + <polygon + :points="memPolygonPoints" + fill="#fff" + fill-opacity="0.5" + /> + <polyline + :points="memPolylinePoints" + fill="none" + stroke="#fff" + stroke-width="1" + /> + <circle + :cx="memHeadX" + :cy="memHeadY" + r="1.5" + fill="#fff" + /> + </mask> + </defs> + <rect + x="-2" y="-2" + :width="viewBoxX + 4" :height="viewBoxY + 4" + :style="`stroke: none; fill: url(#${ memGradientId }); mask: url(#${ memMaskId })`" + /> + <text x="1" y="5">MEM <tspan>{{ memP }}%</tspan></text> + </svg> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onBeforeUnmount } from 'vue'; +import { v4 as uuid } from 'uuid'; + +const props = defineProps<{ + connection: any, + meta: any +}>(); + +let viewBoxX: number = $ref(50); +let viewBoxY: number = $ref(30); +let stats: any[] = $ref([]); +const cpuGradientId = uuid(); +const cpuMaskId = uuid(); +const memGradientId = uuid(); +const memMaskId = uuid(); +let cpuPolylinePoints: string = $ref(''); +let memPolylinePoints: string = $ref(''); +let cpuPolygonPoints: string = $ref(''); +let memPolygonPoints: string = $ref(''); +let cpuHeadX: any = $ref(null); +let cpuHeadY: any = $ref(null); +let memHeadX: any = $ref(null); +let memHeadY: any = $ref(null); +let cpuP: string = $ref(''); +let memP: string = $ref(''); + +onMounted(() => { + props.connection.on('stats', onStats); + props.connection.on('statsLog', onStatsLog); + props.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + }); +}); + +onBeforeUnmount(() => { + props.connection.off('stats', onStats); + props.connection.off('statsLog', onStatsLog); +}); + +function onStats(connStats) { + stats.push(connStats); + if (stats.length > 50) stats.shift(); + + let cpuPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - s.cpu) * viewBoxY]); + let memPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - (s.mem.active / props.meta.mem.total)) * viewBoxY]); + cpuPolylinePoints = cpuPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' '); + memPolylinePoints = memPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' '); + + cpuPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${cpuPolylinePoints} ${viewBoxX},${viewBoxY}`; + memPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${memPolylinePoints} ${viewBoxX},${viewBoxY}`; + + cpuHeadX = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][0]; + cpuHeadY = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][1]; + memHeadX = memPolylinePointsStats[memPolylinePointsStats.length - 1][0]; + memHeadY = memPolylinePointsStats[memPolylinePointsStats.length - 1][1]; + + cpuP = (connStats.cpu * 100).toFixed(0); + memP = (connStats.mem.active / props.meta.mem.total * 100).toFixed(0); +} + +function onStatsLog(statsLog) { + for (const revStats of [...statsLog].reverse()) { + onStats(revStats); + } +} +</script> + +<style lang="scss" scoped> +.lcfyofjk { + display: flex; + + > svg { + display: block; + padding: 10px; + width: 50%; + + &:first-child { + padding-right: 5px; + } + + &:last-child { + padding-left: 5px; + } + + > text { + font-size: 4.5px; + fill: currentColor; + + > tspan { + opacity: 0.5; + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/server-metric/cpu.vue b/packages/frontend/src/widgets/server-metric/cpu.vue new file mode 100644 index 0000000000..e7b2226d1f --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/cpu.vue @@ -0,0 +1,65 @@ +<template> +<div class="vrvdvrys"> + <XPie class="pie" :value="usage"/> + <div> + <p><i class="ti ti-cpu"></i>CPU</p> + <p>{{ meta.cpu.cores }} Logical cores</p> + <p>{{ meta.cpu.model }}</p> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onBeforeUnmount } from 'vue'; +import XPie from './pie.vue'; + +const props = defineProps<{ + connection: any, + meta: any +}>(); + +let usage: number = $ref(0); + +function onStats(stats) { + usage = stats.cpu; +} + +onMounted(() => { + props.connection.on('stats', onStats); +}); + +onBeforeUnmount(() => { + props.connection.off('stats', onStats); +}); +</script> + +<style lang="scss" scoped> +.vrvdvrys { + display: flex; + padding: 16px; + + > .pie { + height: 82px; + flex-shrink: 0; + margin-right: 16px; + } + + > div { + flex: 1; + + > p { + margin: 0; + font-size: 0.8em; + + &:first-child { + font-weight: bold; + margin-bottom: 4px; + + > i { + margin-right: 4px; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/server-metric/disk.vue b/packages/frontend/src/widgets/server-metric/disk.vue new file mode 100644 index 0000000000..3d22d05383 --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/disk.vue @@ -0,0 +1,57 @@ +<template> +<div class="zbwaqsat"> + <XPie class="pie" :value="usage"/> + <div> + <p><i class="ti ti-database"></i>Disk</p> + <p>Total: {{ bytes(total, 1) }}</p> + <p>Free: {{ bytes(available, 1) }}</p> + <p>Used: {{ bytes(used, 1) }}</p> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XPie from './pie.vue'; +import bytes from '@/filters/bytes'; + +const props = defineProps<{ + meta: any; // TODO +}>(); + +const usage = $computed(() => props.meta.fs.used / props.meta.fs.total); +const total = $computed(() => props.meta.fs.total); +const used = $computed(() => props.meta.fs.used); +const available = $computed(() => props.meta.fs.total - props.meta.fs.used); +</script> + +<style lang="scss" scoped> +.zbwaqsat { + display: flex; + padding: 16px; + + > .pie { + height: 82px; + flex-shrink: 0; + margin-right: 16px; + } + + > div { + flex: 1; + + > p { + margin: 0; + font-size: 0.8em; + + &:first-child { + font-weight: bold; + margin-bottom: 4px; + + > i { + margin-right: 4px; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue new file mode 100644 index 0000000000..bc3fca6fc1 --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/index.vue @@ -0,0 +1,87 @@ +<template> +<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent"> + <template #header><i class="ti ti-server"></i>{{ i18n.ts._widgets.serverMetric }}</template> + <template #func><button class="_button" @click="toggleView()"><i class="ti ti-selector"></i></button></template> + + <div v-if="meta" class="mkw-serverMetric"> + <XCpuMemory v-if="widgetProps.view === 0" :connection="connection" :meta="meta"/> + <XNet v-else-if="widgetProps.view === 1" :connection="connection" :meta="meta"/> + <XCpu v-else-if="widgetProps.view === 2" :connection="connection" :meta="meta"/> + <XMemory v-else-if="widgetProps.view === 3" :connection="connection" :meta="meta"/> + <XDisk v-else-if="widgetProps.view === 4" :connection="connection" :meta="meta"/> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from '../widget'; +import XCpuMemory from './cpu-mem.vue'; +import XNet from './net.vue'; +import XCpu from './cpu.vue'; +import XMemory from './mem.vue'; +import XDisk from './disk.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import { GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; + +const name = 'serverMetric'; + +const widgetPropsDef = { + showHeader: { + type: 'boolean' as const, + default: true, + }, + transparent: { + type: 'boolean' as const, + default: false, + }, + view: { + type: 'number' as const, + default: 0, + hidden: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure, save } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const meta = ref(null); + +os.api('server-info', {}).then(res => { + meta.value = res; +}); + +const toggleView = () => { + if (widgetProps.view === 4) { + widgetProps.view = 0; + } else { + widgetProps.view++; + } + save(); +}; + +const connection = stream.useChannel('serverStats'); +onUnmounted(() => { + connection.dispose(); +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> diff --git a/packages/frontend/src/widgets/server-metric/mem.vue b/packages/frontend/src/widgets/server-metric/mem.vue new file mode 100644 index 0000000000..6018eb4265 --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/mem.vue @@ -0,0 +1,73 @@ +<template> +<div class="zlxnikvl"> + <XPie class="pie" :value="usage"/> + <div> + <p><i class="fas fa-memory"></i>RAM</p> + <p>Total: {{ bytes(total, 1) }}</p> + <p>Used: {{ bytes(used, 1) }}</p> + <p>Free: {{ bytes(free, 1) }}</p> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onBeforeUnmount } from 'vue'; +import XPie from './pie.vue'; +import bytes from '@/filters/bytes'; + +const props = defineProps<{ + connection: any, + meta: any +}>(); + +let usage: number = $ref(0); +let total: number = $ref(0); +let used: number = $ref(0); +let free: number = $ref(0); + +function onStats(stats) { + usage = stats.mem.active / props.meta.mem.total; + total = props.meta.mem.total; + used = stats.mem.active; + free = total - used; +} + +onMounted(() => { + props.connection.on('stats', onStats); +}); + +onBeforeUnmount(() => { + props.connection.off('stats', onStats); +}); +</script> + +<style lang="scss" scoped> +.zlxnikvl { + display: flex; + padding: 16px; + + > .pie { + height: 82px; + flex-shrink: 0; + margin-right: 16px; + } + + > div { + flex: 1; + + > p { + margin: 0; + font-size: 0.8em; + + &:first-child { + font-weight: bold; + margin-bottom: 4px; + + > i { + margin-right: 4px; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/server-metric/net.vue b/packages/frontend/src/widgets/server-metric/net.vue new file mode 100644 index 0000000000..ab8b0fe471 --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/net.vue @@ -0,0 +1,140 @@ +<template> +<div class="oxxrhrto"> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`"> + <polygon + :points="inPolygonPoints" + fill="#94a029" + fill-opacity="0.5" + /> + <polyline + :points="inPolylinePoints" + fill="none" + stroke="#94a029" + stroke-width="1" + /> + <circle + :cx="inHeadX" + :cy="inHeadY" + r="1.5" + fill="#94a029" + /> + <text x="1" y="5">NET rx <tspan>{{ bytes(inRecent) }}</tspan></text> + </svg> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`"> + <polygon + :points="outPolygonPoints" + fill="#ff9156" + fill-opacity="0.5" + /> + <polyline + :points="outPolylinePoints" + fill="none" + stroke="#ff9156" + stroke-width="1" + /> + <circle + :cx="outHeadX" + :cy="outHeadY" + r="1.5" + fill="#ff9156" + /> + <text x="1" y="5">NET tx <tspan>{{ bytes(outRecent) }}</tspan></text> + </svg> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onBeforeUnmount } from 'vue'; +import bytes from '@/filters/bytes'; + +const props = defineProps<{ + connection: any, + meta: any +}>(); + +let viewBoxX: number = $ref(50); +let viewBoxY: number = $ref(30); +let stats: any[] = $ref([]); +let inPolylinePoints: string = $ref(''); +let outPolylinePoints: string = $ref(''); +let inPolygonPoints: string = $ref(''); +let outPolygonPoints: string = $ref(''); +let inHeadX: any = $ref(null); +let inHeadY: any = $ref(null); +let outHeadX: any = $ref(null); +let outHeadY: any = $ref(null); +let inRecent: number = $ref(0); +let outRecent: number = $ref(0); + +onMounted(() => { + props.connection.on('stats', onStats); + props.connection.on('statsLog', onStatsLog); + props.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + }); +}); + +onBeforeUnmount(() => { + props.connection.off('stats', onStats); + props.connection.off('statsLog', onStatsLog); +}); + +function onStats(connStats) { + stats.push(connStats); + if (stats.length > 50) stats.shift(); + + const inPeak = Math.max(1024 * 64, Math.max(...stats.map(s => s.net.rx))); + const outPeak = Math.max(1024 * 64, Math.max(...stats.map(s => s.net.tx))); + + let inPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - (s.net.rx / inPeak)) * viewBoxY]); + let outPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - (s.net.tx / outPeak)) * viewBoxY]); + inPolylinePoints = inPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' '); + outPolylinePoints = outPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' '); + + inPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${inPolylinePoints} ${viewBoxX},${viewBoxY}`; + outPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${outPolylinePoints} ${viewBoxX},${viewBoxY}`; + + inHeadX = inPolylinePointsStats[inPolylinePointsStats.length - 1][0]; + inHeadY = inPolylinePointsStats[inPolylinePointsStats.length - 1][1]; + outHeadX = outPolylinePointsStats[outPolylinePointsStats.length - 1][0]; + outHeadY = outPolylinePointsStats[outPolylinePointsStats.length - 1][1]; + + inRecent = connStats.net.rx; + outRecent = connStats.net.tx; +} + +function onStatsLog(statsLog) { + for (const revStats of [...statsLog].reverse()) { + onStats(revStats); + } +} +</script> + +<style lang="scss" scoped> +.oxxrhrto { + display: flex; + + > svg { + display: block; + padding: 10px; + width: 50%; + + &:first-child { + padding-right: 5px; + } + + &:last-child { + padding-left: 5px; + } + + > text { + font-size: 4.5px; + fill: currentColor; + + > tspan { + opacity: 0.5; + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/server-metric/pie.vue b/packages/frontend/src/widgets/server-metric/pie.vue new file mode 100644 index 0000000000..868dbc0484 --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/pie.vue @@ -0,0 +1,52 @@ +<template> +<svg class="hsalcinq" viewBox="0 0 1 1" preserveAspectRatio="none"> + <circle + :r="r" + cx="50%" cy="50%" + fill="none" + stroke-width="0.1" + stroke="rgba(0, 0, 0, 0.05)" + /> + <circle + :r="r" + cx="50%" cy="50%" + :stroke-dasharray="Math.PI * (r * 2)" + :stroke-dashoffset="strokeDashoffset" + fill="none" + stroke-width="0.1" + :stroke="color" + /> + <text x="50%" y="50%" dy="0.05" text-anchor="middle">{{ (value * 100).toFixed(0) }}%</text> +</svg> +</template> + +<script lang="ts" setup> +import { } from 'vue'; + +const props = defineProps<{ + value: number; +}>(); + +const r = 0.45; + +const color = $computed(() => `hsl(${180 - (props.value * 180)}, 80%, 70%)`); +const strokeDashoffset = $computed(() => (1 - props.value) * (Math.PI * (r * 2))); +</script> + +<style lang="scss" scoped> +.hsalcinq { + display: block; + height: 100%; + + > circle { + transform-origin: center; + transform: rotate(-90deg); + transition: stroke-dashoffset 0.5s ease; + } + + > text { + font-size: 0.15px; + fill: currentColor; + } +} +</style> diff --git a/packages/frontend/src/widgets/slideshow.vue b/packages/frontend/src/widgets/slideshow.vue new file mode 100644 index 0000000000..e317b8ab94 --- /dev/null +++ b/packages/frontend/src/widgets/slideshow.vue @@ -0,0 +1,159 @@ +<template> +<div class="kvausudm _panel mkw-slideshow" :style="{ height: widgetProps.height + 'px' }"> + <div @click="choose"> + <p v-if="widgetProps.folderId == null"> + {{ i18n.ts.folder }} + </p> + <p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ $t('no-image') }}</p> + <div ref="slideA" class="slide a"></div> + <div ref="slideB" class="slide b"></div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { i18n } from '@/i18n'; + +const name = 'slideshow'; + +const widgetPropsDef = { + height: { + type: 'number' as const, + default: 300, + }, + folderId: { + type: 'string' as const, + default: null, + hidden: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure, save } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const images = ref([]); +const fetching = ref(true); +const slideA = ref<HTMLElement>(); +const slideB = ref<HTMLElement>(); + +const change = () => { + if (images.value.length === 0) return; + + const index = Math.floor(Math.random() * images.value.length); + const img = `url(${ images.value[index].url })`; + + slideB.value.style.backgroundImage = img; + + slideB.value.classList.add('anime'); + window.setTimeout(() => { + // 既にこのウィジェットがunmountされていたら要素がない + if (slideA.value == null) return; + + slideA.value.style.backgroundImage = img; + + slideB.value.classList.remove('anime'); + }, 1000); +}; + +const fetch = () => { + fetching.value = true; + + os.api('drive/files', { + folderId: widgetProps.folderId, + type: 'image/*', + limit: 100, + }).then(res => { + images.value = res; + fetching.value = false; + slideA.value.style.backgroundImage = ''; + slideB.value.style.backgroundImage = ''; + change(); + }); +}; + +const choose = () => { + os.selectDriveFolder(false).then(folder => { + if (folder == null) { + return; + } + widgetProps.folderId = folder.id; + save(); + fetch(); + }); +}; + +useInterval(change, 10000, { + immediate: false, + afterMounted: true, +}); + +onMounted(() => { + if (widgetProps.folderId != null) { + fetch(); + } +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.kvausudm { + position: relative; + + > div { + width: 100%; + height: 100%; + cursor: pointer; + + > p { + display: block; + margin: 1em; + text-align: center; + color: #888; + } + + > * { + pointer-events: none; + } + + > .slide { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center; + + &.b { + opacity: 0; + } + + &.anime { + transition: opacity 1s; + opacity: 1; + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/timeline.vue b/packages/frontend/src/widgets/timeline.vue new file mode 100644 index 0000000000..e48444d33f --- /dev/null +++ b/packages/frontend/src/widgets/timeline.vue @@ -0,0 +1,129 @@ +<template> +<MkContainer :show-header="widgetProps.showHeader" :style="`height: ${widgetProps.height}px;`" :scrollable="true" class="mkw-timeline"> + <template #header> + <button class="_button" @click="choose"> + <i v-if="widgetProps.src === 'home'" class="ti ti-home"></i> + <i v-else-if="widgetProps.src === 'local'" class="ti ti-messages"></i> + <i v-else-if="widgetProps.src === 'social'" class="ti ti-share"></i> + <i v-else-if="widgetProps.src === 'global'" class="ti ti-world"></i> + <i v-else-if="widgetProps.src === 'list'" class="ti ti-list"></i> + <i v-else-if="widgetProps.src === 'antenna'" class="ti ti-antenna"></i> + <span style="margin-left: 8px;">{{ widgetProps.src === 'list' ? widgetProps.list.name : widgetProps.src === 'antenna' ? widgetProps.antenna.name : $t('_timelines.' + widgetProps.src) }}</span> + <i :class="menuOpened ? 'ti ti-chevron-up' : 'ti ti-chevron-down'" style="margin-left: 8px;"></i> + </button> + </template> + + <div> + <XTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'; +import { GetFormResultType } from '@/scripts/form'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; +import XTimeline from '@/components/MkTimeline.vue'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; + +const name = 'timeline'; + +const widgetPropsDef = { + showHeader: { + type: 'boolean' as const, + default: true, + }, + height: { + type: 'number' as const, + default: 300, + }, + src: { + type: 'string' as const, + default: 'home', + hidden: true, + }, + antenna: { + type: 'object' as const, + default: null, + hidden: true, + }, + list: { + type: 'object' as const, + default: null, + hidden: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure, save } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const menuOpened = ref(false); + +const setSrc = (src) => { + widgetProps.src = src; + save(); +}; + +const choose = async (ev) => { + menuOpened.value = true; + const [antennas, lists] = await Promise.all([ + os.api('antennas/list'), + os.api('users/lists/list'), + ]); + const antennaItems = antennas.map(antenna => ({ + text: antenna.name, + icon: 'ti ti-antenna', + action: () => { + widgetProps.antenna = antenna; + setSrc('antenna'); + }, + })); + const listItems = lists.map(list => ({ + text: list.name, + icon: 'ti ti-list', + action: () => { + widgetProps.list = list; + setSrc('list'); + }, + })); + os.popupMenu([{ + text: i18n.ts._timelines.home, + icon: 'ti ti-home', + action: () => { setSrc('home'); }, + }, { + text: i18n.ts._timelines.local, + icon: 'ti ti-messages', + action: () => { setSrc('local'); }, + }, { + text: i18n.ts._timelines.social, + icon: 'ti ti-share', + action: () => { setSrc('social'); }, + }, { + text: i18n.ts._timelines.global, + icon: 'ti ti-world', + action: () => { setSrc('global'); }, + }, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], ev.currentTarget ?? ev.target).then(() => { + menuOpened.value = false; + }); +}; + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> diff --git a/packages/frontend/src/widgets/trends.vue b/packages/frontend/src/widgets/trends.vue new file mode 100644 index 0000000000..02eec0431e --- /dev/null +++ b/packages/frontend/src/widgets/trends.vue @@ -0,0 +1,120 @@ +<template> +<MkContainer :show-header="widgetProps.showHeader" class="mkw-trends"> + <template #header><i class="ti ti-hash"></i>{{ i18n.ts._widgets.trends }}</template> + + <div class="wbrkwala"> + <MkLoading v-if="fetching"/> + <transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="tags"> + <div v-for="stat in stats" :key="stat.tag"> + <div class="tag"> + <MkA class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA> + <p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p> + </div> + <MkMiniChart class="chart" :src="stat.chart"/> + </div> + </transition-group> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import MkContainer from '@/components/MkContainer.vue'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { i18n } from '@/i18n'; + +const name = 'hashtags'; + +const widgetPropsDef = { + showHeader: { + type: 'boolean' as const, + default: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const stats = ref([]); +const fetching = ref(true); + +const fetch = () => { + os.api('hashtags/trend').then(res => { + stats.value = res; + fetching.value = false; + }); +}; + +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.wbrkwala { + height: (62px + 1px) + (62px + 1px) + (62px + 1px) + (62px + 1px) + 62px; + overflow: hidden; + + > .tags { + .chart-move { + transition: transform 1s ease; + } + + > div { + display: flex; + align-items: center; + padding: 14px 16px; + border-bottom: solid 0.5px var(--divider); + + > .tag { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + + > .a { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 18px; + } + + > p { + margin: 0; + font-size: 75%; + opacity: 0.7; + line-height: 16px; + } + } + + > .chart { + height: 30px; + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/unix-clock.vue b/packages/frontend/src/widgets/unix-clock.vue new file mode 100644 index 0000000000..cf85ac782c --- /dev/null +++ b/packages/frontend/src/widgets/unix-clock.vue @@ -0,0 +1,116 @@ +<template> +<div class="mkw-unixClock _monospace" :class="{ _panel: !widgetProps.transparent }" :style="{ fontSize: `${widgetProps.fontSize}em` }"> + <div v-if="widgetProps.showLabel" class="label">UNIX Epoch</div> + <div class="time"> + <span v-text="ss"></span> + <span v-if="widgetProps.showMs" class="colon" :class="{ showColon }">:</span> + <span v-if="widgetProps.showMs" v-text="ms"></span> + </div> + <div v-if="widgetProps.showLabel" class="label">UTC</div> +</div> +</template> + +<script lang="ts" setup> +import { onUnmounted, ref, watch } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; + +const name = 'unixClock'; + +const widgetPropsDef = { + transparent: { + type: 'boolean' as const, + default: false, + }, + fontSize: { + type: 'number' as const, + default: 1.5, + step: 0.1, + }, + showMs: { + type: 'boolean' as const, + default: true, + }, + showLabel: { + type: 'boolean' as const, + default: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +let intervalId; +const ss = ref(''); +const ms = ref(''); +const showColon = ref(false); +let prevSec: string | null = null; + +watch(showColon, (v) => { + if (v) { + window.setTimeout(() => { + showColon.value = false; + }, 30); + } +}); + +const tick = () => { + const now = new Date(); + ss.value = Math.floor(now.getTime() / 1000).toString(); + ms.value = Math.floor(now.getTime() % 1000 / 10).toString().padStart(2, '0'); + if (ss.value !== prevSec) showColon.value = true; + prevSec = ss.value; +}; + +tick(); + +watch(() => widgetProps.showMs, () => { + if (intervalId) window.clearInterval(intervalId); + intervalId = window.setInterval(tick, widgetProps.showMs ? 10 : 1000); +}, { immediate: true }); + +onUnmounted(() => { + window.clearInterval(intervalId); +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.mkw-unixClock { + padding: 16px 0; + text-align: center; + + > .label { + font-size: 65%; + opacity: 0.7; + } + + > .time { + > .colon { + opacity: 0; + transition: opacity 1s ease; + + &.showColon { + opacity: 1; + transition: opacity 0s; + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/user-list.vue b/packages/frontend/src/widgets/user-list.vue new file mode 100644 index 0000000000..9ffbf0d8e3 --- /dev/null +++ b/packages/frontend/src/widgets/user-list.vue @@ -0,0 +1,136 @@ +<template> +<MkContainer :show-header="widgetProps.showHeader" class="mkw-userList"> + <template #header><i class="ti ti-users"></i>{{ list ? list.name : i18n.ts._widgets.userList }}</template> + <template #func><button class="_button" @click="configure()"><i class="ti ti-settings"></i></button></template> + + <div :class="$style.root"> + <div v-if="widgetProps.listId == null" class="init"> + <MkButton primary @click="chooseList">{{ i18n.ts._widgets._userList.chooseList }}</MkButton> + </div> + <MkLoading v-else-if="fetching"/> + <div v-else class="users"> + <MkA v-for="user in users" :key="user.id" class="user"> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> + </MkA> + </div> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import MkContainer from '@/components/MkContainer.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { i18n } from '@/i18n'; +import MkButton from '@/components/MkButton.vue'; + +const name = 'userList'; + +const widgetPropsDef = { + showHeader: { + type: 'boolean' as const, + default: true, + }, + listId: { + type: 'string' as const, + default: null, + hidden: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure, save } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +let list = $ref(); +let users = $ref([]); +let fetching = $ref(true); + +async function chooseList() { + const lists = await os.api('users/lists/list'); + const { canceled, result: list } = await os.select({ + title: i18n.ts.selectList, + items: lists.map(x => ({ + value: x, text: x.name, + })), + default: widgetProps.listId, + }); + if (canceled) return; + + widgetProps.listId = list.id; + save(); + fetch(); +} + +const fetch = () => { + if (widgetProps.listId == null) { + fetching = false; + return; + } + + os.api('users/lists/show', { + listId: widgetProps.listId, + }).then(_list => { + list = _list; + os.api('users/show', { + userIds: list.userIds, + }).then(_users => { + users = _users; + fetching = false; + }); + }); +}; + +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" module> +.root { + &:global { + > .init { + padding: 16px; + } + + > .users { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(30px, 40px)); + grid-gap: 12px; + place-content: center; + padding: 16px; + + > .user { + width: 100%; + height: 100%; + aspect-ratio: 1; + + > .avatar { + width: 100%; + height: 100%; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/widgets/widget.ts b/packages/frontend/src/widgets/widget.ts new file mode 100644 index 0000000000..8bd56a5966 --- /dev/null +++ b/packages/frontend/src/widgets/widget.ts @@ -0,0 +1,73 @@ +import { reactive, watch } from 'vue'; +import { throttle } from 'throttle-debounce'; +import { Form, GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import { deepClone } from '@/scripts/clone'; + +export type Widget<P extends Record<string, unknown>> = { + id: string; + data: Partial<P>; +}; + +export type WidgetComponentProps<P extends Record<string, unknown>> = { + widget?: Widget<P>; +}; + +export type WidgetComponentEmits<P extends Record<string, unknown>> = { + (ev: 'updateProps', props: P); +}; + +export type WidgetComponentExpose = { + name: string; + id: string | null; + configure: () => void; +}; + +export const useWidgetPropsManager = <F extends Form & Record<string, { default: any; }>>( + name: string, + propsDef: F, + props: Readonly<WidgetComponentProps<GetFormResultType<F>>>, + emit: WidgetComponentEmits<GetFormResultType<F>>, +): { + widgetProps: GetFormResultType<F>; + save: () => void; + configure: () => void; +} => { + const widgetProps = reactive(props.widget ? deepClone(props.widget.data) : {}); + + const mergeProps = () => { + for (const prop of Object.keys(propsDef)) { + if (typeof widgetProps[prop] === 'undefined') { + widgetProps[prop] = propsDef[prop].default; + } + } + }; + watch(widgetProps, () => { + mergeProps(); + }, { deep: true, immediate: true }); + + const save = throttle(3000, () => { + emit('updateProps', widgetProps); + }); + + const configure = async () => { + const form = deepClone(propsDef); + for (const item of Object.keys(form)) { + form[item].default = widgetProps[item]; + } + const { canceled, result } = await os.form(name, form); + if (canceled) return; + + for (const key of Object.keys(result)) { + widgetProps[key] = result[key]; + } + + save(); + }; + + return { + widgetProps, + save, + configure, + }; +}; |