fix: format script; chore: format

This commit is contained in:
Kainoa Kanter 2023-04-07 17:01:42 -07:00
parent aeb0839da9
commit 6bf1cbc0ef
430 changed files with 36649 additions and 21793 deletions

View File

@ -48,8 +48,8 @@ Thank you for your PR! Before creating a PR, please check the following:
- If there is an Issue which will be resolved by this PR, please include a reference to the Issue in the text. Good examples include `Closing: #21` or `Resolves: #21` - If there is an Issue which will be resolved by this PR, please include a reference to the Issue in the text. Good examples include `Closing: #21` or `Resolves: #21`
- Check if there are any documents that need to be created or updated due to this change. - Check if there are any documents that need to be created or updated due to this change.
- If you have added a feature or fixed a bug, please add a test case if possible. - If you have added a feature or fixed a bug, please add a test case if possible.
- Please make sure that tests and Lint are passed in advance. - Please make sure that formatting, tests and Lint are passed in advance.
- You can run it with `pnpm run test` and `pnpm run lint`. [See more info](#testing) - You can run it with `pnpm run format`, `pnpm run test` and `pnpm run lint`. [See more info](#testing)
- If this PR includes UI changes, please attach a screenshot in the text. - If this PR includes UI changes, please attach a screenshot in the text.
Thanks for your cooperation 🤗 Thanks for your cooperation 🤗

View File

@ -27,7 +27,7 @@
"e2e": "start-server-and-test start:test http://localhost:61812 cy:run", "e2e": "start-server-and-test start:test http://localhost:61812 cy:run",
"mocha": "pnpm --filter backend run mocha", "mocha": "pnpm --filter backend run mocha",
"test": "pnpm run mocha", "test": "pnpm run mocha",
"format": "gulp format", "format": "pnpm rome format packages/**/* --write && pnpm --filter client run format",
"clean": "pnpm node ./scripts/clean.js", "clean": "pnpm node ./scripts/clean.js",
"clean-all": "pnpm node ./scripts/clean-all.js", "clean-all": "pnpm node ./scripts/clean-all.js",
"cleanall": "pnpm run clean-all" "cleanall": "pnpm run clean-all"

View File

@ -0,0 +1,15 @@
{
"tabWidth": 4,
"useTabs": true,
"singleQuote": false,
"vueIndentScriptAndStyle": false,
"plugins": ["vue"],
"overrides": [
{
"files": "*.vue",
"options": {
"parser": "vue"
}
}
]
}

View File

@ -4,7 +4,8 @@
"scripts": { "scripts": {
"watch": "pnpm vite build --watch --mode development", "watch": "pnpm vite build --watch --mode development",
"build": "pnpm vite build", "build": "pnpm vite build",
"lint": "pnpm rome check \"src/**/*.{ts,vue}\"" "lint": "pnpm rome check \"src/**/*.{ts,vue}\"",
"format": "pnpm prettier --write '**/*.vue'"
}, },
"devDependencies": { "devDependencies": {
"@discordapp/twemoji": "14.0.2", "@discordapp/twemoji": "14.0.2",
@ -54,6 +55,8 @@
"matter-js": "0.18.0", "matter-js": "0.18.0",
"mfm-js": "0.23.2", "mfm-js": "0.23.2",
"photoswipe": "5.3.4", "photoswipe": "5.3.4",
"prettier": "2.8.7",
"prettier-plugin-vue": "1.1.6",
"prismjs": "1.29.0", "prismjs": "1.29.0",
"punycode": "2.1.1", "punycode": "2.1.1",
"querystring": "0.2.1", "querystring": "0.2.1",

View File

@ -1,64 +1,93 @@
<template> <template>
<div class="bcekxzvu _gap _panel"> <div class="bcekxzvu _gap _panel">
<div class="target"> <div class="target">
<MkA v-user-preview="report.targetUserId" class="info" :to="`/user-info/${report.targetUserId}`"> <MkA
<MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true" :disable-link="true"/> v-user-preview="report.targetUserId"
<div class="names"> class="info"
<MkUserName class="name" :user="report.targetUser"/> :to="`/user-info/${report.targetUserId}`"
<MkAcct class="acct" :user="report.targetUser" style="display: block;"/> >
<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>
</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> </div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import MkButton from '@/components/MkButton.vue'; import MkButton from "@/components/MkButton.vue";
import MkSwitch from '@/components/form/switch.vue'; import MkSwitch from "@/components/form/switch.vue";
import MkKeyValue from '@/components/MkKeyValue.vue'; import MkKeyValue from "@/components/MkKeyValue.vue";
import { acct, userPage } from '@/filters/user'; import { acct, userPage } from "@/filters/user";
import * as os from '@/os'; import * as os from "@/os";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
report: any; report: any;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'resolved', reportId: string): void; (ev: "resolved", reportId: string): void;
}>(); }>();
let forward = $ref(props.report.forwarded); let forward = $ref(props.report.forwarded);
function resolve() { function resolve() {
os.apiWithDialog('admin/resolve-abuse-user-report', { os.apiWithDialog("admin/resolve-abuse-user-report", {
forward: forward, forward: forward,
reportId: props.report.id, reportId: props.report.id,
}).then(() => { }).then(() => {
emit('resolved', props.report.id); emit("resolved", props.report.id);
}); });
} }
</script> </script>
@ -81,7 +110,16 @@ function resolve() {
padding: 14px; padding: 14px;
border-radius: 8px; border-radius: 8px;
--c: rgb(255 196 0 / 15%); --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-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; background-size: 16px 16px;
> .avatar { > .avatar {

View File

@ -1,35 +1,52 @@
<template> <template>
<XWindow ref="uiWindow" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')"> <XWindow
<template #header> ref="uiWindow"
<i class="ph-warning-circle ph-bold ph-lg" style="margin-right: 0.5em;"></i> :initial-width="400"
<I18n :src="i18n.ts.reportAbuseOf" tag="span"> :initial-height="500"
<template #name> :can-resize="true"
<b><MkAcct :user="user"/></b> @closed="emit('closed')"
</template> >
</I18n> <template #header>
</template> <i
<div class="dpvffvvy _monolithic_"> class="ph-warning-circle ph-bold ph-lg"
<div class="_section"> style="margin-right: 0.5em"
<MkTextarea v-model="comment"> ></i>
<template #label>{{ i18n.ts.details }}</template> <I18n :src="i18n.ts.reportAbuseOf" tag="span">
<template #caption>{{ i18n.ts.fillAbuseReportDescription }}</template> <template #name>
</MkTextarea> <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> </div>
<div class="_section"> </XWindow>
<MkButton primary full :disabled="comment.length === 0" @click="send">{{ i18n.ts.send }}</MkButton>
</div>
</div>
</XWindow>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from "vue";
import * as Misskey from 'calckey-js'; import * as Misskey from "calckey-js";
import XWindow from '@/components/MkWindow.vue'; import XWindow from "@/components/MkWindow.vue";
import MkTextarea from '@/components/form/textarea.vue'; import MkTextarea from "@/components/form/textarea.vue";
import MkButton from '@/components/MkButton.vue'; import MkButton from "@/components/MkButton.vue";
import * as os from '@/os'; import * as os from "@/os";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
user: Misskey.entities.User; user: Misskey.entities.User;
@ -37,23 +54,27 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
const uiWindow = ref<InstanceType<typeof XWindow>>(); const uiWindow = ref<InstanceType<typeof XWindow>>();
const comment = ref(props.initialComment || ''); const comment = ref(props.initialComment || "");
function send() { function send() {
os.apiWithDialog('users/report-abuse', { os.apiWithDialog(
userId: props.user.id, "users/report-abuse",
comment: comment.value, {
}, undefined).then(res => { userId: props.user.id,
comment: comment.value,
},
undefined
).then((res) => {
os.alert({ os.alert({
type: 'success', type: "success",
text: i18n.ts.abuseReported text: i18n.ts.abuseReported,
}); });
uiWindow.value?.close(); uiWindow.value?.close();
emit('closed'); emit("closed");
}); });
} }
</script> </script>

View File

@ -1,32 +1,62 @@
<template> <template>
<svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none"> <svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none">
<template v-if="props.graduations === 'dots'"> <template v-if="props.graduations === 'dots'">
<circle <circle
v-for="(angle, i) in graduationsMajor" v-for="(angle, i) in graduationsMajor"
:cx="5 + (Math.sin(angle) * (5 - graduationsPadding))" :cx="5 + Math.sin(angle) * (5 - graduationsPadding)"
:cy="5 - (Math.cos(angle) * (5 - graduationsPadding))" :cy="5 - Math.cos(angle) * (5 - graduationsPadding)"
:r="0.125" :r="0.125"
:fill="(props.twentyfour ? h : h % 12) === i ? nowColor : majorGraduationColor" :fill="
:opacity="!props.fadeGraduations || (props.twentyfour ? h : h % 12) === i ? 1 : Math.max(0, 1 - (angleDiff(hAngle, angle) / Math.PI) - numbersOpacityFactor)" (props.twentyfour ? h : h % 12) === i
/> ? nowColor
</template> : majorGraduationColor
<template v-else-if="props.graduations === 'numbers'"> "
<text :opacity="
v-for="(angle, i) in texts" !props.fadeGraduations ||
:x="5 + (Math.sin(angle) * (5 - textsPadding))" (props.twentyfour ? h : h % 12) === i
:y="5 - (Math.cos(angle) * (5 - textsPadding))" ? 1
text-anchor="middle" : Math.max(
dominant-baseline="middle" 0,
:font-size="(props.twentyfour ? h : h % 12) === i ? 1 : 0.7" 1 -
:font-weight="(props.twentyfour ? h : h % 12) === i ? 'bold' : 'normal'" angleDiff(hAngle, angle) / Math.PI -
:fill="(props.twentyfour ? h : h % 12) === i ? nowColor : 'currentColor'" numbersOpacityFactor
: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>
</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 <line
:x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))" :x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))"
:y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))" :y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))"
@ -38,50 +68,61 @@
/> />
--> -->
<line <line
class="s" class="s"
:class="{ animate: !disableSAnimate && sAnimation !== 'none', elastic: sAnimation === 'elastic', easeOut: sAnimation === 'easeOut' }" :class="{
:x1="5 - (0 * (sHandLengthRatio * handsTailLength))" animate: !disableSAnimate && sAnimation !== 'none',
:y1="5 + (1 * (sHandLengthRatio * handsTailLength))" elastic: sAnimation === 'elastic',
:x2="5 + (0 * ((sHandLengthRatio * 5) - handsPadding))" easeOut: sAnimation === 'easeOut',
:y2="5 - (1 * ((sHandLengthRatio * 5) - handsPadding))" }"
:stroke="sHandColor" :x1="5 - 0 * (sHandLengthRatio * handsTailLength)"
:stroke-width="thickness / 2" :y1="5 + 1 * (sHandLengthRatio * handsTailLength)"
:style="`transform: rotateZ(${sAngle}rad)`" :x2="5 + 0 * (sHandLengthRatio * 5 - handsPadding)"
stroke-linecap="round" :y2="5 - 1 * (sHandLengthRatio * 5 - handsPadding)"
/> :stroke="sHandColor"
:stroke-width="thickness / 2"
:style="`transform: rotateZ(${sAngle}rad)`"
stroke-linecap="round"
/>
<line <line
:x1="5 - (Math.sin(mAngle) * (mHandLengthRatio * handsTailLength))" :x1="5 - Math.sin(mAngle) * (mHandLengthRatio * handsTailLength)"
:y1="5 + (Math.cos(mAngle) * (mHandLengthRatio * handsTailLength))" :y1="5 + Math.cos(mAngle) * (mHandLengthRatio * handsTailLength)"
:x2="5 + (Math.sin(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" :x2="5 + Math.sin(mAngle) * (mHandLengthRatio * 5 - handsPadding)"
:y2="5 - (Math.cos(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" :y2="5 - Math.cos(mAngle) * (mHandLengthRatio * 5 - handsPadding)"
:stroke="mHandColor" :stroke="mHandColor"
:stroke-width="thickness" :stroke-width="thickness"
stroke-linecap="round" stroke-linecap="round"
/> />
<line <line
:x1="5 - (Math.sin(hAngle) * (hHandLengthRatio * handsTailLength))" :x1="5 - Math.sin(hAngle) * (hHandLengthRatio * handsTailLength)"
:y1="5 + (Math.cos(hAngle) * (hHandLengthRatio * handsTailLength))" :y1="5 + Math.cos(hAngle) * (hHandLengthRatio * handsTailLength)"
:x2="5 + (Math.sin(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" :x2="5 + Math.sin(hAngle) * (hHandLengthRatio * 5 - handsPadding)"
:y2="5 - (Math.cos(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" :y2="5 - Math.cos(hAngle) * (hHandLengthRatio * 5 - handsPadding)"
:stroke="hHandColor" :stroke="hHandColor"
:stroke-width="thickness" :stroke-width="thickness"
stroke-linecap="round" stroke-linecap="round"
/> />
</svg> </svg>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, onMounted, onBeforeUnmount, shallowRef, nextTick } from 'vue'; import {
import tinycolor from 'tinycolor2'; ref,
import { globalEvents } from '@/events.js'; 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 // https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles
const angleDiff = (a: number, b: number) => { const angleDiff = (a: number, b: number) => {
const x = Math.abs(a - b); const x = Math.abs(a - b);
return Math.abs((x + Math.PI) % (Math.PI * 2) - Math.PI); return Math.abs(((x + Math.PI) % (Math.PI * 2)) - Math.PI);
}; };
const graduationsPadding = 0.5; const graduationsPadding = 0.5;
@ -93,28 +134,31 @@ const mHandLengthRatio = 1;
const sHandLengthRatio = 1; const sHandLengthRatio = 1;
const numbersOpacityFactor = 0.35; const numbersOpacityFactor = 0.35;
const props = withDefaults(defineProps<{ const props = withDefaults(
thickness?: number; defineProps<{
offset?: number; thickness?: number;
twentyfour?: boolean; offset?: number;
graduations?: 'none' | 'dots' | 'numbers'; twentyfour?: boolean;
fadeGraduations?: boolean; graduations?: "none" | "dots" | "numbers";
sAnimation?: 'none' | 'elastic' | 'easeOut'; fadeGraduations?: boolean;
}>(), { sAnimation?: "none" | "elastic" | "easeOut";
numbers: false, }>(),
thickness: 0.1, {
offset: 0 - new Date().getTimezoneOffset(), numbers: false,
twentyfour: false, thickness: 0.1,
graduations: 'dots', offset: 0 - new Date().getTimezoneOffset(),
fadeGraduations: true, twentyfour: false,
sAnimation: 'elastic', graduations: "dots",
}); fadeGraduations: true,
sAnimation: "elastic",
}
);
const graduationsMajor = computed(() => { const graduationsMajor = computed(() => {
const angles: number[] = []; const angles: number[] = [];
const times = props.twentyfour ? 24 : 12; const times = props.twentyfour ? 24 : 12;
for (let i = 0; i < times; i++) { for (let i = 0; i < times; i++) {
const angle = Math.PI * i / (times / 2); const angle = (Math.PI * i) / (times / 2);
angles.push(angle); angles.push(angle);
} }
return angles; return angles;
@ -123,7 +167,7 @@ const texts = computed(() => {
const angles: number[] = []; const angles: number[] = [];
const times = props.twentyfour ? 24 : 12; const times = props.twentyfour ? 24 : 12;
for (let i = 0; i < times; i++) { for (let i = 0; i < times; i++) {
const angle = Math.PI * i / (times / 2); const angle = (Math.PI * i) / (times / 2);
angles.push(angle); angles.push(angle);
} }
return angles; return angles;
@ -147,14 +191,19 @@ let sOneRound = false;
function tick() { function tick() {
const now = new Date(); const now = new Date();
now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset)); now.setMinutes(
now.getMinutes() + (new Date().getTimezoneOffset() + props.offset)
);
s = now.getSeconds(); s = now.getSeconds();
m = now.getMinutes(); m = now.getMinutes();
h = now.getHours(); h = now.getHours();
hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6); hAngle =
mAngle = Math.PI * (m + s / 60) / 30; (Math.PI * ((h % (props.twentyfour ? 24 : 12)) + (m + s / 60) / 60)) /
if (sOneRound) { // (59->0) (props.twentyfour ? 12 : 6);
sAngle = Math.PI * 60 / 30; mAngle = (Math.PI * (m + s / 60)) / 30;
if (sOneRound) {
// (59->0)
sAngle = (Math.PI * 60) / 30;
window.setTimeout(() => { window.setTimeout(() => {
disableSAnimate = true; disableSAnimate = true;
window.setTimeout(() => { window.setTimeout(() => {
@ -165,7 +214,7 @@ function tick() {
}, 100); }, 100);
}, 700); }, 700);
} else { } else {
sAngle = Math.PI * s / 30; sAngle = (Math.PI * s) / 30;
} }
sOneRound = s === 59; sOneRound = s === 59;
} }
@ -174,12 +223,18 @@ tick();
function calcColors() { function calcColors() {
const computedStyle = getComputedStyle(document.documentElement); const computedStyle = getComputedStyle(document.documentElement);
const dark = tinycolor(computedStyle.getPropertyValue('--bg')).isDark(); const dark = tinycolor(computedStyle.getPropertyValue("--bg")).isDark();
const accent = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(); const accent = tinycolor(
majorGraduationColor = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; 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)'; //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)'; sHandColor = dark ? "rgba(255, 255, 255, 0.5)" : "rgba(0, 0, 0, 0.3)";
mHandColor = tinycolor(computedStyle.getPropertyValue('--fg')).toHexString(); mHandColor = tinycolor(
computedStyle.getPropertyValue("--fg")
).toHexString();
hHandColor = accent; hHandColor = accent;
nowColor = accent; nowColor = accent;
} }
@ -195,13 +250,13 @@ onMounted(() => {
}; };
update(); update();
globalEvents.on('themeChanged', calcColors); globalEvents.on("themeChanged", calcColors);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
enabled = false; enabled = false;
globalEvents.off('themeChanged', calcColors); globalEvents.off("themeChanged", calcColors);
}); });
</script> </script>
@ -214,11 +269,11 @@ onBeforeUnmount(() => {
transform-origin: 50% 50%; transform-origin: 50% 50%;
&.animate.elastic { &.animate.elastic {
transition: transform .2s cubic-bezier(.4,2.08,.55,.44); transition: transform 0.2s cubic-bezier(0.4, 2.08, 0.55, 0.44);
} }
&.animate.easeOut { &.animate.easeOut {
transition: transform .7s cubic-bezier(0,.7,.3,1); transition: transform 0.7s cubic-bezier(0, 0.7, 0.3, 1);
} }
} }
} }

View File

@ -1,49 +1,107 @@
<template> <template>
<div ref="rootEl" class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}"> <div
<ol v-if="type === 'user'" ref="suggests" class="users"> ref="rootEl"
<li v-for="user in users" tabindex="-1" class="user" @click="complete(type, user)" @keydown="onKeydown"> class="swhvrteh _popup _shadow"
<img class="avatar" :src="user.avatarUrl"/> :style="{ zIndex }"
<span class="name"> @contextmenu.prevent="() => {}"
<MkUserName :key="user.id" :user="user"/> >
</span> <ol v-if="type === 'user'" ref="suggests" class="users">
<span class="username">@{{ acct(user) }}</span> <li
</li> v-for="user in users"
<li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li> tabindex="-1"
</ol> class="user"
<ol v-else-if="hashtags.length > 0" ref="suggests" class="hashtags"> @click="complete(type, user)"
<li v-for="hashtag in hashtags" tabindex="-1" @click="complete(type, hashtag)" @keydown="onKeydown"> @keydown="onKeydown"
<span class="name">{{ hashtag }}</span> >
</li> <img class="avatar" :src="user.avatarUrl" />
</ol> <span class="name">
<ol v-else-if="emojis.length > 0" ref="suggests" class="emojis"> <MkUserName :key="user.id" :user="user" />
<li v-for="emoji in emojis" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown"> </span>
<span v-if="emoji.isCustomEmoji" class="emoji"><img :src="defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span> <span class="username">@{{ acct(user) }}</span>
<span v-else-if="!defaultStore.state.useOsNativeEmojis" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span> </li>
<span v-else class="emoji">{{ emoji.emoji }}</span> <li
<span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span> tabindex="-1"
<span v-if="emoji.aliasOf" class="alias">({{ emoji.aliasOf }})</span> class="choose"
</li> @click="chooseUser()"
</ol> @keydown="onKeydown"
<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"> {{ i18n.ts.selectUser }}
<span class="tag">{{ tag }}</span> </li>
</li> </ol>
</ol> <ol v-else-if="hashtags.length > 0" ref="suggests" class="hashtags">
</div> <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.useOsNativeEmojis"
class="emoji"
><img :src="emoji.url" :alt="emoji.emoji"
/></span>
<span v-else class="emoji">{{ emoji.emoji }}</span>
<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> </template>
<script lang="ts"> <script lang="ts">
import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; import {
import contains from '@/scripts/contains'; markRaw,
import { char2filePath } from '@/scripts/twemoji-base'; ref,
import { getStaticImageUrl } from '@/scripts/get-static-image-url'; onUpdated,
import { acct } from '@/filters/user'; onMounted,
import * as os from '@/os'; onBeforeUnmount,
import { MFM_TAGS } from '@/scripts/mfm-tags'; nextTick,
import { defaultStore } from '@/store'; watch,
import { emojilist } from '@/scripts/emojilist'; } from "vue";
import { instance } from '@/instance'; import contains from "@/scripts/contains";
import { i18n } from '@/i18n'; import { char2filePath } from "@/scripts/twemoji-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 = { type EmojiDef = {
emoji: string; emoji: string;
@ -53,9 +111,9 @@ type EmojiDef = {
isCustomEmoji?: boolean; isCustomEmoji?: boolean;
}; };
const lib = emojilist.filter(x => x.category !== 'flags'); const lib = emojilist.filter((x) => x.category !== "flags");
const emjdb: EmojiDef[] = lib.map(x => ({ const emjdb: EmojiDef[] = lib.map((x) => ({
emoji: x.char, emoji: x.char,
name: x.name, name: x.name,
url: char2filePath(x.char), url: char2filePath(x.char),
@ -85,7 +143,7 @@ for (const x of customEmojis) {
name: x.name, name: x.name,
emoji: `:${x.name}:`, emoji: `:${x.name}:`,
url: x.url, url: x.url,
isCustomEmoji: true isCustomEmoji: true,
}); });
if (x.aliases) { if (x.aliases) {
@ -95,7 +153,7 @@ for (const x of customEmojis) {
aliasOf: x.name, aliasOf: x.name,
emoji: `:${x.name}:`, emoji: `:${x.name}:`,
url: x.url, url: x.url,
isCustomEmoji: true isCustomEmoji: true,
}); });
} }
} }
@ -125,8 +183,8 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'done', value: { type: string; value: any }): void; (event: "done", value: { type: string; value: any }): void;
(event: 'closed'): void; (event: "closed"): void;
}>(); }>();
const suggests = ref<Element>(); const suggests = ref<Element>();
@ -135,36 +193,37 @@ const rootEl = ref<HTMLDivElement>();
const fetching = ref(true); const fetching = ref(true);
const users = ref<any[]>([]); const users = ref<any[]>([]);
const hashtags = ref<any[]>([]); const hashtags = ref<any[]>([]);
const emojis = ref<(EmojiDef)[]>([]); const emojis = ref<EmojiDef[]>([]);
const items = ref<Element[] | HTMLCollection>([]); const items = ref<Element[] | HTMLCollection>([]);
const mfmTags = ref<string[]>([]); const mfmTags = ref<string[]>([]);
const select = ref(-1); const select = ref(-1);
const zIndex = os.claimZIndex('high'); const zIndex = os.claimZIndex("high");
function complete(type: string, value: any) { function complete(type: string, value: any) {
emit('done', { type, value }); emit("done", { type, value });
emit('closed'); emit("closed");
if (type === 'emoji') { if (type === "emoji") {
let recents = defaultStore.state.recentlyUsedEmojis; let recents = defaultStore.state.recentlyUsedEmojis;
recents = recents.filter((emoji: any) => emoji !== value); recents = recents.filter((emoji: any) => emoji !== value);
recents.unshift(value); recents.unshift(value);
defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); defaultStore.set("recentlyUsedEmojis", recents.splice(0, 32));
} }
} }
function setPosition() { function setPosition() {
if (!rootEl.value) return; if (!rootEl.value) return;
if (props.x + rootEl.value.offsetWidth > window.innerWidth) { if (props.x + rootEl.value.offsetWidth > window.innerWidth) {
rootEl.value.style.left = (window.innerWidth - rootEl.value.offsetWidth) + 'px'; rootEl.value.style.left =
window.innerWidth - rootEl.value.offsetWidth + "px";
} else { } else {
rootEl.value.style.left = `${props.x}px`; rootEl.value.style.left = `${props.x}px`;
} }
if (props.y + rootEl.value.offsetHeight > window.innerHeight) { if (props.y + rootEl.value.offsetHeight > window.innerHeight) {
rootEl.value.style.top = (props.y - rootEl.value.offsetHeight) + 'px'; rootEl.value.style.top = props.y - rootEl.value.offsetHeight + "px";
rootEl.value.style.marginTop = '0'; rootEl.value.style.marginTop = "0";
} else { } else {
rootEl.value.style.top = props.y + 'px'; rootEl.value.style.top = props.y + "px";
rootEl.value.style.marginTop = 'calc(1em + 8px)'; rootEl.value.style.marginTop = "calc(1em + 8px)";
} }
} }
@ -172,10 +231,10 @@ function exec() {
select.value = -1; select.value = -1;
if (suggests.value) { if (suggests.value) {
for (const el of Array.from(items.value)) { for (const el of Array.from(items.value)) {
el.removeAttribute('data-selected'); el.removeAttribute("data-selected");
} }
} }
if (props.type === 'user') { if (props.type === "user") {
if (!props.q) { if (!props.q) {
users.value = []; users.value = [];
fetching.value = false; fetching.value = false;
@ -189,20 +248,22 @@ function exec() {
users.value = JSON.parse(cache); users.value = JSON.parse(cache);
fetching.value = false; fetching.value = false;
} else { } else {
os.api('users/search-by-username-and-host', { os.api("users/search-by-username-and-host", {
username: props.q, username: props.q,
limit: 10, limit: 10,
detail: false detail: false,
}).then(searchedUsers => { }).then((searchedUsers) => {
users.value = searchedUsers as any[]; users.value = searchedUsers as any[];
fetching.value = false; fetching.value = false;
// //
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers)); sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
}); });
} }
} else if (props.type === 'hashtag') { } else if (props.type === "hashtag") {
if (!props.q || props.q === '') { if (!props.q || props.q === "") {
hashtags.value = JSON.parse(localStorage.getItem('hashtags') || '[]'); hashtags.value = JSON.parse(
localStorage.getItem("hashtags") || "[]"
);
fetching.value = false; fetching.value = false;
} else { } else {
const cacheKey = `autocomplete:hashtag:${props.q}`; const cacheKey = `autocomplete:hashtag:${props.q}`;
@ -212,59 +273,80 @@ function exec() {
hashtags.value = hashtags; hashtags.value = hashtags;
fetching.value = false; fetching.value = false;
} else { } else {
os.api('hashtags/search', { os.api("hashtags/search", {
query: props.q, query: props.q,
limit: 30 limit: 30,
}).then(searchedHashtags => { }).then((searchedHashtags) => {
hashtags.value = searchedHashtags as any[]; hashtags.value = searchedHashtags as any[];
fetching.value = false; fetching.value = false;
// //
sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags)); sessionStorage.setItem(
cacheKey,
JSON.stringify(searchedHashtags)
);
}); });
} }
} }
} else if (props.type === 'emoji') { } else if (props.type === "emoji") {
if (!props.q || props.q === '') { if (!props.q || props.q === "") {
// 使 // 使
emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[]; emojis.value = defaultStore.state.recentlyUsedEmojis
.map((emoji) =>
emojiDb.find((dbEmoji) => dbEmoji.emoji === emoji)
)
.filter((x) => x) as EmojiDef[];
return; return;
} }
const matched: EmojiDef[] = []; const matched: EmojiDef[] = [];
const max = 30; const max = 30;
emojiDb.some(x => { emojiDb.some((x) => {
if (x.name.startsWith(props.q ?? '') && !x.aliasOf && !matched.some(y => y.emoji === x.emoji)) matched.push(x); if (
x.name.startsWith(props.q ?? "") &&
!x.aliasOf &&
!matched.some((y) => y.emoji === x.emoji)
)
matched.push(x);
return matched.length === max; return matched.length === max;
}); });
if (matched.length < max) { if (matched.length < max) {
emojiDb.some(x => { emojiDb.some((x) => {
if (x.name.startsWith(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x); if (
x.name.startsWith(props.q ?? "") &&
!matched.some((y) => y.emoji === x.emoji)
)
matched.push(x);
return matched.length === max; return matched.length === max;
}); });
} }
if (matched.length < max) { if (matched.length < max) {
emojiDb.some(x => { emojiDb.some((x) => {
if (x.name.includes(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x); if (
x.name.includes(props.q ?? "") &&
!matched.some((y) => y.emoji === x.emoji)
)
matched.push(x);
return matched.length === max; return matched.length === max;
}); });
} }
emojis.value = matched; emojis.value = matched;
} else if (props.type === 'mfmTag') { } else if (props.type === "mfmTag") {
if (!props.q || props.q === '') { if (!props.q || props.q === "") {
mfmTags.value = MFM_TAGS; mfmTags.value = MFM_TAGS;
return; return;
} }
mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? '')); mfmTags.value = MFM_TAGS.filter((tag) => tag.startsWith(props.q ?? ""));
} }
} }
function onMousedown(event: Event) { function onMousedown(event: Event) {
if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close(); if (!contains(rootEl.value, event.target) && rootEl.value !== event.target)
props.close();
} }
function onKeydown(event: KeyboardEvent) { function onKeydown(event: KeyboardEvent) {
@ -274,7 +356,7 @@ function onKeydown(event: KeyboardEvent) {
}; };
switch (event.key) { switch (event.key) {
case 'Enter': case "Enter":
if (select.value !== -1) { if (select.value !== -1) {
cancel(); cancel();
(items.value[select.value] as any).click(); (items.value[select.value] as any).click();
@ -283,12 +365,12 @@ function onKeydown(event: KeyboardEvent) {
} }
break; break;
case 'Escape': case "Escape":
cancel(); cancel();
props.close(); props.close();
break; break;
case 'ArrowUp': case "ArrowUp":
if (select.value !== -1) { if (select.value !== -1) {
cancel(); cancel();
selectPrev(); selectPrev();
@ -297,8 +379,8 @@ function onKeydown(event: KeyboardEvent) {
} }
break; break;
case 'Tab': case "Tab":
case 'ArrowDown': case "ArrowDown":
cancel(); cancel();
selectNext(); selectNext();
break; break;
@ -322,19 +404,19 @@ function selectPrev() {
function applySelect() { function applySelect() {
for (const el of Array.from(items.value)) { for (const el of Array.from(items.value)) {
el.removeAttribute('data-selected'); el.removeAttribute("data-selected");
} }
if (select.value !== -1) { if (select.value !== -1) {
items.value[select.value].setAttribute('data-selected', 'true'); items.value[select.value].setAttribute("data-selected", "true");
(items.value[select.value] as any).focus(); (items.value[select.value] as any).focus();
} }
} }
function chooseUser() { function chooseUser() {
props.close(); props.close();
os.selectUser().then(user => { os.selectUser().then((user) => {
complete('user', user); complete("user", user);
props.textarea.focus(); props.textarea.focus();
}); });
} }
@ -347,28 +429,31 @@ onUpdated(() => {
onMounted(() => { onMounted(() => {
setPosition(); setPosition();
props.textarea.addEventListener('keydown', onKeydown); props.textarea.addEventListener("keydown", onKeydown);
for (const el of Array.from(document.querySelectorAll('body *'))) { for (const el of Array.from(document.querySelectorAll("body *"))) {
el.addEventListener('mousedown', onMousedown); el.addEventListener("mousedown", onMousedown);
} }
nextTick(() => { nextTick(() => {
exec(); exec();
watch(() => props.q, () => { watch(
nextTick(() => { () => props.q,
exec(); () => {
}); nextTick(() => {
}); exec();
});
}
);
}); });
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
props.textarea.removeEventListener('keydown', onKeydown); props.textarea.removeEventListener("keydown", onKeydown);
for (const el of Array.from(document.querySelectorAll('body *'))) { for (const el of Array.from(document.querySelectorAll("body *"))) {
el.removeEventListener('mousedown', onMousedown); el.removeEventListener("mousedown", onMousedown);
} }
}); });
</script> </script>
@ -399,7 +484,8 @@ onBeforeUnmount(() => {
font-size: 0.9em; font-size: 0.9em;
cursor: default; cursor: default;
&, * { &,
* {
user-select: none; user-select: none;
} }
@ -412,10 +498,11 @@ onBeforeUnmount(() => {
background: var(--X3); background: var(--X3);
} }
&[data-selected='true'] { &[data-selected="true"] {
background: var(--accent); background: var(--accent);
&, * { &,
* {
color: #fff !important; color: #fff !important;
} }
} }
@ -423,7 +510,8 @@ onBeforeUnmount(() => {
&:active { &:active {
background: var(--accentDarken); background: var(--accentDarken);
&, * { &,
* {
color: #fff !important; color: #fff !important;
} }
} }
@ -431,7 +519,6 @@ onBeforeUnmount(() => {
} }
> .users > li { > .users > li {
.avatar { .avatar {
min-width: 28px; min-width: 28px;
min-height: 28px; min-height: 28px;
@ -447,7 +534,6 @@ onBeforeUnmount(() => {
} }
> .emojis > li { > .emojis > li {
.emoji { .emoji {
display: inline-block; display: inline-block;
margin: 0 4px 0 0; margin: 0 4px 0 0;
@ -465,7 +551,6 @@ onBeforeUnmount(() => {
} }
> .mfmTags > li { > .mfmTags > li {
.name { .name {
} }
} }

View File

@ -1,14 +1,14 @@
<template> <template>
<div class="defgtij"> <div class="defgtij">
<div v-for="user in users" :key="user.id" class="avatar-holder"> <div v-for="user in users" :key="user.id" class="avatar-holder">
<MkAvatar :user="user" :show-indicator="true" class="avatar"/> <MkAvatar :user="user" :show-indicator="true" class="avatar" />
</div>
</div> </div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from 'vue'; import { onMounted, ref } from "vue";
import * as os from '@/os'; import * as os from "@/os";
const props = defineProps<{ const props = defineProps<{
userIds: string[]; userIds: string[];
@ -17,8 +17,8 @@ const props = defineProps<{
const users = ref([]); const users = ref([]);
onMounted(async () => { onMounted(async () => {
users.value = await os.api('users/show', { users.value = await os.api("users/show", {
userIds: props.userIds userIds: props.userIds,
}); });
}); });
</script> </script>

View File

@ -1,34 +1,36 @@
<template> <template>
<button <button
v-if="!link" class="bghgjjyj _button" v-if="!link"
:class="{ inline, primary, gradate, danger, rounded, full }" class="bghgjjyj _button"
:type="type" :class="{ inline, primary, gradate, danger, rounded, full }"
@click="emit('click', $event)" :type="type"
@mousedown="onMousedown" @click="emit('click', $event)"
> @mousedown="onMousedown"
<div ref="ripples" class="ripples"></div> >
<div class="content"> <div ref="ripples" class="ripples"></div>
<slot></slot> <div class="content">
</div> <slot></slot>
</button> </div>
<MkA </button>
v-else class="bghgjjyj _button" <MkA
:class="{ inline, primary, gradate, danger, rounded, full }" v-else
:to="to" class="bghgjjyj _button"
@mousedown="onMousedown" :class="{ inline, primary, gradate, danger, rounded, full }"
> :to="to"
<div ref="ripples" class="ripples"></div> @mousedown="onMousedown"
<div class="content"> >
<slot></slot> <div ref="ripples" class="ripples"></div>
</div> <div class="content">
</MkA> <slot></slot>
</div>
</MkA>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { nextTick, onMounted } from 'vue'; import { nextTick, onMounted } from "vue";
const props = defineProps<{ const props = defineProps<{
type?: 'button' | 'submit' | 'reset'; type?: "button" | "submit" | "reset";
primary?: boolean; primary?: boolean;
gradate?: boolean; gradate?: boolean;
rounded?: boolean; rounded?: boolean;
@ -42,7 +44,7 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'click', payload: MouseEvent): void; (ev: "click", payload: MouseEvent): void;
}>(); }>();
let el = $ref<HTMLElement | null>(null); let el = $ref<HTMLElement | null>(null);
@ -73,23 +75,28 @@ function onMousedown(evt: MouseEvent): void {
const target = evt.target! as HTMLElement; const target = evt.target! as HTMLElement;
const rect = target.getBoundingClientRect(); const rect = target.getBoundingClientRect();
const ripple = document.createElement('div'); const ripple = document.createElement("div");
ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px'; ripple.style.top = (evt.clientY - rect.top - 1).toString() + "px";
ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px'; ripple.style.left = (evt.clientX - rect.left - 1).toString() + "px";
ripples!.appendChild(ripple); ripples!.appendChild(ripple);
const circleCenterX = evt.clientX - rect.left; const circleCenterX = evt.clientX - rect.left;
const circleCenterY = evt.clientY - rect.top; const circleCenterY = evt.clientY - rect.top;
const scale = calcCircleScale(target.clientWidth, target.clientHeight, circleCenterX, circleCenterY); const scale = calcCircleScale(
target.clientWidth,
target.clientHeight,
circleCenterX,
circleCenterY
);
window.setTimeout(() => { window.setTimeout(() => {
ripple.style.transform = 'scale(' + (scale / 2) + ')'; ripple.style.transform = "scale(" + scale / 2 + ")";
}, 1); }, 1);
window.setTimeout(() => { window.setTimeout(() => {
ripple.style.transition = 'all 1s ease'; ripple.style.transition = "all 1s ease";
ripple.style.opacity = '0'; ripple.style.opacity = "0";
}, 1000); }, 1000);
window.setTimeout(() => { window.setTimeout(() => {
if (ripples) ripples.removeChild(ripple); if (ripples) ripples.removeChild(ripple);
@ -151,7 +158,11 @@ function onMousedown(evt: MouseEvent): void {
&.gradate { &.gradate {
font-weight: bold; font-weight: bold;
color: var(--fgOnAccent) !important; color: var(--fgOnAccent) !important;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); background: linear-gradient(
90deg,
var(--buttonGradateA),
var(--buttonGradateB)
);
&:not(:disabled):hover { &:not(:disabled):hover {
background: linear-gradient(90deg, var(--X8), var(--X8)); background: linear-gradient(90deg, var(--X8), var(--X8));
@ -212,7 +223,7 @@ function onMousedown(evt: MouseEvent): void {
background: rgba(0, 0, 0, 0.1); background: rgba(0, 0, 0, 0.1);
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
transition: all 0.5s cubic-bezier(0,.5,0,1); transition: all 0.5s cubic-bezier(0, 0.5, 0, 1);
} }
} }

View File

@ -1,33 +1,46 @@
<template> <template>
<div> <div>
<span v-if="!available">{{ i18n.ts.waiting }}<MkEllipsis/></span> <span v-if="!available">{{ i18n.ts.waiting }}<MkEllipsis /></span>
<div ref="captchaEl"></div> <div ref="captchaEl"></div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
import { defaultStore } from '@/store'; import { defaultStore } from "@/store";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
type Captcha = { type Captcha = {
render(container: string | Node, options: { render(
readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown; container: string | Node,
}): string; options: {
readonly [_ in
| "sitekey"
| "theme"
| "type"
| "size"
| "tabindex"
| "callback"
| "expired"
| "expired-callback"
| "error-callback"
| "endpoint"]?: unknown;
}
): string;
remove(id: string): void; remove(id: string): void;
execute(id: string): void; execute(id: string): void;
reset(id?: string): void; reset(id?: string): void;
getResponse(id: string): string; getResponse(id: string): string;
}; };
type CaptchaProvider = 'hcaptcha' | 'recaptcha'; type CaptchaProvider = "hcaptcha" | "recaptcha";
type CaptchaContainer = { type CaptchaContainer = {
readonly [_ in CaptchaProvider]?: Captcha; readonly [_ in CaptchaProvider]?: Captcha;
}; };
declare global { declare global {
interface Window extends CaptchaContainer { } interface Window extends CaptchaContainer {}
} }
const props = defineProps<{ const props = defineProps<{
@ -37,7 +50,7 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'update:modelValue', v: string | null): void; (ev: "update:modelValue", v: string | null): void;
}>(); }>();
const available = ref(false); const available = ref(false);
@ -46,8 +59,10 @@ const captchaEl = ref<HTMLDivElement | undefined>();
const variable = computed(() => { const variable = computed(() => {
switch (props.provider) { switch (props.provider) {
case 'hcaptcha': return 'hcaptcha'; case "hcaptcha":
case 'recaptcha': return 'grecaptcha'; return "hcaptcha";
case "recaptcha":
return "grecaptcha";
} }
}); });
@ -55,22 +70,30 @@ const loaded = !!window[variable.value];
const src = computed(() => { const src = computed(() => {
switch (props.provider) { switch (props.provider) {
case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off'; case "hcaptcha":
case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit'; return "https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off";
case "recaptcha":
return "https://www.recaptcha.net/recaptcha/api.js?render=explicit";
} }
}); });
const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha); const captcha = computed<Captcha>(
() => window[variable.value] || ({} as unknown as Captcha)
);
if (loaded) { if (loaded) {
available.value = true; available.value = true;
} else { } else {
(document.getElementById(props.provider) || document.head.appendChild(Object.assign(document.createElement('script'), { (
async: true, document.getElementById(props.provider) ||
id: props.provider, document.head.appendChild(
src: src.value, Object.assign(document.createElement("script"), {
}))) async: true,
.addEventListener('load', () => available.value = true); id: props.provider,
src: src.value,
})
)
).addEventListener("load", () => (available.value = true));
} }
function reset() { function reset() {
@ -81,10 +104,10 @@ function requestRender() {
if (captcha.value.render && captchaEl.value instanceof Element) { if (captcha.value.render && captchaEl.value instanceof Element) {
captcha.value.render(captchaEl.value, { captcha.value.render(captchaEl.value, {
sitekey: props.sitekey, sitekey: props.sitekey,
theme: defaultStore.state.darkMode ? 'dark' : 'light', theme: defaultStore.state.darkMode ? "dark" : "light",
callback: callback, callback: callback,
'expired-callback': callback, "expired-callback": callback,
'error-callback': callback, "error-callback": callback,
}); });
} else { } else {
window.setTimeout(requestRender, 1); window.setTimeout(requestRender, 1);
@ -92,7 +115,7 @@ function requestRender() {
} }
function callback(response?: string) { function callback(response?: string) {
emit('update:modelValue', typeof response === 'string' ? response : null); emit("update:modelValue", typeof response === "string" ? response : null);
} }
onMounted(() => { onMounted(() => {
@ -110,5 +133,4 @@ onBeforeUnmount(() => {
defineExpose({ defineExpose({
reset, reset,
}); });
</script> </script>

View File

@ -1,34 +1,41 @@
<template> <template>
<button class="hdcaacmi _button" <button
:class="{ wait, active: isFollowing, full }" class="hdcaacmi _button"
:disabled="wait" :class="{ wait, active: isFollowing, full }"
@click="onClick" :disabled="wait"
> @click="onClick"
<template v-if="!wait"> >
<template v-if="isFollowing"> <template v-if="!wait">
<span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ph-minus ph-bold ph-lg"></i> <template v-if="isFollowing">
<span v-if="full">{{ i18n.ts.unfollow }}</span
><i class="ph-minus ph-bold ph-lg"></i>
</template>
<template v-else>
<span v-if="full">{{ i18n.ts.follow }}</span
><i class="ph-plus ph-bold ph-lg"></i>
</template>
</template> </template>
<template v-else> <template v-else>
<span v-if="full">{{ i18n.ts.follow }}</span><i class="ph-plus ph-bold ph-lg"></i> <span v-if="full">{{ i18n.ts.processing }}</span
><i class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"></i>
</template> </template>
</template> </button>
<template v-else>
<span v-if="full">{{ i18n.ts.processing }}</span><i class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"></i>
</template>
</button>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from "vue";
import * as os from '@/os'; import * as os from "@/os";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = withDefaults(defineProps<{ const props = withDefaults(
channel: Record<string, any>; defineProps<{
full?: boolean; channel: Record<string, any>;
}>(), { full?: boolean;
full: false, }>(),
}); {
full: false,
}
);
const isFollowing = ref<boolean>(props.channel.isFollowing); const isFollowing = ref<boolean>(props.channel.isFollowing);
const wait = ref(false); const wait = ref(false);
@ -38,13 +45,13 @@ async function onClick() {
try { try {
if (isFollowing.value) { if (isFollowing.value) {
await os.api('channels/unfollow', { await os.api("channels/unfollow", {
channelId: props.channel.id channelId: props.channel.id,
}); });
isFollowing.value = false; isFollowing.value = false;
} else { } else {
await os.api('channels/follow', { await os.api("channels/follow", {
channelId: props.channel.id channelId: props.channel.id,
}); });
isFollowing.value = true; isFollowing.value = true;
} }

View File

@ -1,41 +1,57 @@
<template> <template>
<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1"> <MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1">
<div class="banner" :style="bannerStyle"> <div class="banner" :style="bannerStyle">
<div class="fade"></div> <div class="fade"></div>
<div class="name"><i class="ph-television ph-bold ph-lg"></i> {{ channel.name }}</div> <div class="name">
<div class="status"> <i class="ph-television ph-bold ph-lg"></i> {{ channel.name }}
<div>
<i class="ph-users ph-bold ph-lg ph-fw ph-lg"></i>
<I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;">
<template #n>
<b>{{ channel.usersCount }}</b>
</template>
</I18n>
</div> </div>
<div> <div class="status">
<i class="ph-pencil ph-bold ph-lg ph-fw ph-lg"></i> <div>
<I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"> <i class="ph-users ph-bold ph-lg ph-fw ph-lg"></i>
<template #n> <I18n
<b>{{ channel.notesCount }}</b> :src="i18n.ts._channel.usersCount"
</template> tag="span"
</I18n> style="margin-left: 4px"
>
<template #n>
<b>{{ channel.usersCount }}</b>
</template>
</I18n>
</div>
<div>
<i class="ph-pencil ph-bold ph-lg ph-fw ph-lg"></i>
<I18n
:src="i18n.ts._channel.notesCount"
tag="span"
style="margin-left: 4px"
>
<template #n>
<b>{{ channel.notesCount }}</b>
</template>
</I18n>
</div>
</div> </div>
</div> </div>
</div> <article v-if="channel.description">
<article v-if="channel.description"> <p :title="channel.description">
<p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p> {{
</article> channel.description.length > 85
<footer> ? channel.description.slice(0, 85) + "…"
<span v-if="channel.lastNotedAt"> : channel.description
{{ i18n.ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/> }}
</span> </p>
</footer> </article>
</MkA> <footer>
<span v-if="channel.lastNotedAt">
{{ i18n.ts.updatedAt }}: <MkTime :time="channel.lastNotedAt" />
</span>
</footer>
</MkA>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from "vue";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
channel: Record<string, any>; channel: Record<string, any>;
@ -45,7 +61,7 @@ const bannerStyle = computed(() => {
if (props.channel.bannerUrl) { if (props.channel.bannerUrl) {
return { backgroundImage: `url(${props.channel.bannerUrl})` }; return { backgroundImage: `url(${props.channel.bannerUrl})` };
} else { } else {
return { backgroundColor: '#4c5e6d' }; return { backgroundColor: "#4c5e6d" };
} }
}); });
</script> </script>
@ -155,5 +171,4 @@ const bannerStyle = computed(() => {
} }
} }
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,35 @@
<template> <template>
<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'top'" :inner-margin="16" @closed="emit('closed')"> <MkTooltip
<div v-if="title || series" class="qpcyisrl"> ref="tooltip"
<div v-if="title" class="title">{{ title }}</div> :showing="showing"
<template v-if="series"> :x="x"
<div v-for="x in series" class="series"> :y="y"
<span class="color" :style="{ background: x.backgroundColor, borderColor: x.borderColor }"></span> :max-width="340"
<span>{{ x.text }}</span> :direction="'top'"
</div> :inner-margin="16"
</template> @closed="emit('closed')"
</div> >
</MkTooltip> <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> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import MkTooltip from './MkTooltip.vue'; import MkTooltip from "./MkTooltip.vue";
const props = defineProps<{ const props = defineProps<{
showing: boolean; showing: boolean;
@ -29,7 +44,7 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
</script> </script>

View File

@ -1,56 +1,72 @@
<template> <template>
<MkA <MkA
class="rivslvers" class="rivslvers"
tabindex="-1" tabindex="-1"
:class="{ :class="{
isMe: isMe(message), isMe: isMe(message),
isRead: message.groupId ? message.reads.includes($i?.id) : message.isRead, isRead: message.groupId
}" ? message.reads.includes($i?.id)
:to=" : message.isRead,
message.groupId }"
? `/my/messaging/group/${message.groupId}` :to="
: `/my/messaging/${getAcct( message.groupId
isMe(message) ? message.recipient : message.user ? `/my/messaging/group/${message.groupId}`
)}` : `/my/messaging/${getAcct(
" isMe(message) ? message.recipient : message.user
> )}`
<div class="message _block"> "
<MkAvatar >
class="avatar" <div class="message _block">
:user=" <MkAvatar
message.groupId class="avatar"
? message.user :user="
: isMe(message) message.groupId
? message.user
: isMe(message)
? message.recipient ? message.recipient
: message.user : message.user
" "
:show-indicator="true" :show-indicator="true"
/> />
<header v-if="message.groupId"> <header v-if="message.groupId">
<span class="name">{{ message.group.name }}</span> <span class="name">{{ message.group.name }}</span>
<MkTime :time="message.createdAt" class="time"/> <MkTime :time="message.createdAt" class="time" />
</header> </header>
<header v-else> <header v-else>
<span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span> <span class="name"
<span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span> ><MkUserName
<MkTime :time="message.createdAt" class="time"/> :user="
</header> isMe(message) ? message.recipient : message.user
<div class="body"> "
<p class="text"> /></span>
<span v-if="isMe(message)" class="me">{{ i18n.ts.you }}: </span> <span class="username"
<Mfm v-if="message.text != null && message.text.length > 0" :text="message.text"/> >@{{
<span v-else> 📎</span> acct(isMe(message) ? message.recipient : message.user)
</p> }}</span
>
<MkTime :time="message.createdAt" class="time" />
</header>
<div class="body">
<p class="text">
<span v-if="isMe(message)" class="me"
>{{ i18n.ts.you }}:
</span>
<Mfm
v-if="message.text != null && message.text.length > 0"
:text="message.text"
/>
<span v-else> 📎</span>
</p>
</div>
</div> </div>
</div> </MkA>
</MkA>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import * as Acct from 'calckey-js/built/acct'; import * as Acct from "calckey-js/built/acct";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import { acct } from '@/filters/user'; import { acct } from "@/filters/user";
import { $i } from '@/account'; import { $i } from "@/account";
const getAcct = Acct.toString; const getAcct = Acct.toString;
@ -138,7 +154,6 @@ function isMe(message): boolean {
} }
> .body { > .body {
> .text { > .text {
display: block; display: block;
margin: 0 0 0 0; margin: 0 0 0 0;

View File

@ -1,28 +1,28 @@
<template> <template>
<XModalWindow <XModalWindow
ref="dialog" ref="dialog"
:width="600" :width="600"
@close="dialog?.close()" @close="dialog?.close()"
@closed="$emit('closed')" @closed="$emit('closed')"
> >
<template #header>{{ i18n.ts._mfm.cheatSheet }}</template> <template #header>{{ i18n.ts._mfm.cheatSheet }}</template>
<div class="_monolithic_"> <div class="_monolithic_">
<div class="_section"> <div class="_section">
<XCheatSheet/> <XCheatSheet />
</div>
</div> </div>
</div> </XModalWindow>
</XModalWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import XModalWindow from '@/components/MkModalWindow.vue'; import XModalWindow from "@/components/MkModalWindow.vue";
import XCheatSheet from '@/pages/mfm-cheat-sheet.vue'; import XCheatSheet from "@/pages/mfm-cheat-sheet.vue";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done'): void; (ev: "done"): void;
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
const dialog = $ref<InstanceType<typeof XModalWindow>>(); const dialog = $ref<InstanceType<typeof XModalWindow>>();
@ -41,6 +41,4 @@ function close(res) {
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
</style> </style>

View File

@ -1,12 +1,15 @@
<template> <template>
<code v-if="inline" :class="`language-${prismLang}`" v-html="html"></code> <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> <pre
v-else
:class="`language-${prismLang}`"
><code :class="`language-${prismLang}`" v-html="html"></code></pre>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from "vue";
import Prism from 'prismjs'; import Prism from "prismjs";
import 'prismjs/themes/prism-okaidia.css'; import "prismjs/themes/prism-okaidia.css";
const props = defineProps<{ const props = defineProps<{
code: string; code: string;
@ -14,6 +17,14 @@ const props = defineProps<{
inline?: boolean; inline?: boolean;
}>(); }>();
const prismLang = computed(() => Prism.languages[props.lang] ? props.lang : 'js'); const prismLang = computed(() =>
const html = computed(() => Prism.highlight(props.code, Prism.languages[prismLang.value], prismLang.value)); Prism.languages[props.lang] ? props.lang : "js"
);
const html = computed(() =>
Prism.highlight(
props.code,
Prism.languages[prismLang.value],
prismLang.value
)
);
</script> </script>

View File

@ -1,9 +1,9 @@
<template> <template>
<XCode :code="code" :lang="lang" :inline="inline"/> <XCode :code="code" :lang="lang" :inline="inline" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent } from "vue";
defineProps<{ defineProps<{
code: string; code: string;
@ -11,5 +11,7 @@ defineProps<{
inline?: boolean; inline?: boolean;
}>(); }>();
const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')); const XCode = defineAsyncComponent(
() => import("@/components/MkCode.core.vue")
);
</script> </script>

View File

@ -1,35 +1,67 @@
<template> <template>
<div v-size="{ max: [380] }" class="ukygtjoj _panel" :class="{ naked, thin, hideHeader: !showHeader, scrollable, closed: !showBody }"> <div
<header v-if="showHeader" ref="header"> v-size="{ max: [380] }"
<div class="title"><slot name="header"></slot></div> class="ukygtjoj _panel"
<div class="sub"> :class="{
<slot name="func"></slot> naked,
<button v-if="foldable" class="_button" @click="() => showBody = !showBody"> thin,
<template v-if="showBody"><i class="ph-caret-up ph-bold ph-lg"></i></template> hideHeader: !showHeader,
<template v-else><i class="ph-caret-down ph-bold ph-lg"></i></template> scrollable,
</button> closed: !showBody,
</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 }"> <header v-if="showHeader" ref="header">
<slot></slot> <div class="title"><slot name="header"></slot></div>
<button v-if="omitted" class="fade _button" @click="() => { ignoreOmit = true; omitted = false; }"> <div class="sub">
<span>{{ i18n.ts.showMore }}</span> <slot name="func"></slot>
</button> <button
</div> v-if="foldable"
</transition> class="_button"
</div> @click="() => (showBody = !showBody)"
>
<template v-if="showBody"
><i class="ph-caret-up ph-bold ph-lg"></i
></template>
<template v-else
><i class="ph-caret-down ph-bold ph-lg"></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>{{ i18n.ts.showMore }}</span>
</button>
</div>
</transition>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from "vue";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
export default defineComponent({ export default defineComponent({
props: { props: {
@ -78,22 +110,29 @@ export default defineComponent({
}; };
}, },
mounted() { mounted() {
this.$watch('showBody', showBody => { this.$watch(
const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0; "showBody",
this.$el.style.minHeight = `${headerHeight}px`; (showBody) => {
if (showBody) { const headerHeight = this.showHeader
this.$el.style.flexBasis = 'auto'; ? this.$refs.header.offsetHeight
} else { : 0;
this.$el.style.flexBasis = `${headerHeight}px`; this.$el.style.minHeight = `${headerHeight}px`;
if (showBody) {
this.$el.style.flexBasis = "auto";
} else {
this.$el.style.flexBasis = `${headerHeight}px`;
}
},
{
immediate: true,
} }
}, { );
immediate: true,
});
this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px'); this.$el.style.setProperty("--maxHeight", this.maxHeight + "px");
const calcOmit = () => { const calcOmit = () => {
if (this.omitted || this.ignoreOmit || this.maxHeight == null) return; if (this.omitted || this.ignoreOmit || this.maxHeight == null)
return;
const height = this.$refs.content.offsetHeight; const height = this.$refs.content.offsetHeight;
this.omitted = height > this.maxHeight; this.omitted = height > this.maxHeight;
}; };
@ -113,14 +152,14 @@ export default defineComponent({
const elementHeight = el.getBoundingClientRect().height; const elementHeight = el.getBoundingClientRect().height;
el.style.height = 0; el.style.height = 0;
el.offsetHeight; // reflow el.offsetHeight; // reflow
el.style.height = elementHeight + 'px'; el.style.height = elementHeight + "px";
}, },
afterEnter(el) { afterEnter(el) {
el.style.height = null; el.style.height = null;
}, },
leave(el) { leave(el) {
const elementHeight = el.getBoundingClientRect().height; const elementHeight = el.getBoundingClientRect().height;
el.style.height = elementHeight + 'px'; el.style.height = elementHeight + "px";
el.offsetHeight; // reflow el.offsetHeight; // reflow
el.style.height = 0; el.style.height = 0;
}, },
@ -132,7 +171,8 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.container-toggle-enter-active, .container-toggle-leave-active { .container-toggle-enter-active,
.container-toggle-leave-active {
overflow-y: hidden; overflow-y: hidden;
transition: opacity 0.5s, height 0.5s !important; transition: opacity 0.5s, height 0.5s !important;
} }
@ -236,7 +276,8 @@ export default defineComponent({
} }
} }
&.max-width_380px, &.thin { &.max-width_380px,
&.thin {
> header { > header {
> .title { > .title {
padding: 8px 10px; padding: 8px 10px;

View File

@ -1,17 +1,22 @@
<template> <template>
<transition :name="$store.state.animation ? 'fade' : ''" appear> <transition :name="$store.state.animation ? 'fade' : ''" appear>
<div ref="rootEl" class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> <div
<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/> ref="rootEl"
</div> class="nvlagfpb"
</transition> :style="{ zIndex }"
@contextmenu.prevent.stop="() => {}"
>
<MkMenu :items="items" :align="'left'" @close="$emit('closed')" />
</div>
</transition>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onBeforeUnmount } from 'vue'; import { onMounted, onBeforeUnmount } from "vue";
import MkMenu from './MkMenu.vue'; import MkMenu from "./MkMenu.vue";
import { MenuItem } from './types/menu.vue'; import { MenuItem } from "./types/menu.vue";
import contains from '@/scripts/contains'; import contains from "@/scripts/contains";
import * as os from '@/os'; import * as os from "@/os";
const props = defineProps<{ const props = defineProps<{
items: MenuItem[]; items: MenuItem[];
@ -19,12 +24,12 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
let rootEl = $ref<HTMLDivElement>(); let rootEl = $ref<HTMLDivElement>();
let zIndex = $ref<number>(os.claimZIndex('high')); let zIndex = $ref<number>(os.claimZIndex("high"));
onMounted(() => { onMounted(() => {
let left = props.ev.pageX + 1; // + 1 let left = props.ev.pageX + 1; // + 1
@ -52,19 +57,19 @@ onMounted(() => {
rootEl.style.top = `${top}px`; rootEl.style.top = `${top}px`;
rootEl.style.left = `${left}px`; rootEl.style.left = `${left}px`;
for (const el of Array.from(document.querySelectorAll('body *'))) { for (const el of Array.from(document.querySelectorAll("body *"))) {
el.addEventListener('mousedown', onMousedown); el.addEventListener("mousedown", onMousedown);
} }
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
for (const el of Array.from(document.querySelectorAll('body *'))) { for (const el of Array.from(document.querySelectorAll("body *"))) {
el.removeEventListener('mousedown', onMousedown); el.removeEventListener("mousedown", onMousedown);
} }
}); });
function onMousedown(evt: Event) { function onMousedown(evt: Event) {
if (!contains(rootEl, evt.target) && (rootEl !== evt.target)) emit('closed'); if (!contains(rootEl, evt.target) && rootEl !== evt.target) emit("closed");
} }
</script> </script>
@ -73,12 +78,15 @@ function onMousedown(evt: Event) {
position: absolute; position: absolute;
} }
.fade-enter-active, .fade-leave-active { .fade-enter-active,
transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); .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; transform-origin: left top;
} }
.fade-enter-from, .fade-leave-to { .fade-enter-from,
.fade-leave-to {
opacity: 0; opacity: 0;
transform: scale(0.9); transform: scale(0.9);
} }

View File

@ -1,47 +1,55 @@
<template> <template>
<XModalWindow <XModalWindow
ref="dialogEl" ref="dialogEl"
:width="800" :width="800"
:height="500" :height="500"
:scroll="false" :scroll="false"
:with-ok-button="true" :with-ok-button="true"
@close="cancel()" @close="cancel()"
@ok="ok()" @ok="ok()"
@closed="$emit('closed')" @closed="$emit('closed')"
> >
<template #header>{{ i18n.ts.cropImage }}</template> <template #header>{{ i18n.ts.cropImage }}</template>
<template #default="{ width, height }"> <template #default="{ width, height }">
<div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`"> <div
<Transition name="fade"> class="mk-cropper-dialog"
<div v-if="loading" class="loading"> :style="`--vw: ${width}px; --vh: ${height}px;`"
<MkLoading/> >
<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>
</Transition>
<div class="container">
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
</div> </div>
</div> </template>
</template> </XModalWindow>
</XModalWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { nextTick, onMounted } from 'vue'; import { nextTick, onMounted } from "vue";
import * as misskey from 'calckey-js'; import * as misskey from "calckey-js";
import Cropper from 'cropperjs'; import Cropper from "cropperjs";
import tinycolor from 'tinycolor2'; import tinycolor from "tinycolor2";
import XModalWindow from '@/components/MkModalWindow.vue'; import XModalWindow from "@/components/MkModalWindow.vue";
import * as os from '@/os'; import * as os from "@/os";
import { $i } from '@/account'; import { $i } from "@/account";
import { defaultStore } from '@/store'; import { defaultStore } from "@/store";
import { apiUrl, url } from '@/config'; import { apiUrl, url } from "@/config";
import { query } from '@/scripts/url'; import { query } from "@/scripts/url";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'ok', cropped: misskey.entities.DriveFile): void; (ev: "ok", cropped: misskey.entities.DriveFile): void;
(ev: 'cancel'): void; (ev: "cancel"): void;
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
const props = defineProps<{ const props = defineProps<{
@ -60,24 +68,24 @@ let loading = $ref(true);
const ok = async () => { const ok = async () => {
const promise = new Promise<misskey.entities.DriveFile>(async (res) => { const promise = new Promise<misskey.entities.DriveFile>(async (res) => {
const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas(); const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas();
croppedCanvas.toBlob(blob => { croppedCanvas.toBlob((blob) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', blob); formData.append("file", blob);
if (defaultStore.state.uploadFolder) { if (defaultStore.state.uploadFolder) {
formData.append('folderId', defaultStore.state.uploadFolder); formData.append("folderId", defaultStore.state.uploadFolder);
} }
fetch(apiUrl + '/drive/files/create', { fetch(apiUrl + "/drive/files/create", {
method: 'POST', method: "POST",
body: formData, body: formData,
headers: { headers: {
authorization: `Bearer ${$i.token}`, authorization: `Bearer ${$i.token}`,
}, },
}) })
.then(response => response.json()) .then((response) => response.json())
.then(f => { .then((f) => {
res(f); res(f);
}); });
}); });
}); });
@ -85,12 +93,12 @@ const ok = async () => {
const f = await promise; const f = await promise;
emit('ok', f); emit("ok", f);
dialogEl.close(); dialogEl.close();
}; };
const cancel = () => { const cancel = () => {
emit('cancel'); emit("cancel");
dialogEl.close(); dialogEl.close();
}; };
@ -98,31 +106,32 @@ const onImageLoad = () => {
loading = false; loading = false;
if (cropper) { if (cropper) {
cropper.getCropperImage()!.$center('contain'); cropper.getCropperImage()!.$center("contain");
cropper.getCropperSelection()!.$center(); cropper.getCropperSelection()!.$center();
} }
}; };
onMounted(() => { onMounted(() => {
cropper = new Cropper(imgEl, { cropper = new Cropper(imgEl, {});
});
const computedStyle = getComputedStyle(document.documentElement); const computedStyle = getComputedStyle(document.documentElement);
const selection = cropper.getCropperSelection()!; const selection = cropper.getCropperSelection()!;
selection.themeColor = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(); selection.themeColor = tinycolor(
computedStyle.getPropertyValue("--accent")
).toHexString();
selection.aspectRatio = props.aspectRatio; selection.aspectRatio = props.aspectRatio;
selection.initialAspectRatio = props.aspectRatio; selection.initialAspectRatio = props.aspectRatio;
selection.outlined = true; selection.outlined = true;
window.setTimeout(() => { window.setTimeout(() => {
cropper.getCropperImage()!.$center('contain'); cropper.getCropperImage()!.$center("contain");
selection.$center(); selection.$center();
}, 100); }, 100);
// 調 // 調
window.setTimeout(() => { window.setTimeout(() => {
cropper.getCropperImage()!.$center('contain'); cropper.getCropperImage()!.$center("contain");
selection.$center(); selection.$center();
}, 500); }, 500);
}); });

View File

@ -1,16 +1,16 @@
<template> <template>
<button class="nrvgflfu _button" @click.stop="toggle"> <button class="nrvgflfu _button" @click.stop="toggle">
<b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b> <b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b>
<span v-if="!modelValue">{{ label }}</span> <span v-if="!modelValue">{{ label }}</span>
</button> </button>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from "vue";
import { length } from 'stringz'; import { length } from "stringz";
import * as misskey from 'calckey-js'; import * as misskey from "calckey-js";
import { concat } from '@/scripts/array'; import { concat } from "@/scripts/array";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; modelValue: boolean;
@ -18,19 +18,23 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'update:modelValue', v: boolean): void; (ev: "update:modelValue", v: boolean): void;
}>(); }>();
const label = computed(() => { const label = computed(() => {
return concat([ return concat([
props.note.text ? [i18n.t('_cw.chars', { count: length(props.note.text) })] : [], props.note.text
props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length }) ] : [], ? [i18n.t("_cw.chars", { count: length(props.note.text) })]
props.note.poll != null ? [i18n.ts.poll] : [] : [],
] as string[][]).join(' / '); 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 = () => { const toggle = () => {
emit('update:modelValue', !props.modelValue); emit("update:modelValue", !props.modelValue);
}; };
</script> </script>
@ -60,11 +64,11 @@ const toggle = () => {
margin-left: 4px; margin-left: 4px;
&:before { &:before {
content: '('; content: "(";
} }
&:after { &:after {
content: ')'; content: ")";
} }
} }
} }

View File

@ -1,19 +1,21 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, h, PropType, TransitionGroup } from 'vue'; import { defineComponent, h, PropType, TransitionGroup } from "vue";
import MkAd from '@/components/global/MkAd.vue'; import MkAd from "@/components/global/MkAd.vue";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import { defaultStore } from '@/store'; import { defaultStore } from "@/store";
export default defineComponent({ export default defineComponent({
props: { props: {
items: { items: {
type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>, type: Array as PropType<
{ id: string; createdAt: string; _shouldInsertAd_: boolean }[]
>,
required: true, required: true,
}, },
direction: { direction: {
type: String, type: String,
required: false, required: false,
default: 'down', default: "down",
}, },
reversed: { reversed: {
type: Boolean, type: Boolean,
@ -36,7 +38,7 @@ export default defineComponent({
function getDateText(time: string) { function getDateText(time: string) {
const date = new Date(time).getDate(); const date = new Date(time).getDate();
const month = new Date(time).getMonth() + 1; const month = new Date(time).getMonth() + 1;
return i18n.t('monthAndDay', { return i18n.t("monthAndDay", {
month: month.toString(), month: month.toString(),
day: date.toString(), day: date.toString(),
}); });
@ -44,64 +46,81 @@ export default defineComponent({
if (props.items.length === 0) return; if (props.items.length === 0) return;
const renderChildren = () => props.items.map((item, i) => { const renderChildren = () =>
if (!slots || !slots.default) return; props.items.map((item, i) => {
if (!slots || !slots.default) return;
const el = slots.default({ const el = slots.default({
item: item, item: item,
})[0]; })[0];
if (el.key == null && item.id) el.key = item.id; if (el.key == null && item.id) el.key = item.id;
if ( if (
i !== props.items.length - 1 && i !== props.items.length - 1 &&
new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate() new Date(item.createdAt).getDate() !==
) { new Date(props.items[i + 1].createdAt).getDate()
const separator = h('div', { ) {
class: 'separator', const separator = h(
key: item.id + ':separator', "div",
}, h('p', { {
class: 'date', class: "separator",
}, [ key: item.id + ":separator",
h('span', [ },
h('i', { h(
class: 'ph-caret-up ph-bold ph-lg icon', "p",
}), {
getDateText(item.createdAt), class: "date",
]), },
h('span', [ [
getDateText(props.items[i + 1].createdAt), h("span", [
h('i', { h("i", {
class: 'ph-caret-down ph-bold ph-lg icon', class: "ph-caret-up ph-bold ph-lg icon",
}), }),
]), getDateText(item.createdAt),
])); ]),
h("span", [
getDateText(props.items[i + 1].createdAt),
h("i", {
class: "ph-caret-down ph-bold ph-lg icon",
}),
]),
]
)
);
return [el, separator]; 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 { } else {
return el; 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( return () =>
defaultStore.state.animation ? TransitionGroup : 'div', h(
defaultStore.state.animation ? { defaultStore.state.animation ? TransitionGroup : "div",
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), defaultStore.state.animation
name: 'list', ? {
tag: 'div', class: "sqadhkmv" + (props.noGap ? " noGap" : ""),
'data-direction': props.direction, name: "list",
'data-reversed': props.reversed ? 'true' : 'false', tag: "div",
} : { "data-direction": props.direction,
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), "data-reversed": props.reversed ? "true" : "false",
}, }
{ default: renderChildren }); : {
class: "sqadhkmv" + (props.noGap ? " noGap" : ""),
},
{ default: renderChildren }
);
}, },
}); });
</script> </script>
@ -121,7 +140,8 @@ export default defineComponent({
} }
> .list-enter-active { > .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); 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"] { &[data-direction="up"] {

View File

@ -1,63 +1,171 @@
<template> <template>
<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="emit('closed')"> <MkModal
ref="modal"
:prefer-type="'dialog'"
:z-priority="'high'"
@click="done(true)"
@closed="emit('closed')"
>
<div :class="$style.root"> <div :class="$style.root">
<div v-if="icon" :class="$style.icon"> <div v-if="icon" :class="$style.icon">
<i :class="icon"></i> <i :class="icon"></i>
</div> </div>
<div v-else-if="!input && !select" :class="[$style.icon, $style['type_' + type]]"> <div
<i v-if="type === 'success'" :class="$style.iconInner" class="ph-check ph-bold ph-lg"></i> v-else-if="!input && !select"
<i v-else-if="type === 'error'" :class="$style.iconInner" class="ph-circle-wavy-warning ph-bold ph-lg"></i> :class="[$style.icon, $style['type_' + type]]"
<i v-else-if="type === 'warning'" :class="$style.iconInner" class="ph-warning ph-bold ph-lg"></i> >
<i v-else-if="type === 'info'" :class="$style.iconInner" class="ph-info ph-bold ph-lg"></i> <i
<i v-else-if="type === 'question'" :class="$style.iconInner" class="ph-circle-question ph-bold ph-lg"></i> v-if="type === 'success'"
<MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/> :class="$style.iconInner"
class="ph-check ph-bold ph-lg"
></i>
<i
v-else-if="type === 'error'"
:class="$style.iconInner"
class="ph-circle-wavy-warning ph-bold ph-lg"
></i>
<i
v-else-if="type === 'warning'"
:class="$style.iconInner"
class="ph-warning ph-bold ph-lg"
></i>
<i
v-else-if="type === 'info'"
:class="$style.iconInner"
class="ph-info ph-bold ph-lg"
></i>
<i
v-else-if="type === 'question'"
:class="$style.iconInner"
class="ph-circle-question ph-bold ph-lg"
></i>
<MkLoading
v-else-if="type === 'waiting'"
:class="$style.iconInner"
:em="true"
/>
</div> </div>
<header v-if="title" :class="$style.title"><Mfm :text="title"/></header> <header v-if="title" :class="$style.title">
<header v-if="title == null && (input && input.type === 'password')" :class="$style.title"><Mfm :text="i18n.ts.password"/></header> <Mfm :text="title" />
<div v-if="text" :class="$style.text"><Mfm :text="text"/></div> </header>
<MkInput v-if="input && input.type !== 'paragraph'" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown"> <header
<template v-if="input.type === 'password'" #prefix><i class="ph-password ph-bold ph-lg"></i></template> v-if="title == null && input && input.type === 'password'"
:class="$style.title"
>
<Mfm :text="i18n.ts.password" />
</header>
<div v-if="text" :class="$style.text"><Mfm :text="text" /></div>
<MkInput
v-if="input && input.type !== 'paragraph'"
v-model="inputValue"
autofocus
:type="input.type || 'text'"
:placeholder="input.placeholder || undefined"
@keydown="onInputKeydown"
>
<template v-if="input.type === 'password'" #prefix
><i class="ph-password ph-bold ph-lg"></i
></template>
</MkInput> </MkInput>
<MkTextarea v-if="input && input.type === 'paragraph'" v-model="inputValue" autofocus :type="paragraph" :placeholder="input.placeholder || undefined"> <MkTextarea
v-if="input && input.type === 'paragraph'"
v-model="inputValue"
autofocus
:type="paragraph"
:placeholder="input.placeholder || undefined"
>
</MkTextarea> </MkTextarea>
<MkSelect v-if="select" v-model="selectedValue" autofocus> <MkSelect v-if="select" v-model="selectedValue" autofocus>
<template v-if="select.items"> <template v-if="select.items">
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option> <option v-for="item in select.items" :value="item.value">
{{ item.text }}
</option>
</template> </template>
<template v-else> <template v-else>
<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label"> <optgroup
<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option> v-for="groupedItem in select.groupedItems"
:label="groupedItem.label"
>
<option
v-for="item in groupedItem.items"
:value="item.value"
>
{{ item.text }}
</option>
</optgroup> </optgroup>
</template> </template>
</MkSelect> </MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> <div
v-if="(showOkButton || showCancelButton) && !actions"
:class="$style.buttons"
>
<div v-if="!isYesNo"> <div v-if="!isYesNo">
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ (showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt }}</MkButton> <MkButton
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ i18n.ts.cancel }}</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>
<div v-else> <div v-else>
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ i18n.ts.yes }}</MkButton> <MkButton
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ i18n.ts.no }}</MkButton> v-if="showOkButton"
inline
primary
:autofocus="!input && !select"
@click="ok"
>{{ i18n.ts.yes }}</MkButton
>
<MkButton
v-if="showCancelButton || input || select"
inline
@click="cancel"
>{{ i18n.ts.no }}</MkButton
>
</div> </div>
</div> </div>
<div v-if="actions" :class="$style.buttons"> <div v-if="actions" :class="$style.buttons">
<MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton> <MkButton
v-for="action in actions"
:key="action.text"
inline
:primary="action.primary"
@click="
() => {
action.callback();
modal?.close();
}
"
>{{ action.text }}</MkButton
>
</div> </div>
</div> </div>
</MkModal> </MkModal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'; import { onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
import MkModal from '@/components/MkModal.vue'; import MkModal from "@/components/MkModal.vue";
import MkButton from '@/components/MkButton.vue'; import MkButton from "@/components/MkButton.vue";
import MkInput from '@/components/form/input.vue'; import MkInput from "@/components/form/input.vue";
import MkTextarea from '@/components/form/textarea.vue'; import MkTextarea from "@/components/form/textarea.vue";
import MkSelect from '@/components/form/select.vue'; import MkSelect from "@/components/form/select.vue";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
type Input = { type Input = {
type: HTMLInputElement['type']; type: HTMLInputElement["type"];
placeholder?: string | null; placeholder?: string | null;
default: any | null; default: any | null;
}; };
@ -77,37 +185,46 @@ type Select = {
default: string | null; default: string | null;
}; };
const props = withDefaults(defineProps<{ const props = withDefaults(
type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting'; defineProps<{
title: string; type?:
text?: string; | "success"
input?: Input; | "error"
select?: Select; | "warning"
icon?: string; | "info"
actions?: { | "question"
text: string; | "waiting";
primary?: boolean, title: string;
callback: (...args: any[]) => void; text?: string;
}[]; input?: Input;
showOkButton?: boolean; select?: Select;
showCancelButton?: boolean; icon?: string;
isYesNo?: boolean; actions?: {
text: string;
primary?: boolean;
callback: (...args: any[]) => void;
}[];
showOkButton?: boolean;
showCancelButton?: boolean;
isYesNo?: boolean;
cancelableByBgClick?: boolean; cancelableByBgClick?: boolean;
okText?: string; okText?: string;
cancelText?: string; cancelText?: string;
}>(), { }>(),
type: 'info', {
showOkButton: true, type: "info",
showCancelButton: false, showOkButton: true,
isYesNo: false, showCancelButton: false,
isYesNo: false,
cancelableByBgClick: true, cancelableByBgClick: true,
}); }
);
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', v: { canceled: boolean; result: any }): void; (ev: "done", v: { canceled: boolean; result: any }): void;
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
const modal = shallowRef<InstanceType<typeof MkModal>>(); const modal = shallowRef<InstanceType<typeof MkModal>>();
@ -116,17 +233,18 @@ const inputValue = ref(props.input?.default || null);
const selectedValue = ref(props.select?.default || null); const selectedValue = ref(props.select?.default || null);
function done(canceled: boolean, result?) { function done(canceled: boolean, result?) {
emit('done', { canceled, result }); emit("done", { canceled, result });
modal.value?.close(); modal.value?.close();
} }
async function ok() { async function ok() {
if (!props.showOkButton) return; if (!props.showOkButton) return;
const result = const result = props.input
props.input ? inputValue.value : ? inputValue.value
props.select ? selectedValue.value : : props.select
true; ? selectedValue.value
: true;
done(false, result); done(false, result);
} }
@ -139,11 +257,11 @@ function onBgClick() {
} }
*/ */
function onKeydown(evt: KeyboardEvent) { function onKeydown(evt: KeyboardEvent) {
if (evt.key === 'Escape') cancel(); if (evt.key === "Escape") cancel();
} }
function onInputKeydown(evt: KeyboardEvent) { function onInputKeydown(evt: KeyboardEvent) {
if (evt.key === 'Enter') { if (evt.key === "Enter") {
evt.preventDefault(); evt.preventDefault();
evt.stopPropagation(); evt.stopPropagation();
ok(); ok();
@ -151,11 +269,11 @@ function onInputKeydown(evt: KeyboardEvent) {
} }
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', onKeydown); document.addEventListener("keydown", onKeydown);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown); document.removeEventListener("keydown", onKeydown);
}); });
</script> </script>

View File

@ -1,33 +1,36 @@
<template> <template>
<span class="zjobosdg"> <span class="zjobosdg">
<span v-text="hh"></span> <span v-text="hh"></span>
<span class="colon" :class="{ showColon }">:</span> <span class="colon" :class="{ showColon }">:</span>
<span v-text="mm"></span> <span v-text="mm"></span>
<span v-if="showS" class="colon" :class="{ showColon }">:</span> <span v-if="showS" class="colon" :class="{ showColon }">:</span>
<span v-if="showS" v-text="ss"></span> <span v-if="showS" v-text="ss"></span>
<span v-if="showMs" class="colon" :class="{ showColon }">:</span> <span v-if="showMs" class="colon" :class="{ showColon }">:</span>
<span v-if="showMs" v-text="ms"></span> <span v-if="showMs" v-text="ms"></span>
</span> </span>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onUnmounted, ref, watch } from 'vue'; import { onUnmounted, ref, watch } from "vue";
const props = withDefaults(defineProps<{ const props = withDefaults(
showS?: boolean; defineProps<{
showMs?: boolean; showS?: boolean;
offset?: number; showMs?: boolean;
}>(), { offset?: number;
showS: true, }>(),
showMs: false, {
offset: 0 - new Date().getTimezoneOffset(), showS: true,
}); showMs: false,
offset: 0 - new Date().getTimezoneOffset(),
}
);
let intervalId; let intervalId;
const hh = ref(''); const hh = ref("");
const mm = ref(''); const mm = ref("");
const ss = ref(''); const ss = ref("");
const ms = ref(''); const ms = ref("");
const showColon = ref(false); const showColon = ref(false);
let prevSec: number | null = null; let prevSec: number | null = null;
@ -41,21 +44,29 @@ watch(showColon, (v) => {
const tick = () => { const tick = () => {
const now = new Date(); const now = new Date();
now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset)); now.setMinutes(
hh.value = now.getHours().toString().padStart(2, '0'); now.getMinutes() + (new Date().getTimezoneOffset() + props.offset)
mm.value = now.getMinutes().toString().padStart(2, '0'); );
ss.value = now.getSeconds().toString().padStart(2, '0'); hh.value = now.getHours().toString().padStart(2, "0");
ms.value = Math.floor(now.getMilliseconds() / 10).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; if (now.getSeconds() !== prevSec) showColon.value = true;
prevSec = now.getSeconds(); prevSec = now.getSeconds();
}; };
tick(); tick();
watch(() => props.showMs, () => { watch(
if (intervalId) window.clearInterval(intervalId); () => props.showMs,
intervalId = window.setInterval(tick, props.showMs ? 10 : 1000); () => {
}, { immediate: true }); if (intervalId) window.clearInterval(intervalId);
intervalId = window.setInterval(tick, props.showMs ? 10 : 1000);
},
{ immediate: true }
);
onUnmounted(() => { onUnmounted(() => {
window.clearInterval(intervalId); window.clearInterval(intervalId);

View File

@ -1,102 +1,131 @@
<template> <template>
<div <div
class="ncvczrfv" class="ncvczrfv"
:class="{ isSelected }" :class="{ isSelected }"
draggable="true" draggable="true"
:title="title" :title="title"
@click="onClick" @click="onClick"
@contextmenu.stop="onContextmenu" @contextmenu.stop="onContextmenu"
@dragstart="onDragstart" @dragstart="onDragstart"
@dragend="onDragend" @dragend="onDragend"
> >
<div v-if="$i?.avatarId == file.id" class="label"> <div v-if="$i?.avatarId == file.id" class="label">
<img src="/client-assets/label.svg"/> <img src="/client-assets/label.svg" />
<p>{{ i18n.ts.avatar }}</p> <p>{{ i18n.ts.avatar }}</p>
</div> </div>
<div v-if="$i?.bannerId == file.id" class="label"> <div v-if="$i?.bannerId == file.id" class="label">
<img src="/client-assets/label.svg"/> <img src="/client-assets/label.svg" />
<p>{{ i18n.ts.banner }}</p> <p>{{ i18n.ts.banner }}</p>
</div> </div>
<div v-if="file.isSensitive" class="label red"> <div v-if="file.isSensitive" class="label red">
<img src="/client-assets/label-red.svg"/> <img src="/client-assets/label-red.svg" />
<p>{{ i18n.ts.nsfw }}</p> <p>{{ i18n.ts.nsfw }}</p>
</div> </div>
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain" />
<p class="name"> <p class="name">
<span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> <span>{{
<span v-if="file.name.lastIndexOf('.') != -1" class="ext">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> file.name.lastIndexOf(".") != -1
</p> ? file.name.substr(0, file.name.lastIndexOf("."))
</div> : file.name
}}</span>
<span v-if="file.name.lastIndexOf('.') != -1" class="ext">{{
file.name.substr(file.name.lastIndexOf("."))
}}</span>
</p>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineAsyncComponent, ref } from 'vue'; import { computed, defineAsyncComponent, ref } from "vue";
import * as Misskey from 'calckey-js'; import * as Misskey from "calckey-js";
import copyToClipboard from '@/scripts/copy-to-clipboard'; import copyToClipboard from "@/scripts/copy-to-clipboard";
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import MkDriveFileThumbnail from "@/components/MkDriveFileThumbnail.vue";
import bytes from '@/filters/bytes'; import bytes from "@/filters/bytes";
import * as os from '@/os'; import * as os from "@/os";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import { $i } from '@/account'; import { $i } from "@/account";
const props = withDefaults(defineProps<{ const props = withDefaults(
file: Misskey.entities.DriveFile; defineProps<{
isSelected?: boolean; file: Misskey.entities.DriveFile;
selectMode?: boolean; isSelected?: boolean;
}>(), { selectMode?: boolean;
isSelected: false, }>(),
selectMode: false, {
}); isSelected: false,
selectMode: false,
}
);
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'chosen', r: Misskey.entities.DriveFile): void; (ev: "chosen", r: Misskey.entities.DriveFile): void;
(ev: 'dragstart'): void; (ev: "dragstart"): void;
(ev: 'dragend'): void; (ev: "dragend"): void;
}>(); }>();
const isDragging = ref(false); const isDragging = ref(false);
const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`); const title = computed(
() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`
);
function getMenu() { function getMenu() {
return [{ return [
text: i18n.ts.rename, {
icon: 'ph-cursor-text ph-bold ph-lg', text: i18n.ts.rename,
action: rename, icon: "ph-cursor-text ph-bold ph-lg",
}, { action: rename,
text: props.file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, },
icon: props.file.isSensitive ? 'ph-eye ph-bold ph-lg' : 'ph-eye-slash ph-bold ph-lg', {
action: toggleSensitive, text: props.file.isSensitive
}, { ? i18n.ts.unmarkAsSensitive
text: i18n.ts.describeFile, : i18n.ts.markAsSensitive,
icon: 'ph-cursor-text ph-bold ph-lg', icon: props.file.isSensitive
action: describe, ? "ph-eye ph-bold ph-lg"
}, null, { : "ph-eye-slash ph-bold ph-lg",
text: i18n.ts.copyUrl, action: toggleSensitive,
icon: 'ph-link-simple ph-bold ph-lg', },
action: copyUrl, {
}, { text: i18n.ts.describeFile,
type: 'a', icon: "ph-cursor-text ph-bold ph-lg",
href: props.file.url, action: describe,
target: '_blank', },
text: i18n.ts.download, null,
icon: 'ph-download-simple ph-bold ph-lg', {
download: props.file.name, text: i18n.ts.copyUrl,
}, null, { icon: "ph-link-simple ph-bold ph-lg",
text: i18n.ts.delete, action: copyUrl,
icon: 'ph-trash ph-bold ph-lg', },
danger: true, {
action: deleteFile, type: "a",
}]; href: props.file.url,
target: "_blank",
text: i18n.ts.download,
icon: "ph-download-simple ph-bold ph-lg",
download: props.file.name,
},
null,
{
text: i18n.ts.delete,
icon: "ph-trash ph-bold ph-lg",
danger: true,
action: deleteFile,
},
];
} }
function onClick(ev: MouseEvent) { function onClick(ev: MouseEvent) {
if (props.selectMode) { if (props.selectMode) {
emit('chosen', props.file); emit("chosen", props.file);
} else { } else {
os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); os.popupMenu(
getMenu(),
(ev.currentTarget ?? ev.target ?? undefined) as
| HTMLElement
| undefined
);
} }
} }
@ -106,17 +135,20 @@ function onContextmenu(ev: MouseEvent) {
function onDragstart(ev: DragEvent) { function onDragstart(ev: DragEvent) {
if (ev.dataTransfer) { if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file)); ev.dataTransfer.setData(
_DATA_TRANSFER_DRIVE_FILE_,
JSON.stringify(props.file)
);
} }
isDragging.value = true; isDragging.value = true;
emit('dragstart'); emit("dragstart");
} }
function onDragend() { function onDragend() {
isDragging.value = false; isDragging.value = false;
emit('dragend'); emit("dragend");
} }
function rename() { function rename() {
@ -126,7 +158,7 @@ function rename() {
default: props.file.name, default: props.file.name,
}).then(({ canceled, result: name }) => { }).then(({ canceled, result: name }) => {
if (canceled) return; if (canceled) return;
os.api('drive/files/update', { os.api("drive/files/update", {
fileId: props.file.id, fileId: props.file.id,
name: name, name: name,
}); });
@ -134,27 +166,32 @@ function rename() {
} }
function describe() { function describe() {
os.popup(defineAsyncComponent(() => import('@/components/MkMediaCaption.vue')), { os.popup(
title: i18n.ts.describeFile, defineAsyncComponent(() => import("@/components/MkMediaCaption.vue")),
input: { {
placeholder: i18n.ts.inputNewDescription, title: i18n.ts.describeFile,
default: props.file.comment != null ? props.file.comment : '', input: {
placeholder: i18n.ts.inputNewDescription,
default: props.file.comment != null ? props.file.comment : "",
},
image: props.file,
}, },
image: props.file, {
}, { done: (result) => {
done: result => { if (!result || result.canceled) return;
if (!result || result.canceled) return; let comment = result.result;
let comment = result.result; os.api("drive/files/update", {
os.api('drive/files/update', { fileId: props.file.id,
fileId: props.file.id, comment: comment.length === 0 ? null : comment,
comment: comment.length === 0 ? null : comment, });
}); },
}, },
}, 'closed'); "closed"
);
} }
function toggleSensitive() { function toggleSensitive() {
os.api('drive/files/update', { os.api("drive/files/update", {
fileId: props.file.id, fileId: props.file.id,
isSensitive: !props.file.isSensitive, isSensitive: !props.file.isSensitive,
}); });
@ -171,12 +208,12 @@ function addApp() {
*/ */
async function deleteFile() { async function deleteFile() {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: "warning",
text: i18n.t('driveFileDeleteConfirm', { name: props.file.name }), text: i18n.t("driveFileDeleteConfirm", { name: props.file.name }),
}); });
if (canceled) return; if (canceled) return;
os.api('drive/files/delete', { os.api("drive/files/delete", {
fileId: props.file.id, fileId: props.file.id,
}); });
} }
@ -189,7 +226,8 @@ async function deleteFile() {
min-height: 180px; min-height: 180px;
border-radius: 8px; border-radius: 8px;
&, * { &,
* {
cursor: pointer; cursor: pointer;
} }

View File

@ -1,56 +1,68 @@
<template> <template>
<div <div
class="rghtznwe" class="rghtznwe"
:class="{ draghover }" :class="{ draghover }"
draggable="true" draggable="true"
:title="title" :title="title"
@click="onClick" @click="onClick"
@contextmenu.stop="onContextmenu" @contextmenu.stop="onContextmenu"
@mouseover="onMouseover" @mouseover="onMouseover"
@mouseout="onMouseout" @mouseout="onMouseout"
@dragover.prevent.stop="onDragover" @dragover.prevent.stop="onDragover"
@dragenter.prevent="onDragenter" @dragenter.prevent="onDragenter"
@dragleave="onDragleave" @dragleave="onDragleave"
@drop.prevent.stop="onDrop" @drop.prevent.stop="onDrop"
@dragstart="onDragstart" @dragstart="onDragstart"
@dragend="onDragend" @dragend="onDragend"
> >
<p class="name"> <p class="name">
<template v-if="hover"><i class="ph-folder-notch-open ph-bold ph-lg ph-fw ph-lg"></i></template> <template v-if="hover"
<template v-if="!hover"><i class="ph-folder-notch ph-bold ph-lg ph-fw ph-lg"></i></template> ><i class="ph-folder-notch-open ph-bold ph-lg ph-fw ph-lg"></i
{{ folder.name }} ></template>
</p> <template v-if="!hover"
<p v-if="defaultStore.state.uploadFolder == folder.id" class="upload"> ><i class="ph-folder-notch ph-bold ph-lg ph-fw ph-lg"></i
{{ i18n.ts.uploadFolder }} ></template>
</p> {{ folder.name }}
<button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button> </p>
</div> <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> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineAsyncComponent, ref } from 'vue'; import { computed, defineAsyncComponent, ref } from "vue";
import * as Misskey from 'calckey-js'; import * as Misskey from "calckey-js";
import * as os from '@/os'; import * as os from "@/os";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import { defaultStore } from '@/store'; import { defaultStore } from "@/store";
const props = withDefaults(defineProps<{ const props = withDefaults(
folder: Misskey.entities.DriveFolder; defineProps<{
isSelected?: boolean; folder: Misskey.entities.DriveFolder;
selectMode?: boolean; isSelected?: boolean;
}>(), { selectMode?: boolean;
isSelected: false, }>(),
selectMode: false, {
}); isSelected: false,
selectMode: false,
}
);
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'chosen', v: Misskey.entities.DriveFolder): void; (ev: "chosen", v: Misskey.entities.DriveFolder): void;
(ev: 'move', v: Misskey.entities.DriveFolder): void; (ev: "move", v: Misskey.entities.DriveFolder): void;
(ev: 'upload', file: File, folder: Misskey.entities.DriveFolder); (ev: "upload", file: File, folder: Misskey.entities.DriveFolder);
(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void; (ev: "removeFile", v: Misskey.entities.DriveFile["id"]): void;
(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void; (ev: "removeFolder", v: Misskey.entities.DriveFolder["id"]): void;
(ev: 'dragstart'): void; (ev: "dragstart"): void;
(ev: 'dragend'): void; (ev: "dragend"): void;
}>(); }>();
const hover = ref(false); const hover = ref(false);
@ -60,11 +72,11 @@ const isDragging = ref(false);
const title = computed(() => props.folder.name); const title = computed(() => props.folder.name);
function checkboxClicked() { function checkboxClicked() {
emit('chosen', props.folder); emit("chosen", props.folder);
} }
function onClick() { function onClick() {
emit('move', props.folder); emit("move", props.folder);
} }
function onMouseover() { function onMouseover() {
@ -81,18 +93,20 @@ function onDragover(ev: DragEvent) {
// //
if (isDragging.value) { if (isDragging.value) {
// //
ev.dataTransfer.dropEffect = 'none'; ev.dataTransfer.dropEffect = "none";
return; return;
} }
const isFile = ev.dataTransfer.items[0].kind === 'file'; const isFile = ev.dataTransfer.items[0].kind === "file";
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; const isDriveFolder =
ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
if (isFile || isDriveFile || isDriveFolder) { if (isFile || isDriveFile || isDriveFolder) {
ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; ev.dataTransfer.dropEffect =
ev.dataTransfer.effectAllowed === "all" ? "copy" : "move";
} else { } else {
ev.dataTransfer.dropEffect = 'none'; ev.dataTransfer.dropEffect = "none";
} }
} }
@ -112,17 +126,17 @@ function onDrop(ev: DragEvent) {
// //
if (ev.dataTransfer.files.length > 0) { if (ev.dataTransfer.files.length > 0) {
for (const file of Array.from(ev.dataTransfer.files)) { for (const file of Array.from(ev.dataTransfer.files)) {
emit('upload', file, props.folder); emit("upload", file, props.folder);
} }
return; return;
} }
//#region //#region
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') { if (driveFile != null && driveFile !== "") {
const file = JSON.parse(driveFile); const file = JSON.parse(driveFile);
emit('removeFile', file.id); emit("removeFile", file.id);
os.api('drive/files/update', { os.api("drive/files/update", {
fileId: file.id, fileId: file.id,
folderId: props.folder.id, folderId: props.folder.id,
}); });
@ -131,33 +145,35 @@ function onDrop(ev: DragEvent) {
//#region //#region
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
if (driveFolder != null && driveFolder !== '') { if (driveFolder != null && driveFolder !== "") {
const folder = JSON.parse(driveFolder); const folder = JSON.parse(driveFolder);
// reject // reject
if (folder.id === props.folder.id) return; if (folder.id === props.folder.id) return;
emit('removeFolder', folder.id); emit("removeFolder", folder.id);
os.api('drive/folders/update', { os.api("drive/folders/update", {
folderId: folder.id, folderId: folder.id,
parentId: props.folder.id, parentId: props.folder.id,
}).then(() => { })
// noop .then(() => {
}).catch(err => { // noop
switch (err) { })
case 'detected-circular-definition': .catch((err) => {
os.alert({ switch (err) {
title: i18n.ts.unableToProcess, case "detected-circular-definition":
text: i18n.ts.circularReferenceFolder, os.alert({
}); title: i18n.ts.unableToProcess,
break; text: i18n.ts.circularReferenceFolder,
default: });
os.alert({ break;
type: 'error', default:
text: i18n.ts.somethingHappened, os.alert({
}); type: "error",
} text: i18n.ts.somethingHappened,
}); });
}
});
} }
//#endregion //#endregion
} }
@ -165,22 +181,25 @@ function onDrop(ev: DragEvent) {
function onDragstart(ev: DragEvent) { function onDragstart(ev: DragEvent) {
if (!ev.dataTransfer) return; if (!ev.dataTransfer) return;
ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder)); ev.dataTransfer.setData(
_DATA_TRANSFER_DRIVE_FOLDER_,
JSON.stringify(props.folder)
);
isDragging.value = true; isDragging.value = true;
// //
// (=) // (=)
emit('dragstart'); emit("dragstart");
} }
function onDragend() { function onDragend() {
isDragging.value = false; isDragging.value = false;
emit('dragend'); emit("dragend");
} }
function go() { function go() {
emit('move', props.folder.id); emit("move", props.folder.id);
} }
function rename() { function rename() {
@ -190,7 +209,7 @@ function rename() {
default: props.folder.name, default: props.folder.name,
}).then(({ canceled, result: name }) => { }).then(({ canceled, result: name }) => {
if (canceled) return; if (canceled) return;
os.api('drive/folders/update', { os.api("drive/folders/update", {
folderId: props.folder.id, folderId: props.folder.id,
name: name, name: name,
}); });
@ -198,54 +217,71 @@ function rename() {
} }
function deleteFolder() { function deleteFolder() {
os.api('drive/folders/delete', { os.api("drive/folders/delete", {
folderId: props.folder.id, folderId: props.folder.id,
}).then(() => { })
if (defaultStore.state.uploadFolder === props.folder.id) { .then(() => {
defaultStore.set('uploadFolder', null); if (defaultStore.state.uploadFolder === props.folder.id) {
} defaultStore.set("uploadFolder", null);
}).catch(err => { }
switch (err.id) { })
case 'b0fc8a17-963c-405d-bfbc-859a487295e1': .catch((err) => {
os.alert({ switch (err.id) {
type: 'error', case "b0fc8a17-963c-405d-bfbc-859a487295e1":
title: i18n.ts.unableToDelete, os.alert({
text: i18n.ts.hasChildFilesOrFolders, type: "error",
}); title: i18n.ts.unableToDelete,
break; text: i18n.ts.hasChildFilesOrFolders,
default: });
os.alert({ break;
type: 'error', default:
text: i18n.ts.unableToDelete, os.alert({
}); type: "error",
} text: i18n.ts.unableToDelete,
}); });
}
});
} }
function setAsUploadFolder() { function setAsUploadFolder() {
defaultStore.set('uploadFolder', props.folder.id); defaultStore.set("uploadFolder", props.folder.id);
} }
function onContextmenu(ev: MouseEvent) { function onContextmenu(ev: MouseEvent) {
os.contextMenu([{ os.contextMenu(
text: i18n.ts.openInWindow, [
icon: 'ph-copy ph-bold ph-lg', {
action: () => { text: i18n.ts.openInWindow,
os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), { icon: "ph-copy ph-bold ph-lg",
initialFolder: props.folder, action: () => {
}, { os.popup(
}, 'closed'); defineAsyncComponent(
}, () => import("@/components/MkDriveWindow.vue")
}, null, { ),
text: i18n.ts.rename, {
icon: 'ph-cursor-text ph-bold ph-lg', initialFolder: props.folder,
action: rename, },
}, null, { {},
text: i18n.ts.delete, "closed"
icon: 'ph-trash ph-bold ph-lg', );
danger: true, },
action: deleteFolder, },
}], ev); null,
{
text: i18n.ts.rename,
icon: "ph-cursor-text ph-bold ph-lg",
action: rename,
},
null,
{
text: i18n.ts.delete,
icon: "ph-trash ph-bold ph-lg",
danger: true,
action: deleteFolder,
},
],
ev
);
} }
</script> </script>
@ -257,7 +293,8 @@ function onContextmenu(ev: MouseEvent) {
background: var(--driveFolderBg); background: var(--driveFolderBg);
border-radius: 4px; border-radius: 4px;
&, * { &,
* {
cursor: pointer; cursor: pointer;
} }

View File

@ -1,22 +1,23 @@
<template> <template>
<div class="drylbebk" <div
:class="{ draghover }" class="drylbebk"
@click="onClick" :class="{ draghover }"
@dragover.prevent.stop="onDragover" @click="onClick"
@dragenter="onDragenter" @dragover.prevent.stop="onDragover"
@dragleave="onDragleave" @dragenter="onDragenter"
@drop.stop="onDrop" @dragleave="onDragleave"
> @drop.stop="onDrop"
<i v-if="folder == null" class="ph-cloud ph-bold ph-lg"></i> >
<span>{{ folder == null ? i18n.ts.drive : folder.name }}</span> <i v-if="folder == null" class="ph-cloud ph-bold ph-lg"></i>
</div> <span>{{ folder == null ? i18n.ts.drive : folder.name }}</span>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from "vue";
import * as Misskey from 'calckey-js'; import * as Misskey from "calckey-js";
import * as os from '@/os'; import * as os from "@/os";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
folder?: Misskey.entities.DriveFolder; folder?: Misskey.entities.DriveFolder;
@ -24,17 +25,21 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'move', v?: Misskey.entities.DriveFolder): void; (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: "upload",
(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void; 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 hover = ref(false);
const draghover = ref(false); const draghover = ref(false);
function onClick() { function onClick() {
emit('move', props.folder); emit("move", props.folder);
} }
function onMouseover() { function onMouseover() {
@ -50,17 +55,19 @@ function onDragover(ev: DragEvent) {
// //
if (props.folder == null && props.parentFolder == null) { if (props.folder == null && props.parentFolder == null) {
ev.dataTransfer.dropEffect = 'none'; ev.dataTransfer.dropEffect = "none";
} }
const isFile = ev.dataTransfer.items[0].kind === 'file'; const isFile = ev.dataTransfer.items[0].kind === "file";
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; const isDriveFolder =
ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
if (isFile || isDriveFile || isDriveFolder) { if (isFile || isDriveFile || isDriveFolder) {
ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; ev.dataTransfer.dropEffect =
ev.dataTransfer.effectAllowed === "all" ? "copy" : "move";
} else { } else {
ev.dataTransfer.dropEffect = 'none'; ev.dataTransfer.dropEffect = "none";
} }
return false; return false;
@ -82,33 +89,33 @@ function onDrop(ev: DragEvent) {
// //
if (ev.dataTransfer.files.length > 0) { if (ev.dataTransfer.files.length > 0) {
for (const file of Array.from(ev.dataTransfer.files)) { for (const file of Array.from(ev.dataTransfer.files)) {
emit('upload', file, props.folder); emit("upload", file, props.folder);
} }
return; return;
} }
//#region //#region
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') { if (driveFile != null && driveFile !== "") {
const file = JSON.parse(driveFile); const file = JSON.parse(driveFile);
emit('removeFile', file.id); emit("removeFile", file.id);
os.api('drive/files/update', { os.api("drive/files/update", {
fileId: file.id, fileId: file.id,
folderId: props.folder ? props.folder.id : null folderId: props.folder ? props.folder.id : null,
}); });
} }
//#endregion //#endregion
//#region //#region
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
if (driveFolder != null && driveFolder !== '') { if (driveFolder != null && driveFolder !== "") {
const folder = JSON.parse(driveFolder); const folder = JSON.parse(driveFolder);
// reject // reject
if (props.folder && folder.id === props.folder.id) return; if (props.folder && folder.id === props.folder.id) return;
emit('removeFolder', folder.id); emit("removeFolder", folder.id);
os.api('drive/folders/update', { os.api("drive/folders/update", {
folderId: folder.id, folderId: folder.id,
parentId: props.folder ? props.folder.id : null parentId: props.folder ? props.folder.id : null,
}); });
} }
//#endregion //#endregion

View File

@ -1,121 +1,181 @@
<template> <template>
<div class="yfudmmck"> <div class="yfudmmck">
<nav> <nav>
<div class="path" @contextmenu.prevent.stop="() => {}"> <div class="path" @contextmenu.prevent.stop="() => {}">
<XNavFolder
:class="{ current: folder == null }"
:parent-folder="folder"
@move="move"
@upload="upload"
@removeFile="removeFile"
@removeFolder="removeFolder"
/>
<template v-for="f in hierarchyFolders">
<span class="separator"><i class="ph-caret-right ph-bold ph-lg"></i></span>
<XNavFolder <XNavFolder
:folder="f" :class="{ current: folder == null }"
:parent-folder="folder" :parent-folder="folder"
@move="move" @move="move"
@upload="upload" @upload="upload"
@removeFile="removeFile" @removeFile="removeFile"
@removeFolder="removeFolder" @removeFolder="removeFolder"
/> />
</template> <template v-for="f in hierarchyFolders">
<span v-if="folder != null" class="separator"><i class="ph-caret-right ph-bold ph-lg"></i></span> <span class="separator"
<span v-if="folder != null" class="folder current">{{ folder.name }}</span> ><i class="ph-caret-right ph-bold ph-lg"></i
></span>
<XNavFolder
:folder="f"
:parent-folder="folder"
@move="move"
@upload="upload"
@removeFile="removeFile"
@removeFolder="removeFolder"
/>
</template>
<span v-if="folder != null" class="separator"
><i class="ph-caret-right ph-bold ph-lg"></i
></span>
<span v-if="folder != null" class="folder current">{{
folder.name
}}</span>
</div>
<button class="menu _button" @click="showMenu">
<i class="ph-dots-three-outline ph-bold ph-lg"></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"
@removeFile="removeFile"
@removeFolder="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>
<button class="menu _button" @click="showMenu"><i class="ph-dots-three-outline ph-bold ph-lg"></i></button> <div v-if="draghover" class="dropzone"></div>
</nav> <input
<div ref="fileInput"
ref="main" class="main" type="file"
:class="{ uploading: uploadings.length > 0, fetching }" accept="*/*"
@dragover.prevent.stop="onDragover" multiple
@dragenter="onDragenter" tabindex="-1"
@dragleave="onDragleave" @change="onChangeFileInput"
@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"
@removeFile="removeFile"
@removeFolder="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>
<div v-if="draghover" class="dropzone"></div>
<input ref="fileInput" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { markRaw, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import {
import * as Misskey from 'calckey-js'; markRaw,
import MkButton from './MkButton.vue'; nextTick,
import XNavFolder from '@/components/MkDrive.navFolder.vue'; onActivated,
import XFolder from '@/components/MkDrive.folder.vue'; onBeforeUnmount,
import XFile from '@/components/MkDrive.file.vue'; onMounted,
import * as os from '@/os'; ref,
import { stream } from '@/stream'; watch,
import { defaultStore } from '@/store'; } from "vue";
import { i18n } from '@/i18n'; import * as Misskey from "calckey-js";
import { uploadFile, uploads } from '@/scripts/upload'; 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<{ const props = withDefaults(
initialFolder?: Misskey.entities.DriveFolder; defineProps<{
type?: string; initialFolder?: Misskey.entities.DriveFolder;
multiple?: boolean; type?: string;
select?: 'file' | 'folder' | null; multiple?: boolean;
}>(), { select?: "file" | "folder" | null;
multiple: false, }>(),
select: null, {
}); multiple: false,
select: null,
}
);
const emit = defineEmits<{ 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: "selected",
(ev: 'move-root'): void; v: Misskey.entities.DriveFile | Misskey.entities.DriveFolder
(ev: 'cd', v: Misskey.entities.DriveFolder | null): void; ): void;
(ev: 'open-folder', v: 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 loadMoreFiles = ref<InstanceType<typeof MkButton>>();
@ -130,7 +190,7 @@ const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
const selectedFiles = ref<Misskey.entities.DriveFile[]>([]); const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]); const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
const uploadings = uploads; const uploadings = uploads;
const connection = stream.useChannel('drive'); const connection = stream.useChannel("drive");
const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // $ref使 const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // $ref使
// //
@ -143,10 +203,14 @@ const isDragSource = ref(false);
const fetching = ref(true); const fetching = ref(true);
const ilFilesObserver = new IntersectionObserver( const ilFilesObserver = new IntersectionObserver(
(entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(), (entries) =>
entries.some((entry) => entry.isIntersecting) &&
!fetching.value &&
moreFiles.value &&
fetchMoreFiles()
); );
watch(folder, () => emit('cd', folder.value)); watch(folder, () => emit("cd", folder.value));
function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) { function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) {
addFile(file, true); addFile(file, true);
@ -165,11 +229,15 @@ function onStreamDriveFileDeleted(fileId: string) {
removeFile(fileId); removeFile(fileId);
} }
function onStreamDriveFolderCreated(createdFolder: Misskey.entities.DriveFolder) { function onStreamDriveFolderCreated(
createdFolder: Misskey.entities.DriveFolder
) {
addFolder(createdFolder, true); addFolder(createdFolder, true);
} }
function onStreamDriveFolderUpdated(updatedFolder: Misskey.entities.DriveFolder) { function onStreamDriveFolderUpdated(
updatedFolder: Misskey.entities.DriveFolder
) {
const current = folder.value ? folder.value.id : null; const current = folder.value ? folder.value.id : null;
if (current !== updatedFolder.parentId) { if (current !== updatedFolder.parentId) {
removeFolder(updatedFolder); removeFolder(updatedFolder);
@ -188,17 +256,19 @@ function onDragover(ev: DragEvent): any {
// //
if (isDragSource.value) { if (isDragSource.value) {
// //
ev.dataTransfer.dropEffect = 'none'; ev.dataTransfer.dropEffect = "none";
return; return;
} }
const isFile = ev.dataTransfer.items[0].kind === 'file'; const isFile = ev.dataTransfer.items[0].kind === "file";
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; const isDriveFolder =
ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
if (isFile || isDriveFile || isDriveFolder) { if (isFile || isDriveFile || isDriveFolder) {
ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; ev.dataTransfer.dropEffect =
ev.dataTransfer.effectAllowed === "all" ? "copy" : "move";
} else { } else {
ev.dataTransfer.dropEffect = 'none'; ev.dataTransfer.dropEffect = "none";
} }
return false; return false;
@ -227,11 +297,11 @@ function onDrop(ev: DragEvent): any {
//#region //#region
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') { if (driveFile != null && driveFile !== "") {
const file = JSON.parse(driveFile); const file = JSON.parse(driveFile);
if (files.value.some(f => f.id === file.id)) return; if (files.value.some((f) => f.id === file.id)) return;
removeFile(file.id); removeFile(file.id);
os.api('drive/files/update', { os.api("drive/files/update", {
fileId: file.id, fileId: file.id,
folderId: folder.value ? folder.value.id : null, folderId: folder.value ? folder.value.id : null,
}); });
@ -240,33 +310,35 @@ function onDrop(ev: DragEvent): any {
//#region //#region
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
if (driveFolder != null && driveFolder !== '') { if (driveFolder != null && driveFolder !== "") {
const droppedFolder = JSON.parse(driveFolder); const droppedFolder = JSON.parse(driveFolder);
// reject // reject
if (folder.value && droppedFolder.id === folder.value.id) return false; if (folder.value && droppedFolder.id === folder.value.id) return false;
if (folders.value.some(f => f.id === droppedFolder.id)) return false; if (folders.value.some((f) => f.id === droppedFolder.id)) return false;
removeFolder(droppedFolder.id); removeFolder(droppedFolder.id);
os.api('drive/folders/update', { os.api("drive/folders/update", {
folderId: droppedFolder.id, folderId: droppedFolder.id,
parentId: folder.value ? folder.value.id : null, parentId: folder.value ? folder.value.id : null,
}).then(() => { })
// noop .then(() => {
}).catch(err => { // noop
switch (err) { })
case 'detected-circular-definition': .catch((err) => {
os.alert({ switch (err) {
title: i18n.ts.unableToProcess, case "detected-circular-definition":
text: i18n.ts.circularReferenceFolder, os.alert({
}); title: i18n.ts.unableToProcess,
break; text: i18n.ts.circularReferenceFolder,
default: });
os.alert({ break;
type: 'error', default:
text: i18n.ts.somethingHappened, os.alert({
}); type: "error",
} text: i18n.ts.somethingHappened,
}); });
}
});
} }
//#endregion //#endregion
} }
@ -278,11 +350,11 @@ function selectLocalFile() {
function urlUpload() { function urlUpload() {
os.inputText({ os.inputText({
title: i18n.ts.uploadFromUrl, title: i18n.ts.uploadFromUrl,
type: 'url', type: "url",
placeholder: i18n.ts.uploadFromUrlDescription, placeholder: i18n.ts.uploadFromUrlDescription,
}).then(({ canceled, result: url }) => { }).then(({ canceled, result: url }) => {
if (canceled || !url) return; if (canceled || !url) return;
os.api('drive/files/upload-from-url', { os.api("drive/files/upload-from-url", {
url: url, url: url,
folderId: folder.value ? folder.value.id : undefined, folderId: folder.value ? folder.value.id : undefined,
}); });
@ -300,10 +372,10 @@ function createFolder() {
placeholder: i18n.ts.folderName, placeholder: i18n.ts.folderName,
}).then(({ canceled, result: name }) => { }).then(({ canceled, result: name }) => {
if (canceled) return; if (canceled) return;
os.api('drive/folders/create', { os.api("drive/folders/create", {
name: name, name: name,
parentId: folder.value ? folder.value.id : undefined, parentId: folder.value ? folder.value.id : undefined,
}).then(createdFolder => { }).then((createdFolder) => {
addFolder(createdFolder, true); addFolder(createdFolder, true);
}); });
}); });
@ -316,10 +388,10 @@ function renameFolder(folderToRename: Misskey.entities.DriveFolder) {
default: folderToRename.name, default: folderToRename.name,
}).then(({ canceled, result: name }) => { }).then(({ canceled, result: name }) => {
if (canceled) return; if (canceled) return;
os.api('drive/folders/update', { os.api("drive/folders/update", {
folderId: folderToRename.id, folderId: folderToRename.id,
name: name, name: name,
}).then(updatedFolder => { }).then((updatedFolder) => {
// FIXME: // FIXME:
move(updatedFolder); move(updatedFolder);
}); });
@ -327,27 +399,29 @@ function renameFolder(folderToRename: Misskey.entities.DriveFolder) {
} }
function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) {
os.api('drive/folders/delete', { os.api("drive/folders/delete", {
folderId: folderToDelete.id, folderId: folderToDelete.id,
}).then(() => { })
// .then(() => {
move(folderToDelete.parentId); //
}).catch(err => { move(folderToDelete.parentId);
switch (err.id) { })
case 'b0fc8a17-963c-405d-bfbc-859a487295e1': .catch((err) => {
os.alert({ switch (err.id) {
type: 'error', case "b0fc8a17-963c-405d-bfbc-859a487295e1":
title: i18n.ts.unableToDelete, os.alert({
text: i18n.ts.hasChildFilesOrFolders, type: "error",
}); title: i18n.ts.unableToDelete,
break; text: i18n.ts.hasChildFilesOrFolders,
default: });
os.alert({ break;
type: 'error', default:
text: i18n.ts.unableToDelete, os.alert({
}); type: "error",
} text: i18n.ts.unableToDelete,
}); });
}
});
} }
function onChangeFileInput() { function onChangeFileInput() {
@ -357,46 +431,62 @@ function onChangeFileInput() {
} }
} }
function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null) { function upload(
uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal.value).then(res => { file: File,
folderToUpload?: Misskey.entities.DriveFolder | null
) {
uploadFile(
file,
folderToUpload && typeof folderToUpload === "object"
? folderToUpload.id
: null,
undefined,
keepOriginal.value
).then((res) => {
addFile(res, true); addFile(res, true);
}); });
} }
function chooseFile(file: Misskey.entities.DriveFile) { function chooseFile(file: Misskey.entities.DriveFile) {
const isAlreadySelected = selectedFiles.value.some(f => f.id === file.id); const isAlreadySelected = selectedFiles.value.some((f) => f.id === file.id);
if (props.multiple) { if (props.multiple) {
if (isAlreadySelected) { if (isAlreadySelected) {
selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id); selectedFiles.value = selectedFiles.value.filter(
(f) => f.id !== file.id
);
} else { } else {
selectedFiles.value.push(file); selectedFiles.value.push(file);
} }
emit('change-selection', selectedFiles.value); emit("change-selection", selectedFiles.value);
} else { } else {
if (isAlreadySelected) { if (isAlreadySelected) {
emit('selected', file); emit("selected", file);
} else { } else {
selectedFiles.value = [file]; selectedFiles.value = [file];
emit('change-selection', [file]); emit("change-selection", [file]);
} }
} }
} }
function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) { function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) {
const isAlreadySelected = selectedFolders.value.some(f => f.id === folderToChoose.id); const isAlreadySelected = selectedFolders.value.some(
(f) => f.id === folderToChoose.id
);
if (props.multiple) { if (props.multiple) {
if (isAlreadySelected) { if (isAlreadySelected) {
selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToChoose.id); selectedFolders.value = selectedFolders.value.filter(
(f) => f.id !== folderToChoose.id
);
} else { } else {
selectedFolders.value.push(folderToChoose); selectedFolders.value.push(folderToChoose);
} }
emit('change-selection', selectedFolders.value); emit("change-selection", selectedFolders.value);
} else { } else {
if (isAlreadySelected) { if (isAlreadySelected) {
emit('selected', folderToChoose); emit("selected", folderToChoose);
} else { } else {
selectedFolders.value = [folderToChoose]; selectedFolders.value = [folderToChoose];
emit('change-selection', [folderToChoose]); emit("change-selection", [folderToChoose]);
} }
} }
} }
@ -405,26 +495,26 @@ function move(target?: Misskey.entities.DriveFolder) {
if (!target) { if (!target) {
goRoot(); goRoot();
return; return;
} else if (typeof target === 'object') { } else if (typeof target === "object") {
target = target.id; target = target.id;
} }
fetching.value = true; fetching.value = true;
os.api('drive/folders/show', { os.api("drive/folders/show", {
folderId: target, folderId: target,
}).then(folderToMove => { }).then((folderToMove) => {
folder.value = folderToMove; folder.value = folderToMove;
hierarchyFolders.value = []; hierarchyFolders.value = [];
const dive = folderToDive => { const dive = (folderToDive) => {
hierarchyFolders.value.unshift(folderToDive); hierarchyFolders.value.unshift(folderToDive);
if (folderToDive.parent) dive(folderToDive.parent); if (folderToDive.parent) dive(folderToDive.parent);
}; };
if (folderToMove.parent) dive(folderToMove.parent); if (folderToMove.parent) dive(folderToMove.parent);
emit('open-folder', folderToMove); emit("open-folder", folderToMove);
fetch(); fetch();
}); });
} }
@ -433,8 +523,8 @@ function addFolder(folderToAdd: Misskey.entities.DriveFolder, unshift = false) {
const current = folder.value ? folder.value.id : null; const current = folder.value ? folder.value.id : null;
if (current !== folderToAdd.parentId) return; if (current !== folderToAdd.parentId) return;
if (folders.value.some(f => f.id === folderToAdd.id)) { if (folders.value.some((f) => f.id === folderToAdd.id)) {
const exist = folders.value.map(f => f.id).indexOf(folderToAdd.id); const exist = folders.value.map((f) => f.id).indexOf(folderToAdd.id);
folders.value[exist] = folderToAdd; folders.value[exist] = folderToAdd;
return; return;
} }
@ -450,8 +540,8 @@ function addFile(fileToAdd: Misskey.entities.DriveFile, unshift = false) {
const current = folder.value ? folder.value.id : null; const current = folder.value ? folder.value.id : null;
if (current !== fileToAdd.folderId) return; if (current !== fileToAdd.folderId) return;
if (files.value.some(f => f.id === fileToAdd.id)) { if (files.value.some((f) => f.id === fileToAdd.id)) {
const exist = files.value.map(f => f.id).indexOf(fileToAdd.id); const exist = files.value.map((f) => f.id).indexOf(fileToAdd.id);
files.value[exist] = fileToAdd; files.value[exist] = fileToAdd;
return; return;
} }
@ -464,13 +554,14 @@ function addFile(fileToAdd: Misskey.entities.DriveFile, unshift = false) {
} }
function removeFolder(folderToRemove: Misskey.entities.DriveFolder | string) { function removeFolder(folderToRemove: Misskey.entities.DriveFolder | string) {
const folderIdToRemove = typeof folderToRemove === 'object' ? folderToRemove.id : folderToRemove; const folderIdToRemove =
folders.value = folders.value.filter(f => f.id !== folderIdToRemove); typeof folderToRemove === "object" ? folderToRemove.id : folderToRemove;
folders.value = folders.value.filter((f) => f.id !== folderIdToRemove);
} }
function removeFile(file: Misskey.entities.DriveFile | string) { function removeFile(file: Misskey.entities.DriveFile | string) {
const fileId = typeof file === 'object' ? file.id : file; const fileId = typeof file === "object" ? file.id : file;
files.value = files.value.filter(f => f.id !== fileId); files.value = files.value.filter((f) => f.id !== fileId);
} }
function appendFile(file: Misskey.entities.DriveFile) { function appendFile(file: Misskey.entities.DriveFile) {
@ -495,7 +586,7 @@ function goRoot() {
folder.value = null; folder.value = null;
hierarchyFolders.value = []; hierarchyFolders.value = [];
emit('move-root'); emit("move-root");
fetch(); fetch();
} }
@ -509,30 +600,37 @@ async function fetch() {
const foldersMax = 30; const foldersMax = 30;
const filesMax = 30; const filesMax = 30;
const foldersPromise = os.api('drive/folders', { const foldersPromise = os
folderId: folder.value ? folder.value.id : null, .api("drive/folders", {
limit: foldersMax + 1, folderId: folder.value ? folder.value.id : null,
}).then(fetchedFolders => { limit: foldersMax + 1,
if (fetchedFolders.length === foldersMax + 1) { })
moreFolders.value = true; .then((fetchedFolders) => {
fetchedFolders.pop(); if (fetchedFolders.length === foldersMax + 1) {
} moreFolders.value = true;
return fetchedFolders; fetchedFolders.pop();
}); }
return fetchedFolders;
});
const filesPromise = os.api('drive/files', { const filesPromise = os
folderId: folder.value ? folder.value.id : null, .api("drive/files", {
type: props.type, folderId: folder.value ? folder.value.id : null,
limit: filesMax + 1, type: props.type,
}).then(fetchedFiles => { limit: filesMax + 1,
if (fetchedFiles.length === filesMax + 1) { })
moreFiles.value = true; .then((fetchedFiles) => {
fetchedFiles.pop(); if (fetchedFiles.length === filesMax + 1) {
} moreFiles.value = true;
return fetchedFiles; fetchedFiles.pop();
}); }
return fetchedFiles;
});
const [fetchedFolders, fetchedFiles] = await Promise.all([foldersPromise, filesPromise]); const [fetchedFolders, fetchedFiles] = await Promise.all([
foldersPromise,
filesPromise,
]);
for (const x of fetchedFolders) appendFolder(x); for (const x of fetchedFolders) appendFolder(x);
for (const x of fetchedFiles) appendFile(x); for (const x of fetchedFiles) appendFile(x);
@ -546,12 +644,12 @@ function fetchMoreFiles() {
const max = 30; const max = 30;
// //
os.api('drive/files', { os.api("drive/files", {
folderId: folder.value ? folder.value.id : null, folderId: folder.value ? folder.value.id : null,
type: props.type, type: props.type,
untilId: files.value[files.value.length - 1].id, untilId: files.value[files.value.length - 1].id,
limit: max + 1, limit: max + 1,
}).then(files => { }).then((files) => {
if (files.length === max + 1) { if (files.length === max + 1) {
moreFiles.value = true; moreFiles.value = true;
files.pop(); files.pop();
@ -564,41 +662,71 @@ function fetchMoreFiles() {
} }
function getMenu() { function getMenu() {
return [{ return [
type: 'switch', {
text: i18n.ts.keepOriginalUploading, type: "switch",
ref: keepOriginal, text: i18n.ts.keepOriginalUploading,
}, null, { ref: keepOriginal,
text: i18n.ts.addFile, },
type: 'label', null,
}, { {
text: i18n.ts.upload, text: i18n.ts.addFile,
icon: 'ph-upload-simple ph-bold ph-lg', type: "label",
action: () => { selectLocalFile(); }, },
}, { {
text: i18n.ts.fromUrl, text: i18n.ts.upload,
icon: 'ph-link-simple ph-bold ph-lg', icon: "ph-upload-simple ph-bold ph-lg",
action: () => { urlUpload(); }, action: () => {
}, null, { selectLocalFile();
text: folder.value ? folder.value.name : i18n.ts.drive, },
type: 'label', },
}, folder.value ? { {
text: i18n.ts.renameFolder, text: i18n.ts.fromUrl,
icon: 'ph-cursor-text ph-bold ph-lg', icon: "ph-link-simple ph-bold ph-lg",
action: () => { renameFolder(folder.value); }, action: () => {
} : undefined, folder.value ? { urlUpload();
text: i18n.ts.deleteFolder, },
icon: 'ph-trash ph-bold ph-lg', },
action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); }, null,
} : undefined, { {
text: i18n.ts.createFolder, text: folder.value ? folder.value.name : i18n.ts.drive,
icon: 'ph-folder-notch-plus ph-bold ph-lg', type: "label",
action: () => { createFolder(); }, },
}]; folder.value
? {
text: i18n.ts.renameFolder,
icon: "ph-cursor-text ph-bold ph-lg",
action: () => {
renameFolder(folder.value);
},
}
: undefined,
folder.value
? {
text: i18n.ts.deleteFolder,
icon: "ph-trash ph-bold ph-lg",
action: () => {
deleteFolder(
folder.value as Misskey.entities.DriveFolder
);
},
}
: undefined,
{
text: i18n.ts.createFolder,
icon: "ph-folder-notch-plus ph-bold ph-lg",
action: () => {
createFolder();
},
},
];
} }
function showMenu(ev: MouseEvent) { function showMenu(ev: MouseEvent) {
os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); os.popupMenu(
getMenu(),
(ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined
);
} }
function onContextmenu(ev: MouseEvent) { function onContextmenu(ev: MouseEvent) {
@ -612,12 +740,12 @@ onMounted(() => {
}); });
} }
connection.on('fileCreated', onStreamDriveFileCreated); connection.on("fileCreated", onStreamDriveFileCreated);
connection.on('fileUpdated', onStreamDriveFileUpdated); connection.on("fileUpdated", onStreamDriveFileUpdated);
connection.on('fileDeleted', onStreamDriveFileDeleted); connection.on("fileDeleted", onStreamDriveFileDeleted);
connection.on('folderCreated', onStreamDriveFolderCreated); connection.on("folderCreated", onStreamDriveFolderCreated);
connection.on('folderUpdated', onStreamDriveFolderUpdated); connection.on("folderUpdated", onStreamDriveFolderUpdated);
connection.on('folderDeleted', onStreamDriveFolderDeleted); connection.on("folderDeleted", onStreamDriveFolderDeleted);
if (props.initialFolder) { if (props.initialFolder) {
move(props.initialFolder); move(props.initialFolder);
@ -656,7 +784,8 @@ onBeforeUnmount(() => {
font-size: 0.9em; font-size: 0.9em;
box-shadow: 0 1px 0 var(--divider); box-shadow: 0 1px 0 var(--divider);
&, * { &,
* {
user-select: none; user-select: none;
} }
@ -714,7 +843,8 @@ onBeforeUnmount(() => {
overflow: auto; overflow: auto;
padding: var(--margin); padding: var(--margin);
&, * { &,
* {
user-select: none; user-select: none;
} }
@ -735,7 +865,6 @@ onBeforeUnmount(() => {
} }
> .contents { > .contents {
> .folders, > .folders,
> .files { > .files {
display: flex; display: flex;

View File

@ -1,23 +1,48 @@
<template> <template>
<div ref="thumbnail" class="zdjebgpv"> <div ref="thumbnail" class="zdjebgpv">
<ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/> <ImgWithBlurhash
<i v-else-if="is === 'image'" class="ph-file-image ph-bold ph-lg icon"></i> v-if="isThumbnailAvailable"
<i v-else-if="is === 'video'" class="ph-file-video ph-bold ph-lg icon"></i> :hash="file.blurhash"
<i v-else-if="is === 'audio' || is === 'midi'" class="ph-file-audio ph-bold ph-lg icon"></i> :src="file.thumbnailUrl"
<i v-else-if="is === 'csv'" class="ph-file-csv ph-bold ph-lg icon"></i> :alt="file.name"
<i v-else-if="is === 'pdf'" class="ph-file-pdf ph-bold ph-lg icon"></i> :title="file.name"
<i v-else-if="is === 'textfile'" class="ph-file-text ph-bold ph-lg icon"></i> :cover="fit !== 'contain'"
<i v-else-if="is === 'archive'" class="ph-file-zip ph-bold ph-lg icon"></i> />
<i v-else class="ph-file ph-bold ph-lg icon"></i> <i
v-else-if="is === 'image'"
class="ph-file-image ph-bold ph-lg icon"
></i>
<i
v-else-if="is === 'video'"
class="ph-file-video ph-bold ph-lg icon"
></i>
<i
v-else-if="is === 'audio' || is === 'midi'"
class="ph-file-audio ph-bold ph-lg icon"
></i>
<i v-else-if="is === 'csv'" class="ph-file-csv ph-bold ph-lg icon"></i>
<i v-else-if="is === 'pdf'" class="ph-file-pdf ph-bold ph-lg icon"></i>
<i
v-else-if="is === 'textfile'"
class="ph-file-text ph-bold ph-lg icon"
></i>
<i
v-else-if="is === 'archive'"
class="ph-file-zip ph-bold ph-lg icon"
></i>
<i v-else class="ph-file ph-bold ph-lg icon"></i>
<i v-if="isThumbnailAvailable && is === 'video'" class="ph-file-video ph-bold ph-lg icon-sub"></i> <i
</div> v-if="isThumbnailAvailable && is === 'video'"
class="ph-file-video ph-bold ph-lg icon-sub"
></i>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from "vue";
import type * as Misskey from 'calckey-js'; import type * as Misskey from "calckey-js";
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import ImgWithBlurhash from "@/components/MkImgWithBlurhash.vue";
const props = defineProps<{ const props = defineProps<{
file: Misskey.entities.DriveFile; file: Misskey.entities.DriveFile;
@ -25,30 +50,33 @@ const props = defineProps<{
}>(); }>();
const is = computed(() => { const is = computed(() => {
if (props.file.type.startsWith('image/')) return 'image'; if (props.file.type.startsWith("image/")) return "image";
if (props.file.type.startsWith('video/')) return 'video'; if (props.file.type.startsWith("video/")) return "video";
if (props.file.type === 'audio/midi') return 'midi'; if (props.file.type === "audio/midi") return "midi";
if (props.file.type.startsWith('audio/')) return 'audio'; if (props.file.type.startsWith("audio/")) return "audio";
if (props.file.type.endsWith('/csv')) return 'csv'; if (props.file.type.endsWith("/csv")) return "csv";
if (props.file.type.endsWith('/pdf')) return 'pdf'; if (props.file.type.endsWith("/pdf")) return "pdf";
if (props.file.type.startsWith('text/')) return 'textfile'; if (props.file.type.startsWith("text/")) return "textfile";
if ([ if (
'application/zip', [
'application/x-cpio', "application/zip",
'application/x-bzip', "application/x-cpio",
'application/x-bzip2', "application/x-bzip",
'application/java-archive', "application/x-bzip2",
'application/x-rar-compressed', "application/java-archive",
'application/x-tar', "application/x-rar-compressed",
'application/gzip', "application/x-tar",
'application/x-7z-compressed', "application/gzip",
].some(archiveType => archiveType === props.file.type)) return 'archive'; "application/x-7z-compressed",
return 'unknown'; ].some((archiveType) => archiveType === props.file.type)
)
return "archive";
return "unknown";
}); });
const isThumbnailAvailable = computed(() => { const isThumbnailAvailable = computed(() => {
return props.file.thumbnailUrl return props.file.thumbnailUrl
? (is.value === 'image' as const || is.value === 'video') ? is.value === ("image" as const) || is.value === "video"
: false; : false;
}); });
</script> </script>

View File

@ -1,41 +1,61 @@
<template> <template>
<XModalWindow <XModalWindow
ref="dialog" ref="dialog"
:width="800" :width="800"
:height="500" :height="500"
:with-ok-button="true" :with-ok-button="true"
:ok-button-disabled="(type === 'file') && (selected.length === 0)" :ok-button-disabled="type === 'file' && selected.length === 0"
@click="cancel()" @click="cancel()"
@close="cancel()" @close="cancel()"
@ok="ok()" @ok="ok()"
@closed="emit('closed')" @closed="emit('closed')"
> >
<template #header> <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> multiple
</template> ? type === "file"
<XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/> ? i18n.ts.selectFiles
</XModalWindow> : 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"
@changeSelection="onChangeSelection"
@selected="ok()"
/>
</XModalWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from "vue";
import * as Misskey from 'calckey-js'; import * as Misskey from "calckey-js";
import XDrive from '@/components/MkDrive.vue'; import XDrive from "@/components/MkDrive.vue";
import XModalWindow from '@/components/MkModalWindow.vue'; import XModalWindow from "@/components/MkModalWindow.vue";
import number from '@/filters/number'; import number from "@/filters/number";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
withDefaults(defineProps<{ withDefaults(
type?: 'file' | 'folder'; defineProps<{
multiple: boolean; type?: "file" | "folder";
}>(), { multiple: boolean;
type: 'file', }>(),
}); {
type: "file",
}
);
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', r?: Misskey.entities.DriveFile[]): void; (ev: "done", r?: Misskey.entities.DriveFile[]): void;
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
const dialog = ref<InstanceType<typeof XModalWindow>>(); const dialog = ref<InstanceType<typeof XModalWindow>>();
@ -43,12 +63,12 @@ const dialog = ref<InstanceType<typeof XModalWindow>>();
const selected = ref<Misskey.entities.DriveFile[]>([]); const selected = ref<Misskey.entities.DriveFile[]>([]);
function ok() { function ok() {
emit('done', selected.value); emit("done", selected.value);
dialog.value?.close(); dialog.value?.close();
} }
function cancel() { function cancel() {
emit('done'); emit("done");
dialog.value?.close(); dialog.value?.close();
} }

View File

@ -1,30 +1,30 @@
<template> <template>
<XWindow <XWindow
ref="window" ref="window"
:initial-width="800" :initial-width="800"
:initial-height="500" :initial-height="500"
:can-resize="true" :can-resize="true"
@closed="emit('closed')" @closed="emit('closed')"
> >
<template #header> <template #header>
{{ i18n.ts.drive }} {{ i18n.ts.drive }}
</template> </template>
<XDrive :initial-folder="initialFolder"/> <XDrive :initial-folder="initialFolder" />
</XWindow> </XWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import * as Misskey from 'calckey-js'; import * as Misskey from "calckey-js";
import XDrive from '@/components/MkDrive.vue'; import XDrive from "@/components/MkDrive.vue";
import XWindow from '@/components/MkWindow.vue'; import XWindow from "@/components/MkWindow.vue";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
defineProps<{ defineProps<{
initialFolder?: Misskey.entities.DriveFolder; initialFolder?: Misskey.entities.DriveFolder;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
</script> </script>

View File

@ -1,24 +1,32 @@
<template> <template>
<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと --> <!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと -->
<section> <section>
<header class="_acrylic" @click="shown = !shown"> <header class="_acrylic" @click="shown = !shown">
<i class="toggle ph-fw ph-lg" :class="shown ? 'ph-caret-down-bold ph-lg' : 'ph-caret-up ph-bold ph-lg'"></i> <slot></slot> ({{ emojis.length }}) <i
</header> class="toggle ph-fw ph-lg"
<div v-if="shown" class="body"> :class="
<button shown
v-for="emoji in emojis" ? 'ph-caret-down-bold ph-lg'
:key="emoji" : 'ph-caret-up ph-bold ph-lg'
class="_button item" "
@click="emit('chosen', emoji, $event)" ></i>
> <slot></slot> ({{ emojis.length }})
<MkEmoji class="emoji" :emoji="emoji" :normal="true"/> </header>
</button> <div v-if="shown" class="body">
</div> <button
</section> 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> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from "vue";
const props = defineProps<{ const props = defineProps<{
emojis: string[]; emojis: string[];
@ -26,11 +34,10 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'chosen', v: string, event: MouseEvent): void; (ev: "chosen", v: string, event: MouseEvent): void;
}>(); }>();
const shown = ref(!!props.initialShown); const shown = ref(!!props.initialShown);
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped></style>
</style>

View File

@ -1,107 +1,191 @@
<template> <template>
<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }"> <div
<input ref="search" v-model.trim="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @paste.stop="paste" @keyup.enter="done()"> class="omfetrab"
<div ref="emojis" class="emojis"> :class="['s' + size, 'w' + width, 'h' + height, { asDrawer }]"
<section class="result"> :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }"
<div v-if="searchResultCustom.length > 0" class="body"> >
<button <input
v-for="emoji in searchResultCustom" ref="search"
:key="emoji.id" v-model.trim="q"
class="_button item" class="search"
:title="emoji.name" data-prevent-emoji-insert
tabindex="0" :class="{ filled: q != null && q != '' }"
@click="chosen(emoji, $event)" :placeholder="i18n.ts.search"
> type="search"
<!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>--> @paste.stop="paste"
<img class="emoji" :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> @keyup.enter="done()"
</button> />
</div> <div ref="emojis" class="emojis">
<div v-if="searchResultUnicode.length > 0" class="body"> <section class="result">
<button <div v-if="searchResultCustom.length > 0" class="body">
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 <button
v-for="emoji in pinned" v-for="emoji in searchResultCustom"
:key="emoji" :key="emoji.id"
class="_button item" class="_button item"
:title="emoji.name"
tabindex="0" tabindex="0"
@click="chosen(emoji, $event)" @click="chosen(emoji, $event)"
> >
<MkEmoji class="emoji" :emoji="emoji" :normal="true"/> <!--<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> </button>
</div> </div>
</section> </section>
<section> <div v-if="tab === 'index'" class="group index">
<header class="_acrylic"><i class="ph-alarm ph-bold ph-fw ph-lg"></i> {{ i18n.ts.recentUsed }}</header> <section v-if="showPinned">
<div class="body"> <div class="body">
<button <button
v-for="emoji in recentlyUsedEmojis" v-for="emoji in pinned"
:key="emoji" :key="emoji"
class="_button item" class="_button item"
@click="chosen(emoji, $event)" tabindex="0"
> @click="chosen(emoji, $event)"
<MkEmoji class="emoji" :emoji="emoji" :normal="true"/> >
</button> <MkEmoji
</div> class="emoji"
</section> :emoji="emoji"
:normal="true"
/>
</button>
</div>
</section>
<section>
<header class="_acrylic">
<i class="ph-alarm ph-bold ph-fw ph-lg"></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>{{ 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>{{ 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>
<div v-once class="group"> <div class="tabs">
<header>{{ i18n.ts.customEmojis }}</header> <button
<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> class="_button tab"
</div> :class="{ active: tab === 'index' }"
<div v-once class="group"> @click="tab = 'index'"
<header>{{ 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> <i class="ph-asterisk ph-bold ph-lg ph-fw ph-lg"></i>
</button>
<button
class="_button tab"
:class="{ active: tab === 'custom' }"
@click="tab = 'custom'"
>
<i class="ph-smiley ph-bold ph-lg ph-fw ph-lg"></i>
</button>
<button
class="_button tab"
:class="{ active: tab === 'unicode' }"
@click="tab = 'unicode'"
>
<i class="ph-leaf ph-bold ph-lg ph-fw ph-lg"></i>
</button>
<button
class="_button tab"
:class="{ active: tab === 'tags' }"
@click="tab = 'tags'"
>
<i class="ph-hash ph-bold ph-lg ph-fw ph-lg"></i>
</button>
</div> </div>
</div> </div>
<div class="tabs">
<button class="_button tab" :class="{ active: tab === 'index' }" @click="tab = 'index'"><i class="ph-asterisk ph-bold ph-lg ph-fw ph-lg"></i></button>
<button class="_button tab" :class="{ active: tab === 'custom' }" @click="tab = 'custom'"><i class="ph-smiley ph-bold ph-lg ph-fw ph-lg"></i></button>
<button class="_button tab" :class="{ active: tab === 'unicode' }" @click="tab = 'unicode'"><i class="ph-leaf ph-bold ph-lg ph-fw ph-lg"></i></button>
<button class="_button tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'"><i class="ph-hash ph-bold ph-lg ph-fw ph-lg"></i></button>
</div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, watch, onMounted } from 'vue'; import { ref, computed, watch, onMounted } from "vue";
import * as Misskey from 'calckey-js'; import * as Misskey from "calckey-js";
import XSection from '@/components/MkEmojiPicker.section.vue'; import XSection from "@/components/MkEmojiPicker.section.vue";
import { emojilist, UnicodeEmojiDef, unicodeEmojiCategories as categories } from '@/scripts/emojilist'; import {
import { getStaticImageUrl } from '@/scripts/get-static-image-url'; emojilist,
import Ripple from '@/components/MkRipple.vue'; UnicodeEmojiDef,
import * as os from '@/os'; unicodeEmojiCategories as categories,
import { isTouchUsing } from '@/scripts/touch'; } from "@/scripts/emojilist";
import { deviceKind } from '@/scripts/device-kind'; import { getStaticImageUrl } from "@/scripts/get-static-image-url";
import { emojiCategories, instance } from '@/instance'; import Ripple from "@/components/MkRipple.vue";
import { i18n } from '@/i18n'; import * as os from "@/os";
import { defaultStore } from '@/store'; 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<{ const props = withDefaults(
showPinned?: boolean; defineProps<{
asReactionPicker?: boolean; showPinned?: boolean;
maxHeight?: number; asReactionPicker?: boolean;
asDrawer?: boolean; maxHeight?: number;
}>(), { asDrawer?: boolean;
showPinned: true, }>(),
}); {
showPinned: true,
}
);
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'chosen', v: string): void; (ev: "chosen", v: string): void;
}>(); }>();
const search = ref<HTMLInputElement>(); const search = ref<HTMLInputElement>();
@ -116,41 +200,48 @@ const {
recentlyUsedEmojis, recentlyUsedEmojis,
} = defaultStore.reactiveState; } = defaultStore.reactiveState;
const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1); const size = computed(() =>
const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3); props.asReactionPicker ? reactionPickerSize.value : 1
const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2); );
const width = computed(() =>
props.asReactionPicker ? reactionPickerWidth.value : 3
);
const height = computed(() =>
props.asReactionPicker ? reactionPickerHeight.value : 2
);
const customEmojiCategories = emojiCategories; const customEmojiCategories = emojiCategories;
const customEmojis = instance.emojis; const customEmojis = instance.emojis;
const q = ref<string | null>(null); const q = ref<string | null>(null);
const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]); const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]);
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]); const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index'); const tab = ref<"index" | "custom" | "unicode" | "tags">("index");
watch(q, () => { watch(q, () => {
if (emojis.value) emojis.value.scrollTop = 0; if (emojis.value) emojis.value.scrollTop = 0;
if (q.value == null || q.value === '') { if (q.value == null || q.value === "") {
searchResultCustom.value = []; searchResultCustom.value = [];
searchResultUnicode.value = []; searchResultUnicode.value = [];
return; return;
} }
const newQ = q.value.replace(/:/g, '').toLowerCase(); const newQ = q.value.replace(/:/g, "").toLowerCase();
const searchCustom = () => { const searchCustom = () => {
const max = 8; const max = 8;
const emojis = customEmojis; const emojis = customEmojis;
const matches = new Set<Misskey.entities.CustomEmoji>(); const matches = new Set<Misskey.entities.CustomEmoji>();
const exactMatch = emojis.find(emoji => emoji.name === newQ); const exactMatch = emojis.find((emoji) => emoji.name === newQ);
if (exactMatch) matches.add(exactMatch); if (exactMatch) matches.add(exactMatch);
if (newQ.includes(' ')) { // AND if (newQ.includes(" ")) {
const keywords = newQ.split(' '); // AND
const keywords = newQ.split(" ");
// //
for (const emoji of emojis) { for (const emoji of emojis) {
if (keywords.every(keyword => emoji.name.includes(keyword))) { if (keywords.every((keyword) => emoji.name.includes(keyword))) {
matches.add(emoji); matches.add(emoji);
if (matches.size >= max) break; if (matches.size >= max) break;
} }
@ -159,7 +250,15 @@ watch(q, () => {
// //
for (const emoji of emojis) { for (const emoji of emojis) {
if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) { if (
keywords.every(
(keyword) =>
emoji.name.includes(keyword) ||
emoji.aliases.some((alias) =>
alias.includes(keyword)
)
)
) {
matches.add(emoji); matches.add(emoji);
if (matches.size >= max) break; if (matches.size >= max) break;
} }
@ -174,7 +273,7 @@ watch(q, () => {
if (matches.size >= max) return matches; if (matches.size >= max) return matches;
for (const emoji of emojis) { for (const emoji of emojis) {
if (emoji.aliases.some(alias => alias.startsWith(newQ))) { if (emoji.aliases.some((alias) => alias.startsWith(newQ))) {
matches.add(emoji); matches.add(emoji);
if (matches.size >= max) break; if (matches.size >= max) break;
} }
@ -190,7 +289,7 @@ watch(q, () => {
if (matches.size >= max) return matches; if (matches.size >= max) return matches;
for (const emoji of emojis) { for (const emoji of emojis) {
if (emoji.aliases.some(alias => alias.includes(newQ))) { if (emoji.aliases.some((alias) => alias.includes(newQ))) {
matches.add(emoji); matches.add(emoji);
if (matches.size >= max) break; if (matches.size >= max) break;
} }
@ -205,15 +304,16 @@ watch(q, () => {
const emojis = emojilist; const emojis = emojilist;
const matches = new Set<UnicodeEmojiDef>(); const matches = new Set<UnicodeEmojiDef>();
const exactMatch = emojis.find(emoji => emoji.name === newQ); const exactMatch = emojis.find((emoji) => emoji.name === newQ);
if (exactMatch) matches.add(exactMatch); if (exactMatch) matches.add(exactMatch);
if (newQ.includes(' ')) { // AND if (newQ.includes(" ")) {
const keywords = newQ.split(' '); // AND
const keywords = newQ.split(" ");
// //
for (const emoji of emojis) { for (const emoji of emojis) {
if (keywords.every(keyword => emoji.name.includes(keyword))) { if (keywords.every((keyword) => emoji.name.includes(keyword))) {
matches.add(emoji); matches.add(emoji);
if (matches.size >= max) break; if (matches.size >= max) break;
} }
@ -222,7 +322,15 @@ watch(q, () => {
// //
for (const emoji of emojis) { for (const emoji of emojis) {
if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) { if (
keywords.every(
(keyword) =>
emoji.name.includes(keyword) ||
emoji.keywords.some((alias) =>
alias.includes(keyword)
)
)
) {
matches.add(emoji); matches.add(emoji);
if (matches.size >= max) break; if (matches.size >= max) break;
} }
@ -237,7 +345,9 @@ watch(q, () => {
if (matches.size >= max) return matches; if (matches.size >= max) return matches;
for (const emoji of emojis) { for (const emoji of emojis) {
if (emoji.keywords.some(keyword => keyword.startsWith(newQ))) { if (
emoji.keywords.some((keyword) => keyword.startsWith(newQ))
) {
matches.add(emoji); matches.add(emoji);
if (matches.size >= max) break; if (matches.size >= max) break;
} }
@ -253,7 +363,7 @@ watch(q, () => {
if (matches.size >= max) return matches; if (matches.size >= max) return matches;
for (const emoji of emojis) { for (const emoji of emojis) {
if (emoji.keywords.some(keyword => keyword.includes(newQ))) { if (emoji.keywords.some((keyword) => keyword.includes(newQ))) {
matches.add(emoji); matches.add(emoji);
if (matches.size >= max) break; if (matches.size >= max) break;
} }
@ -268,7 +378,7 @@ watch(q, () => {
}); });
function focus() { function focus() {
if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) { if (!["smartphone", "tablet"].includes(deviceKind) && !isTouchUsing) {
search.value?.focus({ search.value?.focus({
preventScroll: true, preventScroll: true,
}); });
@ -277,36 +387,40 @@ function focus() {
function reset() { function reset() {
if (emojis.value) emojis.value.scrollTop = 0; if (emojis.value) emojis.value.scrollTop = 0;
q.value = ''; q.value = "";
} }
function getKey(emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef): string { function getKey(
return typeof emoji === 'string' ? emoji : (emoji.char || `:${emoji.name}:`); emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef
): string {
return typeof emoji === "string" ? emoji : emoji.char || `:${emoji.name}:`;
} }
function chosen(emoji: any, ev?: MouseEvent) { function chosen(emoji: any, ev?: MouseEvent) {
const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined; const el =
ev &&
((ev.currentTarget ?? ev.target) as HTMLElement | null | undefined);
if (el) { if (el) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2); const x = rect.left + el.offsetWidth / 2;
const y = rect.top + (el.offsetHeight / 2); const y = rect.top + el.offsetHeight / 2;
os.popup(Ripple, { x, y }, {}, 'end'); os.popup(Ripple, { x, y }, {}, "end");
} }
const key = getKey(emoji); const key = getKey(emoji);
emit('chosen', key); emit("chosen", key);
// 使 // 使
if (!pinned.value.includes(key)) { if (!pinned.value.includes(key)) {
let recents = defaultStore.state.recentlyUsedEmojis; let recents = defaultStore.state.recentlyUsedEmojis;
recents = recents.filter((emoji: any) => emoji !== key); recents = recents.filter((emoji: any) => emoji !== key);
recents.unshift(key); recents.unshift(key);
defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); defaultStore.set("recentlyUsedEmojis", recents.splice(0, 32));
} }
} }
function paste(event: ClipboardEvent) { function paste(event: ClipboardEvent) {
const paste = (event.clipboardData || window.clipboardData).getData('text'); const paste = (event.clipboardData || window.clipboardData).getData("text");
if (done(paste)) { if (done(paste)) {
event.preventDefault(); event.preventDefault();
} }
@ -314,15 +428,17 @@ function paste(event: ClipboardEvent) {
function done(query?: any): boolean | void { function done(query?: any): boolean | void {
if (query == null) query = q.value; if (query == null) query = q.value;
if (query == null || typeof query !== 'string') return; if (query == null || typeof query !== "string") return;
const q2 = query.replaceAll(':', ''); const q2 = query.replaceAll(":", "");
const exactMatchCustom = customEmojis.find(emoji => emoji.name === q2); const exactMatchCustom = customEmojis.find((emoji) => emoji.name === q2);
if (exactMatchCustom) { if (exactMatchCustom) {
chosen(exactMatchCustom); chosen(exactMatchCustom);
return true; return true;
} }
const exactMatchUnicode = emojilist.find(emoji => emoji.char === q2 || emoji.name === q2); const exactMatchUnicode = emojilist.find(
(emoji) => emoji.char === q2 || emoji.name === q2
);
if (exactMatchUnicode) { if (exactMatchUnicode) {
chosen(exactMatchUnicode); chosen(exactMatchUnicode);
return true; return true;
@ -543,7 +659,7 @@ defineExpose({
> .emoji { > .emoji {
height: 1.25em; height: 1.25em;
vertical-align: -.25em; vertical-align: -0.25em;
pointer-events: none; pointer-events: none;
} }
} }

View File

@ -1,58 +1,66 @@
<template> <template>
<MkModal <MkModal
ref="modal" ref="modal"
v-slot="{ type, maxHeight }" v-slot="{ type, maxHeight }"
:z-priority="'middle'" :z-priority="'middle'"
:prefer-type="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'" :prefer-type="
:transparent-bg="true" asReactionPicker &&
:manual-showing="manualShowing" defaultStore.state.reactionPickerUseDrawerForMobile === false
:src="src" ? 'popup'
@click="modal?.close()" : 'auto'
@opening="opening" "
@close="emit('close')" :transparent-bg="true"
@closed="emit('closed')" :manual-showing="manualShowing"
> :src="src"
<MkEmojiPicker @click="modal?.close()"
ref="picker" @opening="opening"
class="ryghynhb _popup _shadow" @close="emit('close')"
:class="{ drawer: type === 'drawer' }" @closed="emit('closed')"
:show-pinned="showPinned" >
:as-reaction-picker="asReactionPicker" <MkEmojiPicker
:as-drawer="type === 'drawer'" ref="picker"
:max-height="maxHeight" class="ryghynhb _popup _shadow"
@chosen="chosen" :class="{ drawer: type === 'drawer' }"
/> :show-pinned="showPinned"
</MkModal> :as-reaction-picker="asReactionPicker"
:as-drawer="type === 'drawer'"
:max-height="maxHeight"
@chosen="chosen"
/>
</MkModal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from "vue";
import MkModal from '@/components/MkModal.vue'; import MkModal from "@/components/MkModal.vue";
import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; import MkEmojiPicker from "@/components/MkEmojiPicker.vue";
import { defaultStore } from '@/store'; import { defaultStore } from "@/store";
withDefaults(defineProps<{ withDefaults(
manualShowing?: boolean | null; defineProps<{
src?: HTMLElement; manualShowing?: boolean | null;
showPinned?: boolean; src?: HTMLElement;
asReactionPicker?: boolean; showPinned?: boolean;
}>(), { asReactionPicker?: boolean;
manualShowing: null, }>(),
showPinned: true, {
asReactionPicker: false, manualShowing: null,
}); showPinned: true,
asReactionPicker: false,
}
);
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', v: any): void; (ev: "done", v: any): void;
(ev: 'close'): void; (ev: "close"): void;
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
const modal = ref<InstanceType<typeof MkModal>>(); const modal = ref<InstanceType<typeof MkModal>>();
const picker = ref<InstanceType<typeof MkEmojiPicker>>(); const picker = ref<InstanceType<typeof MkEmojiPicker>>();
function chosen(emoji: any) { function chosen(emoji: any) {
emit('done', emoji); emit("done", emoji);
modal.value?.close(); modal.value?.close();
} }

View File

@ -1,15 +1,19 @@
<template> <template>
<div v-if="meta" class="xfbouadm" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div> <div
v-if="meta"
class="xfbouadm"
:style="{ backgroundImage: `url(${meta.backgroundImageUrl})` }"
></div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from "vue";
import * as Misskey from 'calckey-js'; import * as Misskey from "calckey-js";
import * as os from '@/os'; import * as os from "@/os";
const meta = ref<Misskey.entities.DetailedInstanceMetadata>(); const meta = ref<Misskey.entities.DetailedInstanceMetadata>();
os.api('meta', { detail: true }).then(gotMeta => { os.api("meta", { detail: true }).then((gotMeta) => {
meta.value = gotMeta; meta.value = gotMeta;
}); });
</script> </script>

View File

@ -1,56 +1,80 @@
<template> <template>
<div> <div>
<MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }"> <MkPagination
<MkA v-slot="{ items }"
v-for="file in items" :pagination="pagination"
:key="file.id" class="urempief"
v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${new Date(file.createdAt).toLocaleString()}\nby ${file.user ? '@' + Acct.toString(file.user) : 'system'}`" :class="{ grid: viewMode === 'grid' }"
:to="`/admin/file/${file.id}`"
class="file _button"
> >
<div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div> <MkA
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> v-for="file in items"
<div v-if="viewMode === 'list'" class="body"> :key="file.id"
<div> v-tooltip.mfm="
<small style="opacity: 0.7;">{{ file.name }}</small> `${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> </div>
<div> <MkDriveFileThumbnail
<MkAcct v-if="file.user" :user="file.user"/> class="thumbnail"
<div v-else>{{ i18n.ts.system }}</div> :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> </div>
<div> </MkA>
<span style="margin-right: 1em;">{{ file.type }}</span> </MkPagination>
<span>{{ bytes(file.size) }}</span> </div>
</div>
<div>
<span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
</div>
</div>
</MkA>
</MkPagination>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from "vue";
import * as Acct from 'calckey-js/built/acct'; import * as Acct from "calckey-js/built/acct";
import MkSwitch from '@/components/ui/switch.vue'; import MkSwitch from "@/components/ui/switch.vue";
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from "@/components/MkPagination.vue";
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import MkDriveFileThumbnail from "@/components/MkDriveFileThumbnail.vue";
import bytes from '@/filters/bytes'; import bytes from "@/filters/bytes";
import * as os from '@/os'; import * as os from "@/os";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
pagination: any; pagination: any;
viewMode: 'grid' | 'list'; viewMode: "grid" | "list";
}>(); }>();
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@keyframes sensitive-blink { @keyframes sensitive-blink {
0% { opacity: 1; } 0% {
50% { opacity: 0; } opacity: 1;
}
50% {
opacity: 0;
}
} }
.urempief { .urempief {

View File

@ -1,15 +1,17 @@
<template> <template>
<span class="mk-file-type-icon"> <span class="mk-file-type-icon">
<template v-if="kind == 'image'"><i class="ph-file-image ph-bold ph-lg"></i></template> <template v-if="kind == 'image'"
</span> ><i class="ph-file-image ph-bold ph-lg"></i
></template>
</span>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from "vue";
const props = defineProps<{ const props = defineProps<{
type: string; type: string;
}>(); }>();
const kind = computed(() => props.type.split('/')[0]); const kind = computed(() => props.type.split("/")[0]);
</script> </script>

View File

@ -1,33 +1,40 @@
<template> <template>
<div v-size="{ max: [500] }" class="ssazuxis"> <div v-size="{ max: [500] }" class="ssazuxis">
<header class="_button" :style="{ background: bg }" @click="showBody = !showBody"> <header
<div class="title"><slot name="header"></slot></div> class="_button"
<div class="divider"></div> :style="{ background: bg }"
<button class="_button"> @click="showBody = !showBody"
<template v-if="showBody"><i class="ph-caret-up ph-bold ph-lg"></i></template> >
<template v-else><i class="ph-caret-down ph-bold ph-lg"></i></template> <div class="title"><slot name="header"></slot></div>
</button> <div class="divider"></div>
</header> <button class="_button">
<transition <template v-if="showBody"
:name="$store.state.animation ? 'folder-toggle' : ''" ><i class="ph-caret-up ph-bold ph-lg"></i
@enter="enter" ></template>
@after-enter="afterEnter" <template v-else
@leave="leave" ><i class="ph-caret-down ph-bold ph-lg"></i
@after-leave="afterLeave" ></template>
> </button>
<div v-show="showBody"> </header>
<slot></slot> <transition
</div> :name="$store.state.animation ? 'folder-toggle' : ''"
</transition> @enter="enter"
</div> @after-enter="afterEnter"
@leave="leave"
@after-leave="afterLeave"
>
<div v-show="showBody">
<slot></slot>
</div>
</transition>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from "vue";
import tinycolor from 'tinycolor2'; import tinycolor from "tinycolor2";
const localStoragePrefix = 'ui:folder:';
const localStoragePrefix = "ui:folder:";
export default defineComponent({ export default defineComponent({
props: { props: {
@ -45,19 +52,28 @@ export default defineComponent({
data() { data() {
return { return {
bg: null, bg: null,
showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded, showBody:
this.persistKey &&
localStorage.getItem(localStoragePrefix + this.persistKey)
? localStorage.getItem(
localStoragePrefix + this.persistKey
) === "t"
: this.expanded,
}; };
}, },
watch: { watch: {
showBody() { showBody() {
if (this.persistKey) { if (this.persistKey) {
localStorage.setItem(localStoragePrefix + this.persistKey, this.showBody ? 't' : 'f'); localStorage.setItem(
localStoragePrefix + this.persistKey,
this.showBody ? "t" : "f"
);
} }
}, },
}, },
mounted() { mounted() {
function getParentBg(el: Element | null): string { function getParentBg(el: Element | null): string {
if (el == null || el.tagName === 'BODY') return 'var(--bg)'; if (el == null || el.tagName === "BODY") return "var(--bg)";
const bg = el.style.background || el.style.backgroundColor; const bg = el.style.background || el.style.backgroundColor;
if (bg) { if (bg) {
return bg; return bg;
@ -66,7 +82,13 @@ export default defineComponent({
} }
} }
const rawBg = getParentBg(this.$el); const rawBg = getParentBg(this.$el);
const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); const bg = tinycolor(
rawBg.startsWith("var(")
? getComputedStyle(document.documentElement).getPropertyValue(
rawBg.slice(4, -1)
)
: rawBg
);
bg.setAlpha(0.85); bg.setAlpha(0.85);
this.bg = bg.toRgbString(); this.bg = bg.toRgbString();
}, },
@ -79,14 +101,14 @@ export default defineComponent({
const elementHeight = el.getBoundingClientRect().height; const elementHeight = el.getBoundingClientRect().height;
el.style.height = 0; el.style.height = 0;
el.offsetHeight; // reflow el.offsetHeight; // reflow
el.style.height = elementHeight + 'px'; el.style.height = elementHeight + "px";
}, },
afterEnter(el) { afterEnter(el) {
el.style.height = null; el.style.height = null;
}, },
leave(el) { leave(el) {
const elementHeight = el.getBoundingClientRect().height; const elementHeight = el.getBoundingClientRect().height;
el.style.height = elementHeight + 'px'; el.style.height = elementHeight + "px";
el.offsetHeight; // reflow el.offsetHeight; // reflow
el.style.height = 0; el.style.height = 0;
}, },
@ -98,7 +120,8 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.folder-toggle-enter-active, .folder-toggle-leave-active { .folder-toggle-enter-active,
.folder-toggle-leave-active {
overflow-y: hidden; overflow-y: hidden;
transition: opacity 0.5s, height 0.5s !important; transition: opacity 0.5s, height 0.5s !important;
} }

View File

@ -1,72 +1,87 @@
<template> <template>
<button <button
class="kpoogebi _button" class="kpoogebi _button"
:class="{ :class="{
wait, wait,
active: isFollowing || hasPendingFollowRequestFromYou, active: isFollowing || hasPendingFollowRequestFromYou,
full, full,
large, large,
blocking: isBlocking blocking: isBlocking,
}" }"
:disabled="wait" :disabled="wait"
@click="onClick" @click="onClick"
> >
<template v-if="!wait"> <template v-if="!wait">
<template v-if="isBlocking"> <template v-if="isBlocking">
<span v-if="full">{{ i18n.ts.blocked }}</span><i class="ph-prohibit ph-bold ph-lg"></i> <span v-if="full">{{ i18n.ts.blocked }}</span
><i class="ph-prohibit ph-bold ph-lg"></i>
</template>
<template
v-else-if="hasPendingFollowRequestFromYou && user.isLocked"
>
<span v-if="full">{{ i18n.ts.followRequestPending }}</span
><i class="ph-hourglass-medium ph-bold ph-lg"></i>
</template>
<template
v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"
>
<!-- つまりリモートフォローの場合 -->
<span v-if="full">{{ i18n.ts.processing }}</span
><i class="ph-circle-notch ph-bold ph-lg fa-pulse"></i>
</template>
<template v-else-if="isFollowing">
<span v-if="full">{{ i18n.ts.unfollow }}</span
><i class="ph-minus ph-bold ph-lg"></i>
</template>
<template v-else-if="!isFollowing && user.isLocked">
<span v-if="full">{{ i18n.ts.followRequest }}</span
><i class="ph-plus ph-bold ph-lg"></i>
</template>
<template v-else-if="!isFollowing && !user.isLocked">
<span v-if="full">{{ i18n.ts.follow }}</span
><i class="ph-plus ph-bold ph-lg"></i>
</template>
</template> </template>
<template v-else-if="hasPendingFollowRequestFromYou && user.isLocked"> <template v-else>
<span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="ph-hourglass-medium ph-bold ph-lg"></i> <span v-if="full">{{ i18n.ts.processing }}</span
><i class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"></i>
</template> </template>
<template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"> </button>
<!-- つまりリモートフォローの場合 -->
<span v-if="full">{{ i18n.ts.processing }}</span><i class="ph-circle-notch ph-bold ph-lg fa-pulse"></i>
</template>
<template v-else-if="isFollowing">
<span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ph-minus ph-bold ph-lg"></i>
</template>
<template v-else-if="!isFollowing && user.isLocked">
<span v-if="full">{{ i18n.ts.followRequest }}</span><i class="ph-plus ph-bold ph-lg"></i>
</template>
<template v-else-if="!isFollowing && !user.isLocked">
<span v-if="full">{{ i18n.ts.follow }}</span><i class="ph-plus ph-bold ph-lg"></i>
</template>
</template>
<template v-else>
<span v-if="full">{{ i18n.ts.processing }}</span><i class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"></i>
</template>
</button>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted } from 'vue'; import { computed, onBeforeUnmount, onMounted } from "vue";
import type * as Misskey from 'calckey-js'; import type * as Misskey from "calckey-js";
import * as os from '@/os'; import * as os from "@/os";
import { stream } from '@/stream'; import { stream } from "@/stream";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const emit = defineEmits(['refresh']) const emit = defineEmits(["refresh"]);
const props = withDefaults(defineProps<{ const props = withDefaults(
user: Misskey.entities.UserDetailed, defineProps<{
full?: boolean, user: Misskey.entities.UserDetailed;
large?: boolean, full?: boolean;
}>(), { large?: boolean;
full: false, }>(),
large: false, {
}); full: false,
large: false,
}
);
const isBlocking = computed(() => props.user.isBlocking); const isBlocking = computed(() => props.user.isBlocking);
let isFollowing = $ref(props.user.isFollowing); let isFollowing = $ref(props.user.isFollowing);
let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou); let hasPendingFollowRequestFromYou = $ref(
props.user.hasPendingFollowRequestFromYou
);
let wait = $ref(false); let wait = $ref(false);
const connection = stream.useChannel('main'); const connection = stream.useChannel("main");
if (props.user.isFollowing == null) { if (props.user.isFollowing == null) {
os.api('users/show', { os.api("users/show", {
userId: props.user.id, userId: props.user.id,
}) }).then(onFollowChange);
.then(onFollowChange);
} }
function onFollowChange(user: Misskey.entities.UserDetailed) { function onFollowChange(user: Misskey.entities.UserDetailed) {
@ -82,40 +97,41 @@ async function onClick() {
try { try {
if (isBlocking.value) { if (isBlocking.value) {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: "warning",
text: i18n.t('unblockConfirm'), text: i18n.t("unblockConfirm"),
}); });
if (canceled) return if (canceled) return;
await os.api("blocking/delete", { await os.api("blocking/delete", {
userId: props.user.id, userId: props.user.id,
}) });
if (props.user.isMuted) { if (props.user.isMuted) {
await os.api("mute/delete", { await os.api("mute/delete", {
userId: props.user.id, userId: props.user.id,
}) });
} }
emit('refresh') emit("refresh");
} } else if (isFollowing) {
else if (isFollowing) {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: "warning",
text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }), text: i18n.t("unfollowConfirm", {
name: props.user.name || props.user.username,
}),
}); });
if (canceled) return; if (canceled) return;
await os.api('following/delete', { await os.api("following/delete", {
userId: props.user.id, userId: props.user.id,
}); });
} else { } else {
if (hasPendingFollowRequestFromYou) { if (hasPendingFollowRequestFromYou) {
await os.api('following/requests/cancel', { await os.api("following/requests/cancel", {
userId: props.user.id, userId: props.user.id,
}); });
hasPendingFollowRequestFromYou = false; hasPendingFollowRequestFromYou = false;
} else { } else {
await os.api('following/create', { await os.api("following/create", {
userId: props.user.id, userId: props.user.id,
}); });
hasPendingFollowRequestFromYou = true; hasPendingFollowRequestFromYou = true;
@ -129,8 +145,8 @@ async function onClick() {
} }
onMounted(() => { onMounted(() => {
connection.on('follow', onFollowChange); connection.on("follow", onFollowChange);
connection.on('unfollow', onFollowChange); connection.on("unfollow", onFollowChange);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {

View File

@ -1,63 +1,93 @@
<template> <template>
<XModalWindow ref="dialog" <XModalWindow
:width="370" ref="dialog"
:height="400" :width="370"
@close="dialog.close()" :height="400"
@closed="emit('closed')" @close="dialog.close()"
> @closed="emit('closed')"
<template #header>{{ i18n.ts.forgotPassword }}</template> >
<template #header>{{ i18n.ts.forgotPassword }}</template>
<form v-if="instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit"> <form
<div class="main _formRoot"> v-if="instance.enableEmail"
<MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required> class="bafeceda"
<template #label>{{ i18n.ts.username }}</template> @submit.prevent="onSubmit"
<template #prefix>@</template> >
</MkInput> <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> <MkInput
<template #label>{{ i18n.ts.emailAddress }}</template> v-model="email"
<template #caption>{{ i18n.ts._forgotPassword.enterEmail }}</template> class="_formBlock"
</MkInput> 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> <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> </div>
<div class="sub"> </XModalWindow>
<MkA to="/about" class="_link">{{ i18n.ts._forgotPassword.ifNoEmail }}</MkA>
</div>
</form>
<div v-else class="bafecedb">
{{ i18n.ts._forgotPassword.contactAdmin }}
</div>
</XModalWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import XModalWindow from '@/components/MkModalWindow.vue'; import XModalWindow from "@/components/MkModalWindow.vue";
import MkButton from '@/components/MkButton.vue'; import MkButton from "@/components/MkButton.vue";
import MkInput from '@/components/form/input.vue'; import MkInput from "@/components/form/input.vue";
import * as os from '@/os'; import * as os from "@/os";
import { instance } from '@/instance'; import { instance } from "@/instance";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done'): void; (ev: "done"): void;
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
let dialog: InstanceType<typeof XModalWindow> = $ref(); let dialog: InstanceType<typeof XModalWindow> = $ref();
let username = $ref(''); let username = $ref("");
let email = $ref(''); let email = $ref("");
let processing = $ref(false); let processing = $ref(false);
async function onSubmit() { async function onSubmit() {
processing = true; processing = true;
await os.apiWithDialog('request-reset-password', { await os.apiWithDialog("request-reset-password", {
username, username,
email, email,
}); });
emit('done'); emit("done");
dialog.close(); dialog.close();
} }
</script> </script>

View File

@ -1,70 +1,170 @@
<template> <template>
<XModalWindow <XModalWindow
ref="dialog" ref="dialog"
:width="450" :width="450"
:can-close="false" :can-close="false"
:with-ok-button="true" :with-ok-button="true"
:ok-button-disabled="false" :ok-button-disabled="false"
@click="cancel()" @click="cancel()"
@ok="ok()" @ok="ok()"
@close="cancel()" @close="cancel()"
@closed="$emit('closed')" @closed="$emit('closed')"
> >
<template #header> <template #header>
{{ title }} {{ title }}
</template> </template>
<MkSpacer :margin-min="20" :margin-max="32"> <MkSpacer :margin-min="20" :margin-max="32">
<div class="xkpnjxcv _formRoot"> <div class="xkpnjxcv _formRoot">
<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)"> <template
<FormInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1" class="_formBlock"> v-for="item in Object.keys(form).filter(
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> (item) => !form[item].hidden
<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"> <FormInput
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> v-if="form[item].type === 'number'"
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> v-model="values[item]"
</FormInput> type="number"
<FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" class="_formBlock"> :step="form[item].step || 1"
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> class="_formBlock"
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> >
</FormTextarea> <template #label
<FormSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]" class="_formBlock"> ><span v-text="form[item].label || item"></span
<span v-text="form[item].label || item"></span> ><span v-if="form[item].required === false">
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> ({{ i18n.ts.optional }})</span
</FormSwitch> ></template
<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"> ({{ i18n.ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{
<option v-for="item in form[item].enum" :key="item.value" :value="item.value">{{ item.label }}</option> form[item].description
</FormSelect> }}</template>
<FormRadios v-else-if="form[item].type === 'radio'" v-model="values[item]" class="_formBlock"> </FormInput>
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <FormInput
<option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option> v-else-if="
</FormRadios> form[item].type === 'string' &&
<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"> !form[item].multiline
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> "
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> v-model="values[item]"
</FormRange> type="text"
<MkButton v-else-if="form[item].type === 'button'" class="_formBlock" @click="form[item].action($event, values)"> class="_formBlock"
<span v-text="form[item].content || item"></span> >
</MkButton> <template #label
</template> ><span v-text="form[item].label || item"></span
</div> ><span v-if="form[item].required === false">
</MkSpacer> ({{ i18n.ts.optional }})</span
</XModalWindow> ></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">
({{ i18n.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">
({{ i18n.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">
({{ i18n.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">
({{ i18n.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> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from "vue";
import FormInput from './form/input.vue'; import FormInput from "./form/input.vue";
import FormTextarea from './form/textarea.vue'; import FormTextarea from "./form/textarea.vue";
import FormSwitch from './form/switch.vue'; import FormSwitch from "./form/switch.vue";
import FormSelect from './form/select.vue'; import FormSelect from "./form/select.vue";
import FormRange from './form/range.vue'; import FormRange from "./form/range.vue";
import MkButton from './MkButton.vue'; import MkButton from "./MkButton.vue";
import FormRadios from './form/radios.vue'; import FormRadios from "./form/radios.vue";
import XModalWindow from '@/components/MkModalWindow.vue'; import XModalWindow from "@/components/MkModalWindow.vue";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
export default defineComponent({ export default defineComponent({
components: { components: {
@ -89,7 +189,7 @@ export default defineComponent({
}, },
}, },
emits: ['done'], emits: ["done"],
data() { data() {
return { return {
@ -106,14 +206,14 @@ export default defineComponent({
methods: { methods: {
ok() { ok() {
this.$emit('done', { this.$emit("done", {
result: this.values, result: this.values,
}); });
this.$refs.dialog.close(); this.$refs.dialog.close();
}, },
cancel() { cancel() {
this.$emit('done', { this.$emit("done", {
canceled: true, canceled: true,
}); });
this.$refs.dialog.close(); this.$refs.dialog.close();
@ -124,6 +224,5 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
.xkpnjxcv { .xkpnjxcv {
} }
</style> </style>

View File

@ -1,14 +1,16 @@
<template> <template>
<XFormula :formula="formula" :block="block"/> <XFormula :formula="formula" :block="block" />
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue'; import { defineComponent, defineAsyncComponent } from "vue";
import * as os from '@/os'; import * as os from "@/os";
export default defineComponent({ export default defineComponent({
components: { components: {
XFormula: defineAsyncComponent(() => import('@/components/MkFormulaCore.vue')), XFormula: defineAsyncComponent(
() => import("@/components/MkFormulaCore.vue")
),
}, },
props: { props: {
formula: { formula: {

View File

@ -1,30 +1,30 @@
<template> <template>
<div v-if="block" v-html="compiledFormula"></div> <div v-if="block" v-html="compiledFormula"></div>
<span v-else v-html="compiledFormula"></span> <span v-else v-html="compiledFormula"></span>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from "vue";
import katex from 'katex'; import katex from "katex";
export default defineComponent({ export default defineComponent({
props: { props: {
formula: { formula: {
type: String, type: String,
required: true required: true,
}, },
block: { block: {
type: Boolean, type: Boolean,
required: true required: true,
} },
}, },
computed: { computed: {
compiledFormula(): any { compiledFormula(): any {
return katex.renderToString(this.formula, { return katex.renderToString(this.formula, {
throwOnError: false throwOnError: false,
} as any); } as any);
} },
} },
}); });
</script> </script>

View File

@ -1,27 +1,31 @@
<template> <template>
<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1"> <MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1">
<div class="thumbnail"> <div class="thumbnail">
<ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/> <ImgWithBlurhash
</div> class="img"
<article> :src="post.files[0].thumbnailUrl"
<header> :hash="post.files[0].blurhash"
<MkAvatar :user="post.user" class="avatar"/> />
</header> </div>
<footer> <article>
<span class="title">{{ post.title }}</span> <header>
</footer> <MkAvatar :user="post.user" class="avatar" />
</article> </header>
</MkA> <footer>
<span class="title">{{ post.title }}</span>
</footer>
</article>
</MkA>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import { userName } from '@/filters/user'; import { userName } from "@/filters/user";
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import ImgWithBlurhash from "@/components/MkImgWithBlurhash.vue";
const props = defineProps<{ const props = defineProps<{
post: any; post: any;
}>(); }>();
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -1,13 +1,16 @@
<template> <template>
<div class="mk-google"> <div class="mk-google">
<input v-model="query" type="search" :placeholder="q"> <input v-model="query" type="search" :placeholder="q" />
<button @click="search"><i class="ph-magnifying-glass ph-bold ph-lg"></i> {{ i18n.ts.searchByGoogle }}</button> <button @click="search">
</div> <i class="ph-magnifying-glass ph-bold ph-lg"></i>
{{ i18n.ts.searchByGoogle }}
</button>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from "vue";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
q: string; q: string;
@ -16,7 +19,10 @@ const props = defineProps<{
const query = ref(props.q); const query = ref(props.q);
const search = () => { const search = () => {
window.open(`https://search.annoyingorange.xyz/search?q=${query.value}`, '_blank'); window.open(
`https://search.annoyingorange.xyz/search?q=${query.value}`,
"_blank"
);
}; };
</script> </script>

View File

@ -1,23 +1,30 @@
<template> <template>
<div ref="rootEl"> <div ref="rootEl">
<MkLoading v-if="fetching"/> <MkLoading v-if="fetching" />
<div v-else> <div v-else>
<canvas ref="chartEl"></canvas> <canvas ref="chartEl"></canvas>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; import {
import { Chart } from 'chart.js'; markRaw,
import tinycolor from 'tinycolor2'; version as vueVersion,
import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; onMounted,
import * as os from '@/os'; onBeforeUnmount,
import { defaultStore } from '@/store'; nextTick,
import { useChartTooltip } from '@/scripts/use-chart-tooltip'; watch,
import { chartVLine } from '@/scripts/chart-vline'; } from "vue";
import { alpha } from '@/scripts/color'; import { Chart } from "chart.js";
import { initChart } from '@/scripts/init-chart'; import tinycolor from "tinycolor2";
import { MatrixController, MatrixElement } from "chartjs-chart-matrix";
import * as os from "@/os";
import { defaultStore } from "@/store";
import { useChartTooltip } from "@/scripts/use-chart-tooltip";
import { chartVLine } from "@/scripts/chart-vline";
import { alpha } from "@/scripts/color";
import { initChart } from "@/scripts/init-chart";
initChart(); initChart();
@ -32,7 +39,7 @@ let chartInstance: Chart = null;
let fetching = $ref(true); let fetching = $ref(true);
const { handler: externalTooltipHandler } = useChartTooltip({ const { handler: externalTooltipHandler } = useChartTooltip({
position: 'middle', position: "middle",
}); });
async function renderChart() { async function renderChart() {
@ -57,7 +64,9 @@ async function renderChart() {
const format = (arr) => { const format = (arr) => {
return arr.map((v, i) => { return arr.map((v, i) => {
const dt = getDate(i); const dt = getDate(i);
const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`; const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1)
.toString()
.padStart(2, "0")}-${dt.getDate().toString().padStart(2, "0")}`;
return { return {
x: iso, x: iso,
y: dt.getDay(), y: dt.getDay(),
@ -69,20 +78,35 @@ async function renderChart() {
let values; let values;
if (props.src === 'active-users') { if (props.src === "active-users") {
const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); const raw = await os.api("charts/active-users", {
limit: chartLimit,
span: "day",
});
values = raw.readWrite; values = raw.readWrite;
} else if (props.src === 'notes') { } else if (props.src === "notes") {
const raw = await os.api('charts/notes', { limit: chartLimit, span: 'day' }); const raw = await os.api("charts/notes", {
limit: chartLimit,
span: "day",
});
values = raw.local.inc; values = raw.local.inc;
} else if (props.src === 'ap-requests-inbox-received') { } else if (props.src === "ap-requests-inbox-received") {
const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); const raw = await os.api("charts/ap-request", {
limit: chartLimit,
span: "day",
});
values = raw.inboxReceived; values = raw.inboxReceived;
} else if (props.src === 'ap-requests-deliver-succeeded') { } else if (props.src === "ap-requests-deliver-succeeded") {
const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); const raw = await os.api("charts/ap-request", {
limit: chartLimit,
span: "day",
});
values = raw.deliverSucceeded; values = raw.deliverSucceeded;
} else if (props.src === 'ap-requests-deliver-failed') { } else if (props.src === "ap-requests-deliver-failed") {
const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); const raw = await os.api("charts/ap-request", {
limit: chartLimit,
span: "day",
});
values = raw.deliverFailed; values = raw.deliverFailed;
} }
@ -90,43 +114,51 @@ async function renderChart() {
await nextTick(); await nextTick();
const color = defaultStore.state.darkMode ? '#ebbcba' : '#d7827e'; const color = defaultStore.state.darkMode ? "#ebbcba" : "#d7827e";
// 3 // 3
const max = values.slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3; const max =
values
.slice()
.sort((a, b) => b - a)
.slice(0, 3)
.reduce((a, b) => a + b, 0) / 3;
const min = Math.max(0, Math.min(...values) - 1); const min = Math.max(0, Math.min(...values) - 1);
const marginEachCell = 4; const marginEachCell = 4;
chartInstance = new Chart(chartEl, { chartInstance = new Chart(chartEl, {
type: 'matrix', type: "matrix",
data: { data: {
datasets: [{ datasets: [
label: 'Read & Write', {
data: format(values), label: "Read & Write",
pointRadius: 0, data: format(values),
borderWidth: 0, pointRadius: 0,
borderJoinStyle: 'round', borderWidth: 0,
borderRadius: 3, borderJoinStyle: "round",
backgroundColor(c) { borderRadius: 3,
const value = c.dataset.data[c.dataIndex].v; backgroundColor(c) {
let a = (value - min) / max; const value = c.dataset.data[c.dataIndex].v;
if (value !== 0) { // 0 let a = (value - min) / max;
a = Math.max(a, 0.05); if (value !== 0) {
} // 0
return alpha(color, a); a = Math.max(a, 0.05);
}
return alpha(color, a);
},
fill: true,
width(c) {
const a = c.chart.chartArea ?? {};
return (a.right - a.left) / weeks - marginEachCell;
},
height(c) {
const a = c.chart.chartArea ?? {};
return (a.bottom - a.top) / 7 - marginEachCell;
},
}, },
fill: true, ],
width(c) {
const a = c.chart.chartArea ?? {};
return (a.right - a.left) / weeks - marginEachCell;
},
height(c) {
const a = c.chart.chartArea ?? {};
return (a.bottom - a.top) / 7 - marginEachCell;
},
}],
}, },
options: { options: {
aspectRatio: wide ? 6 : narrow ? 1.8 : 3.2, aspectRatio: wide ? 6 : narrow ? 1.8 : 3.2,
@ -140,17 +172,17 @@ async function renderChart() {
}, },
scales: { scales: {
x: { x: {
type: 'time', type: "time",
offset: true, offset: true,
position: 'bottom', position: "bottom",
time: { time: {
unit: 'week', unit: "week",
round: 'week', round: "week",
isoWeekday: 0, isoWeekday: 0,
displayFormats: { displayFormats: {
day: 'M/d', day: "M/d",
month: 'Y/M', month: "Y/M",
week: 'M/d', week: "M/d",
}, },
}, },
grid: { grid: {
@ -165,7 +197,7 @@ async function renderChart() {
y: { y: {
offset: true, offset: true,
reverse: true, reverse: true,
position: 'right', position: "right",
grid: { grid: {
display: false, display: false,
}, },
@ -176,7 +208,8 @@ async function renderChart() {
font: { font: {
size: 9, size: 9,
}, },
callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value], callback: (value, index, values) =>
["", "Mon", "", "Wed", "", "Fri", ""][value],
}, },
}, },
}, },
@ -188,12 +221,13 @@ async function renderChart() {
enabled: false, enabled: false,
callbacks: { callbacks: {
title(context) { title(context) {
const v = context[0].dataset.data[context[0].dataIndex]; const v =
context[0].dataset.data[context[0].dataIndex];
return v.d; return v.d;
}, },
label(context) { label(context) {
const v = context.dataset.data[context.dataIndex]; const v = context.dataset.data[context.dataIndex];
return ['Active: ' + v.v]; return ["Active: " + v.v];
}, },
}, },
//mode: 'index', //mode: 'index',
@ -207,10 +241,13 @@ async function renderChart() {
}); });
} }
watch(() => props.src, () => { watch(
fetching = true; () => props.src,
renderChart(); () => {
}); fetching = true;
renderChart();
}
);
onMounted(async () => { onMounted(async () => {
renderChart(); renderChart();

View File

@ -1,31 +1,46 @@
<template> <template>
<MkModal ref="modal" :z-priority="'middle'" @click="modal.close()" @closed="emit('closed')"> <MkModal
<div class="xubzgfga"> ref="modal"
<header>{{ image.name }}</header> :z-priority="'middle'"
<img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/> @click="modal.close()"
<footer> @closed="emit('closed')"
<span>{{ image.type }}</span> >
<span>{{ bytes(image.size) }}</span> <div class="xubzgfga">
<span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span> <header>{{ image.name }}</header>
</footer> <img
</div> :src="image.url"
</MkModal> :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> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import type * as misskey from 'calckey-js'; import type * as misskey from "calckey-js";
import bytes from '@/filters/bytes'; import bytes from "@/filters/bytes";
import number from '@/filters/number'; import number from "@/filters/number";
import MkModal from '@/components/MkModal.vue'; import MkModal from "@/components/MkModal.vue";
const props = withDefaults(defineProps<{ const props = withDefaults(
image: misskey.entities.DriveFile; defineProps<{
}>(), { image: misskey.entities.DriveFile;
}); }>(),
{}
);
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
const modal = $ref<InstanceType<typeof MkModal>>(); const modal = $ref<InstanceType<typeof MkModal>>();

View File

@ -1,30 +1,46 @@
<template> <template>
<div class="xubzgfgb" :class="{ cover }" :title="title"> <div class="xubzgfgb" :class="{ cover }" :title="title">
<canvas v-if="!loaded" ref="canvas" :width="size" :height="size" :title="title"/> <canvas
<img v-if="src" :src="src" :title="title" :type="type" :alt="alt" @load="onLoad"/> v-if="!loaded"
</div> ref="canvas"
:width="size"
:height="size"
:title="title"
/>
<img
v-if="src"
:src="src"
:title="title"
:type="type"
:alt="alt"
@load="onLoad"
/>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from 'vue'; import { onMounted } from "vue";
import { decode } from 'blurhash'; import { decode } from "blurhash";
const props = withDefaults(defineProps<{ const props = withDefaults(
src?: string | null; defineProps<{
hash?: string; src?: string | null;
alt?: string; hash?: string;
type?: string | null; alt?: string;
title?: string | null; type?: string | null;
size?: number; title?: string | null;
cover?: boolean; size?: number;
}>(), { cover?: boolean;
src: null, }>(),
type: null, {
alt: '', src: null,
title: null, type: null,
size: 64, alt: "",
cover: true, title: null,
}); size: 64,
cover: true,
}
);
const canvas = $ref<HTMLCanvasElement>(); const canvas = $ref<HTMLCanvasElement>();
let loaded = $ref(false); let loaded = $ref(false);
@ -32,7 +48,7 @@ let loaded = $ref(false);
function draw() { function draw() {
if (props.hash == null) return; if (props.hash == null) return;
const pixels = decode(props.hash, props.size, props.size); const pixels = decode(props.hash, props.size, props.size);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d");
const imageData = ctx!.createImageData(props.size, props.size); const imageData = ctx!.createImageData(props.size, props.size);
imageData.data.set(pixels); imageData.data.set(pixels);
ctx!.putImageData(imageData, 0, 0); ctx!.putImageData(imageData, 0, 0);

View File

@ -1,13 +1,13 @@
<template> <template>
<div class="fpezltsf" :class="{ warn }"> <div class="fpezltsf" :class="{ warn }">
<i v-if="warn" class="ph-warning ph-bold ph-lg"></i> <i v-if="warn" class="ph-warning ph-bold ph-lg"></i>
<i v-else class="ph-info ph-bold ph-lg"></i> <i v-else class="ph-info ph-bold ph-lg"></i>
<slot></slot> <slot></slot>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
defineProps<{ defineProps<{
warn?: boolean; warn?: boolean;

View File

@ -1,19 +1,32 @@
<template> <template>
<div :class="[$style.root, { yellow: instance.isNotResponding, red: instance.isBlocked, gray: instance.isSuspended }]"> <div
<img class="icon" :src="getInstanceIcon(instance)" alt=""/> :class="[
<div class="body"> $style.root,
<span class="host">{{ instance.name ?? instance.host }}</span> {
<span class="sub _monospace"><b>{{ instance.host }}</b> / {{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</span> 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> </div>
<MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import * as misskey from 'calckey-js'; import * as misskey from "calckey-js";
import MkMiniChart from '@/components/MkMiniChart.vue'; import MkMiniChart from "@/components/MkMiniChart.vue";
import * as os from '@/os'; import * as os from "@/os";
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy'; import { getProxiedImageUrlNullable } from "@/scripts/media-proxy";
const props = defineProps<{ const props = defineProps<{
instance: misskey.entities.Instance; instance: misskey.entities.Instance;
@ -21,14 +34,22 @@ const props = defineProps<{
let chartValues = $ref<number[] | null>(null); let chartValues = $ref<number[] | null>(null);
os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => { os.apiGet("charts/instance", {
host: props.instance.host,
limit: 16 + 1,
span: "day",
}).then((res) => {
// //
res.requests.received.splice(0, 1); res.requests.received.splice(0, 1);
chartValues = res.requests.received; chartValues = res.requests.received;
}); });
function getInstanceIcon(instance): string { function getInstanceIcon(instance): string {
return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png'; return (
getProxiedImageUrlNullable(instance.iconUrl, "preview") ??
getProxiedImageUrlNullable(instance.faviconUrl, "preview") ??
"/client-assets/dummy.png"
);
} }
</script> </script>
@ -86,19 +107,46 @@ function getInstanceIcon(instance): string {
&:global(.yellow) { &:global(.yellow) {
--c: rgb(255 196 0 / 15%); --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-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; background-size: 16px 16px;
} }
&:global(.red) { &:global(.red) {
--c: rgb(255 0 0 / 15%); --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-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; background-size: 16px 16px;
} }
&:global(.gray) { &:global(.gray) {
--c: var(--bg); --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-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; background-size: 16px 16px;
} }
} }

View File

@ -1,87 +1,113 @@
<template> <template>
<XModalWindow <XModalWindow
ref="dialogEl" ref="dialogEl"
:with-ok-button="true" :with-ok-button="true"
:ok-button-disabled="selected == null" :ok-button-disabled="selected == null"
@click="cancel()" @click="cancel()"
@close="cancel()" @close="cancel()"
@ok="ok()" @ok="ok()"
@closed="$emit('closed')" @closed="$emit('closed')"
> >
<template #header>{{ i18n.ts.selectInstance }}</template> <template #header>{{ i18n.ts.selectInstance }}</template>
<div class="mehkoush"> <div class="mehkoush">
<div class="form"> <div class="form">
<MkInput v-model="hostname" :autofocus="true" @update:modelValue="search"> <MkInput
v-model="hostname"
:autofocus="true"
@update:modelValue="search"
>
<template #label>{{ i18n.ts.instance }}</template> <template #label>{{ i18n.ts.instance }}</template>
</MkInput> </MkInput>
</div> </div>
<div v-if="hostname != ''" class="result" :class="{ hit: instances.length > 0 }"> <div
<div v-if="instances.length > 0" class="instances"> v-if="hostname != ''"
<div v-for="item in instances" :key="item.id" class="instance" :class="{ selected: selected && selected.id === item.id }" @click="selected = item" @dblclick="ok()"> class="result"
<div class="body"> :class="{ hit: instances.length > 0 }"
<img class="icon" :src="item.iconUrl ?? '/client-assets/dummy.png'" aria-hidden="true"/> >
<span class="name">{{ item.host }}</span> <div v-if="instances.length > 0" class="instances">
<div
v-for="item in instances"
:key="item.id"
class="instance"
:class="{
selected: selected && selected.id === item.id,
}"
@click="selected = item"
@dblclick="ok()"
>
<div class="body">
<img
class="icon"
:src="
item.iconUrl ?? '/client-assets/dummy.png'
"
aria-hidden="true"
/>
<span class="name">{{ item.host }}</span>
</div>
</div> </div>
</div> </div>
</div> <div v-else class="empty">
<div v-else class="empty"> <span>{{ i18n.ts.noInstances }}</span>
<span>{{ i18n.ts.noInstances }}</span> </div>
</div> </div>
</div> </div>
</div> </XModalWindow>
</XModalWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import MkInput from '@/components/form/input.vue'; import MkInput from "@/components/form/input.vue";
import XModalWindow from '@/components/MkModalWindow.vue'; import XModalWindow from "@/components/MkModalWindow.vue";
import * as os from '@/os'; import * as os from "@/os";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import { Instance } from 'calckey-js/built/entities'; import { Instance } from "calckey-js/built/entities";
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'ok', selected: Instance): void; (ev: "ok", selected: Instance): void;
(ev: 'cancel'): void; (ev: "cancel"): void;
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
let hostname = $ref(''); let hostname = $ref("");
let instances: Instance[] = $ref([]); let instances: Instance[] = $ref([]);
let selected: Instance | null = $ref(null); let selected: Instance | null = $ref(null);
let dialogEl = $ref<InstanceType<typeof XModalWindow>>(); let dialogEl = $ref<InstanceType<typeof XModalWindow>>();
let searchOrderLatch = 0; let searchOrderLatch = 0;
const search = () => { const search = () => {
if (hostname === '') { if (hostname === "") {
instances = []; instances = [];
return; return;
} }
const searchId = ++searchOrderLatch; const searchId = ++searchOrderLatch;
os.api('federation/instances', { os.api("federation/instances", {
host: hostname, host: hostname,
limit: 10, limit: 10,
blocked: false, blocked: false,
suspended: false, suspended: false,
sort: '+pubSub', sort: "+pubSub",
}).then(_instances => { }).then((_instances) => {
if (searchId !== searchOrderLatch) return; if (searchId !== searchOrderLatch) return;
instances = _instances.map(x => ({ instances = _instances.map(
id: x.id, (x) =>
host: x.host, ({
iconUrl: x.iconUrl, id: x.id,
} as Instance)); host: x.host,
iconUrl: x.iconUrl,
} as Instance)
);
}); });
}; };
const ok = () => { const ok = () => {
if (selected == null) return; if (selected == null) return;
emit('ok', selected); emit("ok", selected);
dialogEl?.close(); dialogEl?.close();
}; };
const cancel = () => { const cancel = () => {
emit('cancel'); emit("cancel");
dialogEl?.close(); dialogEl?.close();
}; };
</script> </script>

View File

@ -4,49 +4,82 @@
<template #header>Chart</template> <template #header>Chart</template>
<div :class="$style.chart"> <div :class="$style.chart">
<div class="selects"> <div class="selects">
<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> <MkSelect v-model="chartSrc" style="margin: 0; flex: 1">
<optgroup :label="i18n.ts.federation"> <optgroup :label="i18n.ts.federation">
<option value="federation">{{ i18n.ts._charts.federation }}</option> <option value="federation">
<option value="ap-request">{{ i18n.ts._charts.apRequest }}</option> {{ i18n.ts._charts.federation }}
</option>
<option value="ap-request">
{{ i18n.ts._charts.apRequest }}
</option>
</optgroup> </optgroup>
<optgroup :label="i18n.ts.users"> <optgroup :label="i18n.ts.users">
<option value="users">{{ i18n.ts._charts.usersIncDec }}</option> <option value="users">
<option value="users-total">{{ i18n.ts._charts.usersTotal }}</option> {{ i18n.ts._charts.usersIncDec }}
<option value="active-users">{{ i18n.ts._charts.activeUsers }}</option> </option>
<option value="users-total">
{{ i18n.ts._charts.usersTotal }}
</option>
<option value="active-users">
{{ i18n.ts._charts.activeUsers }}
</option>
</optgroup> </optgroup>
<optgroup :label="i18n.ts.notes"> <optgroup :label="i18n.ts.notes">
<option value="notes">{{ i18n.ts._charts.notesIncDec }}</option> <option value="notes">
<option value="local-notes">{{ i18n.ts._charts.localNotesIncDec }}</option> {{ i18n.ts._charts.notesIncDec }}
<option value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option> </option>
<option value="notes-total">{{ i18n.ts._charts.notesTotal }}</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>
<optgroup :label="i18n.ts.drive"> <optgroup :label="i18n.ts.drive">
<option value="drive-files">{{ i18n.ts._charts.filesIncDec }}</option> <option value="drive-files">
<option value="drive">{{ i18n.ts._charts.storageUsageIncDec }}</option> {{ i18n.ts._charts.filesIncDec }}
</option>
<option value="drive">
{{ i18n.ts._charts.storageUsageIncDec }}
</option>
</optgroup> </optgroup>
</MkSelect> </MkSelect>
<MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px">
<option value="hour">{{ i18n.ts.perHour }}</option> <option value="hour">{{ i18n.ts.perHour }}</option>
<option value="day">{{ i18n.ts.perDay }}</option> <option value="day">{{ i18n.ts.perDay }}</option>
</MkSelect> </MkSelect>
</div> </div>
<div class="chart _panel"> <div class="chart _panel">
<MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="true"></MkChart> <MkChart
:src="chartSrc"
:span="chartSpan"
:limit="chartLimit"
:detailed="true"
></MkChart>
</div> </div>
</div> </div>
</MkFolder> </MkFolder>
<MkFolder class="item"> <MkFolder class="item">
<template #header>Active users heatmap</template> <template #header>Active users heatmap</template>
<MkSelect v-model="heatmapSrc" style="margin: 0 0 12px 0;"> <MkSelect v-model="heatmapSrc" style="margin: 0 0 12px 0">
<option value="active-users">Active users</option> <option value="active-users">Active users</option>
<option value="notes">Notes</option> <option value="notes">Notes</option>
<option value="ap-requests-inbox-received">Fediverse Requests: inboxReceived</option> <option value="ap-requests-inbox-received">
<option value="ap-requests-deliver-succeeded">Fediverse Requests: deliverSucceeded</option> Fediverse Requests: inboxReceived
<option value="ap-requests-deliver-failed">Fediverse Requests: deliverFailed</option> </option>
<option value="ap-requests-deliver-succeeded">
Fediverse Requests: deliverSucceeded
</option>
<option value="ap-requests-deliver-failed">
Fediverse Requests: deliverFailed
</option>
</MkSelect> </MkSelect>
<div class="_panel" :class="$style.heatmap"> <div class="_panel" :class="$style.heatmap">
<MkHeatmap :src="heatmapSrc"/> <MkHeatmap :src="heatmapSrc" />
</div> </div>
</MkFolder> </MkFolder>
@ -69,45 +102,49 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from 'vue'; import { onMounted } from "vue";
import { Chart } from 'chart.js'; import { Chart } from "chart.js";
import MkSelect from '@/components/form/select.vue'; import MkSelect from "@/components/form/select.vue";
import MkChart from '@/components/MkChart.vue'; import MkChart from "@/components/MkChart.vue";
import { useChartTooltip } from '@/scripts/use-chart-tooltip'; import { useChartTooltip } from "@/scripts/use-chart-tooltip";
import * as os from '@/os'; import * as os from "@/os";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import MkHeatmap from '@/components/MkHeatmap.vue'; import MkHeatmap from "@/components/MkHeatmap.vue";
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from "@/components/MkFolder.vue";
import { initChart } from '@/scripts/init-chart'; import { initChart } from "@/scripts/init-chart";
initChart(); initChart();
const chartLimit = 500; const chartLimit = 500;
let chartSpan = $ref<'hour' | 'day'>('hour'); let chartSpan = $ref<"hour" | "day">("hour");
let chartSrc = $ref('active-users'); let chartSrc = $ref("active-users");
let heatmapSrc = $ref('active-users'); let heatmapSrc = $ref("active-users");
let subDoughnutEl = $shallowRef<HTMLCanvasElement>(); let subDoughnutEl = $shallowRef<HTMLCanvasElement>();
let pubDoughnutEl = $shallowRef<HTMLCanvasElement>(); let pubDoughnutEl = $shallowRef<HTMLCanvasElement>();
const { handler: externalTooltipHandler1 } = useChartTooltip({ const { handler: externalTooltipHandler1 } = useChartTooltip({
position: 'middle', position: "middle",
}); });
const { handler: externalTooltipHandler2 } = useChartTooltip({ const { handler: externalTooltipHandler2 } = useChartTooltip({
position: 'middle', position: "middle",
}); });
function createDoughnut(chartEl, tooltip, data) { function createDoughnut(chartEl, tooltip, data) {
const chartInstance = new Chart(chartEl, { const chartInstance = new Chart(chartEl, {
type: 'doughnut', type: "doughnut",
data: { data: {
labels: data.map(x => x.name), labels: data.map((x) => x.name),
datasets: [{ datasets: [
backgroundColor: data.map(x => x.color), {
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'), backgroundColor: data.map((x) => x.color),
borderWidth: 2, borderColor: getComputedStyle(
hoverOffset: 0, document.documentElement
data: data.map(x => x.value), ).getPropertyValue("--panel"),
}], borderWidth: 2,
hoverOffset: 0,
data: data.map((x) => x.value),
},
],
}, },
options: { options: {
maintainAspectRatio: false, maintainAspectRatio: false,
@ -120,7 +157,12 @@ function createDoughnut(chartEl, tooltip, data) {
}, },
}, },
onClick: (ev) => { onClick: (ev) => {
const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; const hit = chartInstance.getElementsAtEventForMode(
ev,
"nearest",
{ intersect: true },
false
)[0];
if (hit && data[hit.index].onClick) { if (hit && data[hit.index].onClick) {
data[hit.index].onClick(); data[hit.index].onClick();
} }
@ -131,7 +173,7 @@ function createDoughnut(chartEl, tooltip, data) {
}, },
tooltip: { tooltip: {
enabled: false, enabled: false,
mode: 'index', mode: "index",
animation: { animation: {
duration: 0, duration: 0,
}, },
@ -145,24 +187,48 @@ function createDoughnut(chartEl, tooltip, data) {
} }
onMounted(() => { onMounted(() => {
os.apiGet('federation/stats', { limit: 30 }).then(fedStats => { os.apiGet("federation/stats", { limit: 30 }).then((fedStats) => {
createDoughnut(subDoughnutEl, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({ createDoughnut(
name: x.host, subDoughnutEl,
color: x.themeColor, externalTooltipHandler1,
value: x.followersCount, fedStats.topSubInstances
onClick: () => { .map((x) => ({
os.pageWindow(`/instance-info/${x.host}`); name: x.host,
}, color: x.themeColor,
})).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowersCount }])); 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 => ({ createDoughnut(
name: x.host, pubDoughnutEl,
color: x.themeColor, externalTooltipHandler2,
value: x.followingCount, fedStats.topPubInstances
onClick: () => { .map((x) => ({
os.pageWindow(`/instance-info/${x.host}`); name: x.host,
}, color: x.themeColor,
})).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowingCount }])); value: x.followingCount,
onClick: () => {
os.pageWindow(`/instance-info/${x.host}`);
},
}))
.concat([
{
name: "(other)",
color: "#80808080",
value: fedStats.otherFollowingCount,
},
])
);
}); });
}); });
</script> </script>
@ -205,7 +271,8 @@ onMounted(() => {
display: flex; display: flex;
gap: 16px; gap: 16px;
> .sub, > .pub { > .sub,
> .pub {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
position: relative; position: relative;

View File

@ -1,41 +1,50 @@
<template> <template>
<div class="hpaizdrt" ref="ticker" :style="bg"> <div class="hpaizdrt" ref="ticker" :style="bg">
<img class="icon" :src="getInstanceIcon(instance)" aria-hidden="true"/> <img class="icon" :src="getInstanceIcon(instance)" aria-hidden="true" />
<span class="name">{{ instance.name }}</span> <span class="name">{{ instance.name }}</span>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { instanceName } from '@/config'; import { instanceName } from "@/config";
import { instance as Instance } from '@/instance'; import { instance as Instance } from "@/instance";
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy'; import { getProxiedImageUrlNullable } from "@/scripts/media-proxy";
const props = defineProps<{ const props = defineProps<{
instance?: { instance?: {
faviconUrl?: string faviconUrl?: string;
name: string name: string;
themeColor?: string themeColor?: string;
} };
}>(); }>();
let ticker = $ref<HTMLElement | null>(null); let ticker = $ref<HTMLElement | null>(null);
// if no instance data is given, this is for the local instance // if no instance data is given, this is for the local instance
const instance = props.instance ?? { const instance = props.instance ?? {
faviconUrl: Instance.iconUrl || Instance.faviconUrl || '/favicon.ico', faviconUrl: Instance.iconUrl || Instance.faviconUrl || "/favicon.ico",
name: instanceName, name: instanceName,
themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content themeColor: (
document.querySelector(
'meta[name="theme-color-orig"]'
) as HTMLMetaElement
)?.content,
}; };
const computedStyle = getComputedStyle(document.documentElement); const computedStyle = getComputedStyle(document.documentElement);
const themeColor = instance.themeColor ?? computedStyle.getPropertyValue('--bg'); const themeColor =
instance.themeColor ?? computedStyle.getPropertyValue("--bg");
const bg = { const bg = {
background: `linear-gradient(90deg, ${themeColor}, ${themeColor}55)`, background: `linear-gradient(90deg, ${themeColor}, ${themeColor}55)`,
}; };
function getInstanceIcon(instance): string { function getInstanceIcon(instance): string {
return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png'; return (
getProxiedImageUrlNullable(instance.iconUrl, "preview") ??
getProxiedImageUrlNullable(instance.faviconUrl, "preview") ??
"/client-assets/dummy.png"
);
} }
</script> </script>
@ -48,10 +57,10 @@ function getInstanceIcon(instance): string {
align-items: center; align-items: center;
height: 1.1em; height: 1.1em;
justify-self: flex-end; justify-self: flex-end;
padding: .2em .4em; padding: 0.2em 0.4em;
padding: .2em .4em; padding: 0.2em 0.4em;
border-radius: 100px; border-radius: 100px;
font-size: .8em; font-size: 0.8em;
text-shadow: 0 2px 2px var(--shadow); text-shadow: 0 2px 2px var(--shadow);
overflow: hidden; overflow: hidden;
.header > .body & { .header > .body & {
@ -80,11 +89,14 @@ function getInstanceIcon(instance): string {
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
text-shadow: -1px -1px 0 var(--bg), 1px -1px 0 var(--bg), -1px 1px 0 var(--bg), 1px 1px 0 var(--bg); text-shadow: -1px -1px 0 var(--bg), 1px -1px 0 var(--bg),
.article > .main &, .header > .body & { -1px 1px 0 var(--bg), 1px 1px 0 var(--bg);
.article > .main &,
.header > .body & {
display: unset; display: unset;
} }
.article > .main &, .header > .body & { .article > .main &,
.header > .body & {
display: unset; display: unset;
} }
} }

View File

@ -1,28 +1,39 @@
<template> <template>
<div class="alqyeyti" :class="{ oneline }"> <div class="alqyeyti" :class="{ oneline }">
<div class="key"> <div class="key">
<slot name="key"></slot> <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="ph-clipboard-text ph-bold"></i>
</button>
</div>
</div> </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="ph-clipboard-text ph-bold"></i></button>
</div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import copyToClipboard from '@/scripts/copy-to-clipboard'; import copyToClipboard from "@/scripts/copy-to-clipboard";
import * as os from '@/os'; import * as os from "@/os";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = withDefaults(defineProps<{ const props = withDefaults(
copy?: string | null; defineProps<{
oneline?: boolean; copy?: string | null;
}>(), { oneline?: boolean;
copy: null, }>(),
oneline: false, {
}); copy: null,
oneline: false,
}
);
const copy_ = () => { const copy_ = () => {
copyToClipboard(props.copy); copyToClipboard(props.copy);

View File

@ -1,61 +1,103 @@
<template> <template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :prefer-type="preferedModalType" :anchor="anchor" :transparent-bg="true" :src="src" @click="modal.close()" @closed="emit('closed')"> <MkModal
<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }"> ref="modal"
<div class="main"> v-slot="{ type, maxHeight }"
<template v-for="item in items"> :prefer-type="preferedModalType"
<button v-if="item.action" v-click-anime class="_button" @click="$event => { item.action($event); close(); }"> :anchor="anchor"
<i class="icon" :class="item.icon"></i> :transparent-bg="true"
<div class="text">{{ item.text }}</div> :src="src"
<span v-if="item.indicate" class="indicator"><i class="ph-circle ph-fill"></i></span> @click="modal.close()"
</button> @closed="emit('closed')"
<MkA v-else v-click-anime :to="item.to" @click.passive="close()"> >
<i class="icon" :class="item.icon"></i> <div
<div class="text">{{ item.text }}</div> class="szkkfdyq _popup _shadow"
<span v-if="item.indicate" class="indicator"><i class="ph-circle ph-fill"></i></span> :class="{ asDrawer: type === 'drawer' }"
</MkA> :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }"
</template> >
<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="ph-circle ph-fill"></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="ph-circle ph-fill"></i
></span>
</MkA>
</template>
</div>
</div> </div>
</div> </MkModal>
</MkModal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import MkModal from '@/components/MkModal.vue'; import MkModal from "@/components/MkModal.vue";
import { navbarItemDef } from '@/navbar'; import { navbarItemDef } from "@/navbar";
import { instanceName } from '@/config'; import { instanceName } from "@/config";
import { defaultStore } from '@/store'; import { defaultStore } from "@/store";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import { deviceKind } from '@/scripts/device-kind'; import { deviceKind } from "@/scripts/device-kind";
import * as os from '@/os'; import * as os from "@/os";
const props = withDefaults(defineProps<{ const props = withDefaults(
src?: HTMLElement; defineProps<{
anchor?: { x: string; y: string; }; src?: HTMLElement;
}>(), { anchor?: { x: string; y: string };
anchor: () => ({ x: 'right', y: 'center' }), }>(),
}); {
anchor: () => ({ x: "right", y: "center" }),
}
);
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'popup' : const preferedModalType =
deviceKind === 'smartphone' ? 'drawer' : deviceKind === "desktop" && props.src != null
'dialog'; ? "popup"
: deviceKind === "smartphone"
? "drawer"
: "dialog";
const modal = $ref<InstanceType<typeof MkModal>>(); const modal = $ref<InstanceType<typeof MkModal>>();
const menu = defaultStore.state.menu; 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 => ({ const items = Object.keys(navbarItemDef)
type: def.to ? 'link' : 'button', .filter((k) => !menu.includes(k))
text: i18n.ts[def.title], .map((k) => navbarItemDef[k])
icon: def.icon, .filter((def) => (def.show == null ? true : def.show))
to: def.to, .map((def) => ({
action: def.action, type: def.to ? "link" : "button",
indicate: def.indicated, text: i18n.ts[def.title],
})); icon: def.icon,
to: def.to,
action: def.action,
indicate: def.indicated,
}));
function close() { function close() {
modal.close(); modal.close();
@ -82,7 +124,8 @@ function close() {
text-align: center; text-align: center;
} }
> .main, > .sub { > .main,
> .sub {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));

View File

@ -1,36 +1,55 @@
<template> <template>
<component :is="self ? 'MkA' : 'a'" ref="el" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target" <component
:title="url" @click.stop :is="self ? 'MkA' : 'a'"
> ref="el"
<slot></slot> class="xlcxczvw _link"
<i v-if="target === '_blank'" class="ph-arrow-square-out ph-bold ph-lg icon"></i> :[attr]="self ? url.substr(local.length) : url"
</component> :rel="rel"
:target="target"
:title="url"
@click.stop
>
<slot></slot>
<i
v-if="target === '_blank'"
class="ph-arrow-square-out ph-bold ph-lg icon"
></i>
</component>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent } from "vue";
import { url as local } from '@/config'; import { url as local } from "@/config";
import { useTooltip } from '@/scripts/use-tooltip'; import { useTooltip } from "@/scripts/use-tooltip";
import * as os from '@/os'; import * as os from "@/os";
const props = withDefaults(defineProps<{ const props = withDefaults(
url: string; defineProps<{
rel?: null | string; url: string;
}>(), { rel?: null | string;
}); }>(),
{}
);
const self = props.url.startsWith(local); const self = props.url.startsWith(local);
const attr = self ? 'to' : 'href'; const attr = self ? "to" : "href";
const target = self ? null : '_blank'; const target = self ? null : "_blank";
const el = $ref(); const el = $ref();
useTooltip($$(el), (showing) => { useTooltip($$(el), (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { os.popup(
showing, defineAsyncComponent(
url: props.url, () => import("@/components/MkUrlPreviewPopup.vue")
source: el, ),
}, {}, 'closed'); {
showing,
url: props.url,
source: el,
},
{},
"closed"
);
}); });
</script> </script>
@ -40,7 +59,7 @@ useTooltip($$(el), (showing) => {
> .icon { > .icon {
padding-left: 2px; padding-left: 2px;
font-size: .9em; font-size: 0.9em;
} }
} }
</style> </style>

View File

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { h, onMounted, onUnmounted, ref, watch } from 'vue'; import { h, onMounted, onUnmounted, ref, watch } from "vue";
export default { export default {
name: 'MarqueeText', name: "MarqueeText",
props: { props: {
duration: { duration: {
type: Number, type: Number,
@ -38,37 +38,35 @@ export default {
calc(); calc();
}); });
onUnmounted(() => { onUnmounted(() => {});
});
return { return {
contentEl, contentEl,
}; };
}, },
render({ render({ $slots, $style, $props: { duration, repeat, paused, reverse } }) {
$slots, $style, $props: { return h("div", { class: [$style.wrap] }, [
duration, repeat, paused, reverse, h(
}, "span",
}) { {
return h('div', { class: [$style.wrap] }, [ ref: "contentEl",
h('span', { class: [paused ? $style.paused : undefined, $style.content],
ref: 'contentEl', },
class: [ Array(repeat).fill(
paused h(
? $style.paused "span",
: undefined, {
$style.content, class: $style.text,
], style: {
}, Array(repeat).fill( animationDirection: reverse
h('span', { ? "reverse"
class: $style.text, : undefined,
style: { },
animationDirection: reverse },
? 'reverse' $slots.default()
: undefined, )
}, )
}, $slots.default()), ),
)),
]); ]);
}, },
}; };
@ -100,7 +98,11 @@ export default {
animation-play-state: paused; animation-play-state: paused;
} }
@keyframes marquee { @keyframes marquee {
0% { transform:translateX(0); } 0% {
100% { transform:translateX(-100%); } transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
} }
</style> </style>

View File

@ -1,70 +1,84 @@
<template> <template>
<div class="mk-media-banner"> <div class="mk-media-banner">
<div v-if="media.isSensitive && hide" class="sensitive" @click="hide = false"> <div
<span class="icon"><i class="ph-warning ph-bold ph-lg"></i></span> v-if="media.isSensitive && hide"
<b>{{ i18n.ts.sensitive }}</b> class="sensitive"
<span>{{ i18n.ts.clickToShow }}</span> @click="hide = false"
</div>
<div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" class="audio">
<VuePlyr
:options="{
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'download',
],
disableContextMenu: false,
}"
> >
<audio <span class="icon"><i class="ph-warning ph-bold ph-lg"></i></span>
ref="audioEl" <b>{{ i18n.ts.sensitive }}</b>
class="audio" <span>{{ i18n.ts.clickToShow }}</span>
:src="media.url" </div>
:title="media.name" <div
controls v-else-if="
preload="metadata" media.type.startsWith('audio') && media.type !== 'audio/midi'
@volumechange="volumechange" "
/> class="audio"
</VuePlyr> >
<VuePlyr
:options="{
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'download',
],
disableContextMenu: false,
}"
>
<audio
ref="audioEl"
class="audio"
:src="media.url"
:title="media.name"
controls
preload="metadata"
@volumechange="volumechange"
/>
</VuePlyr>
</div>
<a
v-else
class="download"
:href="media.url"
:title="media.name"
:download="media.name"
>
<span class="icon"
><i class="ph-download-simple ph-bold ph-lg"></i
></span>
<b>{{ media.name }}</b>
</a>
</div> </div>
<a
v-else class="download"
:href="media.url"
:title="media.name"
:download="media.name"
>
<span class="icon"><i class="ph-download-simple ph-bold ph-lg"></i></span>
<b>{{ media.name }}</b>
</a>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from 'vue'; import { onMounted } from "vue";
import VuePlyr from 'vue-plyr'; import VuePlyr from "vue-plyr";
import type * as misskey from 'calckey-js'; import type * as misskey from "calckey-js";
import { ColdDeviceStorage } from '@/store'; import { ColdDeviceStorage } from "@/store";
import 'vue-plyr/dist/vue-plyr.css'; import "vue-plyr/dist/vue-plyr.css";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = withDefaults(defineProps<{ const props = withDefaults(
media: misskey.entities.DriveFile; defineProps<{
}>(), { media: misskey.entities.DriveFile;
}); }>(),
{}
);
const audioEl = $ref<HTMLAudioElement | null>(); const audioEl = $ref<HTMLAudioElement | null>();
let hide = $ref(true); let hide = $ref(true);
function volumechange() { function volumechange() {
if (audioEl) ColdDeviceStorage.set('mediaVolume', audioEl.volume); if (audioEl) ColdDeviceStorage.set("mediaVolume", audioEl.volume);
} }
onMounted(() => { onMounted(() => {
if (audioEl) audioEl.volume = ColdDeviceStorage.get('mediaVolume'); if (audioEl) audioEl.volume = ColdDeviceStorage.get("mediaVolume");
}); });
</script> </script>
@ -97,7 +111,7 @@ onMounted(() => {
} }
> *:not(:last-child) { > *:not(:last-child) {
margin-right: .2em; margin-right: 0.2em;
} }
> .icon { > .icon {

View File

@ -1,45 +1,81 @@
<template> <template>
<MkModal ref="modal" @click="done(true)" @closed="$emit('closed')"> <MkModal ref="modal" @click="done(true)" @closed="$emit('closed')">
<div class="container"> <div class="container">
<div class="fullwidth top-caption"> <div class="fullwidth top-caption">
<div class="mk-dialog"> <div class="mk-dialog">
<header> <header>
<Mfm v-if="title" class="title" :text="title"/> <Mfm v-if="title" class="title" :text="title" />
<span class="text-count" :class="{ over: remainingLength < 0 }">{{ remainingLength }}</span> <span
<br/> class="text-count"
</header> :class="{ over: remainingLength < 0 }"
<textarea id="captioninput" v-model="inputValue" autofocus :placeholder="input.placeholder" @keydown="onInputKeydown"></textarea> >{{ remainingLength }}</span
<div v-if="(showOkButton || showCaptionButton || showCancelButton)" class="buttons"> >
<MkButton inline primary :disabled="remainingLength < 0" @click="ok">{{ i18n.ts.ok }}</MkButton> <br />
<MkButton inline @click="caption">{{ i18n.ts.caption }}</MkButton> </header>
<MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton> <textarea
id="captioninput"
v-model="inputValue"
autofocus
:placeholder="input.placeholder"
@keydown="onInputKeydown"
></textarea>
<div
v-if="
showOkButton ||
showCaptionButton ||
showCancelButton
"
class="buttons"
>
<MkButton
inline
primary
:disabled="remainingLength < 0"
@click="ok"
>{{ i18n.ts.ok }}</MkButton
>
<MkButton inline @click="caption">{{
i18n.ts.caption
}}</MkButton>
<MkButton inline @click="cancel">{{
i18n.ts.cancel
}}</MkButton>
</div>
</div> </div>
</div> </div>
<div class="hdrwpsaf fullwidth">
<header>{{ image.name }}</header>
<img
id="imgtocaption"
:src="image.url"
:alt="image.comment"
:title="image.comment"
@click="$refs.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>
</div> </div>
<div class="hdrwpsaf fullwidth"> </MkModal>
<header>{{ image.name }}</header>
<img id="imgtocaption" :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.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>
</div>
</MkModal>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from "vue";
import insertTextAtCursor from 'insert-text-at-cursor'; import insertTextAtCursor from "insert-text-at-cursor";
import { length } from 'stringz'; import { length } from "stringz";
import * as os from '@/os'; import * as os from "@/os";
import MkModal from '@/components/MkModal.vue'; import MkModal from "@/components/MkModal.vue";
import MkButton from '@/components/MkButton.vue'; import MkButton from "@/components/MkButton.vue";
import bytes from '@/filters/bytes'; import bytes from "@/filters/bytes";
import number from '@/filters/number'; import number from "@/filters/number";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import { instance } from '@/instance'; import { instance } from "@/instance";
export default defineComponent({ export default defineComponent({
components: { components: {
@ -77,7 +113,7 @@ export default defineComponent({
}, },
}, },
emits: ['done', 'closed'], emits: ["done", "closed"],
data() { data() {
return { return {
@ -91,15 +127,15 @@ export default defineComponent({
const maxCaptionLength = instance.maxCaptionTextLength ?? 512; const maxCaptionLength = instance.maxCaptionTextLength ?? 512;
if (typeof this.inputValue !== "string") return maxCaptionLength; if (typeof this.inputValue !== "string") return maxCaptionLength;
return maxCaptionLength - length(this.inputValue); return maxCaptionLength - length(this.inputValue);
} },
}, },
mounted() { mounted() {
document.addEventListener('keydown', this.onKeydown); document.addEventListener("keydown", this.onKeydown);
}, },
beforeUnmount() { beforeUnmount() {
document.removeEventListener('keydown', this.onKeydown); document.removeEventListener("keydown", this.onKeydown);
}, },
methods: { methods: {
@ -107,7 +143,7 @@ export default defineComponent({
number, number,
done(canceled, result?) { done(canceled, result?) {
this.$emit('done', { canceled, result }); this.$emit("done", { canceled, result });
this.$refs.modal.close(); this.$refs.modal.close();
}, },
@ -129,13 +165,15 @@ export default defineComponent({
}, },
onKeydown(evt) { onKeydown(evt) {
if (evt.which === 27) { // ESC if (evt.which === 27) {
// ESC
this.cancel(); this.cancel();
} }
}, },
onInputKeydown(evt) { onInputKeydown(evt) {
if (evt.which === 13) { // Enter if (evt.which === 13) {
// Enter
if (evt.ctrlKey) { if (evt.ctrlKey) {
evt.preventDefault(); evt.preventDefault();
evt.stopPropagation(); evt.stopPropagation();
@ -145,12 +183,16 @@ export default defineComponent({
}, },
caption() { caption() {
const img = document.getElementById('imgtocaption') as HTMLImageElement; const img = document.getElementById(
const ta = document.getElementById('captioninput') as HTMLTextAreaElement; "imgtocaption"
os.api('drive/files/caption-image', { ) as HTMLImageElement;
const ta = document.getElementById(
"captioninput"
) as HTMLTextAreaElement;
os.api("drive/files/caption-image", {
url: img.src, url: img.src,
}).then(text => { }).then((text) => {
insertTextAtCursor(ta, text.slice(0, (512 - ta.value.length))); insertTextAtCursor(ta, text.slice(0, 512 - ta.value.length));
}); });
}, },
}, },

View File

@ -1,32 +1,50 @@
<template> <template>
<div v-if="hide" class="qjewsnkg" @click="hide = false"> <div v-if="hide" class="qjewsnkg" @click="hide = false">
<ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/> <ImgWithBlurhash
<div class="text"> class="bg"
<div class="wrapper"> :hash="image.blurhash"
<b style="display: block;"><i class="ph-warning ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}</b> :title="image.comment"
<span style="display: block;">{{ i18n.ts.clickToShow }}</span> :alt="image.comment"
/>
<div class="text">
<div class="wrapper">
<b style="display: block"
><i class="ph-warning ph-bold ph-lg"></i>
{{ i18n.ts.sensitive }}</b
>
<span style="display: block">{{ i18n.ts.clickToShow }}</span>
</div>
</div> </div>
</div> </div>
</div> <div v-else class="gqnyydlz">
<div v-else class="gqnyydlz"> <a :href="image.url" :title="image.name">
<a <ImgWithBlurhash
:href="image.url" :hash="image.blurhash"
:title="image.name" :src="url"
> :alt="image.comment"
<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :type="image.type" :title="image.comment" :cover="false"/> :type="image.type"
<div v-if="image.type === 'image/gif'" class="gif">GIF</div> :title="image.comment"
</a> :cover="false"
<button v-tooltip="i18n.ts.hide" class="_button hide" @click="hide = true"><i class="ph-eye-slash ph-bold ph-lg"></i></button> />
</div> <div v-if="image.type === 'image/gif'" class="gif">GIF</div>
</a>
<button
v-tooltip="i18n.ts.hide"
class="_button hide"
@click="hide = true"
>
<i class="ph-eye-slash ph-bold ph-lg"></i>
</button>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { watch } from 'vue'; import { watch } from "vue";
import type * as misskey from 'calckey-js'; import type * as misskey from "calckey-js";
import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import { getStaticImageUrl } from "@/scripts/get-static-image-url";
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import ImgWithBlurhash from "@/components/MkImgWithBlurhash.vue";
import { defaultStore } from '@/store'; import { defaultStore } from "@/store";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
image: misskey.entities.DriveFile; image: misskey.entities.DriveFile;
@ -35,19 +53,28 @@ const props = defineProps<{
let hide = $ref(true); let hide = $ref(true);
const url = (props.raw || defaultStore.state.loadRawImages) const url =
? props.image.url props.raw || defaultStore.state.loadRawImages
: defaultStore.state.disableShowingAnimatedImages ? props.image.url
: defaultStore.state.disableShowingAnimatedImages
? getStaticImageUrl(props.image.thumbnailUrl) ? getStaticImageUrl(props.image.thumbnailUrl)
: props.image.thumbnailUrl; : props.image.thumbnailUrl;
// Plugin:register_note_view_interruptor 使watch // Plugin:register_note_view_interruptor 使watch
watch(() => props.image, () => { watch(
hide = (defaultStore.state.nsfw === 'force') ? true : props.image.isSensitive && (defaultStore.state.nsfw !== 'ignore'); () => props.image,
}, { () => {
deep: true, hide =
immediate: true, defaultStore.state.nsfw === "force"
}); ? true
: props.image.isSensitive &&
defaultStore.state.nsfw !== "ignore";
},
{
deep: true,
immediate: true,
}
);
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -120,7 +147,7 @@ watch(() => props.image, () => {
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
left: 12px; left: 12px;
opacity: .5; opacity: 0.5;
padding: 0 6px; padding: 0 6px;
text-align: center; text-align: center;
top: 12px; top: 12px;

View File

@ -1,29 +1,58 @@
<template> <template>
<div class="hoawjimk"> <div class="hoawjimk">
<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/> <XBanner
<div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container" :class="{ dmWidth: inDm }"> v-for="media in mediaList.filter((media) => !previewable(media))"
<div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length" @click.stop> :key="media.id"
<template v-for="media in mediaList.filter(media => previewable(media))"> :media="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"/> <div
</template> v-if="mediaList.filter((media) => previewable(media)).length > 0"
class="gird-container"
:class="{ dmWidth: inDm }"
>
<div
ref="gallery"
:data-count="
mediaList.filter((media) => previewable(media)).length
"
@click.stop
>
<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>
</div> </div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from 'vue'; import { onMounted, ref } from "vue";
import * as misskey from 'calckey-js'; import * as misskey from "calckey-js";
import PhotoSwipeLightbox from 'photoswipe/lightbox'; import PhotoSwipeLightbox from "photoswipe/lightbox";
import PhotoSwipe from 'photoswipe'; import PhotoSwipe from "photoswipe";
import 'photoswipe/style.css'; import "photoswipe/style.css";
import XBanner from '@/components/MkMediaBanner.vue'; import XBanner from "@/components/MkMediaBanner.vue";
import XImage from '@/components/MkMediaImage.vue'; import XImage from "@/components/MkMediaImage.vue";
import XVideo from '@/components/MkMediaVideo.vue'; import XVideo from "@/components/MkMediaVideo.vue";
import * as os from '@/os'; import * as os from "@/os";
import { FILE_TYPE_BROWSERSAFE } from '@/const'; import { FILE_TYPE_BROWSERSAFE } from "@/const";
import { defaultStore } from '@/store'; import { defaultStore } from "@/store";
const props = defineProps<{ const props = defineProps<{
mediaList: misskey.entities.DriveFile[]; mediaList: misskey.entities.DriveFile[];
@ -32,60 +61,72 @@ const props = defineProps<{
}>(); }>();
const gallery = ref(null); const gallery = ref(null);
const pswpZIndex = os.claimZIndex('middle'); const pswpZIndex = os.claimZIndex("middle");
onMounted(() => { onMounted(() => {
const lightbox = new PhotoSwipeLightbox({ const lightbox = new PhotoSwipeLightbox({
dataSource: props.mediaList dataSource: props.mediaList
.filter(media => { .filter((media) => {
if (media.type === 'image/svg+xml') return true; // svgwebpublicpngtrue if (media.type === "image/svg+xml") return true; // svgwebpublicpngtrue
return media.type.startsWith('image') && FILE_TYPE_BROWSERSAFE.includes(media.type); return (
media.type.startsWith("image") &&
FILE_TYPE_BROWSERSAFE.includes(media.type)
);
}) })
.map(media => { .map((media) => {
const item = { const item = {
src: media.url, src: media.url,
w: media.properties.width, w: media.properties.width,
h: media.properties.height, h: media.properties.height,
alt: media.comment, alt: media.comment,
}; };
if (media.properties.orientation != null && media.properties.orientation >= 5) { if (
media.properties.orientation != null &&
media.properties.orientation >= 5
) {
[item.w, item.h] = [item.h, item.w]; [item.w, item.h] = [item.h, item.w];
} }
return item; return item;
}), }),
gallery: gallery.value, gallery: gallery.value,
children: '.image', children: ".image",
thumbSelector: '.image', thumbSelector: ".image",
loop: false, loop: false,
padding: window.innerWidth > 500 ? { padding:
top: 32, window.innerWidth > 500
bottom: 32, ? {
left: 32, top: 32,
right: 32, bottom: 32,
} : { left: 32,
top: 0, right: 32,
bottom: 0, }
left: 0, : {
right: 0, top: 0,
}, bottom: 0,
imageClickAction: 'close', left: 0,
tapAction: 'toggle-controls', right: 0,
},
imageClickAction: "close",
tapAction: "toggle-controls",
pswpModule: PhotoSwipe, pswpModule: PhotoSwipe,
}); });
lightbox.on('itemData', (ev) => { lightbox.on("itemData", (ev) => {
const { itemData } = ev; const { itemData } = ev;
// element is children // element is children
const { element } = itemData; const { element } = itemData;
const id = element.dataset.id; const id = element.dataset.id;
const file = props.mediaList.find(media => media.id === id); const file = props.mediaList.find((media) => media.id === id);
itemData.src = file.url; itemData.src = file.url;
itemData.w = Number(file.properties.width); itemData.w = Number(file.properties.width);
itemData.h = Number(file.properties.height); itemData.h = Number(file.properties.height);
if (file.properties.orientation != null && file.properties.orientation >= 5) { if (
file.properties.orientation != null &&
file.properties.orientation >= 5
) {
[itemData.w, itemData.h] = [itemData.h, itemData.w]; [itemData.w, itemData.h] = [itemData.h, itemData.w];
} }
itemData.msrc = file.thumbnailUrl; itemData.msrc = file.thumbnailUrl;
@ -93,17 +134,17 @@ onMounted(() => {
itemData.thumbCropped = true; itemData.thumbCropped = true;
}); });
lightbox.on('uiRegister', () => { lightbox.on("uiRegister", () => {
lightbox.pswp.ui.registerElement({ lightbox.pswp.ui.registerElement({
name: 'altText', name: "altText",
className: 'pwsp__alt-text-container', className: "pwsp__alt-text-container",
appendTo: 'wrapper', appendTo: "wrapper",
onInit: (el, pwsp) => { onInit: (el, pwsp) => {
let textBox = document.createElement('p'); let textBox = document.createElement("p");
textBox.className = 'pwsp__alt-text'; textBox.className = "pwsp__alt-text";
el.appendChild(textBox); el.appendChild(textBox);
let preventProp = function(ev: Event): void { let preventProp = function (ev: Event): void {
ev.stopPropagation(); ev.stopPropagation();
}; };
@ -114,7 +155,7 @@ onMounted(() => {
el.onpointercancel = preventProp; el.onpointercancel = preventProp;
el.onpointermove = preventProp; el.onpointermove = preventProp;
pwsp.on('change', () => { pwsp.on("change", () => {
textBox.textContent = pwsp.currSlide.data.alt?.trim(); textBox.textContent = pwsp.currSlide.data.alt?.trim();
}); });
}, },
@ -125,15 +166,17 @@ onMounted(() => {
}); });
const previewable = (file: misskey.entities.DriveFile): boolean => { const previewable = (file: misskey.entities.DriveFile): boolean => {
if (file.type === 'image/svg+xml') return true; // svgwebpublic/thumbnailpngtrue if (file.type === "image/svg+xml") return true; // svgwebpublic/thumbnailpngtrue
// FILE_TYPE_BROWSERSAFE // FILE_TYPE_BROWSERSAFE
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type); return (
(file.type.startsWith("video") || file.type.startsWith("image")) &&
FILE_TYPE_BROWSERSAFE.includes(file.type)
);
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.hoawjimk { .hoawjimk {
> .dmWidth { > .dmWidth {
min-width: 20rem; min-width: 20rem;
max-width: 40rem; max-width: 40rem;
@ -147,9 +190,9 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
overflow: hidden; overflow: hidden;
&:before { &:before {
content: ''; content: "";
display: block; display: block;
padding-top: 56.25% // 16:9; padding-top: 56.25%; // 16:9;
} }
> div { > div {
@ -221,7 +264,7 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
<style lang="scss"> <style lang="scss">
.pswp { .pswp {
// //
//z-index: v-bind(pswpZIndex); //z-index: v-bind(pswpZIndex);
z-index: 2000000; z-index: 2000000;
} }
.pwsp__alt-text-container { .pwsp__alt-text-container {
@ -254,5 +297,4 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
.pwsp__alt-text:empty { .pwsp__alt-text:empty {
display: none; display: none;
} }
</style> </style>

View File

@ -1,58 +1,66 @@
<template> <template>
<div v-if="hide" class="icozogqfvdetwohsdglrbswgrejoxbdj" @click="hide = false"> <div
<div> v-if="hide"
<b><i class="ph-warning ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}</b> class="icozogqfvdetwohsdglrbswgrejoxbdj"
<span>{{ i18n.ts.clickToShow }}</span> @click="hide = false"
</div>
</div>
<div v-else class="kkjnbbplepmiyuadieoenjgutgcmtsvu">
<VuePlyr
:options="{
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'pip',
'download',
'fullscreen'
],
disableContextMenu: false,
}"
> >
<video <div>
:poster="video.thumbnailUrl" <b
:title="video.comment" ><i class="ph-warning ph-bold ph-lg"></i>
:aria-label="video.comment" {{ i18n.ts.sensitive }}</b
preload="none"
controls
@contextmenu.stop
>
<source
:src="video.url"
:type="video.type"
> >
</video> <span>{{ i18n.ts.clickToShow }}</span>
</VuePlyr> </div>
<i class="ph-eye-slash ph-bold ph-lg" @click="hide = true"></i> </div>
</div> <div v-else class="kkjnbbplepmiyuadieoenjgutgcmtsvu">
<VuePlyr
:options="{
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'pip',
'download',
'fullscreen',
],
disableContextMenu: false,
}"
>
<video
:poster="video.thumbnailUrl"
:title="video.comment"
:aria-label="video.comment"
preload="none"
controls
@contextmenu.stop
>
<source :src="video.url" :type="video.type" />
</video>
</VuePlyr>
<i class="ph-eye-slash ph-bold ph-lg" @click="hide = true"></i>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from "vue";
import VuePlyr from 'vue-plyr'; import VuePlyr from "vue-plyr";
import type * as misskey from 'calckey-js'; import type * as misskey from "calckey-js";
import { defaultStore } from '@/store'; import { defaultStore } from "@/store";
import 'vue-plyr/dist/vue-plyr.css'; import "vue-plyr/dist/vue-plyr.css";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
video: misskey.entities.DriveFile; video: misskey.entities.DriveFile;
}>(); }>();
const hide = ref((defaultStore.state.nsfw === 'force') ? true : props.video.isSensitive && (defaultStore.state.nsfw !== 'ignore')); const hide = ref(
defaultStore.state.nsfw === "force"
? true
: props.video.isSensitive && defaultStore.state.nsfw !== "ignore"
);
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -67,7 +75,7 @@ const hide = ref((defaultStore.state.nsfw === 'force') ? true : props.video.isSe
background-color: var(--fg); background-color: var(--fg);
color: var(--accentLighten); color: var(--accentLighten);
font-size: 14px; font-size: 14px;
opacity: .5; opacity: 0.5;
padding: 3px 6px; padding: 3px 6px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;

View File

@ -1,40 +1,68 @@
<template> <template>
<MkA v-if="url.startsWith('/')" v-user-preview="canonical" class="akbvjaqn" :class="{ isMe }" :to="url" :style="{ background: bgCss }" @click.stop> <MkA
<img class="icon" :src="`/avatar/@${username}@${host}`" alt=""> v-if="url.startsWith('/')"
<span class="main"> v-user-preview="canonical"
<span class="username">@{{ username }}</span> class="akbvjaqn"
<span v-if="(host != localHost) || $store.state.showFullAcct" class="host">@{{ toUnicode(host) }}</span> :class="{ isMe }"
</span> :to="url"
</MkA> :style="{ background: bgCss }"
<a v-else class="akbvjaqn" :href="url" target="_blank" rel="noopener" :style="{ background: bgCss }" @click.stop> @click.stop
<span class="main"> >
<span class="username">@{{ username }}</span> <img class="icon" :src="`/avatar/@${username}@${host}`" alt="" />
<span class="host">@{{ toUnicode(host) }}</span> <span class="main">
</span> <span class="username">@{{ username }}</span>
</a> <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 }"
@click.stop
>
<span class="main">
<span class="username">@{{ username }}</span>
<span class="host">@{{ toUnicode(host) }}</span>
</span>
</a>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { toUnicode } from 'punycode'; import { toUnicode } from "punycode";
import { } from 'vue'; import {} from "vue";
import tinycolor from 'tinycolor2'; import tinycolor from "tinycolor2";
import { host as localHost } from '@/config'; import { host as localHost } from "@/config";
import { $i } from '@/account'; import { $i } from "@/account";
const props = defineProps<{ const props = defineProps<{
username: string; username: string;
host: string; host: string;
}>(); }>();
const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`; const canonical =
props.host === localHost
? `@${props.username}`
: `@${props.username}@${toUnicode(props.host)}`;
const url = `/${canonical}`; const url = `/${canonical}`;
const isMe = $i && ( const isMe =
`@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() $i &&
); `@${props.username}@${toUnicode(props.host)}` ===
`@${$i.username}@${toUnicode(localHost)}`.toLowerCase();
const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention')); const bg = tinycolor(
getComputedStyle(document.documentElement).getPropertyValue(
isMe ? "--mentionMe" : "--mention"
)
);
bg.setAlpha(0.1); bg.setAlpha(0.1);
const bgCss = bg.toRgbString(); const bgCss = bg.toRgbString();
</script> </script>

View File

@ -1,15 +1,29 @@
<template> <template>
<div ref="el" class="sfhdhdhr"> <div ref="el" class="sfhdhdhr">
<MkMenu ref="menu" :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/> <MkMenu
</div> ref="menu"
:items="items"
:align="align"
:width="width"
:as-drawer="false"
@close="onChildClosed"
/>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { on } from 'events'; import { on } from "events";
import { nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'; import {
import MkMenu from './MkMenu.vue'; nextTick,
import { MenuItem } from '@/types/menu'; onBeforeUnmount,
import * as os from '@/os'; onMounted,
onUnmounted,
ref,
watch,
} from "vue";
import MkMenu from "./MkMenu.vue";
import { MenuItem } from "@/types/menu";
import * as os from "@/os";
const props = defineProps<{ const props = defineProps<{
items: MenuItem[]; items: MenuItem[];
@ -20,27 +34,27 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'closed'): void; (ev: "closed"): void;
(ev: 'actioned'): void; (ev: "actioned"): void;
}>(); }>();
const el = ref<HTMLElement>(); const el = ref<HTMLElement>();
const align = 'left'; const align = "left";
function setPosition() { function setPosition() {
const rootRect = props.rootElement.getBoundingClientRect(); const rootRect = props.rootElement.getBoundingClientRect();
const rect = props.targetElement.getBoundingClientRect(); const rect = props.targetElement.getBoundingClientRect();
const left = props.targetElement.offsetWidth; const left = props.targetElement.offsetWidth;
const top = (rect.top - rootRect.top) - 8; const top = rect.top - rootRect.top - 8;
el.value.style.left = left + 'px'; el.value.style.left = left + "px";
el.value.style.top = top + 'px'; el.value.style.top = top + "px";
} }
function onChildClosed(actioned?: boolean) { function onChildClosed(actioned?: boolean) {
if (actioned) { if (actioned) {
emit('actioned'); emit("actioned");
} else { } else {
emit('closed'); emit("closed");
} }
} }
@ -53,7 +67,7 @@ onMounted(() => {
defineExpose({ defineExpose({
checkHit: (ev: MouseEvent) => { checkHit: (ev: MouseEvent) => {
return (ev.target === el.value || el.value.contains(ev.target)); return ev.target === el.value || el.value.contains(ev.target);
}, },
}); });
</script> </script>

View File

@ -1,93 +1,225 @@
<template> <template>
<div> <div>
<div <div
ref="itemsEl" v-hotkey="keymap" ref="itemsEl"
class="rrevdjwt _popup _shadow" v-hotkey="keymap"
:class="{ center: align === 'center', asDrawer }" class="rrevdjwt _popup _shadow"
:style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }" :class="{ center: align === 'center', asDrawer }"
@contextmenu.self="e => e.preventDefault()" :style="{
> width: width && !asDrawer ? width + 'px' : '',
<template v-for="(item, i) in items2"> maxHeight: maxHeight ? maxHeight + 'px' : '',
<div v-if="item === null" class="divider"></div> }"
<span v-else-if="item.type === 'label'" class="label item"> @contextmenu.self="(e) => e.preventDefault()"
<span :style="item.textStyle || ''">{{ item.text }}</span> >
<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 :style="item.textStyle || ''">{{ 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="ph-fw ph-lg"
:class="item.icon"
></i>
<span v-else-if="item.icons">
<i
v-for="icon in item.icons"
class="ph-fw ph-lg"
:class="icon"
></i>
</span>
<MkAvatar
v-if="item.avatar"
:user="item.avatar"
class="avatar"
/>
<span :style="item.textStyle || ''">{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"
><i class="ph-circle ph-fill"></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="ph-fw ph-lg"
:class="item.icon"
></i>
<span v-else-if="item.icons">
<i
v-for="icon in item.icons"
class="ph-fw ph-lg"
:class="icon"
></i>
</span>
<span :style="item.textStyle || ''">{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"
><i class="ph-circle ph-fill"></i
></span>
</a>
<button
v-else-if="item.type === 'user' && !items.hidden"
: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="ph-circle ph-fill"></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"
:style="item.textStyle || ''"
>{{ 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="ph-fw ph-lg"
:class="item.icon"
></i>
<span v-else-if="item.icons">
<i
v-for="icon in item.icons"
class="ph-fw ph-lg"
:class="icon"
></i>
</span>
<span :style="item.textStyle || ''">{{ item.text }}</span>
<span class="caret"
><i class="ph-caret-right ph-bold ph-lg ph-fw ph-lg"></i
></span>
</button>
<button
v-else-if="!item.hidden"
: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="ph-fw ph-lg"
:class="item.icon"
></i>
<span v-else-if="item.icons">
<i
v-for="icon in item.icons"
class="ph-fw ph-lg"
:class="icon"
></i>
</span>
<MkAvatar
v-if="item.avatar"
:user="item.avatar"
class="avatar"
/>
<span :style="item.textStyle || ''">{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"
><i class="ph-circle ph-fill"></i
></span>
</button>
</template>
<span v-if="items2.length === 0" class="none item">
<span>{{ i18n.ts.none }}</span>
</span> </span>
<span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item"> </div>
<span><MkEllipsis/></span> <div v-if="childMenu" class="child">
</span> <XChild
<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)"> ref="child"
<i v-if="item.icon" class="ph-fw ph-lg" :class="item.icon"></i> :items="childMenu"
<span v-else-if="item.icons"> :target-element="childTarget"
<i v-for="icon in item.icons" class="ph-fw ph-lg" :class="icon"></i> :root-element="itemsEl"
</span> showing
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> @actioned="childActioned"
<span :style="item.textStyle || ''">{{ item.text }}</span> />
<span v-if="item.indicate" class="indicator"><i class="ph-circle ph-fill"></i></span> </div>
</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="ph-fw ph-lg" :class="item.icon"></i>
<span v-else-if="item.icons">
<i v-for="icon in item.icons" class="ph-fw ph-lg" :class="icon"></i>
</span>
<span :style="item.textStyle || ''">{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"><i class="ph-circle ph-fill"></i></span>
</a>
<button v-else-if="item.type === 'user' && !items.hidden" :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="ph-circle ph-fill"></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" :style="item.textStyle || ''">{{ 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="ph-fw ph-lg" :class="item.icon"></i>
<span v-else-if="item.icons">
<i v-for="icon in item.icons" class="ph-fw ph-lg" :class="icon"></i>
</span>
<span :style="item.textStyle || ''">{{ item.text }}</span>
<span class="caret"><i class="ph-caret-right ph-bold ph-lg ph-fw ph-lg"></i></span>
</button>
<button v-else-if="!item.hidden" :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="ph-fw ph-lg" :class="item.icon"></i>
<span v-else-if="item.icons">
<i v-for="icon in item.icons" class="ph-fw ph-lg" :class="icon"></i>
</span>
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
<span :style="item.textStyle || ''">{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"><i class="ph-circle ph-fill"></i></span>
</button>
</template>
<span v-if="items2.length === 0" class="none item">
<span>{{ i18n.ts.none }}</span>
</span>
</div> </div>
<div v-if="childMenu" class="child">
<XChild ref="child" :items="childMenu" :target-element="childTarget" :root-element="itemsEl" showing @actioned="childActioned"/>
</div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, menu, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, onUnmounted, Ref, ref, watch } from 'vue'; import {
import { focusPrev, focusNext } from '@/scripts/focus'; computed,
import FormSwitch from '@/components/form/switch.vue'; menu,
import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu'; defineAsyncComponent,
import * as os from '@/os'; nextTick,
import { i18n } from '@/i18n'; 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 XChild = defineAsyncComponent(() => import("./MkMenu.child.vue"));
const props = defineProps<{ const props = defineProps<{
items: MenuItem[]; items: MenuItem[];
viaKeyboard?: boolean; viaKeyboard?: boolean;
asDrawer?: boolean; asDrawer?: boolean;
align?: 'center' | string; align?: "center" | string;
width?: number; width?: number;
maxHeight?: number; maxHeight?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'close', actioned?: boolean): void; (ev: "close", actioned?: boolean): void;
}>(); }>();
let itemsEl = $ref<HTMLDivElement>(); let itemsEl = $ref<HTMLDivElement>();
@ -97,31 +229,38 @@ let items2: InnerMenuItem[] = $ref([]);
let child = $ref<InstanceType<typeof XChild>>(); let child = $ref<InstanceType<typeof XChild>>();
let keymap = computed(() => ({ let keymap = computed(() => ({
'up|k|shift+tab': focusUp, "up|k|shift+tab": focusUp,
'down|j|tab': focusDown, "down|j|tab": focusDown,
'esc': close, esc: close,
})); }));
let childShowingItem = $ref<MenuItem | null>(); let childShowingItem = $ref<MenuItem | null>();
watch(() => props.items, () => { watch(
const items: (MenuItem | MenuPending)[] = [...props.items].filter(item => item !== undefined); () => props.items,
() => {
const items: (MenuItem | MenuPending)[] = [...props.items].filter(
(item) => item !== undefined
);
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const item = items[i]; const item = items[i];
if (item && 'then' in item) { // if item is Promise if (item && "then" in item) {
items[i] = { type: 'pending' }; // if item is Promise
item.then(actualItem => { items[i] = { type: "pending" };
items2[i] = actualItem; item.then((actualItem) => {
}); items2[i] = actualItem;
});
}
} }
}
items2 = items as InnerMenuItem[]; items2 = items as InnerMenuItem[];
}, { },
immediate: true, {
}); immediate: true,
}
);
let childMenu = $ref<MenuItem[] | null>(); let childMenu = $ref<MenuItem[] | null>();
let childTarget = $ref<HTMLElement | null>(); let childTarget = $ref<HTMLElement | null>();
@ -137,7 +276,11 @@ function childActioned() {
} }
function onGlobalMousedown(event: MouseEvent) { function onGlobalMousedown(event: MouseEvent) {
if (childTarget && (event.target === childTarget || childTarget.contains(event.target))) return; if (
childTarget &&
(event.target === childTarget || childTarget.contains(event.target))
)
return;
if (child && child.checkHit(event)) return; if (child && child.checkHit(event)) return;
closeChild(); closeChild();
} }
@ -169,7 +312,7 @@ function clicked(fn: MenuAction, ev: MouseEvent) {
} }
function close(actioned = false) { function close(actioned = false) {
emit('close', actioned); emit("close", actioned);
} }
function focusUp() { function focusUp() {
@ -187,11 +330,13 @@ onMounted(() => {
}); });
} }
document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); document.addEventListener("mousedown", onGlobalMousedown, {
passive: true,
});
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('mousedown', onGlobalMousedown); document.removeEventListener("mousedown", onGlobalMousedown);
}); });
</script> </script>

View File

@ -1,35 +1,34 @@
<template> <template>
<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" style="overflow:visible"> <svg :viewBox="`0 0 ${viewBoxX} ${viewBoxY}`" style="overflow: visible">
<defs> <defs>
<linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0"> <linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0">
<stop offset="0%" :stop-color="color" stop-opacity="0"></stop> <stop offset="0%" :stop-color="color" stop-opacity="0"></stop>
<stop offset="100%" :stop-color="color" stop-opacity="0.65"></stop> <stop
</linearGradient> offset="100%"
</defs> :stop-color="color"
<polygon stop-opacity="0.65"
:points="polygonPoints" ></stop>
:style="`stroke: none; fill: url(#${ gradientId });`" </linearGradient>
/> </defs>
<polyline <polygon
:points="polylinePoints" :points="polygonPoints"
fill="none" :style="`stroke: none; fill: url(#${gradientId});`"
:stroke="color" />
stroke-width="2" <polyline
/> :points="polylinePoints"
<circle fill="none"
:cx="headX" :stroke="color"
:cy="headY" stroke-width="2"
r="3" />
:fill="color" <circle :cx="headX" :cy="headY" r="3" :fill="color" />
/> </svg>
</svg>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onUnmounted, watch } from 'vue'; import { onUnmounted, watch } from "vue";
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from "uuid";
import tinycolor from 'tinycolor2'; import tinycolor from "tinycolor2";
import { useInterval } from '@/scripts/use-interval'; import { useInterval } from "@/scripts/use-interval";
const props = defineProps<{ const props = defineProps<{
src: number[]; src: number[];
@ -38,12 +37,14 @@ const props = defineProps<{
const viewBoxX = 50; const viewBoxX = 50;
const viewBoxY = 50; const viewBoxY = 50;
const gradientId = uuid(); const gradientId = uuid();
let polylinePoints = $ref(''); let polylinePoints = $ref("");
let polygonPoints = $ref(''); let polygonPoints = $ref("");
let headX = $ref<number | null>(null); let headX = $ref<number | null>(null);
let headY = $ref<number | null>(null); let headY = $ref<number | null>(null);
let clock = $ref<number | null>(null); let clock = $ref<number | null>(null);
const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent')); const accent = tinycolor(
getComputedStyle(document.documentElement).getPropertyValue("--accent")
);
const color = accent.toRgbString(); const color = accent.toRgbString();
function draw(): void { function draw(): void {
@ -52,12 +53,12 @@ function draw(): void {
const _polylinePoints = stats.map((n, i) => [ const _polylinePoints = stats.map((n, i) => [
i * (viewBoxX / (stats.length - 1)), i * (viewBoxX / (stats.length - 1)),
(1 - (n / peak)) * viewBoxY, (1 - n / peak) * viewBoxY,
]); ]);
polylinePoints = _polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); polylinePoints = _polylinePoints.map((xy) => `${xy[0]},${xy[1]}`).join(" ");
polygonPoints = `0,${ viewBoxY } ${ polylinePoints } ${ viewBoxX },${ viewBoxY }`; polygonPoints = `0,${viewBoxY} ${polylinePoints} ${viewBoxX},${viewBoxY}`;
headX = _polylinePoints[_polylinePoints.length - 1][0]; headX = _polylinePoints[_polylinePoints.length - 1][0];
headY = _polylinePoints[_polylinePoints.length - 1][1]; headY = _polylinePoints[_polylinePoints.length - 1][1];

View File

@ -1,15 +1,64 @@
<template> <template>
<Transition <Transition
:name="transitionName" :name="transitionName"
:enter-active-class="$style['transition_' + transitionName + '_enterActive']" :enter-active-class="
:leave-active-class="$style['transition_' + transitionName + '_leaveActive']" $style['transition_' + transitionName + '_enterActive']
:enter-from-class="$style['transition_' + transitionName + '_enterFrom']" "
:leave-active-class="
$style['transition_' + transitionName + '_leaveActive']
"
:enter-from-class="
$style['transition_' + transitionName + '_enterFrom']
"
:leave-to-class="$style['transition_' + transitionName + '_leaveTo']" :leave-to-class="$style['transition_' + transitionName + '_leaveTo']"
:duration="transitionDuration" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened" :duration="transitionDuration"
appear
@after-leave="emit('closed')"
@enter="emit('opening')"
@after-enter="onOpened"
> >
<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog' || type === 'dialog:top', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> <div
<div class="_modalBg data-cy-bg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent, 'data-cy-transparent': isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div> v-show="manualShowing != null ? manualShowing : showing"
<div ref="content" :class="[$style.content, { [$style.fixed]: fixed, top: type === 'dialog:top' }]" :style="{ zIndex }" @click.self="onBgClick"> v-hotkey.global="keymap"
:class="[
$style.root,
{
[$style.drawer]: type === 'drawer',
[$style.dialog]: type === 'dialog' || type === 'dialog:top',
[$style.popup]: type === 'popup',
},
]"
:style="{
zIndex,
pointerEvents: (manualShowing != null ? manualShowing : showing)
? 'auto'
: 'none',
'--transformOrigin': transformOrigin,
}"
>
<div
class="_modalBg data-cy-bg"
:class="[
$style.bg,
{
[$style.bgTransparent]: isEnableBgTransparent,
'data-cy-transparent': isEnableBgTransparent,
},
]"
:style="{ zIndex }"
@click="onBgClick"
@mousedown="onBgClick"
@contextmenu.prevent.stop="() => {}"
></div>
<div
ref="content"
:class="[
$style.content,
{ [$style.fixed]: fixed, top: type === 'dialog:top' },
]"
:style="{ zIndex }"
@click.self="onBgClick"
>
<slot :max-height="maxHeight" :type="type"></slot> <slot :max-height="maxHeight" :type="type"></slot>
</div> </div>
</div> </div>
@ -17,94 +66,103 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { nextTick, onMounted, watch, provide } from 'vue'; import { nextTick, onMounted, watch, provide } from "vue";
import * as os from '@/os'; import * as os from "@/os";
import { isTouchUsing } from '@/scripts/touch'; import { isTouchUsing } from "@/scripts/touch";
import { defaultStore } from '@/store'; import { defaultStore } from "@/store";
import { deviceKind } from '@/scripts/device-kind'; import { deviceKind } from "@/scripts/device-kind";
function getFixedContainer(el: Element | null): Element | null { function getFixedContainer(el: Element | null): Element | null {
if (el == null || el.tagName === 'BODY') return null; if (el == null || el.tagName === "BODY") return null;
const position = window.getComputedStyle(el).getPropertyValue('position'); const position = window.getComputedStyle(el).getPropertyValue("position");
if (position === 'fixed') { if (position === "fixed") {
return el; return el;
} else { } else {
return getFixedContainer(el.parentElement); return getFixedContainer(el.parentElement);
} }
} }
type ModalTypes = 'popup' | 'dialog' | 'dialog:top' | 'drawer'; type ModalTypes = "popup" | "dialog" | "dialog:top" | "drawer";
const props = withDefaults(defineProps<{ const props = withDefaults(
manualShowing?: boolean | null; defineProps<{
anchor?: { x: string; y: string; }; manualShowing?: boolean | null;
src?: HTMLElement; anchor?: { x: string; y: string };
preferType?: ModalTypes | 'auto'; src?: HTMLElement;
zPriority?: 'low' | 'middle' | 'high'; preferType?: ModalTypes | "auto";
noOverlap?: boolean; zPriority?: "low" | "middle" | "high";
transparentBg?: boolean; noOverlap?: boolean;
}>(), { transparentBg?: boolean;
manualShowing: null, }>(),
src: null, {
anchor: () => ({ x: 'center', y: 'bottom' }), manualShowing: null,
preferType: 'auto', src: null,
zPriority: 'low', anchor: () => ({ x: "center", y: "bottom" }),
noOverlap: true, preferType: "auto",
transparentBg: false, zPriority: "low",
}); noOverlap: true,
transparentBg: false,
}
);
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'opening'): void; (ev: "opening"): void;
(ev: 'opened'): void; (ev: "opened"): void;
(ev: 'click'): void; (ev: "click"): void;
(ev: 'esc'): void; (ev: "esc"): void;
(ev: 'close'): void; (ev: "close"): void;
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
provide('modal', true); provide("modal", true);
let maxHeight = $ref<number>(); let maxHeight = $ref<number>();
let fixed = $ref(false); let fixed = $ref(false);
let transformOrigin = $ref('center'); let transformOrigin = $ref("center");
let showing = $ref(true); let showing = $ref(true);
let content = $shallowRef<HTMLElement>(); let content = $shallowRef<HTMLElement>();
const zIndex = os.claimZIndex(props.zPriority); const zIndex = os.claimZIndex(props.zPriority);
let useSendAnime = $ref(false); let useSendAnime = $ref(false);
const type = $computed<ModalTypes>(() => { const type = $computed<ModalTypes>(() => {
if (props.preferType === 'auto') { if (props.preferType === "auto") {
if (!defaultStore.state.disableDrawer && isTouchUsing && deviceKind === 'smartphone') { if (
return 'drawer'; !defaultStore.state.disableDrawer &&
isTouchUsing &&
deviceKind === "smartphone"
) {
return "drawer";
} else { } else {
return props.src != null ? 'popup' : 'dialog'; return props.src != null ? "popup" : "dialog";
} }
} else { } else {
return props.preferType!; return props.preferType!;
} }
}); });
const isEnableBgTransparent = $computed(() => props.transparentBg && (type === 'popup')); const isEnableBgTransparent = $computed(
let transitionName = $computed((() => () => props.transparentBg && type === "popup"
);
let transitionName = $computed(() =>
defaultStore.state.animation defaultStore.state.animation
? useSendAnime ? useSendAnime
? 'send' ? "send"
: type === 'drawer' : type === "drawer"
? 'modal-drawer' ? "modal-drawer"
: type === 'popup' : type === "popup"
? 'modal-popup' ? "modal-popup"
: 'modal' : "modal"
: '' : ""
)); );
let transitionDuration = $computed((() => let transitionDuration = $computed(() =>
transitionName === 'send' transitionName === "send"
? 400 ? 400
: transitionName === 'modal-popup' : transitionName === "modal-popup"
? 100 ? 100
: transitionName === 'modal' : transitionName === "modal"
? 200 ? 200
: transitionName === 'modal-drawer' : transitionName === "modal-drawer"
? 200 ? 200
: 0 : 0
)); );
let contentClicking = false; let contentClicking = false;
@ -114,30 +172,30 @@ function close(opts: { useSendAnimation?: boolean } = {}) {
} }
// eslint-disable-next-line vue/no-mutating-props // eslint-disable-next-line vue/no-mutating-props
if (props.src) props.src.style.pointerEvents = 'auto'; if (props.src) props.src.style.pointerEvents = "auto";
showing = false; showing = false;
emit('close'); emit("close");
} }
function onBgClick() { function onBgClick() {
if (contentClicking) return; if (contentClicking) return;
emit('click'); emit("click");
} }
if (type === 'drawer') { if (type === "drawer") {
maxHeight = window.innerHeight / 1.5; maxHeight = window.innerHeight / 1.5;
} }
const keymap = { const keymap = {
'esc': () => emit('esc'), esc: () => emit("esc"),
}; };
const MARGIN = 16; const MARGIN = 16;
const align = () => { const align = () => {
if (props.src == null) return; if (props.src == null) return;
if (type === 'drawer') return; if (type === "drawer") return;
if (type === 'dialog') return; if (type === "dialog") return;
if (content == null) return; if (content == null) return;
@ -152,19 +210,19 @@ const align = () => {
const x = srcRect.left + (fixed ? 0 : window.pageXOffset); const x = srcRect.left + (fixed ? 0 : window.pageXOffset);
const y = srcRect.top + (fixed ? 0 : window.pageYOffset); const y = srcRect.top + (fixed ? 0 : window.pageYOffset);
if (props.anchor.x === 'center') { if (props.anchor.x === "center") {
left = x + (props.src.offsetWidth / 2) - (width / 2); left = x + props.src.offsetWidth / 2 - width / 2;
} else if (props.anchor.x === 'left') { } else if (props.anchor.x === "left") {
// TODO // TODO
} else if (props.anchor.x === 'right') { } else if (props.anchor.x === "right") {
left = x + props.src.offsetWidth; left = x + props.src.offsetWidth;
} }
if (props.anchor.y === 'center') { if (props.anchor.y === "center") {
top = (y - (height / 2)); top = y - height / 2;
} else if (props.anchor.y === 'top') { } else if (props.anchor.y === "top") {
// TODO // TODO
} else if (props.anchor.y === 'bottom') { } else if (props.anchor.y === "bottom") {
top = y + props.src.offsetHeight; top = y + props.src.offsetHeight;
} }
@ -174,20 +232,20 @@ const align = () => {
left = window.innerWidth - width; left = window.innerWidth - width;
} }
const underSpace = (window.innerHeight - MARGIN) - top; const underSpace = window.innerHeight - MARGIN - top;
const upperSpace = (srcRect.top - MARGIN); const upperSpace = srcRect.top - MARGIN;
// //
if (top + height > (window.innerHeight - MARGIN)) { if (top + height > window.innerHeight - MARGIN) {
if (props.noOverlap && props.anchor.x === 'center') { if (props.noOverlap && props.anchor.x === "center") {
if (underSpace >= (upperSpace / 3)) { if (underSpace >= upperSpace / 3) {
maxHeight = underSpace; maxHeight = underSpace;
} else { } else {
maxHeight = upperSpace; maxHeight = upperSpace;
top = (upperSpace + MARGIN) - height; top = upperSpace + MARGIN - height;
} }
} else { } else {
top = (window.innerHeight - MARGIN) - height; top = window.innerHeight - MARGIN - height;
} }
} else { } else {
maxHeight = underSpace; maxHeight = underSpace;
@ -198,20 +256,26 @@ const align = () => {
left = window.innerWidth - width + window.scrollX - 1; left = window.innerWidth - width + window.scrollX - 1;
} }
const underSpace = (window.innerHeight - MARGIN) - (top - window.pageYOffset); const underSpace =
const upperSpace = (srcRect.top - MARGIN); window.innerHeight - MARGIN - (top - window.pageYOffset);
const upperSpace = srcRect.top - MARGIN;
// //
if (top + height - window.scrollY > (window.innerHeight - MARGIN)) { if (top + height - window.scrollY > window.innerHeight - MARGIN) {
if (props.noOverlap && props.anchor.x === 'center') { if (props.noOverlap && props.anchor.x === "center") {
if (underSpace >= (upperSpace / 3)) { if (underSpace >= upperSpace / 3) {
maxHeight = underSpace; maxHeight = underSpace;
} else { } else {
maxHeight = upperSpace; maxHeight = upperSpace;
top = window.scrollY + ((upperSpace + MARGIN) - height); top = window.scrollY + (upperSpace + MARGIN - height);
} }
} else { } else {
top = (window.innerHeight - MARGIN) - height + window.pageYOffset - 1; top =
window.innerHeight -
MARGIN -
height +
window.pageYOffset -
1;
} }
} else { } else {
maxHeight = underSpace; maxHeight = underSpace;
@ -226,55 +290,76 @@ const align = () => {
left = 0; left = 0;
} }
let transformOriginX = 'center'; let transformOriginX = "center";
let transformOriginY = 'center'; let transformOriginY = "center";
if (top >= srcRect.top + props.src.offsetHeight + (fixed ? 0 : window.pageYOffset)) { if (
transformOriginY = 'top'; top >=
} else if ((top + height) <= srcRect.top + (fixed ? 0 : window.pageYOffset)) { srcRect.top + props.src.offsetHeight + (fixed ? 0 : window.pageYOffset)
transformOriginY = 'bottom'; ) {
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)) { if (
transformOriginX = 'left'; left >=
} else if ((left + width) <= srcRect.left + (fixed ? 0 : window.pageXOffset)) { srcRect.left + props.src.offsetWidth + (fixed ? 0 : window.pageXOffset)
transformOriginX = 'right'; ) {
transformOriginX = "left";
} else if (
left + width <=
srcRect.left + (fixed ? 0 : window.pageXOffset)
) {
transformOriginX = "right";
} }
transformOrigin = `${transformOriginX} ${transformOriginY}`; transformOrigin = `${transformOriginX} ${transformOriginY}`;
content.style.left = left + 'px'; content.style.left = left + "px";
content.style.top = top + 'px'; content.style.top = top + "px";
}; };
const onOpened = () => { const onOpened = () => {
emit('opened'); emit("opened");
// //
const el = content!.children[0]; const el = content!.children[0];
el.addEventListener('mousedown', ev => { el.addEventListener(
contentClicking = true; "mousedown",
window.addEventListener('mouseup', ev => { (ev) => {
// click mouseup contentClicking = true;
window.setTimeout(() => { window.addEventListener(
contentClicking = false; "mouseup",
}, 100); (ev) => {
}, { passive: true, once: true }); // click mouseup
}, { passive: true }); window.setTimeout(() => {
contentClicking = false;
}, 100);
},
{ passive: true, once: true }
);
},
{ passive: true }
);
}; };
onMounted(() => { onMounted(() => {
watch(() => props.src, async () => { watch(
if (props.src) { () => props.src,
// eslint-disable-next-line vue/no-mutating-props async () => {
props.src.style.pointerEvents = 'none'; if (props.src) {
} // eslint-disable-next-line vue/no-mutating-props
fixed = (type === 'drawer') || (getFixedContainer(props.src) != null); props.src.style.pointerEvents = "none";
}
fixed = type === "drawer" || getFixedContainer(props.src) != null;
await nextTick(); await nextTick();
align(); align();
}, { immediate: true }); },
{ immediate: true }
);
nextTick(() => { nextTick(() => {
new ResizeObserver((entries, observer) => { new ResizeObserver((entries, observer) => {
@ -297,7 +382,8 @@ defineExpose({
> .content { > .content {
transform: translateY(0px); transform: translateY(0px);
transition: opacity 0.3s ease-in, transform 0.3s cubic-bezier(.5,-0.5,1,.5) !important; transition: opacity 0.3s ease-in,
transform 0.3s cubic-bezier(0.5, -0.5, 1, 0.5) !important;
} }
} }
.transition_send_enterFrom, .transition_send_enterFrom,
@ -346,7 +432,8 @@ defineExpose({
> .content { > .content {
transform-origin: var(--transformOrigin); 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; transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1),
transform 0.2s cubic-bezier(0, 0, 0.2, 1) !important;
} }
} }
.transition_modal-popup_enterFrom, .transition_modal-popup_enterFrom,
@ -369,7 +456,7 @@ defineExpose({
} }
> .content { > .content {
transition: transform 0.2s cubic-bezier(0,.5,0,1) !important; transition: transform 0.2s cubic-bezier(0, 0.5, 0, 1) !important;
} }
} }
.transition_modal-drawer_leaveActive { .transition_modal-drawer_leaveActive {
@ -378,7 +465,7 @@ defineExpose({
} }
> .content { > .content {
transition: transform 0.2s cubic-bezier(0,.5,0,1) !important; transition: transform 0.2s cubic-bezier(0, 0.5, 0, 1) !important;
} }
} }
.transition_modal-drawer_enterFrom, .transition_modal-drawer_enterFrom,
@ -404,15 +491,39 @@ defineExpose({
margin: auto; margin: auto;
padding: 32px; padding: 32px;
// TODO: mask-imageiOS // TODO: mask-imageiOS
-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%); -webkit-mask-image: linear-gradient(
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%); 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; overflow: auto;
display: flex; display: flex;
@media (max-width: 500px) { @media (max-width: 500px) {
padding: 16px; 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%); -webkit-mask-image: linear-gradient(
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%); 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(*) { > ::v-deep(*) {
@ -424,7 +535,6 @@ defineExpose({
margin-top: 0; margin-top: 0;
} }
} }
} }
} }

View File

@ -1,51 +1,80 @@
<template> <template>
<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> <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
<div class="header" @contextmenu="onContextmenu"> ref="rootEl"
<button v-if="history.length > 0" v-tooltip="i18n.ts.goBack" class="_button" @click="back()"><i class="ph-caret-left ph-bold ph-lg"></i></button> class="hrmcaedk _narrow_"
<span v-else style="display: inline-block; width: 20px"></span> :style="{
<span v-if="pageMetadata?.value" class="title"> width: `${width}px`,
<i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i> height: height ? `min(${height}px, 100%)` : '100%',
<span>{{ pageMetadata?.value.title }}</span> }"
</span> >
<button class="_button" @click="$refs.modal.close()"><i class="ph-x ph-bold ph-lg"></i></button> <div class="header" @contextmenu="onContextmenu">
<button
v-if="history.length > 0"
v-tooltip="i18n.ts.goBack"
class="_button"
@click="back()"
>
<i class="ph-caret-left ph-bold ph-lg"></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="ph-x ph-bold ph-lg"></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> </div>
<div class="body"> </MkModal>
<MkStickyContainer>
<template #header><MkPageHeader v-if="pageMetadata?.value && !pageMetadata?.value.hideHeader" :info="pageMetadata?.value"/></template>
<RouterView :router="router"/>
</MkStickyContainer>
</div>
</div>
</MkModal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ComputedRef, provide } from 'vue'; import { ComputedRef, provide } from "vue";
import MkModal from '@/components/MkModal.vue'; import MkModal from "@/components/MkModal.vue";
import { popout as _popout } from '@/scripts/popout'; import { popout as _popout } from "@/scripts/popout";
import copyToClipboard from '@/scripts/copy-to-clipboard'; import copyToClipboard from "@/scripts/copy-to-clipboard";
import { url } from '@/config'; import { url } from "@/config";
import * as os from '@/os'; import * as os from "@/os";
import { mainRouter, routes } from '@/router'; import { mainRouter, routes } from "@/router";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; import {
import { Router } from '@/nirax'; PageMetadata,
provideMetadataReceiver,
setPageMetadata,
} from "@/scripts/page-metadata";
import { Router } from "@/nirax";
const props = defineProps<{ const props = defineProps<{
initialPath: string; initialPath: string;
}>(); }>();
defineEmits<{ defineEmits<{
(ev: 'closed'): void; (ev: "closed"): void;
(ev: 'click'): void; (ev: "click"): void;
}>(); }>();
const router = new Router(routes, props.initialPath); const router = new Router(routes, props.initialPath);
router.addListener('push', ctx => { router.addListener("push", (ctx) => {});
});
let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
let rootEl = $ref(); let rootEl = $ref();
@ -55,40 +84,47 @@ let width = $ref(860);
let height = $ref(660); let height = $ref(660);
const history = []; const history = [];
provide('router', router); provide("router", router);
provideMetadataReceiver((info) => { provideMetadataReceiver((info) => {
pageMetadata = info; pageMetadata = info;
}); });
provide('shouldOmitHeaderTitle', true); provide("shouldOmitHeaderTitle", true);
provide('shouldHeaderThin', true); provide("shouldHeaderThin", true);
const pageUrl = $computed(() => url + path); const pageUrl = $computed(() => url + path);
const contextmenu = $computed(() => { const contextmenu = $computed(() => {
return [{ return [
type: 'label', {
text: path, type: "label",
}, { text: path,
icon: 'ph-arrows-out-simple ph-bold ph-lg',
text: i18n.ts.showInPage,
action: expand,
}, {
icon: 'ph-arrow-square-out ph-bold ph-lg',
text: i18n.ts.popout,
action: popout,
}, null, {
icon: 'ph-arrow-square-out ph-bold ph-lg',
text: i18n.ts.openInNewTab,
action: () => {
window.open(pageUrl, '_blank');
modal.close();
}, },
}, { {
icon: 'ph-link-simple ph-bold ph-lg', icon: "ph-arrows-out-simple ph-bold ph-lg",
text: i18n.ts.copyLink, text: i18n.ts.showInPage,
action: () => { action: expand,
copyToClipboard(pageUrl);
}, },
}]; {
icon: "ph-arrow-square-out ph-bold ph-lg",
text: i18n.ts.popout,
action: popout,
},
null,
{
icon: "ph-arrow-square-out ph-bold ph-lg",
text: i18n.ts.openInNewTab,
action: () => {
window.open(pageUrl, "_blank");
modal.close();
},
},
{
icon: "ph-link-simple ph-bold ph-lg",
text: i18n.ts.copyLink,
action: () => {
copyToClipboard(pageUrl);
},
},
];
}); });
function navigate(path, record = true) { function navigate(path, record = true) {

View File

@ -1,13 +1,51 @@
<template> <template>
<MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')"> <MkModal
<div ref="rootEl" class="ebkgoccj" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown"> ref="modal"
:prefer-type="'dialog'"
@click="onBgClick"
@closed="$emit('closed')"
>
<div
ref="rootEl"
class="ebkgoccj"
:style="{
width: `${width}px`,
height: scroll
? height
? `${height}px`
: null
: height
? `min(${height}px, 100%)`
: '100%',
}"
@keydown="onKeydown"
>
<div ref="headerEl" class="header"> <div ref="headerEl" class="header">
<button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="ph-x ph-bold ph-lg"></i></button> <button
v-if="withOkButton"
class="_button"
@click="$emit('close')"
>
<i class="ph-x ph-bold ph-lg"></i>
</button>
<span class="title"> <span class="title">
<slot name="header"></slot> <slot name="header"></slot>
</span> </span>
<button v-if="!withOkButton" class="_button" @click="$emit('close')"><i class="ph-x ph-bold ph-lg"></i></button> <button
<button v-if="withOkButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="ph-check ph-bold ph-lg"></i></button> v-if="!withOkButton"
class="_button"
@click="$emit('close')"
>
<i class="ph-x ph-bold ph-lg"></i>
</button>
<button
v-if="withOkButton"
class="_button"
:disabled="okButtonDisabled"
@click="$emit('ok')"
>
<i class="ph-check ph-bold ph-lg"></i>
</button>
</div> </div>
<div class="body"> <div class="body">
<slot :width="bodyWidth" :height="bodyHeight"></slot> <slot :width="bodyWidth" :height="bodyHeight"></slot>
@ -17,28 +55,31 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onUnmounted } from 'vue'; import { onMounted, onUnmounted } from "vue";
import MkModal from './MkModal.vue'; import MkModal from "./MkModal.vue";
const props = withDefaults(defineProps<{ const props = withDefaults(
withOkButton: boolean; defineProps<{
okButtonDisabled: boolean; withOkButton: boolean;
width: number; okButtonDisabled: boolean;
height: number | null; width: number;
scroll: boolean; height: number | null;
}>(), { scroll: boolean;
withOkButton: false, }>(),
okButtonDisabled: false, {
width: 400, withOkButton: false,
height: null, okButtonDisabled: false,
scroll: true, width: 400,
}); height: null,
scroll: true,
}
);
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'click'): void; (event: "click"): void;
(event: 'close'): void; (event: "close"): void;
(event: 'closed'): void; (event: "closed"): void;
(event: 'ok'): void; (event: "ok"): void;
}>(); }>();
let modal = $shallowRef<InstanceType<typeof MkModal>>(); let modal = $shallowRef<InstanceType<typeof MkModal>>();
@ -52,11 +93,12 @@ const close = () => {
}; };
const onBgClick = () => { const onBgClick = () => {
emit('click'); emit("click");
}; };
const onKeydown = (evt) => { const onKeydown = (evt) => {
if (evt.which === 27) { // Esc if (evt.which === 27) {
// Esc
evt.preventDefault(); evt.preventDefault();
evt.stopPropagation(); evt.stopPropagation();
close(); close();
@ -146,4 +188,3 @@ defineExpose({
} }
} }
</style> </style>

View File

@ -1,14 +1,17 @@
<template> <template>
<div class="msjugskd _block"> <div class="msjugskd _block">
<i class="ph-airplane-takeoff ph-bold ph-lg" style="margin-right: 8px;"/> <i
{{ i18n.ts.accountMoved }} class="ph-airplane-takeoff ph-bold ph-lg"
<MkMention class="link" :username="acct" :host="host"/> style="margin-right: 8px"
</div> />
{{ i18n.ts.accountMoved }}
<MkMention class="link" :username="acct" :host="host" />
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import MkMention from './MkMention.vue'; import MkMention from "./MkMention.vue";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
defineProps<{ defineProps<{
acct: string; acct: string;

View File

@ -1,147 +1,286 @@
<template> <template>
<div <div
v-if="!muted.muted" v-if="!muted.muted"
v-show="!isDeleted" v-show="!isDeleted"
ref="el" ref="el"
v-hotkey="keymap" v-hotkey="keymap"
v-size="{ max: [500, 450, 350, 300] }" v-size="{ max: [500, 450, 350, 300] }"
class="tkcbzcuz" class="tkcbzcuz"
:tabindex="!isDeleted ? '-1' : null" :tabindex="!isDeleted ? '-1' : null"
:class="{ renote: isRenote }" :class="{ renote: isRenote }"
> >
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/> <MkNoteSub
<div class="note-context" @click="noteClick"> v-if="appearNote.reply"
<div class="line"></div> :note="appearNote.reply"
<div v-if="appearNote._prId_" class="info"><i class="ph-megaphone-simple-bold ph-lg"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click.stop="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ph-x ph-bold ph-lg"></i></button></div> class="reply-to"
<div v-if="appearNote._featuredId_" class="info"><i class="ph-lightning ph-bold ph-lg"></i> {{ i18n.ts.featured }}</div> />
<div v-if="pinned" class="info"><i class="ph-push-pin ph-bold ph-lg"></i>{{ i18n.ts.pinnedNote }}</div> <div class="note-context" @click="noteClick">
<div v-if="isRenote" class="renote"> <div class="line"></div>
<i class="ph-repeat ph-bold ph-lg"></i> <div v-if="appearNote._prId_" class="info">
<I18n :src="i18n.ts.renotedBy" tag="span"> <i class="ph-megaphone-simple-bold ph-lg"></i>
<template #user> {{ i18n.ts.promotion
<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)" @click.stop> }}<button class="_textButton hide" @click.stop="readPromo()">
<MkUserName :user="note.user"/> {{ i18n.ts.hideThisNote }}
</MkA> <i class="ph-x ph-bold ph-lg"></i>
</template>
</I18n>
<div class="info">
<button ref="renoteTime" class="_button time" @click.stop="showRenoteMenu()">
<i v-if="isMyRenote" class="ph-dots-three-outline ph-bold ph-lg dropdownIcon"></i>
<MkTime :time="note.createdAt"/>
</button> </button>
<MkVisibility :note="note"/> </div>
<div v-if="appearNote._featuredId_" class="info">
<i class="ph-lightning ph-bold ph-lg"></i>
{{ i18n.ts.featured }}
</div>
<div v-if="pinned" class="info">
<i class="ph-push-pin ph-bold ph-lg"></i
>{{ i18n.ts.pinnedNote }}
</div>
<div v-if="isRenote" class="renote">
<i class="ph-repeat ph-bold ph-lg"></i>
<I18n :src="i18n.ts.renotedBy" tag="span">
<template #user>
<MkA
v-user-preview="note.userId"
class="name"
:to="userPage(note.user)"
@click.stop
>
<MkUserName :user="note.user" />
</MkA>
</template>
</I18n>
<div class="info">
<button
ref="renoteTime"
class="_button time"
@click.stop="showRenoteMenu()"
>
<i
v-if="isMyRenote"
class="ph-dots-three-outline ph-bold ph-lg dropdownIcon"
></i>
<MkTime :time="note.createdAt" />
</button>
<MkVisibility :note="note" />
</div>
</div> </div>
</div> </div>
</div> <article
<article class="article" @contextmenu.stop="onContextmenu" @click="noteClick"> class="article"
<div class="main"> @contextmenu.stop="onContextmenu"
<div class="header-container"> @click="noteClick"
<MkAvatar class="avatar" :user="appearNote.user"/> >
<XNoteHeader class="header" :note="appearNote" :mini="true"/> <div class="main">
</div> <div class="header-container">
<div class="body"> <MkAvatar class="avatar" :user="appearNote.user" />
<p v-if="appearNote.cw != null" class="cw"> <XNoteHeader
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :custom-emojis="appearNote.emojis" :i="$i"/> class="header"
<br/> :note="appearNote"
<XCwButton v-model="showContent" :note="appearNote"/> :mini="true"
</p> />
<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed, isLong }"> </div>
<div class="text"> <div class="body">
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> <p v-if="appearNote.cw != null" class="cw">
<!-- <a v-if="appearNote.renote != null" class="rp">RN:</a> --> <Mfm
<div v-if="translating || translation" class="translation"> v-if="appearNote.cw != ''"
<MkLoading v-if="translating" mini/> class="text"
<div v-else class="translated"> :text="appearNote.cw"
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> :author="appearNote.user"
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> :custom-emojis="appearNote.emojis"
:i="$i"
/>
<br />
<XCwButton v-model="showContent" :note="appearNote" />
</p>
<div
v-show="appearNote.cw == null || showContent"
class="content"
:class="{ collapsed, isLong }"
>
<div class="text">
<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
>{{
i18n.t("translatedFrom", {
x: translation.sourceLang,
})
}}:
</b>
<Mfm
:text="translation.text"
:author="appearNote.user"
:i="$i"
:custom-emojis="appearNote.emojis"
/>
</div>
</div> </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"
@click.stop="
router.push(notePage(appearNote.renote))
"
/>
</div>
<button
v-if="isLong && collapsed"
class="fade _button"
@click.stop="collapsed = false"
>
<span>{{ i18n.ts.showMore }}</span>
</button>
<button
v-else-if="isLong && !collapsed"
class="showLess _button"
@click.stop="collapsed = true"
>
<span>{{ i18n.ts.showLess }}</span>
</button>
</div> </div>
<div v-if="appearNote.files.length > 0" class="files"> <MkA
<XMediaList :media-list="appearNote.files"/> v-if="appearNote.channel && !inChannel"
</div> class="channel"
<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/> :to="`/channels/${appearNote.channel.id}`"
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/> @click.stop
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote" @click.stop="router.push(notePage(appearNote.renote))"/></div> ><i class="ph-television ph-bold ph-lg"></i>
<button v-if="isLong && collapsed" class="fade _button" @click.stop="collapsed = false"> {{ appearNote.channel.name }}</MkA
<span>{{ i18n.ts.showMore }}</span> >
</button>
<button v-else-if="isLong && !collapsed" class="showLess _button" @click.stop="collapsed = true">
<span>{{ i18n.ts.showLess }}</span>
</button>
</div> </div>
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`" @click.stop><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA> <footer ref="el" class="footer" @click.stop>
<XReactionsViewer
ref="reactionsViewer"
:note="appearNote"
/>
<button
v-tooltip.noDelay.bottom="i18n.ts.reply"
class="button _button"
@click="reply()"
>
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
<template v-if="appearNote.repliesCount > 0">
<p class="count">{{ appearNote.repliesCount }}</p>
</template>
</button>
<XRenoteButton
ref="renoteButton"
class="button"
:note="appearNote"
:count="appearNote.renoteCount"
/>
<XStarButton
v-if="appearNote.myReaction == null"
ref="starButton"
class="button"
:note="appearNote"
/>
<button
v-if="appearNote.myReaction == null"
ref="reactButton"
v-tooltip.noDelay.bottom="i18n.ts.reaction"
class="button _button"
@click="react()"
>
<i class="ph-smiley ph-bold ph-lg"></i>
</button>
<button
v-if="appearNote.myReaction != null"
ref="reactButton"
class="button _button reacted"
@click="undoReact(appearNote)"
>
<i class="ph-minus ph-bold ph-lg"></i>
</button>
<XQuoteButton class="button" :note="appearNote" />
<button
ref="menuButton"
v-tooltip.noDelay.bottom="i18n.ts.more"
class="button _button"
@click="menu()"
>
<i class="ph-dots-three-outline ph-bold ph-lg"></i>
</button>
</footer>
</div> </div>
<footer ref="el" class="footer" @click.stop> </article>
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/> </div>
<button v-tooltip.noDelay.bottom="i18n.ts.reply" class="button _button" @click="reply()"> <div v-else class="muted" @click="muted.muted = false">
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i> <I18n :src="i18n.ts.userSaysSomethingReason" tag="small">
<template v-if="appearNote.repliesCount > 0"> <template #name>
<p class="count">{{ appearNote.repliesCount }}</p> <MkA
</template> v-user-preview="appearNote.userId"
</button> class="name"
<XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/> :to="userPage(appearNote.user)"
<XStarButton v-if="appearNote.myReaction == null" ref="starButton" class="button" :note="appearNote"/> >
<button v-if="appearNote.myReaction == null" ref="reactButton" v-tooltip.noDelay.bottom="i18n.ts.reaction" class="button _button" @click="react()"> <MkUserName :user="appearNote.user" />
<i class="ph-smiley ph-bold ph-lg"></i> </MkA>
</button> </template>
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)"> <template #reason>
<i class="ph-minus ph-bold ph-lg"></i> <b class="_blur_text">{{ muted.matched.join(", ") }}</b>
</button> </template>
<XQuoteButton class="button" :note="appearNote"/> </I18n>
<button ref="menuButton" v-tooltip.noDelay.bottom="i18n.ts.more" class="button _button" @click="menu()"> </div>
<i class="ph-dots-three-outline ph-bold ph-lg"></i>
</button>
</footer>
</div>
</article>
</div>
<div v-else class="muted" @click="muted.muted = false">
<I18n :src="i18n.ts.userSaysSomethingReason" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
<template #reason>
<b class="_blur_text">{{ muted.matched.join(", ") }}</b>
</template>
</I18n>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue'; import { computed, inject, onMounted, onUnmounted, reactive, ref } from "vue";
import * as mfm from 'mfm-js'; import * as mfm from "mfm-js";
import type { Ref } from 'vue'; import type { Ref } from "vue";
import type * as misskey from 'calckey-js'; import type * as misskey from "calckey-js";
import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSub from "@/components/MkNoteSub.vue";
import XNoteHeader from '@/components/MkNoteHeader.vue'; import XNoteHeader from "@/components/MkNoteHeader.vue";
import XNoteSimple from '@/components/MkNoteSimple.vue'; import XNoteSimple from "@/components/MkNoteSimple.vue";
import XMediaList from '@/components/MkMediaList.vue'; import XMediaList from "@/components/MkMediaList.vue";
import XCwButton from '@/components/MkCwButton.vue'; import XCwButton from "@/components/MkCwButton.vue";
import XPoll from '@/components/MkPoll.vue'; import XPoll from "@/components/MkPoll.vue";
import XRenoteButton from '@/components/MkRenoteButton.vue'; import XRenoteButton from "@/components/MkRenoteButton.vue";
import XReactionsViewer from '@/components/MkReactionsViewer.vue'; import XReactionsViewer from "@/components/MkReactionsViewer.vue";
import XStarButton from '@/components/MkStarButton.vue'; import XStarButton from "@/components/MkStarButton.vue";
import XQuoteButton from '@/components/MkQuoteButton.vue'; import XQuoteButton from "@/components/MkQuoteButton.vue";
import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkUrlPreview from "@/components/MkUrlPreview.vue";
import MkVisibility from '@/components/MkVisibility.vue'; import MkVisibility from "@/components/MkVisibility.vue";
import { pleaseLogin } from '@/scripts/please-login'; import { pleaseLogin } from "@/scripts/please-login";
import { focusPrev, focusNext } from '@/scripts/focus'; import { focusPrev, focusNext } from "@/scripts/focus";
import { getWordMute } from '@/scripts/check-word-mute'; import { getWordMute } from "@/scripts/check-word-mute";
import { useRouter } from '@/router'; import { useRouter } from "@/router";
import { userPage } from '@/filters/user'; import { userPage } from "@/filters/user";
import * as os from '@/os'; import * as os from "@/os";
import { defaultStore, noteViewInterruptors } from '@/store'; import { defaultStore, noteViewInterruptors } from "@/store";
import { reactionPicker } from '@/scripts/reaction-picker'; import { reactionPicker } from "@/scripts/reaction-picker";
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; import { extractUrlFromMfm } from "@/scripts/extract-url-from-mfm";
import { $i } from '@/account'; import { $i } from "@/account";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import { getNoteMenu } from '@/scripts/get-note-menu'; import { getNoteMenu } from "@/scripts/get-note-menu";
import { useNoteCapture } from '@/scripts/use-note-capture'; import { useNoteCapture } from "@/scripts/use-note-capture";
import { notePage } from '@/filters/note'; import { notePage } from "@/filters/note";
import { deepClone } from '@/scripts/clone'; import { deepClone } from "@/scripts/clone";
const router = useRouter(); const router = useRouter();
@ -150,7 +289,7 @@ const props = defineProps<{
pinned?: boolean; pinned?: boolean;
}>(); }>();
const inChannel = inject('inChannel', null); const inChannel = inject("inChannel", null);
let note = $ref(deepClone(props.note)); let note = $ref(deepClone(props.note));
@ -165,12 +304,11 @@ if (noteViewInterruptors.length > 0) {
}); });
} }
const isRenote = ( const isRenote =
note.renote != null && note.renote != null &&
note.text == null && note.text == null &&
note.fileIds.length === 0 && note.fileIds.length === 0 &&
note.poll == null note.poll == null;
);
const el = ref<HTMLElement>(); const el = ref<HTMLElement>();
const menuButton = ref<HTMLElement>(); const menuButton = ref<HTMLElement>();
@ -178,29 +316,33 @@ const starButton = ref<InstanceType<typeof XStarButton>>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>(); const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
const renoteTime = ref<HTMLElement>(); const renoteTime = ref<HTMLElement>();
const reactButton = ref<HTMLElement>(); const reactButton = ref<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note); let appearNote = $computed(() =>
const isMyRenote = $i && ($i.id === note.userId); isRenote ? (note.renote as misskey.entities.Note) : note
);
const isMyRenote = $i && $i.id === note.userId;
const showContent = ref(false); const showContent = ref(false);
const isLong = (appearNote.cw == null && appearNote.text != null && ( const isLong =
(appearNote.text.split('\n').length > 9) || appearNote.cw == null &&
(appearNote.text.length > 500) appearNote.text != null &&
)); (appearNote.text.split("\n").length > 9 || appearNote.text.length > 500);
const collapsed = ref(appearNote.cw == null && isLong); const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = ref(false); const isDeleted = ref(false);
const muted = ref(getWordMute(appearNote, $i, defaultStore.state.mutedWords)); const muted = ref(getWordMute(appearNote, $i, defaultStore.state.mutedWords));
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5) : null; const urls = appearNote.text
? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5)
: null;
const keymap = { const keymap = {
'r': () => reply(true), r: () => reply(true),
'e|a|plus': () => react(true), "e|a|plus": () => react(true),
'q': () => renoteButton.value.renote(true), q: () => renoteButton.value.renote(true),
'up|k|shift+tab': focusBefore, "up|k|shift+tab": focusBefore,
'down|j|tab': focusAfter, "down|j|tab": focusAfter,
'esc': blur, esc: blur,
'm|o': () => menu(true), "m|o": () => menu(true),
's': () => showContent.value !== showContent.value, s: () => showContent.value !== showContent.value,
}; };
useNoteCapture({ useNoteCapture({
@ -211,76 +353,113 @@ useNoteCapture({
function reply(viaKeyboard = false): void { function reply(viaKeyboard = false): void {
pleaseLogin(); pleaseLogin();
os.post({ os.post(
reply: appearNote, {
animation: !viaKeyboard, reply: appearNote,
}, () => { animation: !viaKeyboard,
focus(); },
}); () => {
focus();
}
);
} }
function react(viaKeyboard = false): void { function react(viaKeyboard = false): void {
pleaseLogin(); pleaseLogin();
blur(); blur();
reactionPicker.show(reactButton.value, reaction => { reactionPicker.show(
os.api('notes/reactions/create', { reactButton.value,
noteId: appearNote.id, (reaction) => {
reaction: reaction, os.api("notes/reactions/create", {
}); noteId: appearNote.id,
}, () => { reaction: reaction,
focus(); });
}); },
() => {
focus();
}
);
} }
function undoReact(note): void { function undoReact(note): void {
const oldReaction = note.myReaction; const oldReaction = note.myReaction;
if (!oldReaction) return; if (!oldReaction) return;
os.api('notes/reactions/delete', { os.api("notes/reactions/delete", {
noteId: note.id, noteId: note.id,
}); });
} }
const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null); const currentClipPage = inject<Ref<misskey.entities.Clip> | null>(
"currentClipPage",
null
);
function onContextmenu(ev: MouseEvent): void { function onContextmenu(ev: MouseEvent): void {
const isLink = (el: HTMLElement) => { const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true; if (el.tagName === "A") return true;
if (el.parentElement) { if (el.parentElement) {
return isLink(el.parentElement); return isLink(el.parentElement);
} }
}; };
if (isLink(ev.target)) return; if (isLink(ev.target)) return;
if (window.getSelection().toString() !== '') return; if (window.getSelection().toString() !== "") return;
if (defaultStore.state.useReactionPickerForContextMenu) { if (defaultStore.state.useReactionPickerForContextMenu) {
ev.preventDefault(); ev.preventDefault();
react(); react();
} else { } else {
os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), ev).then(focus); os.contextMenu(
getNoteMenu({
note: note,
translating,
translation,
menuButton,
isDeleted,
currentClipPage,
}),
ev
).then(focus);
} }
} }
function menu(viaKeyboard = false): void { function menu(viaKeyboard = false): void {
os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, { os.popupMenu(
viaKeyboard, getNoteMenu({
}).then(focus); note: note,
translating,
translation,
menuButton,
isDeleted,
currentClipPage,
}),
menuButton.value,
{
viaKeyboard,
}
).then(focus);
} }
function showRenoteMenu(viaKeyboard = false): void { function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return; if (!isMyRenote) return;
os.popupMenu([{ os.popupMenu(
text: i18n.ts.unrenote, [
icon: 'ph-trash ph-bold ph-lg', {
danger: true, text: i18n.ts.unrenote,
action: () => { icon: "ph-trash ph-bold ph-lg",
os.api('notes/delete', { danger: true,
noteId: note.id, action: () => {
}); os.api("notes/delete", {
isDeleted.value = true; noteId: note.id,
}, });
}], renoteTime.value, { isDeleted.value = true;
viaKeyboard: viaKeyboard, },
}); },
],
renoteTime.value,
{
viaKeyboard: viaKeyboard,
}
);
} }
function focus() { function focus() {
@ -300,15 +479,15 @@ function focusAfter() {
} }
function noteClick(e) { function noteClick(e) {
if (document.getSelection().type === 'Range') { if (document.getSelection().type === "Range") {
e.stopPropagation(); e.stopPropagation();
} else { } else {
router.push(notePage(appearNote)) router.push(notePage(appearNote));
} }
} }
function readPromo() { function readPromo() {
os.api('promo/read', { os.api("promo/read", {
noteId: appearNote.id, noteId: appearNote.id,
}); });
isDeleted.value = true; isDeleted.value = true;
@ -329,7 +508,7 @@ function readPromo() {
// //
// () // ()
//content-visibility: auto; //content-visibility: auto;
//contain-intrinsic-size: 0 128px; //contain-intrinsic-size: 0 128px;
&:focus-visible { &:focus-visible {
outline: none; outline: none;
@ -354,7 +533,8 @@ function readPromo() {
} }
& > .article > .main { & > .article > .main {
&:hover, &:focus-within { &:hover,
&:focus-within {
:deep(.footer .button) { :deep(.footer .button) {
opacity: 1; opacity: 1;
} }
@ -395,7 +575,7 @@ function readPromo() {
} }
> div > i { > div > i {
margin-left: -.5px; margin-left: -0.5px;
} }
> .info { > .info {
display: flex; display: flex;
@ -414,7 +594,6 @@ function readPromo() {
} }
} }
> .renote { > .renote {
display: flex; display: flex;
align-items: center; align-items: center;
@ -459,7 +638,6 @@ function readPromo() {
padding: 4px 32px 10px; padding: 4px 32px 10px;
cursor: pointer; cursor: pointer;
@media (pointer: coarse) { @media (pointer: coarse) {
cursor: default; cursor: default;
} }
@ -486,7 +664,7 @@ function readPromo() {
min-width: 0; min-width: 0;
> .body { > .body {
margin-top: .7em; margin-top: 0.7em;
> .cw { > .cw {
cursor: default; cursor: default;
@ -525,8 +703,14 @@ function readPromo() {
overflow: hidden; overflow: hidden;
> .text { > .text {
max-height: 9em; max-height: 9em;
mask: linear-gradient(black calc(100% - 64px), transparent); mask: linear-gradient(
-webkit-mask: linear-gradient(black calc(100% - 64px), transparent); black calc(100% - 64px),
transparent
);
-webkit-mask: linear-gradient(
black calc(100% - 64px),
transparent
);
} }
> .fade { > .fade {
display: block; display: block;
@ -576,8 +760,8 @@ function readPromo() {
} }
> .files { > .files {
margin-top: .4em; margin-top: 0.4em;
margin-bottom: .4em; margin-bottom: 0.4em;
} }
> .url-preview { > .url-preview {
margin-top: 8px; margin-top: 8px;
@ -594,8 +778,9 @@ function readPromo() {
padding: 16px; padding: 16px;
border: solid 1px var(--renote); border: solid 1px var(--renote);
border-radius: 8px; border-radius: 8px;
transition: background .2s; transition: background 0.2s;
&:hover, &:focus-within { &:hover,
&:focus-within {
background-color: var(--panelHighlight); background-color: var(--panelHighlight);
} }
} }
@ -623,9 +808,9 @@ function readPromo() {
width: max-content; width: max-content;
min-width: max-content; min-width: max-content;
pointer-events: all; pointer-events: all;
transition: opacity .2s; transition: opacity 0.2s;
&:first-of-type { &:first-of-type {
margin-left: -.5em; margin-left: -0.5em;
} }
&:hover { &:hover {
color: var(--fgHighlighted); color: var(--fgHighlighted);
@ -645,7 +830,6 @@ function readPromo() {
} }
} }
> .reply { > .reply {
border-top: solid 0.5px var(--divider); border-top: solid 0.5px var(--divider);
} }
@ -679,8 +863,6 @@ function readPromo() {
} }
} }
&.max-width_300px { &.max-width_300px {
--avatarSize: 40px; --avatarSize: 40px;
} }

View File

@ -1,153 +1,309 @@
<template> <template>
<div <div
v-if="!muted.muted" v-if="!muted.muted"
v-show="!isDeleted" v-show="!isDeleted"
ref="el" ref="el"
v-hotkey="keymap" v-hotkey="keymap"
v-size="{ max: [500, 450, 350, 300] }" v-size="{ max: [500, 450, 350, 300] }"
class="lxwezrsl _block" class="lxwezrsl _block"
:tabindex="!isDeleted ? '-1' : null" :tabindex="!isDeleted ? '-1' : null"
:class="{ renote: isRenote }" :class="{ renote: isRenote }"
> >
<MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/> <MkNoteSub
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/> v-for="note in conversation"
<div v-if="isRenote" class="renote"> :key="note.id"
<MkAvatar class="avatar" :user="note.user"/> class="reply-to-more"
<i class="ph-repeat ph-bold ph-lg"></i> :note="note"
<I18n :src="i18n.ts.renotedBy" tag="span"> />
<template #user> <MkNoteSub
<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)"> v-if="appearNote.reply"
<MkUserName :user="note.user"/> :note="appearNote.reply"
</MkA> class="reply-to"
</template> />
</I18n> <div v-if="isRenote" class="renote">
<div class="info"> <MkAvatar class="avatar" :user="note.user" />
<button ref="renoteTime" class="_button time" @click="showRenoteMenu()"> <i class="ph-repeat ph-bold ph-lg"></i>
<i v-if="isMyRenote" class="ph-dots-three-outline ph-bold ph-lg dropdownIcon"></i> <I18n :src="i18n.ts.renotedBy" tag="span">
<MkTime :time="note.createdAt"/> <template #user>
</button> <MkA
<MkVisibility :note="note"/> v-user-preview="note.userId"
</div> class="name"
</div> :to="userPage(note.user)"
<article ref="noteEl" class="article" @contextmenu.stop="onContextmenu" tabindex="-1"> >
<header class="header"> <MkUserName :user="note.user" />
<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 :user="appearNote.user"/>
</MkA> </MkA>
<span v-if="appearNote.user.isBot" class="is-bot">bot</span> </template>
<div class="info"> </I18n>
<MkVisibility :note="appearNote"/> <div class="info">
</div> <button
</div> ref="renoteTime"
<div class="username"><MkAcct :user="appearNote.user"/></div> class="_button time"
<MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> @click="showRenoteMenu()"
>
<i
v-if="isMyRenote"
class="ph-dots-three-outline ph-bold ph-lg dropdownIcon"
></i>
<MkTime :time="note.createdAt" />
</button>
<MkVisibility :note="note" />
</div> </div>
</header> </div>
<div class="main"> <article
<div class="body"> ref="noteEl"
<div v-if="appearNote.cw != null" class="cw"> class="article"
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> @contextmenu.stop="onContextmenu"
<br/> tabindex="-1"
<XCwButton v-model="showContent" :note="appearNote"/> >
</div> <header class="header">
<div v-show="appearNote.cw == null || showContent" class="content"> <MkAvatar
<div class="text"> class="avatar"
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> :user="appearNote.user"
<div v-if="translating || translation" class="translation"> :show-indicator="true"
<MkLoading v-if="translating" mini/> />
<div v-else class="translated"> <div class="body">
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> <div class="top">
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> <MkA
</div> v-user-preview="appearNote.user.id"
class="name"
:to="userPage(appearNote.user)"
>
<MkUserName :user="appearNote.user" />
</MkA>
<span v-if="appearNote.user.isBot" class="is-bot"
>bot</span
>
<div class="info">
<MkVisibility :note="appearNote" />
</div> </div>
</div> </div>
<div v-if="appearNote.files.length > 0" class="files"> <div class="username">
<XMediaList :media-list="appearNote.files"/> <MkAcct :user="appearNote.user" />
</div> </div>
<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/> <MkInstanceTicker
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" class="url-preview"/> v-if="showTicker"
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote" @click.stop="router.push(notePage(appearNote.renote))"/></div> class="ticker"
:instance="appearNote.user.instance"
/>
</div> </div>
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA> </header>
<div class="main">
<div class="body">
<div 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"
/>
<br />
<XCwButton v-model="showContent" :note="appearNote" />
</div>
<div
v-show="appearNote.cw == null || showContent"
class="content"
>
<div class="text">
<Mfm
v-if="appearNote.text"
:text="appearNote.text"
:author="appearNote.user"
:i="$i"
:custom-emojis="appearNote.emojis"
/>
<div
v-if="translating || translation"
class="translation"
>
<MkLoading v-if="translating" mini />
<div v-else class="translated">
<b
>{{
i18n.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"
@click.stop="
router.push(notePage(appearNote.renote))
"
/>
</div>
</div>
<MkA
v-if="appearNote.channel && !inChannel"
class="channel"
:to="`/channels/${appearNote.channel.id}`"
><i class="ph-television ph-bold ph-lg"></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
v-tooltip.noDelay.bottom="i18n.ts.reply"
class="button _button"
@click="reply()"
>
<template v-if="appearNote.reply"
><i class="ph-arrow-u-up-left ph-bold ph-lg"></i
></template>
<template v-else
><i class="ph-arrow-bend-up-left ph-bold ph-lg"></i
></template>
<p v-if="appearNote.repliesCount > 0" class="count">
{{ appearNote.repliesCount }}
</p>
</button>
<XRenoteButton
ref="renoteButton"
class="button"
:note="appearNote"
:count="appearNote.renoteCount"
/>
<XStarButton
v-if="appearNote.myReaction == null"
ref="starButton"
class="button"
:note="appearNote"
/>
<button
v-if="appearNote.myReaction == null"
ref="reactButton"
v-tooltip.noDelay.bottom="i18n.ts.reaction"
class="button _button"
@click="react()"
>
<i class="ph-smiley ph-bold ph-lg"></i>
</button>
<button
v-if="appearNote.myReaction != null"
ref="reactButton"
class="button _button reacted"
@click="undoReact(appearNote)"
>
<i class="ph-minus ph-bold ph-lg"></i>
</button>
<XQuoteButton class="button" :note="appearNote" />
<button
ref="menuButton"
v-tooltip.noDelay.bottom="i18n.ts.more"
class="button _button"
@click="menu()"
>
<i class="ph-dots-three-outline ph-bold ph-lg"></i>
</button>
</footer>
</div> </div>
<footer class="footer"> </article>
<div class="info"> <MkNoteSub
<MkA class="created-at" :to="notePage(appearNote)"> v-for="note in directReplies"
<MkTime :time="appearNote.createdAt" mode="detail"/> :key="note.id"
</MkA> :note="note"
</div> class="reply"
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/> :conversation="replies"
<button v-tooltip.noDelay.bottom="i18n.ts.reply" class="button _button" @click="reply()"> />
<template v-if="appearNote.reply"><i class="ph-arrow-u-up-left ph-bold ph-lg"></i></template> </div>
<template v-else><i class="ph-arrow-bend-up-left ph-bold ph-lg"></i></template> <div v-else class="_panel muted" @click="muted.muted = false">
<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p> <I18n :src="i18n.ts.userSaysSomethingReason" tag="small">
</button> <template #name>
<XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/> <MkA
<XStarButton v-if="appearNote.myReaction == null" ref="starButton" class="button" :note="appearNote"/> v-user-preview="appearNote.userId"
<button v-if="appearNote.myReaction == null" ref="reactButton" v-tooltip.noDelay.bottom="i18n.ts.reaction" class="button _button" @click="react()"> class="name"
<i class="ph-smiley ph-bold ph-lg"></i> :to="userPage(appearNote.user)"
</button> >
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)"> <MkUserName :user="appearNote.user" />
<i class="ph-minus ph-bold ph-lg"></i> </MkA>
</button> </template>
<XQuoteButton class="button" :note="appearNote"/> <template #reason>
<button ref="menuButton" v-tooltip.noDelay.bottom="i18n.ts.more" class="button _button" @click="menu()"> <b class="_blur_text">{{ muted.matched.join(", ") }}</b>
<i class="ph-dots-three-outline ph-bold ph-lg"></i> </template>
</button> </I18n>
</footer> </div>
</div>
</article>
<MkNoteSub v-for="note in directReplies" :key="note.id" :note="note" class="reply" :conversation="replies"/>
</div>
<div v-else class="_panel muted" @click="muted.muted = false">
<I18n :src="i18n.ts.userSaysSomethingReason" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
<template #reason>
<b class="_blur_text">{{ muted.matched.join(", ") }}</b>
</template>
</I18n>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, onMounted, onUnmounted, onUpdated, reactive, ref } from 'vue'; import {
import * as mfm from 'mfm-js'; computed,
import type * as misskey from 'calckey-js'; inject,
import MkNoteSub from '@/components/MkNoteSub.vue'; onMounted,
import XNoteSimple from '@/components/MkNoteSimple.vue'; onUnmounted,
import XReactionsViewer from '@/components/MkReactionsViewer.vue'; onUpdated,
import XMediaList from '@/components/MkMediaList.vue'; reactive,
import XCwButton from '@/components/MkCwButton.vue'; ref,
import XPoll from '@/components/MkPoll.vue'; } from "vue";
import XStarButton from '@/components/MkStarButton.vue'; import * as mfm from "mfm-js";
import XRenoteButton from '@/components/MkRenoteButton.vue'; import type * as misskey from "calckey-js";
import XQuoteButton from '@/components/MkQuoteButton.vue'; import MkNoteSub from "@/components/MkNoteSub.vue";
import MkUrlPreview from '@/components/MkUrlPreview.vue'; import XNoteSimple from "@/components/MkNoteSimple.vue";
import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import XReactionsViewer from "@/components/MkReactionsViewer.vue";
import MkVisibility from '@/components/MkVisibility.vue'; import XMediaList from "@/components/MkMediaList.vue";
import { pleaseLogin } from '@/scripts/please-login'; import XCwButton from "@/components/MkCwButton.vue";
import { getWordMute } from '@/scripts/check-word-mute'; import XPoll from "@/components/MkPoll.vue";
import { userPage } from '@/filters/user'; import XStarButton from "@/components/MkStarButton.vue";
import { notePage } from '@/filters/note'; import XRenoteButton from "@/components/MkRenoteButton.vue";
import { useRouter } from '@/router'; import XQuoteButton from "@/components/MkQuoteButton.vue";
import * as os from '@/os'; import MkUrlPreview from "@/components/MkUrlPreview.vue";
import { defaultStore, noteViewInterruptors } from '@/store'; import MkInstanceTicker from "@/components/MkInstanceTicker.vue";
import { reactionPicker } from '@/scripts/reaction-picker'; import MkVisibility from "@/components/MkVisibility.vue";
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; import { pleaseLogin } from "@/scripts/please-login";
import { $i } from '@/account'; import { getWordMute } from "@/scripts/check-word-mute";
import { i18n } from '@/i18n'; import { userPage } from "@/filters/user";
import { getNoteMenu } from '@/scripts/get-note-menu'; import { notePage } from "@/filters/note";
import { useNoteCapture } from '@/scripts/use-note-capture'; import { useRouter } from "@/router";
import { deepClone } from '@/scripts/clone'; import * as os from "@/os";
import { stream } from '@/stream'; import { defaultStore, noteViewInterruptors } from "@/store";
import { NoteUpdatedEvent } from 'calckey-js/built/streaming.types'; 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";
import { stream } from "@/stream";
import { NoteUpdatedEvent } from "calckey-js/built/streaming.types";
const router = useRouter(); const router = useRouter();
@ -156,7 +312,7 @@ const props = defineProps<{
pinned?: boolean; pinned?: boolean;
}>(); }>();
const inChannel = inject('inChannel', null); const inChannel = inject("inChannel", null);
let note = $ref(deepClone(props.note)); let note = $ref(deepClone(props.note));
@ -171,12 +327,11 @@ if (noteViewInterruptors.length > 0) {
}); });
} }
const isRenote = ( const isRenote =
note.renote != null && note.renote != null &&
note.text == null && note.text == null &&
note.fileIds.length === 0 && note.fileIds.length === 0 &&
note.poll == null note.poll == null;
);
const el = ref<HTMLElement>(); const el = ref<HTMLElement>();
const noteEl = $ref(); const noteEl = $ref();
@ -185,28 +340,34 @@ const starButton = ref<InstanceType<typeof XStarButton>>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>(); const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
const renoteTime = ref<HTMLElement>(); const renoteTime = ref<HTMLElement>();
const reactButton = ref<HTMLElement>(); const reactButton = ref<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note); let appearNote = $computed(() =>
const isMyRenote = $i && ($i.id === note.userId); isRenote ? (note.renote as misskey.entities.Note) : note
);
const isMyRenote = $i && $i.id === note.userId;
const showContent = ref(false); const showContent = ref(false);
const isDeleted = ref(false); const isDeleted = ref(false);
const muted = ref(getWordMute(appearNote, $i, defaultStore.state.mutedWords)); const muted = ref(getWordMute(appearNote, $i, defaultStore.state.mutedWords));
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5) : null; const urls = appearNote.text
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); ? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5)
: null;
const showTicker =
defaultStore.state.instanceTicker === "always" ||
(defaultStore.state.instanceTicker === "remote" &&
appearNote.user.instance);
const conversation = ref<misskey.entities.Note[]>([]); const conversation = ref<misskey.entities.Note[]>([]);
const replies = ref<misskey.entities.Note[]>([]); const replies = ref<misskey.entities.Note[]>([]);
const directReplies = ref<misskey.entities.Note[]>([]); const directReplies = ref<misskey.entities.Note[]>([]);
let isScrolling; let isScrolling;
const keymap = { const keymap = {
'r': () => reply(true), r: () => reply(true),
'e|a|plus': () => react(true), "e|a|plus": () => react(true),
'q': () => renoteButton.value.renote(true), q: () => renoteButton.value.renote(true),
'esc': blur, esc: blur,
'm|o': () => menu(true), "m|o": () => menu(true),
's': () => showContent.value !== showContent.value, s: () => showContent.value !== showContent.value,
}; };
useNoteCapture({ useNoteCapture({
@ -217,74 +378,106 @@ useNoteCapture({
function reply(viaKeyboard = false): void { function reply(viaKeyboard = false): void {
pleaseLogin(); pleaseLogin();
os.post({ os.post(
reply: appearNote, {
animation: !viaKeyboard, reply: appearNote,
}, () => { animation: !viaKeyboard,
focus(); },
}); () => {
focus();
}
);
} }
function react(viaKeyboard = false): void { function react(viaKeyboard = false): void {
pleaseLogin(); pleaseLogin();
blur(); blur();
reactionPicker.show(reactButton.value, reaction => { reactionPicker.show(
os.api('notes/reactions/create', { reactButton.value,
noteId: appearNote.id, (reaction) => {
reaction: reaction, os.api("notes/reactions/create", {
}); noteId: appearNote.id,
}, () => { reaction: reaction,
focus(); });
}); },
() => {
focus();
}
);
} }
function undoReact(note): void { function undoReact(note): void {
const oldReaction = note.myReaction; const oldReaction = note.myReaction;
if (!oldReaction) return; if (!oldReaction) return;
os.api('notes/reactions/delete', { os.api("notes/reactions/delete", {
noteId: note.id, noteId: note.id,
}); });
} }
function onContextmenu(ev: MouseEvent): void { function onContextmenu(ev: MouseEvent): void {
const isLink = (el: HTMLElement) => { const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true; if (el.tagName === "A") return true;
if (el.parentElement) { if (el.parentElement) {
return isLink(el.parentElement); return isLink(el.parentElement);
} }
}; };
if (isLink(ev.target)) return; if (isLink(ev.target)) return;
if (window.getSelection().toString() !== '') return; if (window.getSelection().toString() !== "") return;
if (defaultStore.state.useReactionPickerForContextMenu) { if (defaultStore.state.useReactionPickerForContextMenu) {
ev.preventDefault(); ev.preventDefault();
react(); react();
} else { } else {
os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), ev).then(focus); os.contextMenu(
getNoteMenu({
note: note,
translating,
translation,
menuButton,
isDeleted,
}),
ev
).then(focus);
} }
} }
function menu(viaKeyboard = false): void { function menu(viaKeyboard = false): void {
os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), menuButton.value, { os.popupMenu(
viaKeyboard, getNoteMenu({
}).then(focus); note: note,
translating,
translation,
menuButton,
isDeleted,
}),
menuButton.value,
{
viaKeyboard,
}
).then(focus);
} }
function showRenoteMenu(viaKeyboard = false): void { function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return; if (!isMyRenote) return;
os.popupMenu([{ os.popupMenu(
text: i18n.ts.unrenote, [
icon: 'ph-trash ph-bold ph-lg', {
danger: true, text: i18n.ts.unrenote,
action: () => { icon: "ph-trash ph-bold ph-lg",
os.api('notes/delete', { danger: true,
noteId: note.id, action: () => {
}); os.api("notes/delete", {
isDeleted.value = true; noteId: note.id,
}, });
}], renoteTime.value, { isDeleted.value = true;
viaKeyboard: viaKeyboard, },
}); },
],
renoteTime.value,
{
viaKeyboard: viaKeyboard,
}
);
} }
function focus() { function focus() {
@ -295,20 +488,26 @@ function blur() {
noteEl.blur(); noteEl.blur();
} }
os.api('notes/children', { os.api("notes/children", {
noteId: appearNote.id, noteId: appearNote.id,
limit: 30, limit: 30,
depth: 12, depth: 12,
}).then(res => { }).then((res) => {
replies.value = res; replies.value = res;
directReplies.value = res.filter(note => note.replyId === appearNote.id || note.renoteId === appearNote.id).reverse(); directReplies.value = res
.filter(
(note) =>
note.replyId === appearNote.id ||
note.renoteId === appearNote.id
)
.reverse();
}); });
if (appearNote.replyId) { if (appearNote.replyId) {
os.api('notes/conversation', { os.api("notes/conversation", {
noteId: appearNote.replyId, noteId: appearNote.replyId,
limit: 30, limit: 30,
}).then(res => { }).then((res) => {
conversation.value = res.reverse(); conversation.value = res.reverse();
focus(); focus();
}); });
@ -316,24 +515,23 @@ if (appearNote.replyId) {
function onNoteReplied(noteData: NoteUpdatedEvent): void { function onNoteReplied(noteData: NoteUpdatedEvent): void {
const { type, id, body } = noteData; const { type, id, body } = noteData;
if (type === 'replied' && id === appearNote.id) { if (type === "replied" && id === appearNote.id) {
const { id: createdId } = body; const { id: createdId } = body;
os.api('notes/show', { os.api("notes/show", {
noteId: createdId, noteId: createdId,
}).then(note => { }).then((note) => {
if (note.replyId === appearNote.id) { if (note.replyId === appearNote.id) {
replies.value.unshift(note); replies.value.unshift(note);
directReplies.value.unshift(note); directReplies.value.unshift(note);
} }
}); });
} }
} }
document.addEventListener("wheel", () => { document.addEventListener("wheel", () => {
isScrolling = true; isScrolling = true;
}) });
onMounted(() => { onMounted(() => {
stream.on("noteUpdated", onNoteReplied); stream.on("noteUpdated", onNoteReplied);
@ -343,14 +541,13 @@ onMounted(() => {
onUpdated(() => { onUpdated(() => {
if (!isScrolling) { if (!isScrolling) {
noteEl.scrollIntoView() noteEl.scrollIntoView();
} }
}) });
onUnmounted(() => { onUnmounted(() => {
stream.off("noteUpdated", onNoteReplied); stream.off("noteUpdated", onNoteReplied);
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -558,8 +755,9 @@ onUnmounted(() => {
padding: 16px; padding: 16px;
border: solid 1px var(--renote); border: solid 1px var(--renote);
border-radius: 8px; border-radius: 8px;
transition: background .2s; transition: background 0.2s;
&:hover, &:focus-within { &:hover,
&:focus-within {
background-color: var(--panelHighlight); background-color: var(--panelHighlight);
} }
} }
@ -617,7 +815,10 @@ onUnmounted(() => {
} }
// Hover // Hover
.reply :deep(.main), .reply-to, .reply-to-more, :deep(.more) { .reply :deep(.main),
.reply-to,
.reply-to-more,
:deep(.more) {
position: relative; position: relative;
&::before { &::before {
content: ""; content: "";
@ -627,10 +828,11 @@ onUnmounted(() => {
background: var(--panelHighlight); background: var(--panelHighlight);
border-radius: var(--radius); border-radius: var(--radius);
opacity: 0; opacity: 0;
transition: opacity .2s; transition: opacity 0.2s;
z-index: -1; z-index: -1;
} }
&.reply-to, &.reply-to-more { &.reply-to,
&.reply-to-more {
&::before { &::before {
inset: 0px 8px; inset: 0px 8px;
} }
@ -651,7 +853,8 @@ onUnmounted(() => {
&.more::before { &.more::before {
inset: 0 !important; inset: 0 !important;
} }
&:hover, &:focus-within { &:hover,
&:focus-within {
&::before { &::before {
opacity: 1; opacity: 1;
} }
@ -668,13 +871,11 @@ onUnmounted(() => {
// } // }
} }
&.max-width_500px { &.max-width_500px {
font-size: 0.9em; font-size: 0.9em;
} }
&.max-width_450px { &.max-width_450px {
> .reply-to-more:first-child { > .reply-to-more:first-child {
padding-top: 14px; padding-top: 14px;
} }
@ -708,7 +909,6 @@ onUnmounted(() => {
font-size: 0.825em; font-size: 0.825em;
> .article { > .article {
> .main { > .main {
> .footer { > .footer {
> .button { > .button {

View File

@ -1,36 +1,45 @@
<template> <template>
<header class="kkwtjztg"> <header class="kkwtjztg">
<div class="user-info"> <div class="user-info">
<div> <div>
<MkA v-user-preview="note.user.id" class="name" :to="userPage(note.user)" @click.stop> <MkA
<MkUserName :user="note.user" class="mkusername"> v-user-preview="note.user.id"
<span v-if="note.user.isBot" class="is-bot">bot</span> class="name"
</MkUserName> :to="userPage(note.user)"
</MkA> @click.stop
<div class="username"><MkAcct :user="note.user"/></div> >
</div> <MkUserName :user="note.user" class="mkusername">
<div> <span v-if="note.user.isBot" class="is-bot">bot</span>
<div class="info"> </MkUserName>
<MkA class="created-at" :to="notePage(note)">
<MkTime :time="note.createdAt"/>
</MkA> </MkA>
<MkVisibility :note="note"/> <div class="username"><MkAcct :user="note.user" /></div>
</div>
<div>
<div class="info">
<MkA class="created-at" :to="notePage(note)">
<MkTime :time="note.createdAt" />
</MkA>
<MkVisibility :note="note" />
</div>
<MkInstanceTicker
v-if="showTicker"
class="ticker"
:instance="note.user.instance"
/>
</div> </div>
<MkInstanceTicker v-if="showTicker" class="ticker" :instance="note.user.instance"/>
</div> </div>
</div> </header>
</header>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import type * as misskey from 'calckey-js'; import type * as misskey from "calckey-js";
import { defaultStore, noteViewInterruptors } from '@/store'; import { defaultStore, noteViewInterruptors } from "@/store";
import MkVisibility from '@/components/MkVisibility.vue'; import MkVisibility from "@/components/MkVisibility.vue";
import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import MkInstanceTicker from "@/components/MkInstanceTicker.vue";
import { notePage } from '@/filters/note'; import { notePage } from "@/filters/note";
import { userPage } from '@/filters/user'; import { userPage } from "@/filters/user";
import { deepClone } from '@/scripts/clone'; import { deepClone } from "@/scripts/clone";
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
@ -39,10 +48,9 @@ const props = defineProps<{
let note = $ref(deepClone(props.note)); let note = $ref(deepClone(props.note));
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && note.user.instance); const showTicker =
defaultStore.state.instanceTicker === "always" ||
(defaultStore.state.instanceTicker === "remote" && note.user.instance);
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -54,7 +62,7 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS
white-space: nowrap; white-space: nowrap;
justify-self: flex-end; justify-self: flex-end;
border-radius: 100px; border-radius: 100px;
font-size: .8em; font-size: 0.8em;
text-shadow: 0 2px 2px var(--shadow); text-shadow: 0 2px 2px var(--shadow);
> .avatar { > .avatar {
width: 3.7em; width: 3.7em;
@ -73,11 +81,11 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS
width: 0; width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
gap: .1em 0; gap: 0.1em 0;
} }
&:last-child { &:last-child {
max-width: 50%; max-width: 50%;
gap: .3em .5em; gap: 0.3em 0.5em;
} }
.article > .main & { .article > .main & {
display: flex; display: flex;
@ -94,17 +102,17 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS
.name { .name {
// flex: 1 1 0px; // flex: 1 1 0px;
display: inline; display: inline;
margin: 0 .5em 0 0; margin: 0 0.5em 0 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
font-weight: bold; font-weight: bold;
text-decoration: none; text-decoration: none;
text-overflow: ellipsis; text-overflow: ellipsis;
.mkusername >.is-bot { .mkusername > .is-bot {
flex-shrink: 0; flex-shrink: 0;
align-self: center; align-self: center;
margin: 0 .5em 0 0; margin: 0 0.5em 0 0;
padding: 1px 6px; padding: 1px 6px;
font-size: 80%; font-size: 80%;
border: solid 0.5px var(--divider); border: solid 0.5px var(--divider);
@ -118,17 +126,17 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS
.username { .username {
display: inline; display: inline;
margin: 0 .5em 0 0; margin: 0 0.5em 0 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
align-self: flex-start; align-self: flex-start;
font-size: .9em; font-size: 0.9em;
} }
.info { .info {
display: inline-flex; display: inline-flex;
flex-shrink: 0; flex-shrink: 0;
margin-left: .5em; margin-left: 0.5em;
font-size: 0.9em; font-size: 0.9em;
.created-at { .created-at {
max-width: 100%; max-width: 100%;
@ -139,7 +147,7 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS
.ticker { .ticker {
display: inline-flex; display: inline-flex;
margin-left: .5em; margin-left: 0.5em;
vertical-align: middle; vertical-align: middle;
> .name { > .name {
display: none; display: none;

View File

@ -1,22 +1,22 @@
<template> <template>
<div v-size="{ min: [350, 500] }" class="fefdfafb"> <div v-size="{ min: [350, 500] }" class="fefdfafb">
<MkAvatar class="avatar" :user="$i"/> <MkAvatar class="avatar" :user="$i" />
<div class="main"> <div class="main">
<div class="header"> <div class="header">
<MkUserName :user="$i"/> <MkUserName :user="$i" />
</div> </div>
<div class="body"> <div class="body">
<div class="content"> <div class="content">
<Mfm :text="preprocess(text).trim()" :author="$i" :i="$i"/> <Mfm :text="preprocess(text).trim()" :author="$i" :i="$i" />
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import { preprocess } from '@/scripts/preprocess'; import { preprocess } from "@/scripts/preprocess";
const props = defineProps<{ const props = defineProps<{
text: string; text: string;
@ -67,7 +67,6 @@ const props = defineProps<{
} }
> .body { > .body {
> .cw { > .cw {
cursor: default; cursor: default;
display: block; display: block;

View File

@ -1,28 +1,35 @@
<template> <template>
<div v-size="{ min: [350, 500] }" class="yohlumlk"> <div v-size="{ min: [350, 500] }" class="yohlumlk">
<MkAvatar class="avatar" :user="note.user"/> <MkAvatar class="avatar" :user="note.user" />
<div class="main"> <div class="main">
<XNoteHeader class="header" :note="note" :mini="true"/> <XNoteHeader class="header" :note="note" :mini="true" />
<div class="body"> <div class="body">
<p v-if="note.cw != null" class="cw"> <p v-if="note.cw != null" class="cw">
<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> <Mfm
<br/> v-if="note.cw != ''"
<XCwButton v-model="showContent" :note="note"/> class="text"
</p> :text="note.cw"
<div v-show="note.cw == null || showContent" class="content"> :author="note.user"
<MkSubNoteContent class="text" :note="note"/> :i="$i"
:custom-emojis="note.emojis"
/>
<br />
<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> </div>
</div> </div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import * as misskey from 'calckey-js'; import * as misskey from "calckey-js";
import XNoteHeader from '@/components/MkNoteHeader.vue'; import XNoteHeader from "@/components/MkNoteHeader.vue";
import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkSubNoteContent from "@/components/MkSubNoteContent.vue";
import XCwButton from '@/components/MkCwButton.vue'; import XCwButton from "@/components/MkCwButton.vue";
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
@ -74,7 +81,6 @@ const showContent = $ref(false);
} }
> .body { > .body {
> .cw { > .cw {
cursor: default; cursor: default;
display: block; display: block;

View File

@ -1,178 +1,309 @@
<template> <template>
<div ref="el" <div
v-size="{ max: [450, 500] }" ref="el"
class="wrpstxzv" v-size="{ max: [450, 500] }"
:class="{ children: depth > 1, singleStart: replies.length == 1, firstColumn: depth == 1 && conversation }" class="wrpstxzv"
> :class="{
<div v-if="conversation && depth > 1" class="line"></div> children: depth > 1,
<div class="main" @click="noteClick"> singleStart: replies.length == 1,
<div class="avatar-container"> firstColumn: depth == 1 && conversation,
<MkAvatar class="avatar" :user="appearNote.user"/> }"
<div v-if="(!conversation) || replies.length > 0" class="line"></div> >
</div> <div v-if="conversation && depth > 1" class="line"></div>
<div class="body"> <div class="main" @click="noteClick">
<XNoteHeader class="header" :note="note" :mini="true"/> <div class="avatar-container">
<MkAvatar class="avatar" :user="appearNote.user" />
<div
v-if="!conversation || replies.length > 0"
class="line"
></div>
</div>
<div class="body"> <div class="body">
<p v-if="appearNote.cw != null" class="cw"> <XNoteHeader class="header" :note="note" :mini="true" />
<MkA v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`" class="reply-icon" @click.stop> <div class="body">
<i class="ph-arrow-bend-left-up ph-bold ph-lg"></i> <p v-if="appearNote.cw != null" class="cw">
</MkA> <MkA
<MkA v-if="conversation && appearNote.renoteId && appearNote.renoteId != parentId && !appearNote.replyId" :to="`/notes/${appearNote.renoteId}`" class="reply-icon" @click.stop> v-if="appearNote.replyId"
<i class="ph-quotes ph-bold ph-lg"></i> :to="`/notes/${appearNote.replyId}`"
</MkA> class="reply-icon"
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> @click.stop
<br/> >
<XCwButton v-model="showContent" :note="note"/> <i class="ph-arrow-bend-left-up ph-bold ph-lg"></i>
</p> </MkA>
<div v-show="appearNote.cw == null || showContent" class="content"> <MkA
<MkSubNoteContent class="text" :note="note" :detailed="true" :parentId="appearNote.parentId" :conversation="conversation"/> v-if="
</div> conversation &&
<div v-if="translating || translation" class="translation"> appearNote.renoteId &&
<MkLoading v-if="translating" mini/> appearNote.renoteId != parentId &&
<div v-else class="translated"> !appearNote.replyId
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> "
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> :to="`/notes/${appearNote.renoteId}`"
class="reply-icon"
@click.stop
>
<i class="ph-quotes ph-bold ph-lg"></i>
</MkA>
<Mfm
v-if="appearNote.cw != ''"
class="text"
:text="appearNote.cw"
:author="appearNote.user"
:i="$i"
:custom-emojis="appearNote.emojis"
/>
<br />
<XCwButton v-model="showContent" :note="note" />
</p>
<div
v-show="appearNote.cw == null || showContent"
class="content"
>
<MkSubNoteContent
class="text"
:note="note"
:detailed="true"
:parentId="appearNote.parentId"
:conversation="conversation"
/>
</div>
<div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini />
<div v-else class="translated">
<b
>{{
i18n.t("translatedFrom", {
x: translation.sourceLang,
})
}}:
</b>
<Mfm
:text="translation.text"
:author="appearNote.user"
:i="$i"
:custom-emojis="appearNote.emojis"
/>
</div>
</div> </div>
</div> </div>
<footer class="footer" @click.stop>
<XReactionsViewer
ref="reactionsViewer"
:note="appearNote"
/>
<button
v-tooltip.noDelay.bottom="i18n.ts.reply"
class="button _button"
@click="reply()"
>
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
<template v-if="appearNote.repliesCount > 0">
<p class="count">{{ appearNote.repliesCount }}</p>
</template>
</button>
<XRenoteButton
ref="renoteButton"
class="button"
:note="appearNote"
:count="appearNote.renoteCount"
/>
<XStarButton
v-if="appearNote.myReaction == null"
ref="starButton"
class="button"
:note="appearNote"
/>
<button
v-if="appearNote.myReaction == null"
ref="reactButton"
v-tooltip.noDelay.bottom="i18n.ts.reaction"
class="button _button"
@click="react()"
>
<i class="ph-smiley ph-bold ph-lg"></i>
</button>
<button
v-if="appearNote.myReaction != null"
ref="reactButton"
class="button _button reacted"
@click="undoReact(appearNote)"
>
<i class="ph-minus ph-bold ph-lg"></i>
</button>
<XQuoteButton class="button" :note="appearNote" />
<button
ref="menuButton"
v-tooltip.noDelay.bottom="i18n.ts.more"
class="button _button"
@click="menu()"
>
<i class="ph-dots-three-outline ph-bold ph-lg"></i>
</button>
</footer>
</div> </div>
<footer class="footer" @click.stop>
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
<button v-tooltip.noDelay.bottom="i18n.ts.reply" class="button _button" @click="reply()">
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
<template v-if="appearNote.repliesCount > 0">
<p class="count">{{ appearNote.repliesCount }}</p>
</template>
</button>
<XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/>
<XStarButton v-if="appearNote.myReaction == null" ref="starButton" class="button" :note="appearNote"/>
<button v-if="appearNote.myReaction == null" ref="reactButton" v-tooltip.noDelay.bottom="i18n.ts.reaction" class="button _button" @click="react()">
<i class="ph-smiley ph-bold ph-lg"></i>
</button>
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
<i class="ph-minus ph-bold ph-lg"></i>
</button>
<XQuoteButton class="button" :note="appearNote"/>
<button ref="menuButton" v-tooltip.noDelay.bottom="i18n.ts.more" class="button _button" @click="menu()">
<i class="ph-dots-three-outline ph-bold ph-lg"></i>
</button>
</footer>
</div> </div>
<template v-if="conversation">
<template v-if="replies.length == 1">
<MkNoteSub
v-for="reply in replies"
:key="reply.id"
:note="reply"
class="reply single"
:conversation="conversation"
:depth="depth"
:parentId="appearNote.replyId"
/>
</template>
<template v-else-if="depth < 5">
<MkNoteSub
v-for="reply in replies"
:key="reply.id"
:note="reply"
class="reply"
:conversation="conversation"
:depth="depth + 1"
:parentId="appearNote.replyId"
/>
</template>
<div v-else-if="replies.length > 0" class="more">
<div class="line"></div>
<MkA class="text _link" :to="notePage(note)"
>{{ i18n.ts.continueThread }}
<i class="ph-caret-double-right ph-bold ph-lg"></i
></MkA>
</div>
</template>
</div> </div>
<template v-if="conversation">
<template v-if="replies.length == 1">
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply single" :conversation="conversation" :depth="depth" :parentId="appearNote.replyId"/>
</template>
<template v-else-if="depth < 5">
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :conversation="conversation" :depth="depth + 1" :parentId="appearNote.replyId"/>
</template>
<div v-else-if="replies.length > 0" class="more">
<div class="line"></div>
<MkA class="text _link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ph-caret-double-right ph-bold ph-lg"></i></MkA>
</div>
</template>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { inject, ref } from 'vue'; import { inject, ref } from "vue";
import type { Ref } from 'vue'; import type { Ref } from "vue";
import * as misskey from 'calckey-js'; import * as misskey from "calckey-js";
import XNoteHeader from '@/components/MkNoteHeader.vue'; import XNoteHeader from "@/components/MkNoteHeader.vue";
import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkSubNoteContent from "@/components/MkSubNoteContent.vue";
import XReactionsViewer from '@/components/MkReactionsViewer.vue'; import XReactionsViewer from "@/components/MkReactionsViewer.vue";
import XStarButton from '@/components/MkStarButton.vue'; import XStarButton from "@/components/MkStarButton.vue";
import XRenoteButton from '@/components/MkRenoteButton.vue'; import XRenoteButton from "@/components/MkRenoteButton.vue";
import XQuoteButton from '@/components/MkQuoteButton.vue'; import XQuoteButton from "@/components/MkQuoteButton.vue";
import XCwButton from '@/components/MkCwButton.vue'; import XCwButton from "@/components/MkCwButton.vue";
import { pleaseLogin } from '@/scripts/please-login'; import { pleaseLogin } from "@/scripts/please-login";
import { getNoteMenu } from '@/scripts/get-note-menu'; import { getNoteMenu } from "@/scripts/get-note-menu";
import { notePage } from '@/filters/note'; import { notePage } from "@/filters/note";
import { useRouter } from '@/router'; import { useRouter } from "@/router";
import * as os from '@/os'; import * as os from "@/os";
import { reactionPicker } from '@/scripts/reaction-picker'; import { reactionPicker } from "@/scripts/reaction-picker";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import { deepClone } from '@/scripts/clone'; import { deepClone } from "@/scripts/clone";
import { useNoteCapture } from '@/scripts/use-note-capture'; import { useNoteCapture } from "@/scripts/use-note-capture";
const router = useRouter(); const router = useRouter();
const props = withDefaults(defineProps<{ const props = withDefaults(
note: misskey.entities.Note; defineProps<{
conversation?: misskey.entities.Note[]; note: misskey.entities.Note;
parentId?; conversation?: misskey.entities.Note[];
parentId?;
// how many notes are in between this one and the note being viewed in detail // how many notes are in between this one and the note being viewed in detail
depth?: number; depth?: number;
}>(), { }>(),
depth: 1, {
}); depth: 1,
}
);
let note = $ref(deepClone(props.note)); let note = $ref(deepClone(props.note));
const isRenote = ( const isRenote =
note.renote != null && note.renote != null &&
note.text == null && note.text == null &&
note.fileIds.length === 0 && note.fileIds.length === 0 &&
note.poll == null note.poll == null;
);
const el = ref<HTMLElement>(); const el = ref<HTMLElement>();
const menuButton = ref<HTMLElement>(); const menuButton = ref<HTMLElement>();
const starButton = ref<InstanceType<typeof XStarButton>>(); const starButton = ref<InstanceType<typeof XStarButton>>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>(); const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
const reactButton = ref<HTMLElement>(); const reactButton = ref<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note); let appearNote = $computed(() =>
isRenote ? (note.renote as misskey.entities.Note) : note
);
const isDeleted = ref(false); const isDeleted = ref(false);
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
let showContent = $ref(false); let showContent = $ref(false);
const replies: misskey.entities.Note[] = props.conversation?.filter(item => item.replyId === props.note.id || item.renoteId === props.note.id).reverse() ?? []; const replies: misskey.entities.Note[] =
props.conversation
?.filter(
(item) =>
item.replyId === props.note.id ||
item.renoteId === props.note.id
)
.reverse() ?? [];
useNoteCapture({ useNoteCapture({
rootEl: el, rootEl: el,
note: $$(appearNote) note: $$(appearNote),
}); });
function reply(viaKeyboard = false): void { function reply(viaKeyboard = false): void {
pleaseLogin(); pleaseLogin();
os.post({ os.post(
reply: appearNote, {
animation: !viaKeyboard, reply: appearNote,
}, () => { animation: !viaKeyboard,
focus(); },
}); () => {
focus();
}
);
} }
function react(viaKeyboard = false): void { function react(viaKeyboard = false): void {
pleaseLogin(); pleaseLogin();
blur(); blur();
reactionPicker.show(reactButton.value, reaction => { reactionPicker.show(
os.api('notes/reactions/create', { reactButton.value,
noteId: appearNote.id, (reaction) => {
reaction: reaction, os.api("notes/reactions/create", {
}); noteId: appearNote.id,
}, () => { reaction: reaction,
focus(); });
}); },
() => {
focus();
}
);
} }
function undoReact(note): void { function undoReact(note): void {
const oldReaction = note.myReaction; const oldReaction = note.myReaction;
if (!oldReaction) return; if (!oldReaction) return;
os.api('notes/reactions/delete', { os.api("notes/reactions/delete", {
noteId: note.id, noteId: note.id,
}); });
} }
const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null); const currentClipPage = inject<Ref<misskey.entities.Clip> | null>(
"currentClipPage",
null
);
function menu(viaKeyboard = false): void { function menu(viaKeyboard = false): void {
os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, { os.popupMenu(
viaKeyboard, getNoteMenu({
}).then(focus); note: note,
translating,
translation,
menuButton,
isDeleted,
currentClipPage,
}),
menuButton.value,
{
viaKeyboard,
}
).then(focus);
} }
function focus() { function focus() {
@ -184,10 +315,10 @@ function blur() {
} }
function noteClick(e) { function noteClick(e) {
if (document.getSelection().type === 'Range') { if (document.getSelection().type === "Range") {
e.stopPropagation(); e.stopPropagation();
} else { } else {
router.push(notePage(props.note)) router.push(notePage(props.note));
} }
} }
</script> </script>
@ -206,7 +337,6 @@ function noteClick(e) {
} }
} }
> .main { > .main {
display: flex; display: flex;
@ -241,11 +371,12 @@ function noteClick(e) {
.reply-icon { .reply-icon {
display: inline-block; display: inline-block;
border-radius: 6px; border-radius: 6px;
padding: .2em .2em; padding: 0.2em 0.2em;
margin-right: .2em; margin-right: 0.2em;
color: var(--accent); color: var(--accent);
transition: background .2s; transition: background 0.2s;
&:hover, &:focus { &:hover,
&:focus {
background: var(--buttonHoverBg); background: var(--buttonHoverBg);
} }
} }
@ -289,9 +420,9 @@ function noteClick(e) {
width: max-content; width: max-content;
min-width: max-content; min-width: max-content;
pointer-events: all; pointer-events: all;
transition: opacity .2s; transition: opacity 0.2s;
&:first-of-type { &:first-of-type {
margin-left: -.5em; margin-left: -0.5em;
} }
&:hover { &:hover {
color: var(--fgHighlighted); color: var(--fgHighlighted);
@ -320,7 +451,8 @@ function noteClick(e) {
margin-right: 8px !important; margin-right: 8px !important;
} }
} }
> .reply, > .more { > .reply,
> .more {
margin-top: 10px; margin-top: 10px;
&.single { &.single {
padding: 0 !important; padding: 0 !important;
@ -361,15 +493,19 @@ function noteClick(e) {
} }
} }
&.reply, &.reply-to, &.reply-to-more { &.reply,
> .main:hover, > .main:focus-within { &.reply-to,
&.reply-to-more {
> .main:hover,
> .main:focus-within {
:deep(.footer .button) { :deep(.footer .button) {
opacity: 1; opacity: 1;
} }
} }
} }
&.reply-to, &.reply-to-more { &.reply-to,
&.reply-to-more {
padding-bottom: 0; padding-bottom: 0;
&:first-child { &:first-child {
padding-top: 24px; padding-top: 24px;
@ -380,7 +516,9 @@ function noteClick(e) {
} }
// Reply Lines // Reply Lines
&.reply, &.reply-to, &.reply-to-more { &.reply,
&.reply-to,
&.reply-to-more {
--indent: calc(var(--avatarSize) - 5px); --indent: calc(var(--avatarSize) - 5px);
> .main { > .main {
> .avatar-container { > .avatar-container {
@ -413,17 +551,20 @@ function noteClick(e) {
} }
} }
} }
&.reply-to, &.reply-to-more { &.reply-to,
&.reply-to-more {
> .main > .avatar-container > .line { > .main > .avatar-container > .line {
margin-bottom: 0px !important; margin-bottom: 0px !important;
} }
} }
&.single, &.singleStart { &.single,
&.singleStart {
> .main > .avatar-container > .line { > .main > .avatar-container > .line {
margin-bottom: -10px !important; margin-bottom: -10px !important;
} }
} }
.reply.children:not(:last-child) { // Line that goes through multiple replies .reply.children:not(:last-child) {
// Line that goes through multiple replies
position: relative; position: relative;
> .line { > .line {
position: absolute; position: absolute;
@ -473,7 +614,9 @@ function noteClick(e) {
} }
&.firstColumn > .children:last-child > .main { &.firstColumn > .children:last-child > .main {
padding-bottom: 0 !important; padding-bottom: 0 !important;
&::before { bottom: 0 !important } &::before {
bottom: 0 !important;
}
// &::after { content: unset } // &::after { content: unset }
} }
@ -485,7 +628,9 @@ function noteClick(e) {
} }
} }
&.firstColumn { &.firstColumn {
> .main, > .line, > .children:not(.single) > .line { > .main,
> .line,
> .children:not(.single) > .line {
--avatarSize: 35px; --avatarSize: 35px;
--indent: 35px; --indent: 35px;
} }
@ -496,7 +641,8 @@ function noteClick(e) {
} }
&.max-width_450px { &.max-width_450px {
padding: 14px 16px; padding: 14px 16px;
&.reply-to, &.reply-to-more { &.reply-to,
&.reply-to-more {
padding: 14px 16px; padding: 14px 16px;
padding-top: 14px !important; padding-top: 14px !important;
padding-bottom: 0 !important; padding-bottom: 0 !important;

View File

@ -1,29 +1,46 @@
<template> <template>
<MkPagination ref="pagingComponent" :pagination="pagination"> <MkPagination ref="pagingComponent" :pagination="pagination">
<template #empty> <template #empty>
<div class="_fullinfo"> <div class="_fullinfo">
<img src="/static-assets/badges/info.png" class="_ghost" alt="Info"/> <img
<div>{{ i18n.ts.noNotes }}</div> src="/static-assets/badges/info.png"
</div> class="_ghost"
</template> alt="Info"
/>
<div>{{ i18n.ts.noNotes }}</div>
</div>
</template>
<template #default="{ items: notes }"> <template #default="{ items: notes }">
<div class="giivymft" :class="{ noGap }"> <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"> <XList
<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note"/> ref="notes"
</XList> v-slot="{ item: note }"
</div> :items="notes"
</template> :direction="pagination.reversed ? 'up' : 'down'"
</MkPagination> :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> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from "vue";
import type { Paging } from '@/components/MkPagination.vue'; import type { Paging } from "@/components/MkPagination.vue";
import XNote from '@/components/MkNote.vue'; import XNote from "@/components/MkNote.vue";
import XList from '@/components/MkDateSeparatedList.vue'; import XList from "@/components/MkDateSeparatedList.vue";
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from "@/components/MkPagination.vue";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
pagination: Paging; pagination: Paging;

View File

@ -1,99 +1,289 @@
<template> <template>
<div ref="elRef" v-size="{ max: [500, 600] }" class="qglefbjs" :class="notification.type"> <div
<div class="head"> ref="elRef"
<MkAvatar v-if="notification.type === 'pollEnded'" class="icon" :user="notification.note.user"/> v-size="{ max: [500, 600] }"
<MkAvatar v-else-if="notification.user" class="icon" :user="notification.user"/> class="qglefbjs"
<img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/> :class="notification.type"
<div class="sub-icon" :class="notification.type"> >
<i v-if="notification.type === 'follow'" class="ph-hand-waving ph-bold"></i> <div class="head">
<i v-else-if="notification.type === 'receiveFollowRequest'" class="ph-clock ph-bold"></i> <MkAvatar
<i v-else-if="notification.type === 'followRequestAccepted'" class="ph-check ph-bold"></i> v-if="notification.type === 'pollEnded'"
<i v-else-if="notification.type === 'groupInvited'" class="ph-identification-card ph-bold"></i> class="icon"
<i v-else-if="notification.type === 'renote'" class="ph-repeat ph-bold"></i> :user="notification.note.user"
<i v-else-if="notification.type === 'reply'" class="ph-arrow-bend-up-left ph-bold"></i>
<i v-else-if="notification.type === 'mention'" class="ph-at ph-bold"></i>
<i v-else-if="notification.type === 'quote'" class="ph-quotes ph-bold"></i>
<i v-else-if="notification.type === 'pollVote'" class="ph-microphone-stage ph-bold"></i>
<i v-else-if="notification.type === 'pollEnded'" class="ph-microphone-stage ph-bold"></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"
/> />
<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="ph-hand-waving ph-bold"
></i>
<i
v-else-if="notification.type === 'receiveFollowRequest'"
class="ph-clock ph-bold"
></i>
<i
v-else-if="notification.type === 'followRequestAccepted'"
class="ph-check ph-bold"
></i>
<i
v-else-if="notification.type === 'groupInvited'"
class="ph-identification-card ph-bold"
></i>
<i
v-else-if="notification.type === 'renote'"
class="ph-repeat ph-bold"
></i>
<i
v-else-if="notification.type === 'reply'"
class="ph-arrow-bend-up-left ph-bold"
></i>
<i
v-else-if="notification.type === 'mention'"
class="ph-at ph-bold"
></i>
<i
v-else-if="notification.type === 'quote'"
class="ph-quotes ph-bold"
></i>
<i
v-else-if="notification.type === 'pollVote'"
class="ph-microphone-stage ph-bold"
></i>
<i
v-else-if="notification.type === 'pollEnded'"
class="ph-microphone-stage ph-bold"
></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="ph-quotes ph-fill ph-lg"></i>
<Mfm
:text="getNoteSummary(notification.note)"
:plain="true"
:nowrap="!full"
:custom-emojis="notification.note.emojis"
/>
<i class="ph-quotes ph-fill ph-lg"></i>
</MkA>
<MkA
v-if="notification.type === 'renote'"
class="text"
:to="notePage(notification.note)"
:title="getNoteSummary(notification.note.renote)"
>
<i class="ph-quotes ph-fill ph-lg"></i>
<Mfm
:text="getNoteSummary(notification.note.renote)"
:plain="true"
:nowrap="!full"
:custom-emojis="notification.note.renote.emojis"
/>
<i class="ph-quotes ph-fill ph-lg"></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="ph-quotes ph-fill ph-lg"></i>
<Mfm
:text="getNoteSummary(notification.note)"
:plain="true"
:nowrap="!full"
:custom-emojis="notification.note.emojis"
/>
<i class="ph-quotes ph-fill ph-lg"></i>
</MkA>
<MkA
v-if="notification.type === 'pollEnded'"
class="text"
:to="notePage(notification.note)"
:title="getNoteSummary(notification.note)"
>
<i class="ph-quotes ph-fill ph-lg"></i>
<Mfm
:text="getNoteSummary(notification.note)"
:plain="true"
:nowrap="!full"
:custom-emojis="notification.note.emojis"
/>
<i class="ph-quotes ph-fill ph-lg"></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>
</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="ph-quotes ph-fill ph-lg"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
<i class="ph-quotes ph-fill ph-lg"></i>
</MkA>
<MkA v-if="notification.type === 'renote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)">
<i class="ph-quotes ph-fill ph-lg"></i>
<Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.renote.emojis"/>
<i class="ph-quotes ph-fill ph-lg"></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="ph-quotes ph-fill ph-lg"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
<i class="ph-quotes ph-fill ph-lg"></i>
</MkA>
<MkA v-if="notification.type === 'pollEnded'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ph-quotes ph-fill ph-lg"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
<i class="ph-quotes ph-fill ph-lg"></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> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'; import { ref, onMounted, onUnmounted, watch } from "vue";
import * as misskey from 'calckey-js'; import * as misskey from "calckey-js";
import XReactionIcon from '@/components/MkReactionIcon.vue'; import XReactionIcon from "@/components/MkReactionIcon.vue";
import MkFollowButton from '@/components/MkFollowButton.vue'; import MkFollowButton from "@/components/MkFollowButton.vue";
import XReactionTooltip from '@/components/MkReactionTooltip.vue'; import XReactionTooltip from "@/components/MkReactionTooltip.vue";
import { getNoteSummary } from '@/scripts/get-note-summary'; import { getNoteSummary } from "@/scripts/get-note-summary";
import { notePage } from '@/filters/note'; import { notePage } from "@/filters/note";
import { userPage } from '@/filters/user'; import { userPage } from "@/filters/user";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import * as os from '@/os'; import * as os from "@/os";
import { stream } from '@/stream'; import { stream } from "@/stream";
import { useTooltip } from '@/scripts/use-tooltip'; import { useTooltip } from "@/scripts/use-tooltip";
const props = withDefaults(defineProps<{ const props = withDefaults(
notification: misskey.entities.Notification; defineProps<{
withTime?: boolean; notification: misskey.entities.Notification;
full?: boolean; withTime?: boolean;
}>(), { full?: boolean;
withTime: false, }>(),
full: false, {
}); withTime: false,
full: false,
}
);
const elRef = ref<HTMLElement>(null); const elRef = ref<HTMLElement>(null);
const reactionRef = ref(null); const reactionRef = ref(null);
@ -104,8 +294,8 @@ let connection;
onMounted(() => { onMounted(() => {
if (!props.notification.isRead) { if (!props.notification.isRead) {
readObserver = new IntersectionObserver((entries, observer) => { readObserver = new IntersectionObserver((entries, observer) => {
if (!entries.some(entry => entry.isIntersecting)) return; if (!entries.some((entry) => entry.isIntersecting)) return;
stream.send('readNotification', { stream.send("readNotification", {
id: props.notification.id, id: props.notification.id,
}); });
observer.disconnect(); observer.disconnect();
@ -113,8 +303,8 @@ onMounted(() => {
readObserver.observe(elRef.value); readObserver.observe(elRef.value);
connection = stream.useChannel('main'); connection = stream.useChannel("main");
connection.on('readAllNotifications', () => readObserver.disconnect()); connection.on("readAllNotifications", () => readObserver.disconnect());
watch(props.notification.isRead, () => { watch(props.notification.isRead, () => {
readObserver.disconnect(); readObserver.disconnect();
@ -132,31 +322,42 @@ const groupInviteDone = ref(false);
const acceptFollowRequest = () => { const acceptFollowRequest = () => {
followRequestDone.value = true; followRequestDone.value = true;
os.api('following/requests/accept', { userId: props.notification.user.id }); os.api("following/requests/accept", { userId: props.notification.user.id });
}; };
const rejectFollowRequest = () => { const rejectFollowRequest = () => {
followRequestDone.value = true; followRequestDone.value = true;
os.api('following/requests/reject', { userId: props.notification.user.id }); os.api("following/requests/reject", { userId: props.notification.user.id });
}; };
const acceptGroupInvitation = () => { const acceptGroupInvitation = () => {
groupInviteDone.value = true; groupInviteDone.value = true;
os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id }); os.apiWithDialog("users/groups/invitations/accept", {
invitationId: props.notification.invitation.id,
});
}; };
const rejectGroupInvitation = () => { const rejectGroupInvitation = () => {
groupInviteDone.value = true; groupInviteDone.value = true;
os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id }); os.api("users/groups/invitations/reject", {
invitationId: props.notification.invitation.id,
});
}; };
useTooltip(reactionRef, (showing) => { useTooltip(reactionRef, (showing) => {
os.popup(XReactionTooltip, { os.popup(
showing, XReactionTooltip,
reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction, {
emojis: props.notification.note.emojis, showing,
targetElement: reactionRef.value.$el, reaction: props.notification.reaction
}, {}, 'closed'); ? props.notification.reaction.replace(/^:(\w+):$/, ":$1@.:")
: props.notification.reaction,
emojis: props.notification.note.emojis,
targetElement: reactionRef.value.$el,
},
{},
"closed"
);
}); });
</script> </script>
@ -219,7 +420,10 @@ useTooltip(reactionRef, (showing) => {
height: 100%; height: 100%;
} }
&.follow, &.followRequestAccepted, &.receiveFollowRequest, &.groupInvited { &.follow,
&.followRequestAccepted,
&.receiveFollowRequest,
&.groupInvited {
padding: 3px; padding: 3px;
background: #31748f; background: #31748f;
pointer-events: none; pointer-events: none;

View File

@ -1,60 +1,77 @@
<template> <template>
<XModalWindow <XModalWindow
ref="dialog" ref="dialog"
:width="400" :width="400"
:height="450" :height="450"
:with-ok-button="true" :with-ok-button="true"
:ok-button-disabled="false" :ok-button-disabled="false"
@ok="ok()" @ok="ok()"
@close="dialog.close()" @close="dialog.close()"
@closed="emit('closed')" @closed="emit('closed')"
> >
<template #header>{{ i18n.ts.notificationSetting }}</template> <template #header>{{ i18n.ts.notificationSetting }}</template>
<div class="_monolithic_"> <div class="_monolithic_">
<div v-if="showGlobalToggle" class="_section"> <div v-if="showGlobalToggle" class="_section">
<MkSwitch v-model="useGlobalSetting"> <MkSwitch v-model="useGlobalSetting">
{{ i18n.ts.useGlobalSetting }} {{ i18n.ts.useGlobalSetting }}
<template #caption>{{ i18n.ts.useGlobalSettingDesc }}</template> <template #caption>{{
</MkSwitch> 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> </div>
<div v-if="!useGlobalSetting" class="_section"> </XModalWindow>
<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> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import { notificationTypes } from 'calckey-js'; import { notificationTypes } from "calckey-js";
import MkSwitch from './form/switch.vue'; import MkSwitch from "./form/switch.vue";
import MkInfo from './MkInfo.vue'; import MkInfo from "./MkInfo.vue";
import MkButton from './MkButton.vue'; import MkButton from "./MkButton.vue";
import XModalWindow from '@/components/MkModalWindow.vue'; import XModalWindow from "@/components/MkModalWindow.vue";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', v: { includingTypes: string[] | null }): void, (ev: "done", v: { includingTypes: string[] | null }): void;
(ev: 'closed'): void, (ev: "closed"): void;
}>(); }>();
const props = withDefaults(defineProps<{ const props = withDefaults(
includingTypes?: typeof notificationTypes[number][] | null; defineProps<{
showGlobalToggle?: boolean; includingTypes?: (typeof notificationTypes)[number][] | null;
}>(), { showGlobalToggle?: boolean;
includingTypes: () => [], }>(),
showGlobalToggle: true, {
}); includingTypes: () => [],
showGlobalToggle: true,
}
);
let includingTypes = $computed(() => props.includingTypes || []); let includingTypes = $computed(() => props.includingTypes || []);
const dialog = $ref<InstanceType<typeof XModalWindow>>(); const dialog = $ref<InstanceType<typeof XModalWindow>>();
let typesMap = $ref<Record<typeof notificationTypes[number], boolean>>({}); let typesMap = $ref<Record<(typeof notificationTypes)[number], boolean>>({});
let useGlobalSetting = $ref((includingTypes === null || includingTypes.length === 0) && props.showGlobalToggle); let useGlobalSetting = $ref(
(includingTypes === null || includingTypes.length === 0) &&
props.showGlobalToggle
);
for (const ntype of notificationTypes) { for (const ntype of notificationTypes) {
typesMap[ntype] = includingTypes.includes(ntype); typesMap[ntype] = includingTypes.includes(ntype);
@ -62,11 +79,12 @@ for (const ntype of notificationTypes) {
function ok() { function ok() {
if (useGlobalSetting) { if (useGlobalSetting) {
emit('done', { includingTypes: null }); emit("done", { includingTypes: null });
} else { } else {
emit('done', { emit("done", {
includingTypes: (Object.keys(typesMap) as typeof notificationTypes[number][]) includingTypes: (
.filter(type => typesMap[type]), Object.keys(typesMap) as (typeof notificationTypes)[number][]
).filter((type) => typesMap[type]),
}); });
} }
@ -75,13 +93,13 @@ function ok() {
function disableAll() { function disableAll() {
for (const type in typesMap) { for (const type in typesMap) {
typesMap[type as typeof notificationTypes[number]] = false; typesMap[type as (typeof notificationTypes)[number]] = false;
} }
} }
function enableAll() { function enableAll() {
for (const type in typesMap) { for (const type in typesMap) {
typesMap[type as typeof notificationTypes[number]] = true; typesMap[type as (typeof notificationTypes)[number]] = true;
} }
} }
</script> </script>

View File

@ -1,25 +1,33 @@
<template> <template>
<div class="mk-notification-toast" :style="{ zIndex }"> <div class="mk-notification-toast" :style="{ zIndex }">
<transition :name="$store.state.animation ? 'notification-toast' : ''" appear @after-leave="$emit('closed')"> <transition
<XNotification v-if="showing" :notification="notification" class="notification _acrylic"/> :name="$store.state.animation ? 'notification-toast' : ''"
</transition> appear
</div> @after-leave="$emit('closed')"
>
<XNotification
v-if="showing"
:notification="notification"
class="notification _acrylic"
/>
</transition>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from 'vue'; import { onMounted } from "vue";
import XNotification from '@/components/MkNotification.vue'; import XNotification from "@/components/MkNotification.vue";
import * as os from '@/os'; import * as os from "@/os";
defineProps<{ defineProps<{
notification: any; // TODO notification: any; // TODO
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
const zIndex = os.claimZIndex('high'); const zIndex = os.claimZIndex("high");
let showing = $ref(true); let showing = $ref(true);
onMounted(() => { onMounted(() => {
@ -30,10 +38,12 @@ onMounted(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.notification-toast-enter-active, .notification-toast-leave-active { .notification-toast-enter-active,
.notification-toast-leave-active {
transition: opacity 0.3s, transform 0.3s !important; transition: opacity 0.3s, transform 0.3s !important;
} }
.notification-toast-enter-from, .notification-toast-leave-to { .notification-toast-enter-from,
.notification-toast-leave-to {
opacity: 0; opacity: 0;
transform: translateX(-250px); transform: translateX(-250px);
} }

View File

@ -1,54 +1,89 @@
<template> <template>
<MkPagination ref="pagingComponent" :pagination="pagination"> <MkPagination ref="pagingComponent" :pagination="pagination">
<template #empty> <template #empty>
<div class="_fullinfo"> <div class="_fullinfo">
<img src="/static-assets/badges/info.png" class="_ghost" alt="Info"/> <img
<div>{{ i18n.ts.noNotifications }}</div> src="/static-assets/badges/info.png"
</div> class="_ghost"
</template> alt="Info"
/>
<div>{{ i18n.ts.noNotifications }}</div>
</div>
</template>
<template #default="{ items: notifications }"> <template #default="{ items: notifications }">
<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true"> <XList
<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> v-slot="{ item: notification }"
<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> class="elsfgstc"
</XList> :items="notifications"
</template> :no-gap="true"
</MkPagination> >
<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> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineComponent, markRaw, onUnmounted, onMounted, computed, ref } from 'vue'; import {
import { notificationTypes } from 'calckey-js'; defineComponent,
import MkPagination, { Paging } from '@/components/MkPagination.vue'; markRaw,
import XNotification from '@/components/MkNotification.vue'; onUnmounted,
import XList from '@/components/MkDateSeparatedList.vue'; onMounted,
import XNote from '@/components/MkNote.vue'; computed,
import * as os from '@/os'; ref,
import { stream } from '@/stream'; } from "vue";
import { $i } from '@/account'; import { notificationTypes } from "calckey-js";
import { i18n } from '@/i18n'; 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<{ const props = defineProps<{
includeTypes?: typeof notificationTypes[number][]; includeTypes?: (typeof notificationTypes)[number][];
unreadOnly?: boolean; unreadOnly?: boolean;
}>(); }>();
const pagingComponent = ref<InstanceType<typeof MkPagination>>(); const pagingComponent = ref<InstanceType<typeof MkPagination>>();
const pagination: Paging = { const pagination: Paging = {
endpoint: 'i/notifications' as const, endpoint: "i/notifications" as const,
limit: 10, limit: 10,
params: computed(() => ({ params: computed(() => ({
includeTypes: props.includeTypes ?? undefined, includeTypes: props.includeTypes ?? undefined,
excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes, excludeTypes: props.includeTypes
? undefined
: $i.mutingNotificationTypes,
unreadOnly: props.unreadOnly, unreadOnly: props.unreadOnly,
})), })),
}; };
const onNotification = (notification) => { const onNotification = (notification) => {
const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type); const isMuted = props.includeTypes
if (isMuted || document.visibilityState === 'visible') { ? !props.includeTypes.includes(notification.type)
stream.send('readNotification', { : $i.mutingNotificationTypes.includes(notification.type);
if (isMuted || document.visibilityState === "visible") {
stream.send("readNotification", {
id: notification.id, id: notification.id,
}); });
} }
@ -56,7 +91,7 @@ const onNotification = (notification) => {
if (!isMuted) { if (!isMuted) {
pagingComponent.value.prepend({ pagingComponent.value.prepend({
...notification, ...notification,
isRead: document.visibilityState === 'visible', isRead: document.visibilityState === "visible",
}); });
} }
}; };
@ -64,9 +99,9 @@ const onNotification = (notification) => {
let connection; let connection;
onMounted(() => { onMounted(() => {
connection = stream.useChannel('main'); connection = stream.useChannel("main");
connection.on('notification', onNotification); connection.on("notification", onNotification);
connection.on('readAllNotifications', () => { connection.on("readAllNotifications", () => {
if (pagingComponent.value) { if (pagingComponent.value) {
for (const item of pagingComponent.value.queue) { for (const item of pagingComponent.value.queue) {
item.isRead = true; item.isRead = true;
@ -76,15 +111,23 @@ onMounted(() => {
} }
} }
}); });
connection.on('readNotifications', notificationIds => { connection.on("readNotifications", (notificationIds) => {
if (pagingComponent.value) { if (pagingComponent.value) {
for (let i = 0; i < pagingComponent.value.queue.length; i++) { for (let i = 0; i < pagingComponent.value.queue.length; i++) {
if (notificationIds.includes(pagingComponent.value.queue[i].id)) { if (
notificationIds.includes(pagingComponent.value.queue[i].id)
) {
pagingComponent.value.queue[i].isRead = true; pagingComponent.value.queue[i].isRead = true;
} }
} }
for (let i = 0; i < (pagingComponent.value.items || []).length; i++) { for (
if (notificationIds.includes(pagingComponent.value.items[i].id)) { let i = 0;
i < (pagingComponent.value.items || []).length;
i++
) {
if (
notificationIds.includes(pagingComponent.value.items[i].id)
) {
pagingComponent.value.items[i].isRead = true; pagingComponent.value.items[i].isRead = true;
} }
} }

View File

@ -1,11 +1,11 @@
<template> <template>
<span>{{ number(Math.floor(tweened.number)) }}</span> <span>{{ number(Math.floor(tweened.number)) }}</span>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, watch } from 'vue'; import { ref, reactive, watch } from "vue";
import gsap from 'gsap'; import gsap from "gsap";
import number from '@/filters/number'; import number from "@/filters/number";
const props = defineProps<{ const props = defineProps<{
value: number; value: number;
@ -15,9 +15,13 @@ const tweened = reactive({
number: 0, number: 0,
}); });
watch(() => props.value, (n) => { watch(
gsap.to(tweened, { duration: 0.6, number: Number(n) || 0 }); () => props.value,
}, { (n) => {
immediate: true, gsap.to(tweened, { duration: 0.6, number: Number(n) || 0 });
}); },
{
immediate: true,
}
);
</script> </script>

View File

@ -1,12 +1,13 @@
<template> <template>
<span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }"> <span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }">
<slot name="before"></slot>{{ isPlus ? '+' : '' }}{{ number(value) }}<slot name="after"></slot> <slot name="before"></slot>{{ isPlus ? "+" : "" }}{{ number(value)
</span> }}<slot name="after"></slot>
</span>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent } from 'vue'; import { computed, defineComponent } from "vue";
import number from '@/filters/number'; import number from "@/filters/number";
export default defineComponent({ export default defineComponent({
props: { props: {

View File

@ -1,39 +1,66 @@
<template> <template>
<div class="igpposuu _monospace"> <div class="igpposuu _monospace">
<div v-if="value === null" class="null">null</div> <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
<div v-else-if="typeof value === 'string'" class="string">"{{ value }}"</div> v-else-if="typeof value === 'boolean'"
<div v-else-if="typeof value === 'number'" class="number">{{ number(value) }}</div> class="boolean"
<div v-else-if="isArray(value) && isEmpty(value)" class="array empty">[]</div> :class="{ true: value, false: !value }"
<div v-else-if="isArray(value)" class="array"> >
<div v-for="i in value.length" class="element"> {{ value ? "true" : "false" }}
{{ i }}: <XValue :value="value[i - 1]" collapsed/>
</div> </div>
</div> <div v-else-if="typeof value === 'string'" class="string">
<div v-else-if="isObject(value) && isEmpty(value)" class="object empty">{}</div> "{{ value }}"
<div v-else-if="isObject(value)" class="object"> </div>
<div v-for="k in Object.keys(value)" class="kv"> <div v-else-if="typeof value === 'number'" class="number">
<button class="toggle _button" :class="{ visible: collapsable(value[k]) }" @click="collapsed[k] = !collapsed[k]">{{ collapsed[k] ? '+' : '-' }}</button> {{ number(value) }}
<div class="k">{{ k }}:</div> </div>
<div v-if="collapsed[k]" class="v"> <div v-else-if="isArray(value) && isEmpty(value)" class="array empty">
<button class="_button" @click="collapsed[k] = !collapsed[k]"> []
<template v-if="typeof value[k] === 'string'">"..."</template> </div>
<template v-else-if="isArray(value[k])">[...]</template> <div v-else-if="isArray(value)" class="array">
<template v-else-if="isObject(value[k])">{...}</template> <div v-for="i in value.length" class="element">
</button> {{ 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 v-else class="v"><XValue :value="value[k]"/></div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, ref } from 'vue'; import { computed, defineComponent, reactive, ref } from "vue";
import number from '@/filters/number'; import number from "@/filters/number";
export default defineComponent({ export default defineComponent({
name: 'XValue', name: "XValue",
props: { props: {
value: { value: {
@ -51,7 +78,7 @@ export default defineComponent({
} }
function isObject(v): boolean { function isObject(v): boolean {
return typeof v === 'object' && !Array.isArray(v) && v !== null; return typeof v === "object" && !Array.isArray(v) && v !== null;
} }
function isArray(v): boolean { function isArray(v): boolean {
@ -59,7 +86,10 @@ export default defineComponent({
} }
function isEmpty(v): boolean { function isEmpty(v): boolean {
return (isArray(v) && v.length === 0) || (isObject(v) && Object.keys(v).length === 0); return (
(isArray(v) && v.length === 0) ||
(isObject(v) && Object.keys(v).length === 0)
);
} }
function collapsable(v): boolean { function collapsable(v): boolean {

View File

@ -1,12 +1,12 @@
<template> <template>
<div class="zhyxdalp"> <div class="zhyxdalp">
<XValue :value="value" :collapsed="false"/> <XValue :value="value" :collapsed="false" />
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import XValue from './MkObjectView.value.vue'; import XValue from "./MkObjectView.value.vue";
const props = defineProps<{ const props = defineProps<{
value: Record<string, unknown>; value: Record<string, unknown>;
@ -15,6 +15,5 @@ const props = defineProps<{
<style lang="scss" scoped> <style lang="scss" scoped>
.zhyxdalp { .zhyxdalp {
} }
</style> </style>

View File

@ -1,22 +1,41 @@
<template> <template>
<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block" tabindex="-1" :behavior="`${ui === 'deck' ? 'window' : null}`"> <MkA
<div v-if="page.eyeCatchingImage" class="thumbnail" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div> :to="`/@${page.user.username}/pages/${page.name}`"
<article> class="vhpxefrj _block"
<header> tabindex="-1"
<h1 :title="page.title">{{ page.title }}</h1> :behavior="`${ui === 'deck' ? 'window' : null}`"
</header> >
<p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p> <div
<footer> v-if="page.eyeCatchingImage"
<img class="icon" :src="page.user.avatarUrl" aria-label="none"/> class="thumbnail"
<p>{{ userName(page.user) }}</p> :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"
</footer> ></div>
</article> <article>
</MkA> <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"
aria-label="none"
/>
<p>{{ userName(page.user) }}</p>
</footer>
</article>
</MkA>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { userName } from '@/filters/user'; import { userName } from "@/filters/user";
import { ui } from '@/config'; import { ui } from "@/config";
defineProps<{ defineProps<{
page: any; page: any;
@ -149,5 +168,4 @@ defineProps<{
} }
} }
} }
</style> </style>

View File

@ -1,63 +1,74 @@
<template> <template>
<XWindow <XWindow
ref="windowEl" ref="windowEl"
:initial-width="500" :initial-width="500"
:initial-height="500" :initial-height="500"
:can-resize="true" :can-resize="true"
:close-button="true" :close-button="true"
:buttons-left="buttonsLeft" :buttons-left="buttonsLeft"
:buttons-right="buttonsRight" :buttons-right="buttonsRight"
:contextmenu="contextmenu" :contextmenu="contextmenu"
@closed="$emit('closed')" @closed="$emit('closed')"
> >
<template #header> <template #header>
<template v-if="pageMetadata?.value"> <template v-if="pageMetadata?.value">
<i v-if="pageMetadata.value.icon" class="icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i> <i
<span>{{ pageMetadata.value.title }}</span> v-if="pageMetadata.value.icon"
class="icon"
:class="pageMetadata.value.icon"
style="margin-right: 0.5em"
></i>
<span>{{ pageMetadata.value.title }}</span>
</template>
</template> </template>
</template>
<div class="yrolvcoq" :style="{ background: pageMetadata?.value?.bg }"> <div class="yrolvcoq" :style="{ background: pageMetadata?.value?.bg }">
<RouterView :router="router"/> <RouterView :router="router" />
</div> </div>
</XWindow> </XWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ComputedRef, inject, provide } from 'vue'; import { ComputedRef, inject, provide } from "vue";
import RouterView from '@/components/global/RouterView.vue'; import RouterView from "@/components/global/RouterView.vue";
import XWindow from '@/components/MkWindow.vue'; import XWindow from "@/components/MkWindow.vue";
import { popout as _popout } from '@/scripts/popout'; import { popout as _popout } from "@/scripts/popout";
import copyToClipboard from '@/scripts/copy-to-clipboard'; import copyToClipboard from "@/scripts/copy-to-clipboard";
import { url } from '@/config'; import { url } from "@/config";
import * as os from '@/os'; import * as os from "@/os";
import { mainRouter, routes } from '@/router'; import { mainRouter, routes } from "@/router";
import { Router } from '@/nirax'; import { Router } from "@/nirax";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; import {
PageMetadata,
provideMetadataReceiver,
setPageMetadata,
} from "@/scripts/page-metadata";
const props = defineProps<{ const props = defineProps<{
initialPath: string; initialPath: string;
}>(); }>();
defineEmits<{ defineEmits<{
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
const router = new Router(routes, props.initialPath); const router = new Router(routes, props.initialPath);
let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
let windowEl = $ref<InstanceType<typeof XWindow>>(); let windowEl = $ref<InstanceType<typeof XWindow>>();
const history = $ref<{ path: string; key: any; }[]>([{ const history = $ref<{ path: string; key: any }[]>([
path: router.getCurrentPath(), {
key: router.getCurrentKey(), path: router.getCurrentPath(),
}]); key: router.getCurrentKey(),
},
]);
const buttonsLeft = $computed(() => { const buttonsLeft = $computed(() => {
const buttons = []; const buttons = [];
if (history.length > 1) { if (history.length > 1) {
buttons.push({ buttons.push({
icon: 'ph-caret-left ph-bold ph-lg', icon: "ph-caret-left ph-bold ph-lg",
onClick: back, onClick: back,
}); });
} }
@ -65,48 +76,55 @@ const buttonsLeft = $computed(() => {
return buttons; return buttons;
}); });
const buttonsRight = $computed(() => { const buttonsRight = $computed(() => {
const buttons = [{ const buttons = [
icon: 'ph-arrows-out-simple ph-bold ph-lg', {
title: i18n.ts.showInPage, icon: "ph-arrows-out-simple ph-bold ph-lg",
onClick: expand, title: i18n.ts.showInPage,
}]; onClick: expand,
},
];
return buttons; return buttons;
}); });
router.addListener('push', ctx => { router.addListener("push", (ctx) => {
history.push({ path: ctx.path, key: ctx.key }); history.push({ path: ctx.path, key: ctx.key });
}); });
provide('router', router); provide("router", router);
provideMetadataReceiver((info) => { provideMetadataReceiver((info) => {
pageMetadata = info; pageMetadata = info;
}); });
provide('shouldOmitHeaderTitle', true); provide("shouldOmitHeaderTitle", true);
provide('shouldHeaderThin', true); provide("shouldHeaderThin", true);
const contextmenu = $computed(() => ([{ const contextmenu = $computed(() => [
icon: 'ph-arrows-out-simple ph-bold ph-lg', {
text: i18n.ts.showInPage, icon: "ph-arrows-out-simple ph-bold ph-lg",
action: expand, text: i18n.ts.showInPage,
}, { action: expand,
icon: 'ph-arrow-square-out ph-bold ph-lg',
text: i18n.ts.popout,
action: popout,
}, {
icon: 'ph-arrow-square-out ph-bold ph-lg',
text: i18n.ts.openInNewTab,
action: () => {
window.open(url + router.getCurrentPath(), '_blank');
windowEl.close();
}, },
}, { {
icon: 'ph-link-simple ph-bold ph-lg', icon: "ph-arrow-square-out ph-bold ph-lg",
text: i18n.ts.copyLink, text: i18n.ts.popout,
action: () => { action: popout,
copyToClipboard(url + router.getCurrentPath());
}, },
}])); {
icon: "ph-arrow-square-out ph-bold ph-lg",
text: i18n.ts.openInNewTab,
action: () => {
window.open(url + router.getCurrentPath(), "_blank");
windowEl.close();
},
},
{
icon: "ph-link-simple ph-bold ph-lg",
text: i18n.ts.copyLink,
action: () => {
copyToClipboard(url + router.getCurrentPath());
},
},
]);
function menu(ev) { function menu(ev) {
os.popupMenu(contextmenu, ev.currentTarget ?? ev.target); os.popupMenu(contextmenu, ev.currentTarget ?? ev.target);
@ -114,7 +132,10 @@ function menu(ev) {
function back() { function back() {
history.pop(); history.pop();
router.replace(history[history.length - 1].path, history[history.length - 1].key); router.replace(
history[history.length - 1].path,
history[history.length - 1].key
);
} }
function close() { function close() {
@ -122,7 +143,7 @@ function close() {
} }
function expand() { function expand() {
mainRouter.push(router.getCurrentPath(), 'forcePage'); mainRouter.push(router.getCurrentPath(), "forcePage");
windowEl.close(); windowEl.close();
} }

View File

@ -1,357 +1,490 @@
<template> <template>
<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
<MkLoading v-if="fetching"/> <MkLoading v-if="fetching" />
<MkError v-else-if="error" @retry="init()"/> <MkError v-else-if="error" @retry="init()" />
<div v-else-if="empty" key="_empty_" class="empty"> <div v-else-if="empty" key="_empty_" class="empty">
<slot name="empty"> <slot name="empty">
<div class="_fullinfo"> <div class="_fullinfo">
<img src="/static-assets/badges/info.png" class="_ghost" alt="Error"/> <img
src="/static-assets/badges/info.png"
class="_ghost"
alt="Error"
/>
<div>{{ i18n.ts.nothing }}</div> <div>{{ i18n.ts.nothing }}</div>
</div> </div>
</slot> </slot>
</div> </div>
<div v-else ref="rootEl"> <div v-else ref="rootEl">
<div v-show="pagination.reversed && more" key="_more_" class="cxiknjgy _gap"> <div
<MkButton v-if="!moreFetching" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead"> 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 }} {{ i18n.ts.loadMore }}
</MkButton> </MkButton>
<MkLoading v-else class="loading"/> <MkLoading v-else class="loading" />
</div> </div>
<slot :items="items"></slot> <slot :items="items"></slot>
<div v-show="!pagination.reversed && more" key="_more_" class="cxiknjgy _gap"> <div
<MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore"> 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 }} {{ i18n.ts.loadMore }}
</MkButton> </MkButton>
<MkLoading v-else class="loading"/> <MkLoading v-else class="loading" />
</div> </div>
</div> </div>
</transition> </transition>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, ref, watch } from 'vue'; import {
import * as misskey from 'calckey-js'; computed,
import * as os from '@/os'; ComputedRef,
import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from '@/scripts/scroll'; isRef,
import MkButton from '@/components/MkButton.vue'; markRaw,
import { i18n } from '@/i18n'; onActivated,
onDeactivated,
Ref,
ref,
watch,
} from "vue";
import * as misskey from "calckey-js";
import * as os from "@/os";
import {
onScrollTop,
isTopVisible,
getScrollPosition,
getScrollContainer,
} from "@/scripts/scroll";
import MkButton from "@/components/MkButton.vue";
import { i18n } from "@/i18n";
export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = { export type Paging<
endpoint: E; E extends keyof misskey.Endpoints = keyof misskey.Endpoints
limit: number; > = {
params?: misskey.Endpoints[E]['req'] | ComputedRef<misskey.Endpoints[E]['req']>; endpoint: E;
limit: number;
params?:
| misskey.Endpoints[E]["req"]
| ComputedRef<misskey.Endpoints[E]["req"]>;
/** /**
* 検索APIのようなページング不可なエンドポイントを利用する場合 * 検索APIのようなページング不可なエンドポイントを利用する場合
* (そのようなAPIをこの関数で使うのは若干矛盾してるけど) * (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
*/ */
noPaging?: boolean; noPaging?: boolean;
/** /**
* items 配列の中身を逆順にする(新しい方が最後) * items 配列の中身を逆順にする(新しい方が最後)
*/ */
reversed?: boolean; reversed?: boolean;
offsetMode?: boolean; offsetMode?: boolean;
}; };
const SECOND_FETCH_LIMIT = 30; const SECOND_FETCH_LIMIT = 30;
const props = withDefaults(defineProps<{ const props = withDefaults(
defineProps<{
pagination: Paging; pagination: Paging;
disableAutoLoad?: boolean; disableAutoLoad?: boolean;
displayLimit?: number; displayLimit?: number;
}>(), { }>(),
{
displayLimit: 30, displayLimit: 30,
}); }
);
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'queue', count: number): void; (ev: "queue", count: number): void;
}>(); }>();
type Item = { id: string; [another: string]: unknown; }; type Item = { id: string; [another: string]: unknown };
const rootEl = ref<HTMLElement>(); const rootEl = ref<HTMLElement>();
const items = ref<Item[]>([]); const items = ref<Item[]>([]);
const queue = ref<Item[]>([]); const queue = ref<Item[]>([]);
const offset = ref(0); const offset = ref(0);
const fetching = ref(true); const fetching = ref(true);
const moreFetching = ref(false); const moreFetching = ref(false);
const more = ref(false); const more = ref(false);
const backed = ref(false); // const backed = ref(false); //
const isBackTop = ref(false); const isBackTop = ref(false);
const empty = computed(() => items.value.length === 0); const empty = computed(() => items.value.length === 0);
const error = ref(false); const error = ref(false);
const init = async (): Promise<void> => { const init = async (): Promise<void> => {
queue.value = []; queue.value = [];
fetching.value = true; fetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; const params = props.pagination.params
await os.api(props.pagination.endpoint, { ? isRef(props.pagination.params)
? props.pagination.params.value
: props.pagination.params
: {};
await os
.api(props.pagination.endpoint, {
...params, ...params,
limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1, limit: props.pagination.noPaging
}).then(res => { ? props.pagination.limit || 10
for (let i = 0; i < res.length; i++) { : (props.pagination.limit || 10) + 1,
const item = res[i]; })
if (props.pagination.reversed) { .then(
if (i === res.length - 2) item._shouldInsertAd_ = true; (res) => {
} else { for (let i = 0; i < res.length; i++) {
if (i === 3) item._shouldInsertAd_ = true; 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;
} }
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 => { const reload = (): void => {
items.value = []; items.value = [];
init(); init();
}; };
const refresh = async (): void => { const refresh = async (): void => {
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; const params = props.pagination.params
await os.api(props.pagination.endpoint, { ? isRef(props.pagination.params)
? props.pagination.params.value
: props.pagination.params
: {};
await os
.api(props.pagination.endpoint, {
...params, ...params,
limit: items.value.length + 1, limit: items.value.length + 1,
offset: 0, offset: 0,
}).then(res => { })
let ids = items.value.reduce((a, b) => { .then(
a[b.id] = true; (res) => {
return a; let ids = items.value.reduce((a, b) => {
}, {} as { [id: string]: boolean; }); a[b.id] = true;
return a;
}, {} as { [id: string]: boolean });
for (let i = 0; i < res.length; i++) { for (let i = 0; i < res.length; i++) {
const item = res[i]; const item = res[i];
if (!updateItem(item.id, old => item)) { if (!updateItem(item.id, (old) => item)) {
append(item); append(item);
}
delete ids[item.id];
} }
delete ids[item.id];
}
for (const id in ids) { for (const id in ids) {
removeItem(i => i.id === id); removeItem((i) => i.id === id);
}
},
(err) => {
error.value = true;
fetching.value = false;
} }
}, err => { );
error.value = true; };
fetching.value = false;
});
};
const fetchMore = async (): Promise<void> => { const fetchMore = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return; if (
moreFetching.value = true; !more.value ||
backed.value = true; fetching.value ||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; moreFetching.value ||
await os.api(props.pagination.endpoint, { 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, ...params,
limit: SECOND_FETCH_LIMIT + 1, limit: SECOND_FETCH_LIMIT + 1,
...(props.pagination.offsetMode ? { ...(props.pagination.offsetMode
offset: offset.value, ? {
} : props.pagination.reversed ? { offset: offset.value,
sinceId: items.value[0].id, }
} : { : props.pagination.reversed
untilId: items.value[items.value.length - 1].id, ? {
}), sinceId: items.value[0].id,
}).then(res => { }
for (let i = 0; i < res.length; i++) { : {
const item = res[i]; untilId: items.value[items.value.length - 1].id,
if (props.pagination.reversed) { }),
if (i === res.length - 9) item._shouldInsertAd_ = true; })
} else { .then(
if (i === 10) item._shouldInsertAd_ = true; (res) => {
} for (let i = 0; i < res.length; i++) {
} const item = res[i];
if (res.length > SECOND_FETCH_LIMIT) { if (props.pagination.reversed) {
res.pop(); if (i === res.length - 9) item._shouldInsertAd_ = true;
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); } else {
more.value = true; if (i === 10) item._shouldInsertAd_ = 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;
}
} }
} }
} if (res.length > SECOND_FETCH_LIMIT) {
items.value.push(item); res.pop();
// TODO items.value = props.pagination.reversed
} else { ? [...res].reverse().concat(items.value)
// unshiftOK : items.value.concat(res);
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; 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 { } else {
queue.value.push(item); const pos = getScrollPosition(rootEl.value);
onScrollTop(rootEl.value, () => { const viewHeight = container.clientHeight;
for (const queueItem of queue.value) { const height = container.scrollHeight;
prepend(queueItem); 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;
} }
queue.value = []; }
});
} }
} }
};
const append = (item: Item): void => {
items.value.push(item); items.value.push(item);
}; // TODO
} else {
const removeItem = (finder: (item: Item) => boolean): boolean => { // unshiftOK
const i = items.value.findIndex(finder); if (!rootEl.value) {
if (i === -1) { items.value.unshift(item);
return false; return;
} }
items.value.splice(i, 1); const isTop =
return true; isBackTop.value ||
}; (document.body.contains(rootEl.value) &&
isTopVisible(rootEl.value));
const updateItem = (id: Item['id'], replacer: (old: Item) => Item): boolean => { if (isTop) {
const i = items.value.findIndex(item => item.id === id); // Prepend the item
if (i === -1) { items.value.unshift(item);
return false;
//
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 queueItem of queue.value) {
prepend(queueItem);
}
queue.value = [];
});
} }
}
};
items.value[i] = replacer(items.value[i]); const append = (item: Item): void => {
return true; items.value.push(item);
}; };
if (props.pagination.params && isRef(props.pagination.params)) { const removeItem = (finder: (item: Item) => boolean): boolean => {
watch(props.pagination.params, init, { deep: true }); const i = items.value.findIndex(finder);
if (i === -1) {
return false;
} }
watch(queue, (a, b) => { items.value.splice(i, 1);
return true;
};
const updateItem = (id: Item["id"], replacer: (old: Item) => Item): boolean => {
const i = items.value.findIndex((item) => item.id === id);
if (i === -1) {
return false;
}
items.value[i] = replacer(items.value[i]);
return true;
};
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; if (a.length === 0 && b.length === 0) return;
emit('queue', queue.value.length); emit("queue", queue.value.length);
}, { deep: true }); },
{ deep: true }
);
init(); init();
onActivated(() => { onActivated(() => {
isBackTop.value = false; isBackTop.value = false;
}); });
onDeactivated(() => { onDeactivated(() => {
isBackTop.value = window.scrollY === 0; isBackTop.value = window.scrollY === 0;
}); });
defineExpose({ defineExpose({
items, items,
queue, queue,
backed, backed,
reload, reload,
refresh, refresh,
prepend, prepend,
append, append,
removeItem, removeItem,
updateItem, updateItem,
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: opacity 0.125s ease; transition: opacity 0.125s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.cxiknjgy {
> .button {
margin-left: auto;
margin-right: auto;
} }
.fade-enter-from, }
.fade-leave-to { </style>
opacity: 0;
}
.cxiknjgy {
> .button {
margin-left: auto;
margin-right: auto;
}
}
</style>

View File

@ -1,34 +1,62 @@
<template> <template>
<div class="tivcixzd" :class="{ done: closed || isVoted }"> <div class="tivcixzd" :class="{ done: closed || isVoted }">
<ul> <ul>
<li v-for="(choice, i) in note.poll.choices" :key="i" :class="{ voted: choice.voted }" @click.stop="vote(i)"> <li
<div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> v-for="(choice, i) in note.poll.choices"
<span> :key="i"
<template v-if="choice.isVoted"><i class="ph-check ph-bold ph-lg"></i></template> :class="{ voted: choice.voted }"
<Mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/> @click.stop="vote(i)"
<span v-if="showResult" class="votes">({{ i18n.t('_poll.votesCount', { n: choice.votes }) }})</span> >
</span> <div
</li> class="backdrop"
</ul> :style="{
<p v-if="!readOnly"> width: `${
<span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span> showResult ? (choice.votes / total) * 100 : 0
<span> · </span> }%`,
<a v-if="!closed && !isVoted" @click.stop="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a> }"
<span v-if="isVoted">{{ i18n.ts._poll.voted }}</span> ></div>
<span v-else-if="closed">{{ i18n.ts._poll.closed }}</span> <span>
<span v-if="remaining > 0"> · {{ timer }}</span> <template v-if="choice.isVoted"
</p> ><i class="ph-check ph-bold ph-lg"></i
</div> ></template>
<Mfm
:text="choice.text"
:plain="true"
:custom-emojis="note.emojis"
/>
<span v-if="showResult" class="votes"
>({{
i18n.t("_poll.votesCount", { n: choice.votes })
}})</span
>
</span>
</li>
</ul>
<p v-if="!readOnly">
<span>{{ i18n.t("_poll.totalVotes", { n: total }) }}</span>
<span> · </span>
<a
v-if="!closed && !isVoted"
@click.stop="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> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onUnmounted, ref, toRef } from 'vue'; import { computed, onUnmounted, ref, toRef } from "vue";
import * as misskey from 'calckey-js'; import * as misskey from "calckey-js";
import { sum } from '@/scripts/array'; import { sum } from "@/scripts/array";
import { pleaseLogin } from '@/scripts/please-login'; import { pleaseLogin } from "@/scripts/please-login";
import * as os from '@/os'; import * as os from "@/os";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import { useInterval } from '@/scripts/use-interval'; import { useInterval } from "@/scripts/use-interval";
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
@ -37,25 +65,42 @@ const props = defineProps<{
const remaining = ref(-1); const remaining = ref(-1);
const total = computed(() => sum(props.note.poll.choices.map(x => x.votes))); const total = computed(() => sum(props.note.poll.choices.map((x) => x.votes)));
const closed = computed(() => remaining.value === 0); const closed = computed(() => remaining.value === 0);
const isVoted = computed(() => !props.note.poll.multiple && props.note.poll.choices.some(c => c.isVoted)); const isVoted = computed(
const timer = computed(() => i18n.t( () =>
remaining.value >= 86400 ? '_poll.remainingDays' : !props.note.poll.multiple &&
remaining.value >= 3600 ? '_poll.remainingHours' : props.note.poll.choices.some((c) => c.isVoted)
remaining.value >= 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', { );
s: Math.floor(remaining.value % 60), const timer = computed(() =>
m: Math.floor(remaining.value / 60) % 60, i18n.t(
h: Math.floor(remaining.value / 3600) % 24, remaining.value >= 86400
d: Math.floor(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); const showResult = ref(props.readOnly || isVoted.value);
// //
if (props.note.poll.expiresAt) { if (props.note.poll.expiresAt) {
const tick = () => { const tick = () => {
remaining.value = Math.floor(Math.max(new Date(props.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000); remaining.value = Math.floor(
Math.max(
new Date(props.note.poll.expiresAt).getTime() - Date.now(),
0
) / 1000
);
if (remaining.value === 0) { if (remaining.value === 0) {
showResult.value = true; showResult.value = true;
} }
@ -73,12 +118,14 @@ const vote = async (id) => {
if (props.readOnly || closed.value || isVoted.value) return; if (props.readOnly || closed.value || isVoted.value) return;
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'question', type: "question",
text: i18n.t('voteConfirm', { choice: props.note.poll.choices[id].text }), text: i18n.t("voteConfirm", {
choice: props.note.poll.choices[id].text,
}),
}); });
if (canceled) return; if (canceled) return;
await os.api('notes/polls/vote', { await os.api("notes/polls/vote", {
noteId: props.note.id, noteId: props.note.id,
choice: id, choice: id,
}); });
@ -111,7 +158,11 @@ const vote = async (id) => {
left: 0; left: 0;
height: 100%; height: 100%;
background: var(--accent); background: var(--accent);
background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB)); background: linear-gradient(
90deg,
var(--buttonGradateA),
var(--buttonGradateB)
);
transition: width 1s ease; transition: width 1s ease;
} }

View File

@ -1,61 +1,84 @@
<template> <template>
<div class="zmdxowus"> <div class="zmdxowus">
<p v-if="choices.length < 2" class="caution"> <p v-if="choices.length < 2" class="caution">
<i class="ph-warning ph-bold ph-lg"></i>{{ i18n.ts._poll.noOnlyOneChoice }} <i class="ph-warning ph-bold ph-lg"></i
</p> >{{ i18n.ts._poll.noOnlyOneChoice }}
<ul> </p>
<li v-for="(choice, i) in choices" :key="i"> <ul>
<MkInput class="input" small :model-value="choice" :placeholder="i18n.t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)"> <li v-for="(choice, i) in choices" :key="i">
</MkInput> <MkInput
<button class="_button" @click="remove(i)"> class="input"
<i class="ph-x ph-bold ph-lg"></i> small
</button> :model-value="choice"
</li> :placeholder="i18n.t('_poll.choiceN', { n: i + 1 })"
</ul> @update:modelValue="onInput(i, $event)"
<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>
<MkInput v-model="atTime" small type="time" class="input"> <button class="_button" @click="remove(i)">
<template #label>{{ i18n.ts._poll.deadlineTime }}</template> <i class="ph-x ph-bold ph-lg"></i>
</MkInput> </button>
</section> </li>
<section v-else-if="expiration === 'after'"> </ul>
<MkInput v-model="after" small type="number" class="input"> <MkButton v-if="choices.length < 10" class="add" @click="add">{{
<template #label>{{ i18n.ts._poll.duration }}</template> i18n.ts.add
</MkInput> }}</MkButton>
<MkSelect v-model="unit" small> <MkButton v-else class="add" disabled>{{
<option value="second">{{ i18n.ts._time.second }}</option> i18n.ts._poll.noMore
<option value="minute">{{ i18n.ts._time.minute }}</option> }}</MkButton>
<option value="hour">{{ i18n.ts._time.hour }}</option> <MkSwitch v-model="multiple">{{
<option value="day">{{ i18n.ts._time.day }}</option> 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> </MkSelect>
</section> <section v-if="expiration === 'at'">
</div> <MkInput v-model="atDate" small type="date" class="input">
</section> <template #label>{{
</div> 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> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch } from 'vue'; import { ref, watch } from "vue";
import MkInput from './form/input.vue'; import MkInput from "./form/input.vue";
import MkSelect from './form/select.vue'; import MkSelect from "./form/select.vue";
import MkSwitch from './form/switch.vue'; import MkSwitch from "./form/switch.vue";
import MkButton from './MkButton.vue'; import MkButton from "./MkButton.vue";
import { formatDateTimeString } from '@/scripts/format-time-string'; import { formatDateTimeString } from "@/scripts/format-time-string";
import { addTime } from '@/scripts/time'; import { addTime } from "@/scripts/time";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
modelValue: { modelValue: {
@ -66,30 +89,35 @@ const props = defineProps<{
}; };
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'update:modelValue', v: { (
expiresAt: string; ev: "update:modelValue",
expiredAfter: number; v: {
choices: string[]; expiresAt: string;
multiple: boolean; expiredAfter: number;
}): void; choices: string[];
multiple: boolean;
}
): void;
}>(); }>();
const choices = ref(props.modelValue.choices); const choices = ref(props.modelValue.choices);
const multiple = ref(props.modelValue.multiple); const multiple = ref(props.modelValue.multiple);
const expiration = ref('infinite'); const expiration = ref("infinite");
const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd')); const atDate = ref(
const atTime = ref('00:00'); formatDateTimeString(addTime(new Date(), 1, "day"), "yyyy-MM-dd")
);
const atTime = ref("00:00");
const after = ref(0); const after = ref(0);
const unit = ref('second'); const unit = ref("second");
if (props.modelValue.expiresAt) { if (props.modelValue.expiresAt) {
expiration.value = 'at'; expiration.value = "at";
atDate.value = atTime.value = props.modelValue.expiresAt; atDate.value = atTime.value = props.modelValue.expiresAt;
} else if (typeof props.modelValue.expiredAfter === 'number') { } else if (typeof props.modelValue.expiredAfter === "number") {
expiration.value = 'after'; expiration.value = "after";
after.value = props.modelValue.expiredAfter / 1000; after.value = props.modelValue.expiredAfter / 1000;
} else { } else {
expiration.value = 'infinite'; expiration.value = "infinite";
} }
function onInput(i, value) { function onInput(i, value) {
@ -97,7 +125,7 @@ function onInput(i, value) {
} }
function add() { function add() {
choices.value.push(''); choices.value.push("");
// TODO // TODO
// nextTick(() => { // nextTick(() => {
// (this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus(); // (this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus();
@ -116,30 +144,40 @@ function get() {
const calcAfter = () => { const calcAfter = () => {
let base = parseInt(after.value); let base = parseInt(after.value);
switch (unit.value) { switch (unit.value) {
case 'day': base *= 24; case "day":
// fallthrough base *= 24;
case 'hour': base *= 60; // fallthrough
// fallthrough case "hour":
case 'minute': base *= 60; base *= 60;
// fallthrough // fallthrough
case 'second': return base *= 1000; case "minute":
default: return null; base *= 60;
// fallthrough
case "second":
return (base *= 1000);
default:
return null;
} }
}; };
return { return {
choices: choices.value, choices: choices.value,
multiple: multiple.value, multiple: multiple.value,
...( ...(expiration.value === "at"
expiration.value === 'at' ? { expiresAt: calcAt() } : ? { expiresAt: calcAt() }
expiration.value === 'after' ? { expiredAfter: calcAfter() } : {} : expiration.value === "after"
), ? { expiredAfter: calcAfter() }
: {}),
}; };
} }
watch([choices, multiple, expiration, atDate, atTime, after, unit], () => emit('update:modelValue', get()), { watch(
deep: true, [choices, multiple, expiration, atDate, atTime, after, unit],
}); () => emit("update:modelValue", get()),
{
deep: true,
}
);
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -1,25 +1,42 @@
<template> <template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="modal.close()" @closed="emit('closed')"> <MkModal
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq" :class="{ drawer: type === 'drawer' }" @close="modal.close()"/> ref="modal"
</MkModal> 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> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import MkModal from './MkModal.vue'; import MkModal from "./MkModal.vue";
import MkMenu from './MkMenu.vue'; import MkMenu from "./MkMenu.vue";
import { MenuItem } from '@/types/menu'; import { MenuItem } from "@/types/menu";
defineProps<{ defineProps<{
items: MenuItem[]; items: MenuItem[];
align?: 'center' | string; align?: "center" | string;
width?: number; width?: number;
viaKeyboard?: boolean; viaKeyboard?: boolean;
src?: any; src?: any;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
let modal = $ref<InstanceType<typeof MkModal>>(); let modal = $ref<InstanceType<typeof MkModal>>();

File diff suppressed because it is too large Load Diff

View File

@ -1,28 +1,46 @@
<template> <template>
<div v-show="files.length != 0" class="skeikyzd"> <div v-show="files.length != 0" class="skeikyzd">
<XDraggable v-model="_files" class="files" item-key="id" animation="150" delay="100" delay-on-touch-only="true"> <XDraggable
<template #item="{element}"> v-model="_files"
<div class="file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> class="files"
<MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/> item-key="id"
<div v-if="element.isSensitive" class="sensitive"> animation="150"
<i class="ph-warning ph-bold ph-lg icon"></i> delay="100"
delay-on-touch-only="true"
>
<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="ph-warning ph-bold ph-lg icon"></i>
</div>
</div> </div>
</div> </template>
</template> </XDraggable>
</XDraggable> <p class="remain">{{ 16 - files.length }}/16</p>
<p class="remain">{{ 16 - files.length }}/16</p> </div>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue'; import { defineComponent, defineAsyncComponent } from "vue";
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import MkDriveFileThumbnail from "@/components/MkDriveFileThumbnail.vue";
import * as os from '@/os'; import * as os from "@/os";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
export default defineComponent({ export default defineComponent({
components: { components: {
XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), XDraggable: defineAsyncComponent(() =>
import("vuedraggable").then((x) => x.default)
),
MkDriveFileThumbnail, MkDriveFileThumbnail,
}, },
@ -37,7 +55,7 @@ export default defineComponent({
}, },
}, },
emits: ['updated', 'detach', 'changeSensitive', 'changeName'], emits: ["updated", "detach", "changeSensitive", "changeName"],
data() { data() {
return { return {
@ -52,7 +70,7 @@ export default defineComponent({
return this.files; return this.files;
}, },
set(value) { set(value) {
this.$emit('updated', value); this.$emit("updated", value);
}, },
}, },
}, },
@ -62,15 +80,15 @@ export default defineComponent({
if (this.detachMediaFn) { if (this.detachMediaFn) {
this.detachMediaFn(id); this.detachMediaFn(id);
} else { } else {
this.$emit('detach', id); this.$emit("detach", id);
} }
}, },
toggleSensitive(file) { toggleSensitive(file) {
os.api('drive/files/update', { os.api("drive/files/update", {
fileId: file.id, fileId: file.id,
isSensitive: !file.isSensitive, isSensitive: !file.isSensitive,
}).then(() => { }).then(() => {
this.$emit('changeSensitive', file, !file.isSensitive); this.$emit("changeSensitive", file, !file.isSensitive);
}); });
}, },
async rename(file) { async rename(file) {
@ -80,56 +98,86 @@ export default defineComponent({
allowEmpty: false, allowEmpty: false,
}); });
if (canceled) return; if (canceled) return;
os.api('drive/files/update', { os.api("drive/files/update", {
fileId: file.id, fileId: file.id,
name: result, name: result,
}).then(() => { }).then(() => {
this.$emit('changeName', file, result); this.$emit("changeName", file, result);
file.name = result; file.name = result;
}); });
}, },
async describe(file) { async describe(file) {
os.popup(defineAsyncComponent(() => import('@/components/MkMediaCaption.vue')), { os.popup(
title: i18n.ts.describeFile, defineAsyncComponent(
input: { () => import("@/components/MkMediaCaption.vue")
placeholder: i18n.ts.inputNewDescription, ),
default: file.comment !== null ? file.comment : '', {
title: i18n.ts.describeFile,
input: {
placeholder: i18n.ts.inputNewDescription,
default: file.comment !== null ? file.comment : "",
},
image: file,
}, },
image: file, {
}, { done: (result) => {
done: result => { if (!result || result.canceled) return;
if (!result || result.canceled) return; let comment =
let comment = result.result.length === 0 ? null : result.result; result.result.length === 0 ? null : result.result;
os.api('drive/files/update', { os.api("drive/files/update", {
fileId: file.id, fileId: file.id,
comment: comment, comment: comment,
}).then(() => { }).then(() => {
file.comment = comment; file.comment = comment;
}); });
},
}, },
}, 'closed'); "closed"
);
}, },
showFileMenu(file, ev: MouseEvent) { showFileMenu(file, ev: MouseEvent) {
if (this.menu) return; if (this.menu) return;
this.menu = os.popupMenu([{ this.menu = os
text: i18n.ts.renameFile, .popupMenu(
icon: 'ph-cursor-text ph-bold ph-lg', [
action: () => { this.rename(file); }, {
}, { text: i18n.ts.renameFile,
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, icon: "ph-cursor-text ph-bold ph-lg",
icon: file.isSensitive ? 'ph-eye-slash ph-bold ph-lg' : 'ph-eye ph-bold ph-lg', action: () => {
action: () => { this.toggleSensitive(file); }, this.rename(file);
}, { },
text: i18n.ts.describeFile, },
icon: 'ph-cursor-text ph-bold ph-lg', {
action: () => { this.describe(file); }, text: file.isSensitive
}, { ? i18n.ts.unmarkAsSensitive
text: i18n.ts.attachCancel, : i18n.ts.markAsSensitive,
icon: 'ph-circle-wavy-warning ph-bold ph-lg', icon: file.isSensitive
action: () => { this.detachMedia(file.id); }, ? "ph-eye-slash ph-bold ph-lg"
}], ev.currentTarget ?? ev.target).then(() => this.menu = null); : "ph-eye ph-bold ph-lg",
action: () => {
this.toggleSensitive(file);
},
},
{
text: i18n.ts.describeFile,
icon: "ph-cursor-text ph-bold ph-lg",
action: () => {
this.describe(file);
},
},
{
text: i18n.ts.attachCancel,
icon: "ph-circle-wavy-warning ph-bold ph-lg",
action: () => {
this.detachMedia(file.id);
},
},
],
ev.currentTarget ?? ev.target
)
.then(() => (this.menu = null));
}, },
}, },
}); });
@ -172,7 +220,7 @@ export default defineComponent({
top: 0; top: 0;
left: 0; left: 0;
z-index: 2; z-index: 2;
background: rgba(17, 17, 17, .7); background: rgba(17, 17, 17, 0.7);
color: #fff; color: #fff;
> .icon { > .icon {

View File

@ -1,14 +1,28 @@
<template> <template>
<MkModal ref="modal" :prefer-type="'dialog'" @click="modal.close()" @closed="onModalClosed()"> <MkModal
<MkPostForm ref="form" style="margin: 0 auto auto auto;" v-bind="props" autofocus freeze-after-posted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/> ref="modal"
:prefer-type="'dialog'"
@click="modal.close()"
@closed="onModalClosed()"
>
<MkPostForm
ref="form"
style="margin: 0 auto auto auto"
v-bind="props"
autofocus
freeze-after-posted
@posted="onPosted"
@cancel="modal.close()"
@esc="modal.close()"
/>
</MkModal> </MkModal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
import * as misskey from 'calckey-js'; import * as misskey from "calckey-js";
import MkModal from '@/components/MkModal.vue'; import MkModal from "@/components/MkModal.vue";
import MkPostForm from '@/components/MkPostForm.vue'; import MkPostForm from "@/components/MkPostForm.vue";
const props = defineProps<{ const props = defineProps<{
reply?: misskey.entities.Note; reply?: misskey.entities.Note;
@ -28,7 +42,7 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'closed'): void; (ev: "closed"): void;
}>(); }>();
let modal = $shallowRef<InstanceType<typeof MkModal>>(); let modal = $shallowRef<InstanceType<typeof MkModal>>();
@ -41,6 +55,6 @@ function onPosted() {
} }
function onModalClosed() { function onModalClosed() {
emit('closed'); emit("closed");
} }
</script> </script>

View File

@ -1,46 +1,63 @@
<template> <template>
<MkButton <MkButton
v-if="supported && !pushRegistrationInServer" v-if="supported && !pushRegistrationInServer"
type="button" type="button"
primary primary
:gradate="gradate" :gradate="gradate"
:rounded="rounded" :rounded="rounded"
:inline="inline" :inline="inline"
:autofocus="autofocus" :autofocus="autofocus"
:wait="wait" :wait="wait"
:full="full" :full="full"
@click="subscribe" @click="subscribe"
> >
{{ i18n.ts.subscribePushNotification }} {{ i18n.ts.subscribePushNotification }}
</MkButton> </MkButton>
<MkButton <MkButton
v-else-if="!showOnlyToRegister && ($i ? pushRegistrationInServer : pushSubscription)" v-else-if="
type="button" !showOnlyToRegister &&
:primary="false" ($i ? pushRegistrationInServer : pushSubscription)
:gradate="gradate" "
:rounded="rounded" type="button"
:inline="inline" :primary="false"
:autofocus="autofocus" :gradate="gradate"
:wait="wait" :rounded="rounded"
:full="full" :inline="inline"
@click="unsubscribe" :autofocus="autofocus"
> :wait="wait"
{{ i18n.ts.unsubscribePushNotification }} :full="full"
</MkButton> @click="unsubscribe"
<MkButton v-else-if="$i && pushRegistrationInServer" disabled :rounded="rounded" :inline="inline" :wait="wait" :full="full"> >
{{ i18n.ts.pushNotificationAlreadySubscribed }} {{ i18n.ts.unsubscribePushNotification }}
</MkButton> </MkButton>
<MkButton v-else-if="!supported" disabled :rounded="rounded" :inline="inline" :wait="wait" :full="full"> <MkButton
{{ i18n.ts.pushNotificationNotSupported }} v-else-if="$i && pushRegistrationInServer"
</MkButton> 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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { $i, getAccounts } from '@/account'; import { $i, getAccounts } from "@/account";
import MkButton from '@/components/MkButton.vue'; import MkButton from "@/components/MkButton.vue";
import { instance } from '@/instance'; import { instance } from "@/instance";
import { api, apiWithDialog, promiseDialog } from '@/os'; import { api, apiWithDialog, promiseDialog } from "@/os";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
defineProps<{ defineProps<{
primary?: boolean; primary?: boolean;
@ -62,38 +79,60 @@ let registration = $ref<ServiceWorkerRegistration | undefined>();
let supported = $ref(false); let supported = $ref(false);
// If this browser has already subscribed to push notification // If this browser has already subscribed to push notification
let pushSubscription = $ref<PushSubscription | null>(null); let pushSubscription = $ref<PushSubscription | null>(null);
let pushRegistrationInServer = $ref<{ state?: string; key?: string; userId: string; endpoint: string; sendReadMessage: boolean; } | undefined>(); let pushRegistrationInServer = $ref<
| {
state?: string;
key?: string;
userId: string;
endpoint: string;
sendReadMessage: boolean;
}
| undefined
>();
function subscribe() { function subscribe() {
if (!registration || !supported || !instance.swPublickey) return; if (!registration || !supported || !instance.swPublickey) return;
// SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters
return promiseDialog(registration.pushManager.subscribe({ return promiseDialog(
userVisibleOnly: true, registration.pushManager
applicationServerKey: urlBase64ToUint8Array(instance.swPublickey), .subscribe({
}) userVisibleOnly: true,
.then(async subscription => { applicationServerKey: urlBase64ToUint8Array(
pushSubscription = subscription; instance.swPublickey
),
})
.then(
async (subscription) => {
pushSubscription = subscription;
// Register // Register
pushRegistrationInServer = await api('sw/register', { pushRegistrationInServer = await api("sw/register", {
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
auth: encode(subscription.getKey('auth')), auth: encode(subscription.getKey("auth")),
publickey: encode(subscription.getKey('p256dh')), publickey: encode(subscription.getKey("p256dh")),
}); });
}, async err => { // When subscribe failed },
// async (err) => {
if (err?.name === 'NotAllowedError') { // When subscribe failed
console.info('User denied the notification permission request.'); //
return; if (err?.name === "NotAllowedError") {
} console.info(
"User denied the notification permission request."
);
return;
}
// applicationServerKey ( gcm_sender_id) // applicationServerKey ( gcm_sender_id)
// //
// //
// //
await unsubscribe(); await unsubscribe();
}), null, null); }
),
null,
null
);
} }
async function unsubscribe() { async function unsubscribe() {
@ -105,13 +144,13 @@ async function unsubscribe() {
pushRegistrationInServer = undefined; pushRegistrationInServer = undefined;
if ($i && accounts.length >= 2) { if ($i && accounts.length >= 2) {
apiWithDialog('sw/unregister', { apiWithDialog("sw/unregister", {
i: $i.token, i: $i.token,
endpoint, endpoint,
}); });
} else { } else {
pushSubscription.unsubscribe(); pushSubscription.unsubscribe();
apiWithDialog('sw/unregister', { apiWithDialog("sw/unregister", {
endpoint, endpoint,
}); });
pushSubscription = null; pushSubscription = null;
@ -127,10 +166,10 @@ function encode(buffer: ArrayBuffer | null) {
* @param base64String base64 string * @param base64String base64 string
*/ */
function urlBase64ToUint8Array(base64String: string): Uint8Array { function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - base64String.length % 4) % 4); const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding) const base64 = (base64String + padding)
.replace(/-/g, '+') .replace(/-/g, "+")
.replace(/_/g, '/'); .replace(/_/g, "/");
const rawData = window.atob(base64); const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length); const outputArray = new Uint8Array(rawData.length);
@ -144,16 +183,16 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
if (navigator.serviceWorker == null) { if (navigator.serviceWorker == null) {
// TODO: // TODO:
} else { } else {
navigator.serviceWorker.ready.then(async swr => { navigator.serviceWorker.ready.then(async (swr) => {
registration = swr; registration = swr;
pushSubscription = await registration.pushManager.getSubscription(); pushSubscription = await registration.pushManager.getSubscription();
if (instance.swPublickey && ('PushManager' in window) && $i && $i.token) { if (instance.swPublickey && "PushManager" in window && $i && $i.token) {
supported = true; supported = true;
if (pushSubscription) { if (pushSubscription) {
const res = await api('sw/show-registration', { const res = await api("sw/show-registration", {
endpoint: pushSubscription.endpoint, endpoint: pushSubscription.endpoint,
}); });

View File

@ -1,27 +1,31 @@
<template> <template>
<button <button
v-if="canRenote && $store.state.seperateRenoteQuote" v-if="canRenote && $store.state.seperateRenoteQuote"
v-tooltip.noDelay.bottom="i18n.ts.quote" v-tooltip.noDelay.bottom="i18n.ts.quote"
class="eddddedb _button" class="eddddedb _button"
@click="quote()" @click="quote()"
> >
<i class="ph-quotes ph-bold ph-lg"></i> <i class="ph-quotes ph-bold ph-lg"></i>
</button> </button>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from "vue";
import type { Note } from 'calckey-js/built/entities'; import type { Note } from "calckey-js/built/entities";
import { pleaseLogin } from '@/scripts/please-login'; import { pleaseLogin } from "@/scripts/please-login";
import * as os from '@/os'; import * as os from "@/os";
import { $i } from '@/account'; import { $i } from "@/account";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
note: Note; note: Note;
}>(); }>();
const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id); const canRenote = computed(
() =>
["public", "home"].includes(props.note.visibility) ||
props.note.userId === $i?.id
);
function quote(): void { function quote(): void {
pleaseLogin(); pleaseLogin();

View File

@ -1,58 +1,84 @@
<template> <template>
<MkModalWindow <MkModalWindow
ref="dialog" ref="dialog"
:width="400" :width="400"
:height="450" :height="450"
@close="dialog.close()" @close="dialog.close()"
@closed="emit('closed')" @closed="emit('closed')"
> >
<template #header>{{ i18n.ts.reactions }}</template> <template #header>{{ i18n.ts.reactions }}</template>
<MkSpacer :margin-min="20" :margin-max="28"> <MkSpacer :margin-min="20" :margin-max="28">
<div v-if="note" class="_gaps"> <div v-if="note" class="_gaps">
<div v-if="reactions.length === 0" class="_fullinfo"> <div v-if="reactions.length === 0" class="_fullinfo">
<img src="/static-assets/badges/info.png" class="_ghost" alt="Info"/> <img
<div>{{ i18n.ts.nothing }}</div> src="/static-assets/badges/info.png"
</div> class="_ghost"
<template v-else> alt="Info"
<div :class="$style.tabs"> />
<button v-for="reaction in reactions" :key="reaction" :class="[$style.tab, { [$style.tabActive]: tab === reaction }]" class="_button" @click="tab = reaction"> <div>{{ i18n.ts.nothing }}</div>
<MkReactionIcon
ref="reactionRef"
:reaction="reaction ? reaction.replace(/^:(\w+):$/, ':$1@.:') : reaction"
:custom-emojis="note.emojis"
/>
<span style="margin-left: 4px;">{{ note.reactions[reaction] }}</span>
</button>
</div> </div>
<MkA v-for="user in users" :key="user.id" :to="userPage(user)"> <template v-else>
<MkUserCardMini :user="user" :with-chart="false"/> <div :class="$style.tabs">
</MkA> <button
</template> v-for="reaction in reactions"
</div> :key="reaction"
<div v-else> :class="[
<MkLoading/> $style.tab,
</div> { [$style.tabActive]: tab === reaction },
</MkSpacer> ]"
</MkModalWindow> class="_button"
@click="tab = reaction"
>
<MkReactionIcon
ref="reactionRef"
:reaction="
reaction
? reaction.replace(
/^:(\w+):$/,
':$1@.:'
)
: reaction
"
:custom-emojis="note.emojis"
/>
<span style="margin-left: 4px">{{
note.reactions[reaction]
}}</span>
</button>
</div>
<MkA
v-for="user in users"
:key="user.id"
:to="userPage(user)"
>
<MkUserCardMini :user="user" :with-chart="false" />
</MkA>
</template>
</div>
<div v-else>
<MkLoading />
</div>
</MkSpacer>
</MkModalWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, watch } from 'vue'; import { onMounted, watch } from "vue";
import * as misskey from 'calckey-js'; import * as misskey from "calckey-js";
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from "@/components/MkModalWindow.vue";
import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkReactionIcon from "@/components/MkReactionIcon.vue";
import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkUserCardMini from "@/components/MkUserCardMini.vue";
import { userPage } from '@/filters/user'; import { userPage } from "@/filters/user";
import { i18n } from '@/i18n'; import { i18n } from "@/i18n";
import * as os from '@/os'; import * as os from "@/os";
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'closed'): void, (ev: "closed"): void;
}>(); }>();
const props = defineProps<{ const props = defineProps<{
noteId: misskey.entities.Note['id']; noteId: misskey.entities.Note["id"];
}>(); }>();
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
@ -63,17 +89,17 @@ let reactions = $ref<string[]>();
let users = $ref(); let users = $ref();
watch($$(tab), async () => { watch($$(tab), async () => {
const res = await os.api('notes/reactions', { const res = await os.api("notes/reactions", {
noteId: props.noteId, noteId: props.noteId,
type: tab, type: tab,
limit: 30, limit: 30,
}); });
users = res.map(x => x.user); users = res.map((x) => x.user);
}); });
onMounted(() => { onMounted(() => {
os.api('notes/show', { os.api("notes/show", {
noteId: props.noteId, noteId: props.noteId,
}).then((res) => { }).then((res) => {
reactions = Object.keys(res.reactions); reactions = Object.keys(res.reactions);

View File

@ -1,9 +1,15 @@
<template> <template>
<MkEmoji :emoji="reaction" :custom-emojis="customEmojis || []" :is-reaction="true" :normal="true" :no-style="noStyle"/> <MkEmoji
:emoji="reaction"
:custom-emojis="customEmojis || []"
:is-reaction="true"
:normal="true"
:no-style="noStyle"
/>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import {} from "vue";
const props = defineProps<{ const props = defineProps<{
reaction: string; reaction: string;

Some files were not shown because too many files have changed in this diff Show More