Initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": ["@eslint-sets/vue3", "@eslint-sets/vue3-ts"],
|
||||
"plugins": ["file-progress", "prettier"],
|
||||
"rules": {
|
||||
"file-progress/activate": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": true,
|
||||
"singleQuote": false,
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.vue",
|
||||
"options": {
|
||||
"parser": "vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"path-intellisense.mappings": {
|
||||
"@": "${workspaceRoot}/packages/client/src/"
|
||||
},
|
||||
"files.exclude": {
|
||||
"**/.git": true,
|
||||
"**/.svn": true,
|
||||
"**/.hg": true,
|
||||
"**/CVS": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/Thumbs.db": true,
|
||||
"**/_client_dist_": true
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
type FIXME = any;
|
||||
|
||||
declare const _LANGS_: string[][];
|
||||
declare const _VERSION_: string;
|
||||
declare const _ENV_: string;
|
||||
declare const _DEV_: boolean;
|
||||
declare const _PERF_PREFIX_: string;
|
||||
declare const _DATA_TRANSFER_DRIVE_FILE_: string;
|
||||
declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
|
||||
declare const _DATA_TRANSFER_DECK_COLUMN_: string;
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare module "@/themes/*.json5" {
|
||||
import { Theme } from "@/scripts/theme";
|
||||
|
||||
const theme: Theme;
|
||||
|
||||
export default theme;
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vue/macros-global" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"watch": "vite build --watch --mode development",
|
||||
"build": "vite build",
|
||||
"build:debug": "yarn build",
|
||||
"lint": "biome check **/*.ts --apply && yarn lint:vue",
|
||||
"lint:vue": "paralint --ext .vue --fix '**/*.vue' --cache",
|
||||
"format": "biome format * --write && prettier --write '**/*.{scss,vue}' --cache --cache-strategy metadata"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@phosphor-icons/web": "^2.0.3",
|
||||
"@rollup/plugin-json": "^6.0.1",
|
||||
"@rollup/pluginutils": "^5.1.0",
|
||||
"@syuilo/aiscript": "0.17.0",
|
||||
"@types/glob": "8.1.0",
|
||||
"@types/katex": "0.16.0",
|
||||
"@types/matter-js": "0.18.2",
|
||||
"@types/punycode": "2.1.0",
|
||||
"@types/seedrandom": "3.0.5",
|
||||
"@types/throttle-debounce": "5.0.0",
|
||||
"@types/tinycolor2": "1.4.3",
|
||||
"@types/uuid": "8.3.4",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/compiler-sfc": "^3.5.34",
|
||||
"autobind-decorator": "2.4.0",
|
||||
"autosize": "5.0.2",
|
||||
"blurhash": "2.0.5",
|
||||
"broadcast-channel": "5.1.0",
|
||||
"browser-image-resizer": "https://iceshrimp.dev/iceshrimp/browser-image-resizer.git",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "^2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"city-timezones": "^1.2.1",
|
||||
"compare-versions": "6.0.0",
|
||||
"cropperjs": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"emojilib": "^4.0.2",
|
||||
"eventemitter3": "5.0.1",
|
||||
"fast-blurhash": "^1.1.2",
|
||||
"focus-trap": "^7.5.2",
|
||||
"focus-trap-vue": "^4.0.2",
|
||||
"gsap": "^3.12.2",
|
||||
"iceshrimp-sdk": "workspace:*",
|
||||
"idb-keyval": "6.2.1",
|
||||
"insert-text-at-cursor": "0.3.0",
|
||||
"json5": "2.2.3",
|
||||
"katex": "^0.16.45",
|
||||
"matter-js": "^0.20.0",
|
||||
"mfm-js": "^0.25.0",
|
||||
"paralint": "^1.2.1",
|
||||
"photoswipe": "^5.4.4",
|
||||
"plyr": "^3.8.4",
|
||||
"prettier": "3.0.0",
|
||||
"prettier-plugin-vue": "1.1.6",
|
||||
"prismjs": "1.29.0",
|
||||
"punycode": "2.3.0",
|
||||
"rollup": "4.6.1",
|
||||
"s-age": "1.1.2",
|
||||
"sass": "^1.99.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"stringz": "2.1.0",
|
||||
"swiper": "11.2.10",
|
||||
"syuilo-password-strength": "0.0.1",
|
||||
"textarea-caret": "3.1.0",
|
||||
"throttle-debounce": "5.0.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"unicode-emoji-json": "^0.8.0",
|
||||
"uuid": "^14.0.0",
|
||||
"vite": "7.3.3",
|
||||
"vue": "^3.5.34",
|
||||
"vue-draggable-plus": "^0.2.2",
|
||||
"vue-prism-editor": "2.0.0-alpha.2",
|
||||
"vuedraggable": "next"
|
||||
},
|
||||
"dependencies": {
|
||||
"fengari": "^0.1.5",
|
||||
"shogiops": "^0.21.0"
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
declare module "fengari";
|
||||
@@ -0,0 +1,298 @@
|
||||
import { defineAsyncComponent, reactive } from "vue";
|
||||
import * as misskey from "iceshrimp-sdk";
|
||||
import { i18n } from "./i18n";
|
||||
import { del, get, set } from "@/scripts/idb-proxy";
|
||||
import { apiUrl } from "@/config";
|
||||
import { waiting, api, popup, popupMenu, success, alert } from "@/os";
|
||||
import { unisonReload, reloadChannel } from "@/scripts/unison-reload";
|
||||
|
||||
// TODO: 他のタブと永続化されたstateを同期
|
||||
|
||||
type Account = misskey.entities.MeDetailed;
|
||||
|
||||
const accountData = localStorage.getItem("account");
|
||||
|
||||
// TODO: 外部からはreadonlyに
|
||||
export const $i = accountData
|
||||
? reactive(JSON.parse(accountData) as Account)
|
||||
: null;
|
||||
|
||||
export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator);
|
||||
export const iAmAdmin = $i?.isAdmin;
|
||||
|
||||
export async function signout() {
|
||||
waiting();
|
||||
localStorage.removeItem("account");
|
||||
|
||||
await removeAccount($i.id);
|
||||
|
||||
const accounts = await getAccounts();
|
||||
|
||||
//#region Remove service worker registration
|
||||
try {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const push = await registration.pushManager.getSubscription();
|
||||
if (push) {
|
||||
await fetch(`${apiUrl}/sw/unregister`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
i: $i.token,
|
||||
endpoint: push.endpoint,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (accounts.length === 0) {
|
||||
await navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||
return Promise.all(
|
||||
registrations.map((registration) => registration.unregister()),
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (err) {}
|
||||
//#endregion
|
||||
|
||||
document.cookie = "igi=; path=/";
|
||||
|
||||
if (accounts.length > 0) login(accounts[0].token);
|
||||
else unisonReload("/");
|
||||
}
|
||||
|
||||
export async function getAccounts(): Promise<
|
||||
{ id: Account["id"]; token: Account["token"] }[]
|
||||
> {
|
||||
return (await get("accounts")) || [];
|
||||
}
|
||||
|
||||
export async function addAccount(id: Account["id"], token: Account["token"]) {
|
||||
const accounts = await getAccounts();
|
||||
if (!accounts.some((x) => x.id === id)) {
|
||||
await set("accounts", accounts.concat([{ id, token }]));
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAccount(id: Account["id"]) {
|
||||
const accounts = await getAccounts();
|
||||
accounts.splice(
|
||||
accounts.findIndex((x) => x.id === id),
|
||||
1,
|
||||
);
|
||||
|
||||
if (accounts.length > 0) await set("accounts", accounts);
|
||||
else await del("accounts");
|
||||
}
|
||||
|
||||
function fetchAccount(token: string): Promise<Account> {
|
||||
return new Promise((done, fail) => {
|
||||
// Fetch user
|
||||
fetch(`${apiUrl}/i`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
i: token,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (res.error) {
|
||||
if (res.error.id === "a8c724b3-6e9c-4b46-b1a8-bc3ed6258370") {
|
||||
showSuspendedDialog().then(() => {
|
||||
signout();
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
type: "error",
|
||||
title: i18n.ts.failedToFetchAccountInformation,
|
||||
text: JSON.stringify(res.error),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res.token = token;
|
||||
done(res);
|
||||
}
|
||||
})
|
||||
.catch(fail);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAccount(accountData) {
|
||||
for (const [key, value] of Object.entries(accountData)) {
|
||||
$i[key] = value;
|
||||
}
|
||||
localStorage.setItem("account", JSON.stringify($i));
|
||||
}
|
||||
|
||||
export function refreshAccount() {
|
||||
return fetchAccount($i.token).then(updateAccount);
|
||||
}
|
||||
|
||||
export async function login(token: Account["token"], redirect?: string) {
|
||||
waiting();
|
||||
if (_DEV_) console.log("logging as token ", token);
|
||||
const me = await fetchAccount(token);
|
||||
localStorage.setItem("account", JSON.stringify(me));
|
||||
document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う
|
||||
await addAccount(me.id, token);
|
||||
|
||||
if (redirect) {
|
||||
// 他のタブは再読み込みするだけ
|
||||
reloadChannel.postMessage(null);
|
||||
// このページはredirectで指定された先に移動
|
||||
location.href = redirect;
|
||||
return;
|
||||
}
|
||||
|
||||
unisonReload();
|
||||
}
|
||||
|
||||
export async function openAccountMenu(
|
||||
opts: {
|
||||
includeCurrentAccount?: boolean;
|
||||
withExtraOperation: boolean;
|
||||
withoutProfileLink?: boolean;
|
||||
active?: misskey.entities.UserDetailed["id"];
|
||||
onChoose?: (account: misskey.entities.UserDetailed) => void;
|
||||
},
|
||||
ev: MouseEvent,
|
||||
) {
|
||||
function showSigninDialog() {
|
||||
popup(
|
||||
defineAsyncComponent(() => import("@/components/MkSigninDialog.vue")),
|
||||
{},
|
||||
{
|
||||
done: (res) => {
|
||||
addAccount(res.id, res.i);
|
||||
switchAccountWithToken(res.i);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
);
|
||||
}
|
||||
|
||||
function createAccount() {
|
||||
popup(
|
||||
defineAsyncComponent(() => import("@/components/MkSignupDialog.vue")),
|
||||
{},
|
||||
{
|
||||
done: (res) => {
|
||||
addAccount(res.id, res.i);
|
||||
switchAccountWithToken(res.i);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
);
|
||||
}
|
||||
|
||||
async function switchAccount(account: misskey.entities.UserDetailed) {
|
||||
const storedAccounts = await getAccounts();
|
||||
const token = storedAccounts.find((x) => x.id === account.id).token;
|
||||
switchAccountWithToken(token);
|
||||
}
|
||||
|
||||
function switchAccountWithToken(token: string) {
|
||||
login(token);
|
||||
}
|
||||
|
||||
const storedAccounts = await getAccounts().then((accounts) =>
|
||||
accounts.filter((x) => x.id !== $i.id),
|
||||
);
|
||||
const accountsPromise = api("users/show", {
|
||||
userIds: storedAccounts.map((x) => x.id),
|
||||
});
|
||||
|
||||
function createItem(account: misskey.entities.UserDetailed) {
|
||||
return {
|
||||
type: "user",
|
||||
user: account,
|
||||
active: opts.active != null ? opts.active === account.id : false,
|
||||
action: () => {
|
||||
if (opts.onChoose) {
|
||||
opts.onChoose(account);
|
||||
} else {
|
||||
switchAccount(account);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const accountItemPromises = storedAccounts.map(
|
||||
(a) =>
|
||||
new Promise((res) => {
|
||||
accountsPromise.then((accounts) => {
|
||||
const account = accounts.find((x) => x.id === a.id);
|
||||
if (account == null) return res(null);
|
||||
res(createItem(account));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
if (opts.withExtraOperation) {
|
||||
const menu = [
|
||||
...(!opts.withoutProfileLink ? [
|
||||
{
|
||||
type: "link",
|
||||
text: i18n.ts.profile,
|
||||
to: `/@${$i.username}`,
|
||||
avatar: $i,
|
||||
},
|
||||
null,
|
||||
] : []),
|
||||
...(opts.includeCurrentAccount ? [createItem($i)] : []),
|
||||
...accountItemPromises,
|
||||
...(opts.withoutProfileLink ? [null] : []),
|
||||
{
|
||||
type: "parent",
|
||||
icon: "ph-plus ph-bold ph-lg",
|
||||
text: i18n.ts.addAccount,
|
||||
children: [
|
||||
{
|
||||
text: i18n.ts.existingAccount,
|
||||
action: () => {
|
||||
showSigninDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: i18n.ts.createAccount,
|
||||
action: () => {
|
||||
createAccount();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
icon: "ph-users ph-bold ph-lg",
|
||||
text: i18n.ts.manageAccounts,
|
||||
to: "/settings/accounts",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
icon: "ph-sign-out ph-bold ph-lg",
|
||||
text: i18n.ts.logout,
|
||||
action: () => {
|
||||
signout();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
popupMenu(
|
||||
menu,
|
||||
ev.currentTarget ?? ev.target,
|
||||
{
|
||||
align: "left",
|
||||
},
|
||||
);
|
||||
} else {
|
||||
popupMenu(
|
||||
[
|
||||
...(opts.includeCurrentAccount ? [createItem($i)] : []),
|
||||
...accountItemPromises,
|
||||
],
|
||||
ev.currentTarget ?? ev.target,
|
||||
{
|
||||
align: "left",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<section v-if="visible" class="adPreroll _panel">
|
||||
<div class="head">
|
||||
<div class="label">
|
||||
<i class="ph-megaphone-simple-bold ph-lg"></i>
|
||||
<span>Advertisement</span>
|
||||
</div>
|
||||
<MkButton :disabled="remaining > 0" @click="finish">
|
||||
{{ remaining > 0 ? `${remaining}s` : "Skip" }}
|
||||
</MkButton>
|
||||
</div>
|
||||
<MkLoading v-if="loading" />
|
||||
<MkNote v-else-if="note" :note="note" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, ref, watch } from "vue";
|
||||
import * as misskey from "iceshrimp-sdk";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import MkLoading from "@/components/global/MkLoading.vue";
|
||||
import MkNote from "@/components/MkNote.vue";
|
||||
import * as os from "@/os";
|
||||
import { $i } from "@/account";
|
||||
import { defaultStore } from "@/store";
|
||||
|
||||
const props = defineProps<{
|
||||
service: "video" | "audio" | "image" | "karaoke" | "lua4frozen";
|
||||
tag?: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "done"): void;
|
||||
}>();
|
||||
|
||||
const note = ref<misskey.entities.Note | null>(null);
|
||||
const loading = ref(false);
|
||||
const visible = ref(false);
|
||||
const remaining = ref(5);
|
||||
let timer: number | null = null;
|
||||
|
||||
function finish() {
|
||||
if (timer != null) window.clearInterval(timer);
|
||||
timer = null;
|
||||
visible.value = false;
|
||||
emit("done");
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
remaining.value = 5;
|
||||
timer = window.setInterval(() => {
|
||||
remaining.value -= 1;
|
||||
if (remaining.value <= 0 && timer != null) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
visible.value = true;
|
||||
try {
|
||||
note.value = (await (os.api as any)("promo/show", {
|
||||
service: props.service,
|
||||
tag: props.tag || null,
|
||||
hideExplicit: defaultStore.state.nsfw === "force",
|
||||
})) as misskey.entities.Note | null;
|
||||
|
||||
if (!note.value) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($i) {
|
||||
await os.api("promo/read", { noteId: note.value.id });
|
||||
}
|
||||
startTimer();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.service, props.tag],
|
||||
() => {
|
||||
if (timer != null) window.clearInterval(timer);
|
||||
load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer != null) window.clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.adPreroll {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: solid 1px var(--divider);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: leah and other Cutiekey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<span ref="container" :class="$style.root">
|
||||
<span ref="el" :class="$style.inner" style="position: absolute">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, shallowRef } from 'vue';
|
||||
const el = shallowRef<HTMLElement>();
|
||||
const container = shallowRef<HTMLElement>();
|
||||
const props = defineProps({
|
||||
x: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
y: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
speed: {
|
||||
type: String,
|
||||
default: '0.1s',
|
||||
},
|
||||
rotateByVelocity: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
let oldAngle = 0;
|
||||
|
||||
function lerp(a, b, alpha) {
|
||||
return a + alpha * (b - a);
|
||||
}
|
||||
|
||||
const updatePosition = (mouseEvent: MouseEvent) => {
|
||||
if (el.value && container.value) {
|
||||
const containerRect = container.value.getBoundingClientRect();
|
||||
const newX = mouseEvent.clientX - containerRect.left;
|
||||
const newY = mouseEvent.clientY - containerRect.top;
|
||||
let transform = `translate(calc(${props.x ? newX : 0}px - 50%), calc(${props.y ? newY : 0}px - 50%))`;
|
||||
if (props.rotateByVelocity) {
|
||||
const deltaX = newX - lastX;
|
||||
const deltaY = newY - lastY;
|
||||
const angle = lerp(
|
||||
oldAngle,
|
||||
Math.atan2(deltaY, deltaX) * (180 / Math.PI),
|
||||
0.1,
|
||||
);
|
||||
transform += ` rotate(${angle}deg)`;
|
||||
oldAngle = angle;
|
||||
}
|
||||
el.value.style.transform = transform;
|
||||
el.value.style.transition = `transform ${props.speed}`;
|
||||
lastX = newX;
|
||||
lastY = newY;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousemove', updatePosition);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', updatePosition);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.inner {
|
||||
transform-origin: center center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="bcekxzvu _gap _panel">
|
||||
<div class="target">
|
||||
<MkA
|
||||
v-user-preview="report.targetUserId"
|
||||
class="info"
|
||||
:to="`/user-info/${report.targetUserId}`"
|
||||
>
|
||||
<MkAvatar
|
||||
class="avatar"
|
||||
:user="report.targetUser"
|
||||
:show-indicator="true"
|
||||
:disable-link="true"
|
||||
/>
|
||||
<div class="names">
|
||||
<MkUserName class="name" :user="report.targetUser" />
|
||||
<MkAcct
|
||||
class="acct"
|
||||
:user="report.targetUser"
|
||||
style="display: block"
|
||||
/>
|
||||
</div>
|
||||
</MkA>
|
||||
<MkKeyValue class="_formBlock">
|
||||
<template #key>{{ i18n.ts.registeredDate }}</template>
|
||||
<template #value
|
||||
>{{
|
||||
new Date(report.targetUser.createdAt).toLocaleString()
|
||||
}}
|
||||
(<MkTime :time="report.targetUser.createdAt" />)</template
|
||||
>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<div>
|
||||
<Mfm :text="report.comment" />
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter" />
|
||||
</div>
|
||||
<div v-if="report.assignee">
|
||||
{{ i18n.ts.moderator }}:
|
||||
<MkAcct :user="report.assignee" />
|
||||
</div>
|
||||
<div><MkTime :time="report.createdAt" /></div>
|
||||
<div class="action">
|
||||
<MkSwitch
|
||||
v-model="forward"
|
||||
:disabled="
|
||||
report.targetUser.host == null || report.resolved
|
||||
"
|
||||
>
|
||||
{{ i18n.ts.forwardReport }}
|
||||
<template #caption>{{
|
||||
i18n.ts.forwardReportIsAnonymous
|
||||
}}</template>
|
||||
</MkSwitch>
|
||||
<MkButton v-if="!report.resolved" primary @click="resolve">{{
|
||||
i18n.ts.abuseMarkAsResolved
|
||||
}}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import MkSwitch from "@/components/form/switch.vue";
|
||||
import MkKeyValue from "@/components/MkKeyValue.vue";
|
||||
import { acct, userPage } from "@/filters/user";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
report: any;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "resolved", reportId: string): void;
|
||||
}>();
|
||||
|
||||
const forward = ref(props.report.forwarded);
|
||||
|
||||
function resolve() {
|
||||
os.apiWithDialog("admin/resolve-abuse-user-report", {
|
||||
forward: forward.value,
|
||||
reportId: props.report.id,
|
||||
}).then(() => {
|
||||
emit("resolved", props.report.id);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bcekxzvu {
|
||||
display: flex;
|
||||
|
||||
> .target {
|
||||
width: 35%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
padding: 24px;
|
||||
border-right: solid 1px var(--divider);
|
||||
|
||||
> .info {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
--c: rgb(255 196 0 / 15%);
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
var(--c) 16.67%,
|
||||
transparent 16.67%,
|
||||
transparent 50%,
|
||||
var(--c) 50%,
|
||||
var(--c) 66.67%,
|
||||
transparent 66.67%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 16px 16px;
|
||||
|
||||
> .avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
> .names {
|
||||
margin-left: 0.3em;
|
||||
padding: 0 8px;
|
||||
flex: 1;
|
||||
|
||||
> .name {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .detail {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<XWindow
|
||||
ref="uiWindow"
|
||||
:initial-width="400"
|
||||
:initial-height="500"
|
||||
:can-resize="true"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
<i
|
||||
class="ph-warning-circle ph-bold ph-lg"
|
||||
style="margin-right: 0.5em"
|
||||
></i>
|
||||
<I18n :src="i18n.ts.reportAbuseOf" tag="span">
|
||||
<template #name>
|
||||
<b><MkAcct :user="user" /></b>
|
||||
</template>
|
||||
</I18n>
|
||||
</template>
|
||||
<div class="dpvffvvy _monolithic_">
|
||||
<div class="_section">
|
||||
<MkTextarea v-model="comment">
|
||||
<template #label>{{ i18n.ts.details }}</template>
|
||||
<template #caption>{{
|
||||
i18n.ts.fillAbuseReportDescription
|
||||
}}</template>
|
||||
</MkTextarea>
|
||||
</div>
|
||||
<div class="_section">
|
||||
<MkButton
|
||||
primary
|
||||
full
|
||||
:disabled="comment.length === 0"
|
||||
@click="send"
|
||||
>{{ i18n.ts.send }}</MkButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</XWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type * as Misskey from "iceshrimp-sdk";
|
||||
import XWindow from "@/components/MkWindow.vue";
|
||||
import MkTextarea from "@/components/form/textarea.vue";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
user: Misskey.entities.User;
|
||||
initialComment?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
const uiWindow = ref<InstanceType<typeof XWindow>>();
|
||||
const comment = ref(props.initialComment || "");
|
||||
|
||||
function send() {
|
||||
os.apiWithDialog(
|
||||
"users/report-abuse",
|
||||
{
|
||||
userId: props.user.id,
|
||||
comment: comment.value,
|
||||
},
|
||||
undefined,
|
||||
).then((res) => {
|
||||
os.alert({
|
||||
type: "success",
|
||||
text: i18n.ts.abuseReported,
|
||||
});
|
||||
uiWindow.value?.close();
|
||||
emit("closed");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dpvffvvy {
|
||||
--root-margin: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none">
|
||||
<template v-if="props.graduations === 'dots'">
|
||||
<circle
|
||||
v-for="(angle, i) in graduationsMajor"
|
||||
:cx="5 + Math.sin(angle) * (5 - graduationsPadding)"
|
||||
:cy="5 - Math.cos(angle) * (5 - graduationsPadding)"
|
||||
:r="0.125"
|
||||
:fill="
|
||||
(props.twentyfour ? h : h % 12) === i
|
||||
? nowColor
|
||||
: majorGraduationColor
|
||||
"
|
||||
:opacity="
|
||||
!props.fadeGraduations ||
|
||||
(props.twentyfour ? h : h % 12) === i
|
||||
? 1
|
||||
: Math.max(
|
||||
0,
|
||||
1 -
|
||||
angleDiff(hAngle, angle) / Math.PI -
|
||||
numbersOpacityFactor,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="props.graduations === 'numbers'">
|
||||
<text
|
||||
v-for="(angle, i) in texts"
|
||||
:x="5 + Math.sin(angle) * (5 - textsPadding)"
|
||||
:y="5 - Math.cos(angle) * (5 - textsPadding)"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
:font-size="(props.twentyfour ? h : h % 12) === i ? 1 : 0.7"
|
||||
:font-weight="
|
||||
(props.twentyfour ? h : h % 12) === i ? 'bold' : 'normal'
|
||||
"
|
||||
:fill="
|
||||
(props.twentyfour ? h : h % 12) === i
|
||||
? nowColor
|
||||
: 'currentColor'
|
||||
"
|
||||
:opacity="
|
||||
!props.fadeGraduations ||
|
||||
(props.twentyfour ? h : h % 12) === i
|
||||
? 1
|
||||
: Math.max(
|
||||
0,
|
||||
1 -
|
||||
angleDiff(hAngle, angle) / Math.PI -
|
||||
numbersOpacityFactor,
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ i === 0 ? (props.twentyfour ? "24" : "12") : i }}
|
||||
</text>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
<line
|
||||
:x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))"
|
||||
:y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))"
|
||||
:x2="5 + (Math.sin(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
|
||||
:y2="5 - (Math.cos(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
|
||||
:stroke="sHandColor"
|
||||
:stroke-width="thickness / 2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
-->
|
||||
|
||||
<line
|
||||
class="s"
|
||||
:class="{
|
||||
animate: !disableSAnimate && sAnimation !== 'none',
|
||||
elastic: sAnimation === 'elastic',
|
||||
easeOut: sAnimation === 'easeOut',
|
||||
}"
|
||||
:x1="5 - 0 * (sHandLengthRatio * handsTailLength)"
|
||||
:y1="5 + 1 * (sHandLengthRatio * handsTailLength)"
|
||||
:x2="5 + 0 * (sHandLengthRatio * 5 - handsPadding)"
|
||||
:y2="5 - 1 * (sHandLengthRatio * 5 - handsPadding)"
|
||||
:stroke="sHandColor"
|
||||
:stroke-width="thickness / 2"
|
||||
:style="`transform: rotateZ(${sAngle}rad)`"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<line
|
||||
:x1="5 - Math.sin(mAngle) * (mHandLengthRatio * handsTailLength)"
|
||||
:y1="5 + Math.cos(mAngle) * (mHandLengthRatio * handsTailLength)"
|
||||
:x2="5 + Math.sin(mAngle) * (mHandLengthRatio * 5 - handsPadding)"
|
||||
:y2="5 - Math.cos(mAngle) * (mHandLengthRatio * 5 - handsPadding)"
|
||||
:stroke="mHandColor"
|
||||
:stroke-width="thickness"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<line
|
||||
:x1="5 - Math.sin(hAngle) * (hHandLengthRatio * handsTailLength)"
|
||||
:y1="5 + Math.cos(hAngle) * (hHandLengthRatio * handsTailLength)"
|
||||
:x2="5 + Math.sin(hAngle) * (hHandLengthRatio * 5 - handsPadding)"
|
||||
:y2="5 - Math.cos(hAngle) * (hHandLengthRatio * 5 - handsPadding)"
|
||||
:stroke="hHandColor"
|
||||
:stroke-width="thickness"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
} from "vue";
|
||||
import tinycolor from "tinycolor2";
|
||||
import { globalEvents } from "@/events.js";
|
||||
|
||||
// https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles
|
||||
const angleDiff = (a: number, b: number) => {
|
||||
const x = Math.abs(a - b);
|
||||
return Math.abs(((x + Math.PI) % (Math.PI * 2)) - Math.PI);
|
||||
};
|
||||
|
||||
const graduationsPadding = 0.5;
|
||||
const textsPadding = 0.6;
|
||||
const handsPadding = 1;
|
||||
const handsTailLength = 0.7;
|
||||
const hHandLengthRatio = 0.75;
|
||||
const mHandLengthRatio = 1;
|
||||
const sHandLengthRatio = 1;
|
||||
const numbersOpacityFactor = 0.35;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
thickness?: number;
|
||||
offset?: number;
|
||||
twentyfour?: boolean;
|
||||
graduations?: "none" | "dots" | "numbers";
|
||||
fadeGraduations?: boolean;
|
||||
sAnimation?: "none" | "elastic" | "easeOut";
|
||||
}>(),
|
||||
{
|
||||
numbers: false,
|
||||
thickness: 0.1,
|
||||
offset: 0 - new Date().getTimezoneOffset(),
|
||||
twentyfour: false,
|
||||
graduations: "dots",
|
||||
fadeGraduations: true,
|
||||
sAnimation: "elastic",
|
||||
},
|
||||
);
|
||||
|
||||
const graduationsMajor = computed(() => {
|
||||
const angles: number[] = [];
|
||||
const times = props.twentyfour ? 24 : 12;
|
||||
for (let i = 0; i < times; i++) {
|
||||
const angle = (Math.PI * i) / (times / 2);
|
||||
angles.push(angle);
|
||||
}
|
||||
return angles;
|
||||
});
|
||||
const texts = computed(() => {
|
||||
const angles: number[] = [];
|
||||
const times = props.twentyfour ? 24 : 12;
|
||||
for (let i = 0; i < times; i++) {
|
||||
const angle = (Math.PI * i) / (times / 2);
|
||||
angles.push(angle);
|
||||
}
|
||||
return angles;
|
||||
});
|
||||
|
||||
let enabled = true,
|
||||
majorGraduationColor = ref<string>(),
|
||||
// let minorGraduationColor = $ref<string>();
|
||||
sHandColor = ref<string>(),
|
||||
mHandColor = ref<string>(),
|
||||
hHandColor = ref<string>(),
|
||||
nowColor = ref<string>(),
|
||||
h = ref<number>(0),
|
||||
m = ref<number>(0),
|
||||
s = ref<number>(0),
|
||||
hAngle = ref<number>(0),
|
||||
mAngle = ref<number>(0),
|
||||
sAngle = ref<number>(0),
|
||||
disableSAnimate = ref(false),
|
||||
sOneRound = false;
|
||||
|
||||
function tick() {
|
||||
const now = new Date();
|
||||
now.setMinutes(
|
||||
now.getMinutes() + (new Date().getTimezoneOffset() + props.offset),
|
||||
);
|
||||
s.value = now.getSeconds();
|
||||
m.value = now.getMinutes();
|
||||
h.value = now.getHours();
|
||||
hAngle.value =
|
||||
(Math.PI * ((h.value % (props.twentyfour ? 24 : 12)) + (m.value + s.value / 60) / 60)) /
|
||||
(props.twentyfour ? 12 : 6);
|
||||
mAngle.value = (Math.PI * (m.value + s.value / 60)) / 30;
|
||||
if (sOneRound) {
|
||||
// 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない)
|
||||
sAngle.value = (Math.PI * 60) / 30;
|
||||
window.setTimeout(() => {
|
||||
disableSAnimate.value = true;
|
||||
window.setTimeout(() => {
|
||||
sAngle.value = 0;
|
||||
window.setTimeout(() => {
|
||||
disableSAnimate.value = false;
|
||||
}, 100);
|
||||
}, 100);
|
||||
}, 700);
|
||||
} else {
|
||||
sAngle.value = (Math.PI * s.value) / 30;
|
||||
}
|
||||
sOneRound = s.value === 59;
|
||||
}
|
||||
|
||||
tick();
|
||||
|
||||
function calcColors() {
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
const dark = tinycolor(computedStyle.getPropertyValue("--bg")).isDark();
|
||||
const accent = tinycolor(
|
||||
computedStyle.getPropertyValue("--accent"),
|
||||
).toHexString();
|
||||
majorGraduationColor.value = dark
|
||||
? "rgba(255, 255, 255, 0.3)"
|
||||
: "rgba(0, 0, 0, 0.3)";
|
||||
// minorGraduationColor = dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
|
||||
sHandColor.value = dark ? "rgba(255, 255, 255, 0.5)" : "rgba(0, 0, 0, 0.3)";
|
||||
mHandColor.value = tinycolor(
|
||||
computedStyle.getPropertyValue("--fg"),
|
||||
).toHexString();
|
||||
hHandColor.value = accent;
|
||||
nowColor.value = accent;
|
||||
}
|
||||
|
||||
calcColors();
|
||||
|
||||
onMounted(() => {
|
||||
const update = () => {
|
||||
if (enabled) {
|
||||
tick();
|
||||
window.setTimeout(update, 1000);
|
||||
}
|
||||
};
|
||||
update();
|
||||
|
||||
globalEvents.on("themeChanged", calcColors);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
enabled = false;
|
||||
|
||||
globalEvents.off("themeChanged", calcColors);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mbcofsoe {
|
||||
display: block;
|
||||
|
||||
> .s {
|
||||
will-change: transform;
|
||||
transform-origin: 50% 50%;
|
||||
|
||||
&.animate.elastic {
|
||||
transition: transform 0.2s cubic-bezier(0.4, 2.08, 0.55, 0.44);
|
||||
}
|
||||
|
||||
&.animate.easeOut {
|
||||
transition: transform 0.7s cubic-bezier(0, 0.7, 0.3, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<MkModal ref="modal" :z-priority="'middle'" @closed="$emit('closed')">
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.title">
|
||||
<MkSparkle v-if="isGoodNews">{{ title }}</MkSparkle>
|
||||
<p v-else>{{ title }}</p>
|
||||
</div>
|
||||
<div :class="$style.time">
|
||||
<MkTime :time="announcement.createdAt" />
|
||||
<div v-if="announcement.updatedAt">
|
||||
{{ i18n.ts.updatedAt }}:
|
||||
<MkTime :time="announcement.createdAt" />
|
||||
</div>
|
||||
</div>
|
||||
<Mfm :text="text" />
|
||||
<img
|
||||
v-if="imageUrl != null"
|
||||
:key="imageUrl"
|
||||
:src="imageUrl"
|
||||
alt="attached image"
|
||||
/>
|
||||
<MkButton :class="$style.gotIt" primary full @click="gotIt()">{{
|
||||
i18n.ts.gotIt
|
||||
}}</MkButton>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef } from "vue";
|
||||
import MkModal from "@/components/MkModal.vue";
|
||||
import MkSparkle from "@/components/MkSparkle.vue";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
import * as os from "@/os";
|
||||
|
||||
const props = defineProps<{
|
||||
announcement: Announcement;
|
||||
}>();
|
||||
|
||||
const { id, text, title, imageUrl, isGoodNews } = props.announcement;
|
||||
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
|
||||
const gotIt = () => {
|
||||
modal.value.close();
|
||||
os.api("i/read-announcement", { announcementId: id });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
margin: auto;
|
||||
position: relative;
|
||||
padding: 32px;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
|
||||
> img {
|
||||
border-radius: 10px;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.gotIt {
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,564 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="swhvrteh _popup _shadow"
|
||||
:style="{ zIndex }"
|
||||
@contextmenu.prevent="() => {}"
|
||||
>
|
||||
<ol v-if="type === 'user'" ref="suggests" class="users">
|
||||
<li
|
||||
v-for="user in users"
|
||||
tabindex="-1"
|
||||
class="user"
|
||||
@click="complete(type, user)"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<img class="avatar" :src="user.avatarUrl" />
|
||||
<span class="name">
|
||||
<MkUserName :key="user.id" :user="user" />
|
||||
</span>
|
||||
<span class="username">@{{ acct(user) }}</span>
|
||||
</li>
|
||||
<li
|
||||
tabindex="-1"
|
||||
class="choose"
|
||||
@click="chooseUser()"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
{{ i18n.ts.selectUser }}
|
||||
</li>
|
||||
</ol>
|
||||
<ol v-else-if="hashtags.length > 0" ref="suggests" class="hashtags">
|
||||
<li
|
||||
v-for="hashtag in hashtags"
|
||||
tabindex="-1"
|
||||
@click="complete(type, hashtag)"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<span class="name">{{ hashtag }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
<ol v-else-if="emojis.length > 0" ref="suggests" class="emojis">
|
||||
<li
|
||||
v-for="emoji in emojis"
|
||||
tabindex="-1"
|
||||
@click="complete(type, emoji.emoji)"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<span v-if="emoji.isCustomEmoji" class="emoji"
|
||||
><img
|
||||
:src="
|
||||
defaultStore.state.disableShowingAnimatedImages
|
||||
? getStaticImageUrl(emoji.url)
|
||||
: emoji.url
|
||||
"
|
||||
:alt="emoji.emoji"
|
||||
/></span>
|
||||
<span
|
||||
v-else-if="!defaultStore.state.useOsNativeEmojis"
|
||||
class="emoji"
|
||||
><img :src="emoji.url" :alt="emoji.emoji"
|
||||
/></span>
|
||||
<span v-else class="emoji">{{ emoji.emoji }}</span>
|
||||
<span class="name">
|
||||
<template v-for="(between, index) in emoji.name.split(q)" :key="index">
|
||||
<b v-if="index as number > 0">{{ q }}</b>{{ between }}
|
||||
</template>
|
||||
</span>
|
||||
<span v-if="emoji.aliasOf" class="alias"
|
||||
>({{ emoji.aliasOf }})</span
|
||||
>
|
||||
</li>
|
||||
</ol>
|
||||
<ol v-else-if="mfmTags.length > 0" ref="suggests" class="mfmTags">
|
||||
<li
|
||||
v-for="tag in mfmTags"
|
||||
tabindex="-1"
|
||||
@click="complete(type, tag)"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<span class="tag">{{ tag }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
markRaw,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUpdated,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import contains from "@/scripts/contains";
|
||||
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 { addSkinTone, emojilist } from "@/scripts/emojilist";
|
||||
import { instance } from "@/instance";
|
||||
import { i18n } from "@/i18n";
|
||||
import * as Acct from "iceshrimp-sdk/built/acct";
|
||||
|
||||
interface EmojiDef {
|
||||
emoji: string;
|
||||
name: string;
|
||||
aliasOf?: string;
|
||||
url?: string;
|
||||
isCustomEmoji?: boolean;
|
||||
}
|
||||
|
||||
const lib = emojilist.filter((x) => x.category !== "flags");
|
||||
|
||||
for (const emoji of lib) {
|
||||
if (emoji.skin_tone_support) {
|
||||
emoji.emoji = addSkinTone(emoji.emoji);
|
||||
}
|
||||
}
|
||||
|
||||
const emjdb: EmojiDef[] = lib.map((x) => ({
|
||||
emoji: x.emoji,
|
||||
name: x.slug,
|
||||
url: char2filePath(x.emoji),
|
||||
}));
|
||||
|
||||
for (const x of lib) {
|
||||
if (x.keywords) {
|
||||
for (const k of x.keywords) {
|
||||
emjdb.push({
|
||||
emoji: x.emoji,
|
||||
name: k,
|
||||
aliasOf: x.slug,
|
||||
url: char2filePath(x.emoji),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emjdb.sort((a, b) => a.name.length - b.name.length);
|
||||
|
||||
// #region Construct Emoji DB
|
||||
const customEmojis = instance.emojis;
|
||||
const emojiDefinitions: EmojiDef[] = [];
|
||||
|
||||
for (const x of customEmojis) {
|
||||
emojiDefinitions.push({
|
||||
name: x.name,
|
||||
emoji: `:${x.name}:`,
|
||||
url: x.url,
|
||||
isCustomEmoji: true,
|
||||
});
|
||||
|
||||
if (x.aliases) {
|
||||
for (const alias of x.aliases) {
|
||||
emojiDefinitions.push({
|
||||
name: alias,
|
||||
aliasOf: x.name,
|
||||
emoji: `:${x.name}:`,
|
||||
url: x.url,
|
||||
isCustomEmoji: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emojiDefinitions.sort((a, b) => a.name.length - b.name.length);
|
||||
|
||||
const emojiDb = markRaw(emojiDefinitions.concat(emjdb));
|
||||
// #endregion
|
||||
|
||||
export default {
|
||||
emojiDb,
|
||||
emojiDefinitions,
|
||||
emojilist,
|
||||
customEmojis,
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
type: string;
|
||||
q: string | null;
|
||||
textarea: HTMLTextAreaElement;
|
||||
close: () => void;
|
||||
x: number;
|
||||
y: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "done", value: { type: string; value: any }): void;
|
||||
(event: "closed"): void;
|
||||
}>();
|
||||
|
||||
const suggests = ref<Element>();
|
||||
const rootEl = ref<HTMLDivElement>();
|
||||
|
||||
const fetching = ref(true);
|
||||
const users = ref<any[]>([]);
|
||||
const hashtags = ref<any[]>([]);
|
||||
const emojis = ref<EmojiDef[]>([]);
|
||||
const items = ref<Element[] | HTMLCollection>([]);
|
||||
const mfmTags = ref<string[]>([]);
|
||||
const select = ref(-1);
|
||||
const zIndex = os.claimZIndex("high");
|
||||
|
||||
function complete(type: string, value: any) {
|
||||
emit("done", { type, value });
|
||||
emit("closed");
|
||||
if (type === "emoji") {
|
||||
let recents = defaultStore.state.recentlyUsedEmojis;
|
||||
recents = recents.filter((emoji: any) => emoji !== value);
|
||||
recents.unshift(value);
|
||||
defaultStore.set("recentlyUsedEmojis", recents.splice(0, 32));
|
||||
}
|
||||
}
|
||||
|
||||
function setPosition() {
|
||||
if (!rootEl.value) return;
|
||||
if (props.x + rootEl.value.offsetWidth > window.innerWidth) {
|
||||
rootEl.value.style.left =
|
||||
window.innerWidth - rootEl.value.offsetWidth + "px";
|
||||
} else {
|
||||
rootEl.value.style.left = `${props.x}px`;
|
||||
}
|
||||
if (props.y + rootEl.value.offsetHeight > window.innerHeight) {
|
||||
rootEl.value.style.top = props.y - rootEl.value.offsetHeight + "px";
|
||||
rootEl.value.style.marginTop = "0";
|
||||
} else {
|
||||
rootEl.value.style.top = props.y + "px";
|
||||
rootEl.value.style.marginTop = "calc(1em + 8px)";
|
||||
}
|
||||
}
|
||||
|
||||
function exec() {
|
||||
select.value = -1;
|
||||
if (suggests.value) {
|
||||
for (const el of Array.from(items.value)) {
|
||||
el.removeAttribute("data-selected");
|
||||
}
|
||||
}
|
||||
if (props.type === "user") {
|
||||
if (!props.q) {
|
||||
users.value = [];
|
||||
fetching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `autocomplete:user:${props.q}`;
|
||||
const cache = sessionStorage.getItem(cacheKey);
|
||||
|
||||
if (cache) {
|
||||
users.value = JSON.parse(cache);
|
||||
fetching.value = false;
|
||||
} else {
|
||||
const acct = Acct.parse(props.q);
|
||||
os.api("users/search-by-username-and-host", {
|
||||
username: acct.username,
|
||||
host: acct.host ?? undefined,
|
||||
limit: 10,
|
||||
detail: false,
|
||||
}).then((searchedUsers) => {
|
||||
users.value = searchedUsers as any[];
|
||||
fetching.value = false;
|
||||
// キャッシュ
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
|
||||
});
|
||||
}
|
||||
} else if (props.type === "hashtag") {
|
||||
if (!props.q || props.q === "") {
|
||||
hashtags.value = JSON.parse(
|
||||
localStorage.getItem("hashtags") || "[]",
|
||||
);
|
||||
fetching.value = false;
|
||||
} else {
|
||||
const cacheKey = `autocomplete:hashtag:${props.q}`;
|
||||
const cache = sessionStorage.getItem(cacheKey);
|
||||
if (cache) {
|
||||
const hashtags = JSON.parse(cache);
|
||||
hashtags.value = hashtags;
|
||||
fetching.value = false;
|
||||
} else {
|
||||
os.api("hashtags/search", {
|
||||
query: props.q,
|
||||
limit: 30,
|
||||
}).then((searchedHashtags) => {
|
||||
hashtags.value = searchedHashtags as any[];
|
||||
fetching.value = false;
|
||||
// キャッシュ
|
||||
sessionStorage.setItem(
|
||||
cacheKey,
|
||||
JSON.stringify(searchedHashtags),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (props.type === "emoji") {
|
||||
if (!props.q || props.q === "") {
|
||||
// 最近使った絵文字をサジェスト
|
||||
emojis.value = defaultStore.state.recentlyUsedEmojis
|
||||
.map((emoji) =>
|
||||
emojiDb.find((dbEmoji) => dbEmoji.emoji === emoji),
|
||||
)
|
||||
.filter((x) => x) as EmojiDef[];
|
||||
return;
|
||||
}
|
||||
|
||||
const matched: EmojiDef[] = [];
|
||||
const max = 30;
|
||||
|
||||
emojiDb.some((x) => {
|
||||
if (
|
||||
x.name.startsWith(props.q ?? "") &&
|
||||
!x.aliasOf &&
|
||||
!matched.some((y) => y.emoji === x.emoji)
|
||||
)
|
||||
matched.push(x);
|
||||
return matched.length === max;
|
||||
});
|
||||
|
||||
if (matched.length < max) {
|
||||
emojiDb.some((x) => {
|
||||
if (
|
||||
x.name.startsWith(props.q ?? "") &&
|
||||
!matched.some((y) => y.emoji === x.emoji)
|
||||
)
|
||||
matched.push(x);
|
||||
return matched.length === max;
|
||||
});
|
||||
}
|
||||
|
||||
if (matched.length < max) {
|
||||
emojiDb.some((x) => {
|
||||
if (
|
||||
x.name.includes(props.q ?? "") &&
|
||||
!matched.some((y) => y.emoji === x.emoji)
|
||||
)
|
||||
matched.push(x);
|
||||
return matched.length === max;
|
||||
});
|
||||
}
|
||||
|
||||
emojis.value = matched;
|
||||
} else if (props.type === "mfmTag") {
|
||||
if (!props.q || props.q === "") {
|
||||
mfmTags.value = MFM_TAGS;
|
||||
return;
|
||||
}
|
||||
|
||||
mfmTags.value = MFM_TAGS.filter((tag) => tag.startsWith(props.q ?? ""));
|
||||
}
|
||||
}
|
||||
|
||||
function onMousedown(event: Event) {
|
||||
if (!contains(rootEl.value, event.target) && rootEl.value !== event.target)
|
||||
props.close();
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const cancel = () => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
switch (event.key) {
|
||||
case "Enter":
|
||||
if (select.value !== -1) {
|
||||
cancel();
|
||||
(items.value[select.value] as any).click();
|
||||
} else {
|
||||
props.close();
|
||||
}
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
cancel();
|
||||
props.close();
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
if (select.value !== -1) {
|
||||
cancel();
|
||||
selectPrev();
|
||||
} else {
|
||||
props.close();
|
||||
}
|
||||
break;
|
||||
|
||||
case "Tab":
|
||||
case "ArrowDown":
|
||||
cancel();
|
||||
selectNext();
|
||||
break;
|
||||
|
||||
default:
|
||||
event.stopPropagation();
|
||||
props.textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function selectNext() {
|
||||
if (++select.value >= items.value.length) select.value = 0;
|
||||
if (items.value.length === 0) select.value = -1;
|
||||
applySelect();
|
||||
}
|
||||
|
||||
function selectPrev() {
|
||||
if (--select.value < 0) select.value = items.value.length - 1;
|
||||
applySelect();
|
||||
}
|
||||
|
||||
function applySelect() {
|
||||
for (const el of Array.from(items.value)) {
|
||||
el.removeAttribute("data-selected");
|
||||
}
|
||||
|
||||
if (select.value !== -1) {
|
||||
items.value[select.value].setAttribute("data-selected", "true");
|
||||
(items.value[select.value] as any).focus();
|
||||
}
|
||||
}
|
||||
|
||||
function chooseUser() {
|
||||
props.close();
|
||||
os.selectUser().then((user) => {
|
||||
complete("user", user);
|
||||
props.textarea.focus();
|
||||
});
|
||||
}
|
||||
|
||||
onUpdated(() => {
|
||||
setPosition();
|
||||
items.value = suggests.value?.children ?? [];
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setPosition();
|
||||
|
||||
props.textarea.addEventListener("keydown", onKeydown);
|
||||
document.body.addEventListener("mousedown", onMousedown);
|
||||
|
||||
nextTick(() => {
|
||||
exec();
|
||||
|
||||
watch(
|
||||
() => props.q,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
exec();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
props.textarea.removeEventListener("keydown", onKeydown);
|
||||
document.body.removeEventListener("mousedown", onMousedown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.swhvrteh {
|
||||
position: fixed;
|
||||
max-width: 100%;
|
||||
margin-top: calc(1em + 8px);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
top 0.1s ease,
|
||||
left 0.1s ease;
|
||||
|
||||
> ol {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
max-height: 190px;
|
||||
max-width: 500px;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
|
||||
> li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
font-size: 0.9em;
|
||||
cursor: default;
|
||||
|
||||
&,
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
* {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--interactiveElementHovered);
|
||||
}
|
||||
|
||||
&[data-selected="true"] {
|
||||
background: var(--accent);
|
||||
|
||||
&,
|
||||
* {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken);
|
||||
|
||||
&,
|
||||
* {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .users > li {
|
||||
.avatar {
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
max-width: 28px;
|
||||
max-height: 28px;
|
||||
margin: 0 8px 0 0;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0 8px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .emojis > li {
|
||||
.emoji {
|
||||
display: inline-block;
|
||||
margin: 0 4px 0 0;
|
||||
width: 24px;
|
||||
|
||||
> img {
|
||||
width: 24px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
.alias {
|
||||
margin: 0 0 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .mfmTags > li {
|
||||
.name {
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="defgtij">
|
||||
<div v-for="user in users" :key="user.id" class="avatar-holder">
|
||||
<MkAvatar :user="user" :show-indicator="true" class="avatar" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import * as os from "@/os";
|
||||
|
||||
const props = defineProps<{
|
||||
userIds: string[];
|
||||
}>();
|
||||
|
||||
const users = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
users.value = await os.api("users/show", {
|
||||
userIds: props.userIds,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.defgtij {
|
||||
padding: 12px;
|
||||
grid-gap: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(30px, 40px));
|
||||
place-content: center;
|
||||
|
||||
> .avatar-holder {
|
||||
aspect-ratio: 1;
|
||||
|
||||
> .avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<button class="kpoogebi _button bite-button" :class="{
|
||||
full,
|
||||
large,
|
||||
wait,
|
||||
active: hasBittenBack,
|
||||
}" :disabled="wait" @click.stop="onClick" :aria-label="`bite ${user.name || user.username} back`">
|
||||
<span>{{ i18n.ts.biteBack }}</span><i class="ph-tooth ph-bold ph-lg"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type * as Misskey from "iceshrimp-sdk";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
user: Misskey.entities.UserLite,
|
||||
bite: Misskey.entities.Bite,
|
||||
full: boolean,
|
||||
large: boolean,
|
||||
}>(),
|
||||
{
|
||||
full: false,
|
||||
large: false
|
||||
},
|
||||
);
|
||||
|
||||
let wait = ref(false);
|
||||
let hasBittenBack = ref<boolean>(props.bite.replied);
|
||||
|
||||
async function onClick() {
|
||||
wait.value = true;
|
||||
|
||||
try {
|
||||
await os.api("bites/create", {
|
||||
targetType: "bite",
|
||||
targetId: props.bite.id,
|
||||
});
|
||||
hasBittenBack.value = true;
|
||||
} finally {
|
||||
wait.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bite-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
border: solid 1px var(--accent);
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
border-radius: 100px;
|
||||
background: var(--bg);
|
||||
vertical-align: middle;
|
||||
margin-left: 0.5em;
|
||||
|
||||
&.full {
|
||||
padding: 0.2em 0.7em;
|
||||
width: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&.large {
|
||||
font-size: 16px;
|
||||
height: 38px;
|
||||
padding: 0 12px 0 16px;
|
||||
}
|
||||
|
||||
&:not(.full) {
|
||||
width: 31px;
|
||||
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
border: 2px solid var(--focus);
|
||||
border-radius: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--fgOnAccent);
|
||||
background: var(--accent);
|
||||
|
||||
&:hover {
|
||||
background: var(--accentLighten);
|
||||
border-color: var(--accentLighten);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken);
|
||||
border-color: var(--accentDarken);
|
||||
}
|
||||
}
|
||||
|
||||
&.wait {
|
||||
cursor: wait !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
>span {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="!link"
|
||||
class="bghgjjyj _button"
|
||||
:class="{ inline, primary, gradate, danger, rounded, full, mini, chip }"
|
||||
:type="type"
|
||||
@click="emit('click', $event)"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
<div ref="ripples" class="ripples"></div>
|
||||
<div class="content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</button>
|
||||
<MkA
|
||||
v-else
|
||||
class="bghgjjyj _button"
|
||||
:class="{ inline, primary, gradate, danger, rounded, full, mini }"
|
||||
:to="to"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
<div ref="ripples" class="ripples"></div>
|
||||
<div class="content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</MkA>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
type?: "button" | "submit" | "reset";
|
||||
primary?: boolean;
|
||||
gradate?: boolean;
|
||||
rounded?: boolean;
|
||||
inline?: boolean;
|
||||
link?: boolean;
|
||||
to?: string;
|
||||
autofocus?: boolean;
|
||||
wait?: boolean;
|
||||
danger?: boolean;
|
||||
full?: boolean;
|
||||
mini?: boolean;
|
||||
chip?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "click", payload: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
const el = ref<HTMLElement | null>(null);
|
||||
const ripples = ref<HTMLElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
nextTick(() => {
|
||||
el.value!.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function distance(p, q): number {
|
||||
return Math.hypot(p.x - q.x, p.y - q.y);
|
||||
}
|
||||
|
||||
function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY): number {
|
||||
const origin = { x: circleCenterX, y: circleCenterY };
|
||||
const dist1 = distance({ x: 0, y: 0 }, origin);
|
||||
const dist2 = distance({ x: boxW, y: 0 }, origin);
|
||||
const dist3 = distance({ x: 0, y: boxH }, origin);
|
||||
const dist4 = distance({ x: boxW, y: boxH }, origin);
|
||||
return Math.max(dist1, dist2, dist3, dist4) * 2;
|
||||
}
|
||||
|
||||
function onMousedown(evt: MouseEvent): void {
|
||||
const target = evt.target! as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
|
||||
const ripple = document.createElement("div");
|
||||
ripple.style.top = (evt.clientY - rect.top - 1).toString() + "px";
|
||||
ripple.style.left = (evt.clientX - rect.left - 1).toString() + "px";
|
||||
|
||||
ripples.value!.appendChild(ripple);
|
||||
|
||||
const circleCenterX = evt.clientX - rect.left;
|
||||
const circleCenterY = evt.clientY - rect.top;
|
||||
|
||||
const scale = calcCircleScale(
|
||||
target.clientWidth,
|
||||
target.clientHeight,
|
||||
circleCenterX,
|
||||
circleCenterY,
|
||||
);
|
||||
|
||||
window.setTimeout(() => {
|
||||
ripple.style.transform = "scale(" + scale / 2 + ")";
|
||||
}, 1);
|
||||
window.setTimeout(() => {
|
||||
ripple.style.transition = "all 1s ease";
|
||||
ripple.style.opacity = "0";
|
||||
}, 1000);
|
||||
window.setTimeout(() => {
|
||||
if (ripples.value) ripples.value.removeChild(ripple);
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bghgjjyj {
|
||||
position: relative;
|
||||
z-index: 1; // 他コンポーネントのbox-shadowに隠されないようにするため
|
||||
display: block;
|
||||
min-width: 100px;
|
||||
min-height: 35px;
|
||||
width: max-content;
|
||||
padding: 8px 16px;
|
||||
text-align: center;
|
||||
font-weight: normal;
|
||||
font-size: max(12px, 1em);
|
||||
box-shadow: none;
|
||||
text-decoration: none;
|
||||
background: var(--buttonBg);
|
||||
border-radius: 5px;
|
||||
overflow: clip;
|
||||
box-sizing: border-box;
|
||||
transition: background 0.1s ease;
|
||||
margin-right: 0.2rem;
|
||||
margin-left: 0.2rem;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
|
||||
&.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.rounded {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
font-weight: bold;
|
||||
color: var(--fgOnAccent) !important;
|
||||
background: var(--accent);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--accentLightenLess);
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: var(--accentLightenLess);
|
||||
}
|
||||
}
|
||||
|
||||
&.gradate {
|
||||
font-weight: bold;
|
||||
color: var(--fgOnAccent) !important;
|
||||
background: linear-gradient(
|
||||
-45deg,
|
||||
var(--buttonGradateA),
|
||||
var(--buttonGradateB)
|
||||
);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--accentLightenLess);
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: var(--accentLightenLess);
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #eb6f92;
|
||||
|
||||
&.primary {
|
||||
color: #e0def4;
|
||||
background: #eb6f92;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: #eb6f92;
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: #b4637a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.mini {
|
||||
padding: 4px 8px;
|
||||
font-size: max(12px, 0.9em);
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
&.chip {
|
||||
padding: 4px 12px;
|
||||
font-size: max(12px, 0.9em);
|
||||
min-width: unset;
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
&.inline {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
> .ripples {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
::v-deep(div) {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 100%;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transition: all 0.5s cubic-bezier(0, 0.5, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary > .ripples ::v-deep(div) {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
> .content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div>
|
||||
<span v-if="!available">{{ i18n.ts.waiting }}<MkEllipsis /></span>
|
||||
<div ref="captchaEl"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { defaultStore } from "@/store";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
interface Captcha {
|
||||
render(
|
||||
container: string | Node,
|
||||
options: {
|
||||
readonly [_ in
|
||||
| "sitekey"
|
||||
| "theme"
|
||||
| "type"
|
||||
| "size"
|
||||
| "tabindex"
|
||||
| "callback"
|
||||
| "expired"
|
||||
| "expired-callback"
|
||||
| "error-callback"
|
||||
| "endpoint"]?: unknown;
|
||||
},
|
||||
): string;
|
||||
remove(id: string): void;
|
||||
execute(id: string): void;
|
||||
reset(id?: string): void;
|
||||
getResponse(id: string): string;
|
||||
}
|
||||
|
||||
type CaptchaProvider = "hcaptcha" | "recaptcha";
|
||||
|
||||
type CaptchaContainer = {
|
||||
readonly [_ in CaptchaProvider]?: Captcha;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window extends CaptchaContainer {}
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
provider: CaptchaProvider;
|
||||
sitekey: string;
|
||||
modelValue?: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "update:modelValue", v: string | null): void;
|
||||
}>();
|
||||
|
||||
const available = ref(false);
|
||||
|
||||
const captchaEl = ref<HTMLDivElement | undefined>();
|
||||
|
||||
const variable = computed(() => {
|
||||
switch (props.provider) {
|
||||
case "hcaptcha":
|
||||
return "hcaptcha";
|
||||
case "recaptcha":
|
||||
return "grecaptcha";
|
||||
}
|
||||
});
|
||||
|
||||
const loaded = !!window[variable.value];
|
||||
|
||||
const src = computed(() => {
|
||||
switch (props.provider) {
|
||||
case "hcaptcha":
|
||||
return "https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off";
|
||||
case "recaptcha":
|
||||
return "https://www.recaptcha.net/recaptcha/api.js?render=explicit";
|
||||
}
|
||||
});
|
||||
|
||||
const captcha = computed<Captcha>(
|
||||
() => window[variable.value] || ({} as unknown as Captcha),
|
||||
);
|
||||
|
||||
if (loaded) {
|
||||
available.value = true;
|
||||
} else {
|
||||
(
|
||||
document.getElementById(props.provider) ||
|
||||
document.head.appendChild(
|
||||
Object.assign(document.createElement("script"), {
|
||||
async: true,
|
||||
id: props.provider,
|
||||
src: src.value,
|
||||
}),
|
||||
)
|
||||
).addEventListener("load", () => (available.value = true));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (captcha.value.reset) captcha.value.reset();
|
||||
}
|
||||
|
||||
function requestRender() {
|
||||
if (captcha.value.render && captchaEl.value instanceof Element) {
|
||||
captcha.value.render(captchaEl.value, {
|
||||
sitekey: props.sitekey,
|
||||
theme: defaultStore.state.darkMode ? "dark" : "light",
|
||||
callback,
|
||||
"expired-callback": callback,
|
||||
"error-callback": callback,
|
||||
});
|
||||
} else {
|
||||
window.setTimeout(requestRender, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function callback(response?: string) {
|
||||
emit("update:modelValue", typeof response === "string" ? response : null);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (available.value) {
|
||||
requestRender();
|
||||
} else {
|
||||
watch(available, requestRender);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
reset,
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<button
|
||||
class="hdcaacmi _button"
|
||||
:class="{ wait, active: isFollowing, full }"
|
||||
:disabled="wait"
|
||||
@click="onClick"
|
||||
>
|
||||
<template v-if="!wait">
|
||||
<template v-if="isFollowing">
|
||||
<span v-if="full">{{ i18n.ts.unfollow }}</span
|
||||
><i class="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 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>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
channel: Record<string, any>;
|
||||
full?: boolean;
|
||||
}>(),
|
||||
{
|
||||
full: false,
|
||||
},
|
||||
);
|
||||
|
||||
const isFollowing = ref<boolean>(props.channel.isFollowing);
|
||||
const wait = ref(false);
|
||||
|
||||
async function onClick() {
|
||||
wait.value = true;
|
||||
|
||||
try {
|
||||
if (isFollowing.value) {
|
||||
await os.api("channels/unfollow", {
|
||||
channelId: props.channel.id,
|
||||
});
|
||||
isFollowing.value = false;
|
||||
} else {
|
||||
await os.api("channels/follow", {
|
||||
channelId: props.channel.id,
|
||||
});
|
||||
isFollowing.value = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
wait.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hdcaacmi {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
border: solid 1px var(--accent);
|
||||
padding: 0;
|
||||
height: 31px;
|
||||
font-size: 16px;
|
||||
border-radius: 32px;
|
||||
background: #fff;
|
||||
|
||||
&.full {
|
||||
padding: 0 8px 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:not(.full) {
|
||||
width: 31px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
border: 2px solid var(--focus);
|
||||
border-radius: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
//background: mix($primary, #fff, 20);
|
||||
}
|
||||
|
||||
&:active {
|
||||
//background: mix($primary, #fff, 40);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
|
||||
&:hover {
|
||||
background: var(--accentLighten);
|
||||
border-color: var(--accentLighten);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken);
|
||||
border-color: var(--accentDarken);
|
||||
}
|
||||
}
|
||||
|
||||
&.wait {
|
||||
cursor: wait !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> span {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<MkPagination :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img
|
||||
:src="instance.images.notFound"
|
||||
class="_ghost"
|
||||
:alt="i18n.ts.notFound"
|
||||
/>
|
||||
<div>{{ i18n.ts.notFound }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<MkChannelPreview
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="_margin"
|
||||
:channel="extractor(item)"
|
||||
/>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MkChannelPreview from "@/components/MkChannelPreview.vue";
|
||||
import type { Paging } from "@/components/MkPagination.vue";
|
||||
import MkPagination from "@/components/MkPagination.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
import {instance} from "@/instance";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
pagination: Paging;
|
||||
noGap?: boolean;
|
||||
extractor?: (item: any) => any;
|
||||
}>(),
|
||||
{
|
||||
extractor: (item) => item,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1">
|
||||
<div class="banner" :style="bannerStyle">
|
||||
<div class="fade"></div>
|
||||
<div class="name">
|
||||
<i class="ph-television ph-bold ph-lg"></i> {{ channel.name }}
|
||||
</div>
|
||||
<div class="status">
|
||||
<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>
|
||||
<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>
|
||||
<article v-if="channel.description">
|
||||
<p :title="channel.description">
|
||||
{{
|
||||
channel.description.length > 85
|
||||
? channel.description.slice(0, 85) + "…"
|
||||
: channel.description
|
||||
}}
|
||||
</p>
|
||||
</article>
|
||||
<footer>
|
||||
<span v-if="channel.lastNotedAt">
|
||||
{{ i18n.ts.updatedAt }}: <MkTime :time="channel.lastNotedAt" />
|
||||
</span>
|
||||
</footer>
|
||||
</MkA>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
channel: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const bannerStyle = computed(() => {
|
||||
if (props.channel.bannerUrl) {
|
||||
return { backgroundImage: `url(${props.channel.bannerUrl})` };
|
||||
} else {
|
||||
return { backgroundColor: "#4c5e6d" };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.eftoefju {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
> .banner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
|
||||
> .fade {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: linear-gradient(0deg, var(--panel), var(--panelFade));
|
||||
}
|
||||
|
||||
> .name {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
padding: 12px 16px;
|
||||
-webkit-backdrop-filter: var(--blur, blur(8px));
|
||||
backdrop-filter: var(--blur, blur(8px));
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: #fff;
|
||||
font-size: 1.2em;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
> .status {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
padding: 8px 12px;
|
||||
font-size: 80%;
|
||||
-webkit-backdrop-filter: var(--blur, blur(8px));
|
||||
backdrop-filter: var(--blur, blur(8px));
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 16px;
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
padding: 12px 16px;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
|
||||
> span {
|
||||
opacity: 0.7;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
font-size: 0.9em;
|
||||
|
||||
> .banner {
|
||||
height: 80px;
|
||||
|
||||
> .status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
> footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
font-size: 0.8em;
|
||||
|
||||
> .banner {
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<MkTooltip
|
||||
ref="tooltip"
|
||||
:showing="showing"
|
||||
:x="x"
|
||||
:y="y"
|
||||
:max-width="340"
|
||||
:direction="'top'"
|
||||
:inner-margin="16"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<div v-if="title || series" class="qpcyisrl">
|
||||
<div v-if="title" class="title">{{ title }}</div>
|
||||
<template v-if="series">
|
||||
<div v-for="x in series" class="series">
|
||||
<span
|
||||
class="color"
|
||||
:style="{
|
||||
background: x.backgroundColor,
|
||||
borderColor: x.borderColor,
|
||||
}"
|
||||
></span>
|
||||
<span>{{ x.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</MkTooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {} from "vue";
|
||||
import MkTooltip from "./MkTooltip.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
showing: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
title?: string;
|
||||
series?: {
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.qpcyisrl {
|
||||
> .title {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
> .series {
|
||||
> .color {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<MkA
|
||||
class="rivslvers"
|
||||
:class="{
|
||||
isMe: isMe(message),
|
||||
isRead: message.groupId
|
||||
? message.reads.includes($i?.id)
|
||||
: message.isRead,
|
||||
}"
|
||||
:to="
|
||||
message.groupId
|
||||
? `/my/messaging/group/${message.groupId}`
|
||||
: `/my/messaging/${getAcct(
|
||||
isMe(message) ? message.recipient : message.user,
|
||||
)}`
|
||||
"
|
||||
>
|
||||
<div class="message _block">
|
||||
<MkAvatar
|
||||
class="avatar"
|
||||
:user="
|
||||
message.groupId
|
||||
? message.user
|
||||
: isMe(message)
|
||||
? message.recipient
|
||||
: message.user
|
||||
"
|
||||
:show-indicator="true"
|
||||
disable-link
|
||||
/>
|
||||
<header v-if="message.groupId">
|
||||
<span class="name">{{ message.group.name }}</span>
|
||||
<MkTime :time="message.createdAt" class="time" />
|
||||
</header>
|
||||
<header v-else>
|
||||
<span class="name"
|
||||
><MkUserName
|
||||
:user="
|
||||
isMe(message) ? message.recipient : message.user
|
||||
"
|
||||
/></span>
|
||||
<span class="username"
|
||||
>@{{
|
||||
acct(isMe(message) ? message.recipient : message.user)
|
||||
}}</span
|
||||
>
|
||||
<MkTime :time="message.createdAt" class="time" />
|
||||
</header>
|
||||
<div class="body">
|
||||
<p class="text">
|
||||
<span v-if="isMe(message)" class="me"
|
||||
>{{ i18n.ts.you }}:
|
||||
</span>
|
||||
<Mfm
|
||||
v-if="message.text != null && message.text.length > 0"
|
||||
:text="message.text"
|
||||
/>
|
||||
<span v-else> 📎</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</MkA>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as Acct from "iceshrimp-sdk/built/acct";
|
||||
import { i18n } from "@/i18n";
|
||||
import { acct } from "@/filters/user";
|
||||
import { $i } from "@/account";
|
||||
|
||||
const getAcct = Acct.toString;
|
||||
|
||||
const props = defineProps<{
|
||||
message: Record<string, any>;
|
||||
}>();
|
||||
|
||||
function isMe(message): boolean {
|
||||
return message.userId === $i?.id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rivslvers {
|
||||
> .message {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--margin);
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.avatar {
|
||||
filter: saturate(200%);
|
||||
}
|
||||
}
|
||||
|
||||
&.isRead,
|
||||
&.isMe {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:not(.isMe):not(.isRead) {
|
||||
background-color: var(--accentedBg);
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
padding: 20px 30px;
|
||||
|
||||
> header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
> .name {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
> .username {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
> .time {
|
||||
margin: 0 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
float: left;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
margin: 0 16px 0 0;
|
||||
border-radius: 8px;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
> .body {
|
||||
> .text {
|
||||
display: block;
|
||||
margin: 0 0 0 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
text-decoration: none;
|
||||
font-size: 1.1em;
|
||||
color: var(--faceText);
|
||||
|
||||
.me {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
> .image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 512px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_400px {
|
||||
> .message {
|
||||
> div {
|
||||
padding: 16px;
|
||||
font-size: 0.9em;
|
||||
|
||||
> .avatar {
|
||||
margin: 0 12px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<XModalWindow
|
||||
ref="dialog"
|
||||
:width="600"
|
||||
@close="dialog?.close()"
|
||||
@closed="$emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts._mfm.cheatSheet }}</template>
|
||||
<XCheatSheet :popup="true" style="background: var(--bg)" />
|
||||
</XModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import XModalWindow from "@/components/MkModalWindow.vue";
|
||||
import XCheatSheet from "@/pages/mfm-cheat-sheet.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "done"): void;
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
const dialog = ref<InstanceType<typeof XModalWindow>>();
|
||||
|
||||
function close(res) {
|
||||
dialog.value.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<code v-if="inline" :class="`language-${prismLang}`" v-html="html"></code>
|
||||
<pre
|
||||
v-else
|
||||
:class="`language-${prismLang}`"
|
||||
><code :class="`language-${prismLang}`" v-html="html"></code></pre>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/themes/prism-okaidia.css";
|
||||
|
||||
const props = defineProps<{
|
||||
code: string;
|
||||
lang?: string;
|
||||
inline?: boolean;
|
||||
}>();
|
||||
|
||||
const prismLang = computed(() =>
|
||||
Prism.languages[props.lang] ? props.lang : "plaintext",
|
||||
);
|
||||
const html = computed(() =>
|
||||
Prism.highlight(
|
||||
props.code,
|
||||
Prism.languages[prismLang.value],
|
||||
prismLang.value,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<XCode :code="code" :lang="lang" :inline="inline" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent } from "vue";
|
||||
|
||||
defineProps<{
|
||||
code: string;
|
||||
lang?: string;
|
||||
inline?: boolean;
|
||||
}>();
|
||||
|
||||
const XCode = defineAsyncComponent(
|
||||
() => import("@/components/MkCode.core.vue"),
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div
|
||||
v-size="{ max: [380] }"
|
||||
class="ukygtjoj _panel"
|
||||
:class="{
|
||||
naked,
|
||||
thin,
|
||||
hideHeader: !showHeader,
|
||||
scrollable,
|
||||
closed: !showBody,
|
||||
}"
|
||||
>
|
||||
<header v-if="showHeader" ref="header">
|
||||
<div class="title"><slot name="header"></slot></div>
|
||||
<div class="sub">
|
||||
<slot name="func"></slot>
|
||||
<button
|
||||
v-if="foldable"
|
||||
class="_button"
|
||||
@click="() => (showBody = !showBody)"
|
||||
>
|
||||
<template v-if="showBody"
|
||||
><i class="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>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
thin: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
naked: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
foldable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
maxHeight: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showBody: this.expanded,
|
||||
omitted: null,
|
||||
ignoreOmit: false,
|
||||
i18n,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$watch(
|
||||
"showBody",
|
||||
(showBody) => {
|
||||
const headerHeight = this.showHeader
|
||||
? this.$refs.header.offsetHeight
|
||||
: 0;
|
||||
this.$el.style.minHeight = `${headerHeight}px`;
|
||||
if (showBody) {
|
||||
this.$el.style.flexBasis = "auto";
|
||||
} else {
|
||||
this.$el.style.flexBasis = `${headerHeight}px`;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
this.$el.style.setProperty("--maxHeight", this.maxHeight + "px");
|
||||
|
||||
const calcOmit = () => {
|
||||
if (this.omitted || this.ignoreOmit || this.maxHeight == null)
|
||||
return;
|
||||
const height = this.$refs.content.offsetHeight;
|
||||
this.omitted = height > this.maxHeight;
|
||||
};
|
||||
|
||||
calcOmit();
|
||||
new ResizeObserver((entries, observer) => {
|
||||
calcOmit();
|
||||
}).observe(this.$refs.content);
|
||||
},
|
||||
methods: {
|
||||
toggleContent(show: boolean) {
|
||||
if (!this.foldable) return;
|
||||
this.showBody = show;
|
||||
},
|
||||
|
||||
enter(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = 0;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = elementHeight + "px";
|
||||
},
|
||||
afterEnter(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
leave(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = elementHeight + "px";
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = 0;
|
||||
},
|
||||
afterLeave(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container-toggle-enter-active,
|
||||
.container-toggle-leave-active {
|
||||
overflow-y: hidden;
|
||||
transition:
|
||||
opacity 0.5s,
|
||||
height 0.5s !important;
|
||||
}
|
||||
.container-toggle-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.container-toggle-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ukygtjoj {
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
contain: content;
|
||||
|
||||
&.naked {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&.scrollable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
|
||||
> .content {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
> header {
|
||||
position: sticky;
|
||||
top: var(--stickyTop, 0px);
|
||||
left: 0;
|
||||
color: var(--panelHeaderFg);
|
||||
background: var(--panelHeaderBg);
|
||||
border-bottom: solid 0.5px var(--panelHeaderDivider);
|
||||
z-index: 2;
|
||||
line-height: 1.4em;
|
||||
|
||||
> .title {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
|
||||
> ::v-deep(i) {
|
||||
margin-right: 6px;
|
||||
transform: translateY(0.1em);
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .sub {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
|
||||
> ::v-deep(button) {
|
||||
width: 42px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
--stickyTop: 0px;
|
||||
|
||||
&.omitted {
|
||||
position: relative;
|
||||
max-height: var(--maxHeight);
|
||||
overflow: hidden;
|
||||
|
||||
> .fade {
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: linear-gradient(0deg, var(--panel), var(--panelFade));
|
||||
|
||||
> span {
|
||||
display: inline-block;
|
||||
background: var(--panel);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8em;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> span {
|
||||
background: var(--panelHighlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_380px,
|
||||
&.thin {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
._forceContainerFull_ .ukygtjoj {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
._forceContainerFull_.ukygtjoj {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<transition :name="$store.state.animation ? 'fade' : ''" appear>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="nvlagfpb"
|
||||
:style="{ zIndex }"
|
||||
@contextmenu.prevent.stop="() => {}"
|
||||
>
|
||||
<MkMenu :items="items" :align="'left'" @close="$emit('closed')" />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import MkMenu from "./MkMenu.vue";
|
||||
import type { MenuItem } from "./types/menu.vue";
|
||||
import contains from "@/scripts/contains";
|
||||
import * as os from "@/os";
|
||||
|
||||
const props = defineProps<{
|
||||
items: MenuItem[];
|
||||
ev: MouseEvent;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
const rootEl = ref<HTMLDivElement>();
|
||||
|
||||
const zIndex = ref<number>(os.claimZIndex("high"));
|
||||
|
||||
onMounted(() => {
|
||||
let left = props.ev.pageX + 1, // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
|
||||
top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
|
||||
|
||||
const width = rootEl.value.offsetWidth;
|
||||
const height = rootEl.value.offsetHeight;
|
||||
|
||||
if (left + width - window.pageXOffset > window.innerWidth) {
|
||||
left = window.innerWidth - width + window.pageXOffset;
|
||||
}
|
||||
|
||||
if (top + height - window.pageYOffset > window.innerHeight) {
|
||||
top = window.innerHeight - height + window.pageYOffset;
|
||||
}
|
||||
|
||||
if (top < 0) {
|
||||
top = 0;
|
||||
}
|
||||
|
||||
if (left < 0) {
|
||||
left = 0;
|
||||
}
|
||||
|
||||
rootEl.value.style.top = `${top}px`;
|
||||
rootEl.value.style.left = `${left}px`;
|
||||
|
||||
document.body.addEventListener("mousedown", onMousedown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.removeEventListener("mousedown", onMousedown);
|
||||
});
|
||||
|
||||
function onMousedown(evt: Event) {
|
||||
if (!contains(rootEl.value, evt.target) && rootEl.value !== evt.target) emit("closed");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nvlagfpb {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition:
|
||||
opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<XModalWindow
|
||||
ref="dialogEl"
|
||||
:width="800"
|
||||
:height="500"
|
||||
:scroll="false"
|
||||
:with-ok-button="true"
|
||||
@close="cancel()"
|
||||
@ok="ok()"
|
||||
@closed="$emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.cropImage }}</template>
|
||||
<template #default="{ width, height }">
|
||||
<div
|
||||
class="mk-cropper-dialog"
|
||||
:style="`--vw: ${width ? `${width}px` : '100%'}; --vh: ${
|
||||
height ? `${height}px` : '100%'
|
||||
};`"
|
||||
>
|
||||
<Transition name="fade">
|
||||
<div v-if="loading" class="loading">
|
||||
<MkLoading />
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="container">
|
||||
<img
|
||||
ref="imgEl"
|
||||
:src="imgUrl"
|
||||
style="display: none"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</XModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, ref } from 'vue';
|
||||
import type * as misskey from "iceshrimp-sdk";
|
||||
import Cropper from "cropperjs";
|
||||
import tinycolor from "tinycolor2";
|
||||
import XModalWindow from "@/components/MkModalWindow.vue";
|
||||
import * as os from "@/os";
|
||||
import { $i } from "@/account";
|
||||
import { defaultStore } from "@/store";
|
||||
import { apiUrl, url } from "@/config";
|
||||
import { query } from "@/scripts/url";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "ok", cropped: misskey.entities.DriveFile): void;
|
||||
(ev: "cancel"): void;
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
file: misskey.entities.DriveFile;
|
||||
aspectRatio: number;
|
||||
}>();
|
||||
|
||||
const imgUrl = `${url}/proxy/image.webp?${query({
|
||||
url: props.file.url,
|
||||
})}`;
|
||||
const dialogEl = ref<InstanceType<typeof XModalWindow>>();
|
||||
const imgEl = ref<HTMLImageElement>();
|
||||
let cropper: Cropper | null = null,
|
||||
loading = ref(true);
|
||||
|
||||
const ok = async () => {
|
||||
const promise = new Promise<misskey.entities.DriveFile>(async (res) => {
|
||||
const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas();
|
||||
croppedCanvas.toBlob((blob) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", blob);
|
||||
if (defaultStore.state.uploadFolder) {
|
||||
formData.append("folderId", defaultStore.state.uploadFolder);
|
||||
}
|
||||
|
||||
fetch(apiUrl + "/drive/files/create", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: {
|
||||
authorization: `Bearer ${$i.token}`,
|
||||
},
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((f) => {
|
||||
res(f);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
os.promiseDialog(promise);
|
||||
|
||||
const f = await promise;
|
||||
|
||||
emit("ok", f);
|
||||
dialogEl.value.close();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit("cancel");
|
||||
dialogEl.value.close();
|
||||
};
|
||||
|
||||
const onImageLoad = () => {
|
||||
loading.value = false;
|
||||
|
||||
if (cropper) {
|
||||
cropper.getCropperImage()!.$center("contain");
|
||||
cropper.getCropperSelection()!.$center();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
cropper = new Cropper(imgEl.value, {});
|
||||
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
|
||||
const selection = cropper.getCropperSelection()!;
|
||||
selection.themeColor = tinycolor(
|
||||
computedStyle.getPropertyValue("--accent"),
|
||||
).toHexString();
|
||||
selection.aspectRatio = props.aspectRatio;
|
||||
selection.initialAspectRatio = props.aspectRatio;
|
||||
selection.outlined = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
cropper.getCropperImage()!.$center("contain");
|
||||
selection.$center();
|
||||
}, 100);
|
||||
|
||||
// モーダルオープンアニメーションが終わったあとで再度調整
|
||||
window.setTimeout(() => {
|
||||
cropper.getCropperImage()!.$center("contain");
|
||||
selection.$center();
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease 0.5s;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mk-cropper-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--vw);
|
||||
height: var(--vh);
|
||||
position: relative;
|
||||
|
||||
> .loading {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-backdrop-filter: var(--blur, blur(10px));
|
||||
backdrop-filter: var(--blur, blur(10px));
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
> .container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> ::v-deep(cropper-canvas) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> cropper-selection > cropper-handle[action="move"] {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<button
|
||||
ref="el"
|
||||
class="_button"
|
||||
:class="[cwButton, { showLess: modelValue, fade: !modelValue, }]"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<span class="cw-toggle-text"
|
||||
>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}
|
||||
<span class="cw-char-count" v-if="!modelValue">{{ label }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { length } from "stringz";
|
||||
import type * as misskey from "iceshrimp-sdk";
|
||||
import { concat } from "@/scripts/array";
|
||||
import { i18n } from "@/i18n";
|
||||
import {defaultStore} from "@/store";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
note: misskey.entities.Note;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "update:modelValue", v: boolean): void;
|
||||
}>();
|
||||
|
||||
const el = ref<HTMLElement>();
|
||||
|
||||
const label = computed(() => {
|
||||
return concat([
|
||||
props.note.text
|
||||
? [i18n.t("_cw.chars", { count: length(props.note.text) })]
|
||||
: [],
|
||||
props.note.files && props.note.files.length !== 0
|
||||
? [i18n.t("_cw.files", { count: props.note.files.length })]
|
||||
: [],
|
||||
props.note.poll != null ? [i18n.ts.poll] : [],
|
||||
props.note.renote != null ? [i18n.ts.quoteAttached] : [],
|
||||
] as string[][]).join(", ");
|
||||
});
|
||||
|
||||
const cwButton = computed (() => `_button_${defaultStore.state.cwStyle}`);
|
||||
|
||||
const toggle = () => {
|
||||
emit("update:modelValue", !props.modelValue);
|
||||
};
|
||||
|
||||
function focus() {
|
||||
el.value.focus();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
._button_modern {
|
||||
width: 100%;
|
||||
font-weight: 700;
|
||||
z-index: 5;
|
||||
&.showLess {
|
||||
position: sticky;
|
||||
bottom: calc(var(--stickyBottom) - 1em);
|
||||
margin: 20px 0;
|
||||
|
||||
}
|
||||
> span {
|
||||
display: inline-block;
|
||||
padding: 0.5em 0;
|
||||
font-size: 0.8em;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
|
||||
width: 100%;
|
||||
background: var(--cwBg);
|
||||
color: var(--cwFg);
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
|
||||
> span {
|
||||
font-weight: 500;
|
||||
&::before {
|
||||
content: "(";
|
||||
}
|
||||
&::after {
|
||||
content: ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover > span,
|
||||
&:focus > span {
|
||||
background: var(--cwFg) !important;
|
||||
color: var(--cwBg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
._button_alternative {
|
||||
font-weight: 700;
|
||||
z-index: 5;
|
||||
> span {
|
||||
background: var(--cwBg) !important;
|
||||
color: var(--cwFg);
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
> span {
|
||||
font-weight: 500;
|
||||
&::before {
|
||||
content: "(";
|
||||
}
|
||||
&::after {
|
||||
content: ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover > span,
|
||||
&:focus > span {
|
||||
background: var(--cwFg) !important;
|
||||
color: var(--cwBg) !important;
|
||||
}
|
||||
|
||||
&.fade {
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
> span {
|
||||
display: inline-block;
|
||||
background: var(--panel);
|
||||
padding: 0.4em 1em;
|
||||
font-size: 0.8em;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
|
||||
}
|
||||
&:hover {
|
||||
> span {
|
||||
background: var(--panelHighlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.showLess {
|
||||
width: 100%;
|
||||
position: sticky;
|
||||
bottom: calc(var(--stickyBottom) - 1em);
|
||||
padding: 20px;
|
||||
|
||||
> span {
|
||||
display: inline-block;
|
||||
background: var(--panel);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8em;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 7px 7px var(--bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
._button_classic {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.7em;
|
||||
color: var(--cwFg);
|
||||
background: var(--cwBg);
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: var(--cwHoverBg);
|
||||
}
|
||||
|
||||
> span.cw-toggle-text {
|
||||
font-weight: bold;
|
||||
|
||||
> span.cw-char-count {
|
||||
font-weight: normal;
|
||||
|
||||
&:before {
|
||||
content: '(';
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: ')';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import type { PropType } from "vue";
|
||||
import { TransitionGroup, defineComponent, h } from "vue";
|
||||
import { i18n } from "@/i18n";
|
||||
import { defaultStore } from "@/store";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<
|
||||
{ id: string; createdAt: string; }[]
|
||||
>,
|
||||
required: true,
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "down",
|
||||
},
|
||||
reversed: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
noGap: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props, { slots, expose }) {
|
||||
function getDateText(time: string) {
|
||||
const date = new Date(time).getDate();
|
||||
const month = new Date(time).getMonth() + 1;
|
||||
return i18n.t("monthAndDay", {
|
||||
month: month.toString(),
|
||||
day: date.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (props.items.length === 0) return;
|
||||
|
||||
const renderChildren = () =>
|
||||
props.items.map((item, i) => {
|
||||
if (!slots || !slots.default) return;
|
||||
|
||||
const el = slots.default({
|
||||
item,
|
||||
})[0];
|
||||
if (el.key == null && item.id) el.key = item.id;
|
||||
|
||||
if (
|
||||
i !== props.items.length - 1 &&
|
||||
new Date(item.createdAt).getDate() !==
|
||||
new Date(props.items[i + 1].createdAt).getDate()
|
||||
) {
|
||||
const separator = h(
|
||||
"div",
|
||||
{
|
||||
class: "separator",
|
||||
key: item.id + ":separator",
|
||||
},
|
||||
h(
|
||||
"p",
|
||||
{
|
||||
class: "date",
|
||||
},
|
||||
[
|
||||
h("span", [
|
||||
h("i", {
|
||||
class: "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];
|
||||
} else {
|
||||
return el;
|
||||
}
|
||||
});
|
||||
|
||||
return () =>
|
||||
h(
|
||||
defaultStore.state.animation ? TransitionGroup : "div",
|
||||
defaultStore.state.animation
|
||||
? {
|
||||
class: "sqadhkmv" + (props.noGap ? " noGap" : ""),
|
||||
name: "list",
|
||||
tag: "div",
|
||||
"data-direction": props.direction,
|
||||
"data-reversed": props.reversed ? "true" : "false",
|
||||
}
|
||||
: {
|
||||
class: "sqadhkmv" + (props.noGap ? " noGap" : ""),
|
||||
},
|
||||
{ default: renderChildren },
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.sqadhkmv {
|
||||
> *:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-bottom: var(--margin);
|
||||
}
|
||||
|
||||
> .list-move {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
> .list-enter-active {
|
||||
transition:
|
||||
transform 0.7s cubic-bezier(0.23, 1, 0.32, 1),
|
||||
opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
&[data-direction="up"] {
|
||||
> .list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(64px);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-direction="down"] {
|
||||
> .list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-64px);
|
||||
}
|
||||
}
|
||||
|
||||
> .separator {
|
||||
text-align: center;
|
||||
|
||||
> .date {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--dateLabelFg);
|
||||
|
||||
> span {
|
||||
&:first-child {
|
||||
margin-right: 8px;
|
||||
|
||||
> .icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: 8px;
|
||||
|
||||
> .icon {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.noGap {
|
||||
> * {
|
||||
margin: 0 !important;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
|
||||
&:first-child {
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
}
|
||||
&:last-child {
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: solid 0.5px var(--divider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<MkModal
|
||||
ref="modal"
|
||||
:prefer-type="'dialog'"
|
||||
@click="done(true)"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<div :class="$style.root">
|
||||
<div v-if="icon" :class="$style.icon">
|
||||
<i :class="icon"></i>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!input && !select"
|
||||
:class="[$style.icon, $style['type_' + type]]"
|
||||
>
|
||||
<i
|
||||
v-if="type === 'success'"
|
||||
: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>
|
||||
<header v-if="title" :class="$style.title">
|
||||
<Mfm :text="title" />
|
||||
</header>
|
||||
<header
|
||||
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'"
|
||||
ref="inputEl"
|
||||
v-model="inputValue"
|
||||
autofocus
|
||||
:autocomplete="input.autocomplete"
|
||||
:type="(input.type || 'text') as 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | undefined"
|
||||
:placeholder="input.placeholder || undefined"
|
||||
:style="{
|
||||
width: input.type === 'search' ? '300px' : null,
|
||||
}"
|
||||
@keydown="onInputKeydown"
|
||||
>
|
||||
<template v-if="input.type === 'password'" #prefix
|
||||
><i class="ph-password ph-bold ph-lg"></i
|
||||
></template>
|
||||
<template #caption>
|
||||
<span
|
||||
v-if="
|
||||
okButtonDisabled &&
|
||||
disabledReason === 'charactersExceeded'
|
||||
"
|
||||
v-text="
|
||||
i18n.t('_dialog.charactersExceeded', {
|
||||
current: (inputValue as string).length,
|
||||
max: input.maxLength ?? 'NaN',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-else-if="
|
||||
okButtonDisabled &&
|
||||
disabledReason === 'charactersBelow'
|
||||
"
|
||||
v-text="
|
||||
i18n.t('_dialog.charactersBelow', {
|
||||
current: (inputValue as string).length,
|
||||
min: input.minLength ?? 'NaN',
|
||||
})
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</MkInput>
|
||||
<MkTextarea
|
||||
v-if="input && input.type === 'paragraph'"
|
||||
v-model="inputValue"
|
||||
autofocus
|
||||
:type="paragraph"
|
||||
:placeholder="input.placeholder || undefined"
|
||||
>
|
||||
</MkTextarea>
|
||||
<MkSelect v-if="select" v-model="selectedValue" autofocus>
|
||||
<template v-if="select.items">
|
||||
<option v-for="item in select.items" :value="item.value">
|
||||
{{ item.text }}
|
||||
</option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<optgroup
|
||||
v-for="groupedItem in select.groupedItems"
|
||||
:label="groupedItem.label"
|
||||
>
|
||||
<option
|
||||
v-for="item in groupedItem.items"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.text }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</template>
|
||||
</MkSelect>
|
||||
|
||||
<div
|
||||
v-if="(showOkButton || showCancelButton) && !actions"
|
||||
:class="$style.buttons"
|
||||
>
|
||||
<div v-if="!isYesNo">
|
||||
<MkButton
|
||||
v-if="showOkButton"
|
||||
inline
|
||||
primary
|
||||
:autofocus="!input && !select"
|
||||
:disabled="okButtonDisabled"
|
||||
@click="ok"
|
||||
>{{
|
||||
showCancelButton || input || select
|
||||
? i18n.ts.ok
|
||||
: i18n.ts.gotIt
|
||||
}}</MkButton
|
||||
>
|
||||
<MkButton
|
||||
v-if="showCancelButton || input || select"
|
||||
inline
|
||||
@click="cancel"
|
||||
>{{ i18n.ts.cancel }}</MkButton
|
||||
>
|
||||
</div>
|
||||
<div v-else>
|
||||
<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 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
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue';
|
||||
import MkModal from "@/components/MkModal.vue";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import MkInput from "@/components/form/input.vue";
|
||||
import MkTextarea from "@/components/form/textarea.vue";
|
||||
import MkSelect from "@/components/form/select.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
interface Input {
|
||||
type: HTMLInputElement["type"];
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default: string | number | null;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
interface Select {
|
||||
items: {
|
||||
value: string;
|
||||
text: string;
|
||||
}[];
|
||||
groupedItems: {
|
||||
label: string;
|
||||
items: {
|
||||
value: string;
|
||||
text: string;
|
||||
}[];
|
||||
}[];
|
||||
default: string | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type?:
|
||||
| "success"
|
||||
| "error"
|
||||
| "warning"
|
||||
| "info"
|
||||
| "question"
|
||||
| "waiting"
|
||||
| "search";
|
||||
title: string;
|
||||
text?: string;
|
||||
input?: Input;
|
||||
select?: Select;
|
||||
icon?: string;
|
||||
actions?: {
|
||||
text: string;
|
||||
primary?: boolean;
|
||||
callback: (...args: any[]) => void;
|
||||
}[];
|
||||
showOkButton?: boolean;
|
||||
showCancelButton?: boolean;
|
||||
isYesNo?: boolean;
|
||||
|
||||
cancelableByBgClick?: boolean;
|
||||
okText?: string;
|
||||
cancelText?: string;
|
||||
}>(),
|
||||
{
|
||||
type: "info",
|
||||
showOkButton: true,
|
||||
showCancelButton: false,
|
||||
isYesNo: false,
|
||||
|
||||
cancelableByBgClick: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "done", v: { canceled: boolean; result: any }): void;
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
|
||||
const inputValue = ref<string | number | null>(props.input?.default ?? "");
|
||||
const selectedValue = ref(props.select?.default ?? null);
|
||||
|
||||
let disabledReason = ref<null | "charactersExceeded" | "charactersBelow">(
|
||||
null,
|
||||
);
|
||||
const okButtonDisabled = computed<boolean>(() => {
|
||||
if (props.input) {
|
||||
if (props.input.minLength) {
|
||||
if (
|
||||
(inputValue.value || inputValue.value === "") &&
|
||||
(inputValue.value as string).length < props.input.minLength
|
||||
) {
|
||||
disabledReason.value = "charactersBelow";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (props.input.maxLength) {
|
||||
if (
|
||||
inputValue.value &&
|
||||
(inputValue.value as string).length > props.input.maxLength
|
||||
) {
|
||||
disabledReason.value = "charactersExceeded";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const inputEl = ref<typeof MkInput>();
|
||||
|
||||
function done(canceled: boolean, result?) {
|
||||
emit("done", { canceled, result });
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
async function ok() {
|
||||
if (!props.showOkButton) return;
|
||||
|
||||
const result = props.input
|
||||
? inputValue.value
|
||||
: props.select
|
||||
? selectedValue.value
|
||||
: true;
|
||||
done(false, result);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
done(true);
|
||||
}
|
||||
/*
|
||||
function onBgClick() {
|
||||
if (props.cancelableByBgClick) cancel();
|
||||
}
|
||||
*/
|
||||
function onKeydown(evt: KeyboardEvent) {
|
||||
if (evt.key === "Escape") cancel();
|
||||
}
|
||||
|
||||
function onInputKeydown(evt: KeyboardEvent) {
|
||||
if (evt.key === "Enter") {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
ok();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
margin: auto;
|
||||
padding: 32px;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
|
||||
& + .title {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.iconInner {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.type_info {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.type_success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.type_error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.type_warning {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
|
||||
& + .text {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 16px 0 0 0;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<span class="zjobosdg">
|
||||
<span v-text="hh"></span>
|
||||
<span class="colon" :class="{ showColon }">:</span>
|
||||
<span v-text="mm"></span>
|
||||
<span v-if="showS" class="colon" :class="{ showColon }">:</span>
|
||||
<span v-if="showS" v-text="ss"></span>
|
||||
<span v-if="showMs" class="colon" :class="{ showColon }">:</span>
|
||||
<span v-if="showMs" v-text="ms"></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, ref, watch } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showS?: boolean;
|
||||
showMs?: boolean;
|
||||
offset?: number;
|
||||
}>(),
|
||||
{
|
||||
showS: true,
|
||||
showMs: false,
|
||||
offset: 0 - new Date().getTimezoneOffset(),
|
||||
},
|
||||
);
|
||||
|
||||
let intervalId;
|
||||
const hh = ref("");
|
||||
const mm = ref("");
|
||||
const ss = ref("");
|
||||
const ms = ref("");
|
||||
const showColon = ref(false);
|
||||
let prevSec: number | null = null;
|
||||
|
||||
watch(showColon, (v) => {
|
||||
if (v) {
|
||||
window.setTimeout(() => {
|
||||
showColon.value = false;
|
||||
}, 30);
|
||||
}
|
||||
});
|
||||
|
||||
const tick = () => {
|
||||
const now = new Date();
|
||||
now.setMinutes(
|
||||
now.getMinutes() + (new Date().getTimezoneOffset() + props.offset),
|
||||
);
|
||||
hh.value = now.getHours().toString().padStart(2, "0");
|
||||
mm.value = now.getMinutes().toString().padStart(2, "0");
|
||||
ss.value = now.getSeconds().toString().padStart(2, "0");
|
||||
ms.value = Math.floor(now.getMilliseconds() / 10)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
if (now.getSeconds() !== prevSec) showColon.value = true;
|
||||
prevSec = now.getSeconds();
|
||||
};
|
||||
|
||||
tick();
|
||||
|
||||
watch(
|
||||
() => props.showMs,
|
||||
() => {
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
intervalId = window.setInterval(tick, props.showMs ? 10 : 1000);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearInterval(intervalId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.zjobosdg {
|
||||
> .colon {
|
||||
opacity: 0;
|
||||
transition: opacity 1s ease;
|
||||
|
||||
&.showColon {
|
||||
opacity: 1;
|
||||
transition: opacity 0s;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<div
|
||||
class="ncvczrfv"
|
||||
:class="{ isSelected }"
|
||||
draggable="true"
|
||||
:title="title"
|
||||
@click="onClick"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
@dragstart="onDragstart"
|
||||
@dragend="onDragend"
|
||||
>
|
||||
<div v-if="$i?.avatarId == file.id" class="label">
|
||||
<img src="/client-assets/label.svg" />
|
||||
<p>{{ i18n.ts.avatar }}</p>
|
||||
</div>
|
||||
<div v-if="$i?.bannerId == file.id" class="label">
|
||||
<img src="/client-assets/label.svg" />
|
||||
<p>{{ i18n.ts.banner }}</p>
|
||||
</div>
|
||||
<div v-if="file.isSensitive" class="label red">
|
||||
<img src="/client-assets/label-red.svg" />
|
||||
<p>{{ i18n.ts.nsfw }}</p>
|
||||
</div>
|
||||
|
||||
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain" />
|
||||
|
||||
<p class="name">
|
||||
<span>{{
|
||||
file.name.lastIndexOf(".") != -1
|
||||
? file.name.substr(0, file.name.lastIndexOf("."))
|
||||
: file.name
|
||||
}}</span>
|
||||
<span v-if="file.name.lastIndexOf('.') != -1" class="ext">{{
|
||||
file.name.substr(file.name.lastIndexOf("."))
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, ref } from "vue";
|
||||
import * as Misskey from "iceshrimp-sdk";
|
||||
import copyToClipboard from "@/scripts/copy-to-clipboard";
|
||||
import MkDriveFileThumbnail from "@/components/MkDriveFileThumbnail.vue";
|
||||
import bytes from "@/filters/bytes";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
import { $i } from "@/account";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
isSelected?: boolean;
|
||||
selectMode?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isSelected: false,
|
||||
selectMode: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "chosen", r: Misskey.entities.DriveFile): void;
|
||||
(ev: "dragstart"): void;
|
||||
(ev: "dragend"): void;
|
||||
}>();
|
||||
|
||||
const isDragging = ref(false);
|
||||
|
||||
const title = computed(
|
||||
() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`,
|
||||
);
|
||||
|
||||
const downloadUrl = computed(() => {
|
||||
const url = new URL(props.file.url, location.href);
|
||||
url.searchParams.set("download", "1");
|
||||
if ($i?.token) url.searchParams.set("i", $i.token);
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
function getMenu() {
|
||||
return [
|
||||
{
|
||||
text: i18n.ts.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.allowDownload
|
||||
? "ダウンロード禁止にする"
|
||||
: "ダウンロードを許可する",
|
||||
icon: props.file.allowDownload
|
||||
? "ph-lock-simple ph-bold ph-lg"
|
||||
: "ph-lock-open ph-bold ph-lg",
|
||||
action: toggleAllowDownload,
|
||||
},
|
||||
{
|
||||
text: i18n.ts.describeFile,
|
||||
icon: "ph-subtitles ph-bold ph-lg",
|
||||
action: describe,
|
||||
},
|
||||
null,
|
||||
{
|
||||
text: i18n.ts.copyUrl,
|
||||
icon: "ph-link-simple ph-bold ph-lg",
|
||||
action: copyUrl,
|
||||
},
|
||||
{
|
||||
type: "a",
|
||||
href: downloadUrl.value,
|
||||
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) {
|
||||
if (props.selectMode) {
|
||||
emit("chosen", props.file);
|
||||
} else {
|
||||
os.popupMenu(
|
||||
getMenu(),
|
||||
(ev.currentTarget ?? ev.target ?? undefined) as
|
||||
| HTMLElement
|
||||
| undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
os.contextMenu(getMenu(), ev);
|
||||
}
|
||||
|
||||
function onDragstart(ev: DragEvent) {
|
||||
if (ev.dataTransfer) {
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
ev.dataTransfer.setData(
|
||||
_DATA_TRANSFER_DRIVE_FILE_,
|
||||
JSON.stringify(props.file),
|
||||
);
|
||||
}
|
||||
isDragging.value = true;
|
||||
|
||||
emit("dragstart");
|
||||
}
|
||||
|
||||
function onDragend() {
|
||||
isDragging.value = false;
|
||||
emit("dragend");
|
||||
}
|
||||
|
||||
function rename() {
|
||||
os.inputText({
|
||||
title: i18n.ts.renameFile,
|
||||
placeholder: i18n.ts.inputNewFileName,
|
||||
default: props.file.name,
|
||||
}).then(({ canceled, result: name }) => {
|
||||
if (canceled) return;
|
||||
os.api("drive/files/update", {
|
||||
fileId: props.file.id,
|
||||
name: name,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function describe() {
|
||||
os.popup(
|
||||
defineAsyncComponent(() => import("@/components/MkMediaCaption.vue")),
|
||||
{
|
||||
title: i18n.ts.describeFile,
|
||||
input: {
|
||||
placeholder: i18n.ts.inputNewDescription,
|
||||
default: props.file.comment != null ? props.file.comment : "",
|
||||
},
|
||||
image: props.file,
|
||||
},
|
||||
{
|
||||
done: (result) => {
|
||||
if (!result || result.canceled) return;
|
||||
let comment = result.result;
|
||||
os.api("drive/files/update", {
|
||||
fileId: props.file.id,
|
||||
comment: comment.length === 0 ? null : comment,
|
||||
});
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
);
|
||||
}
|
||||
|
||||
function toggleSensitive() {
|
||||
os.api("drive/files/update", {
|
||||
fileId: props.file.id,
|
||||
isSensitive: !props.file.isSensitive,
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAllowDownload() {
|
||||
os.api("drive/files/update", {
|
||||
fileId: props.file.id,
|
||||
allowDownload: !props.file.allowDownload,
|
||||
});
|
||||
}
|
||||
|
||||
async function copyUrl() {
|
||||
await copyToClipboard(props.file.url);
|
||||
await os.success();
|
||||
}
|
||||
/*
|
||||
function addApp() {
|
||||
alert('not implemented yet');
|
||||
}
|
||||
*/
|
||||
async function deleteFile() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: "warning",
|
||||
text: i18n.t("driveFileDeleteConfirm", { name: props.file.name }),
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
os.api("drive/files/delete", {
|
||||
fileId: props.file.id,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ncvczrfv {
|
||||
position: relative;
|
||||
padding: 8px 0 0 0;
|
||||
min-height: 180px;
|
||||
border-radius: 8px;
|
||||
|
||||
&,
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(#000, 0.05);
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #0b65a5;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #c12113;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(#000, 0.1);
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #0b588c;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #ce2212;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.isSelected {
|
||||
background: var(--accent);
|
||||
|
||||
&:hover {
|
||||
background: var(--accentLighten);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken);
|
||||
}
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .name {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
> .label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background: #0c7ac9;
|
||||
}
|
||||
|
||||
&:before {
|
||||
top: 0;
|
||||
left: 57px;
|
||||
width: 28px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
top: 57px;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #c12113;
|
||||
}
|
||||
}
|
||||
|
||||
> img {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
> p {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 19px;
|
||||
left: -28px;
|
||||
width: 120px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 28px;
|
||||
color: #fff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 0.8em;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
color: var(--fg);
|
||||
overflow: hidden;
|
||||
|
||||
> .ext {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<div
|
||||
class="rghtznwe"
|
||||
:class="{ draghover }"
|
||||
draggable="true"
|
||||
:title="title"
|
||||
@click="onClick"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
@mouseover="onMouseover"
|
||||
@mouseout="onMouseout"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@dragenter.prevent="onDragenter"
|
||||
@dragleave="onDragleave"
|
||||
@drop.prevent.stop="onDrop"
|
||||
@dragstart="onDragstart"
|
||||
@dragend="onDragend"
|
||||
>
|
||||
<p class="name">
|
||||
<template v-if="hover"
|
||||
><i class="ph-folder-notch-open ph-bold ph-lg ph-fw ph-lg"></i
|
||||
></template>
|
||||
<template v-if="!hover"
|
||||
><i class="ph-folder-notch ph-bold ph-lg ph-fw ph-lg"></i
|
||||
></template>
|
||||
{{ folder.name }}
|
||||
</p>
|
||||
<p v-if="defaultStore.state.uploadFolder == folder.id" class="upload">
|
||||
{{ i18n.ts.uploadFolder }}
|
||||
</p>
|
||||
<button
|
||||
v-if="selectMode"
|
||||
class="checkbox _button"
|
||||
:class="{ checked: isSelected }"
|
||||
@click.prevent.stop="checkboxClicked"
|
||||
></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, ref } from "vue";
|
||||
import * as Misskey from "iceshrimp-sdk";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
import { defaultStore } from "@/store";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
folder: Misskey.entities.DriveFolder;
|
||||
isSelected?: boolean;
|
||||
selectMode?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isSelected: false,
|
||||
selectMode: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "chosen", v: Misskey.entities.DriveFolder): void;
|
||||
(ev: "move", v: Misskey.entities.DriveFolder): void;
|
||||
(ev: "upload", file: File, folder: Misskey.entities.DriveFolder);
|
||||
(ev: "removeFile", v: Misskey.entities.DriveFile["id"]): void;
|
||||
(ev: "removeFolder", v: Misskey.entities.DriveFolder["id"]): void;
|
||||
(ev: "dragstart"): void;
|
||||
(ev: "dragend"): void;
|
||||
}>();
|
||||
|
||||
const hover = ref(false);
|
||||
const draghover = ref(false);
|
||||
const isDragging = ref(false);
|
||||
|
||||
const title = computed(() => props.folder.name);
|
||||
|
||||
function checkboxClicked() {
|
||||
emit("chosen", props.folder);
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
emit("move", props.folder);
|
||||
}
|
||||
|
||||
function onMouseover() {
|
||||
hover.value = true;
|
||||
}
|
||||
|
||||
function onMouseout() {
|
||||
hover.value = false;
|
||||
}
|
||||
|
||||
function onDragover(ev: DragEvent) {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
// 自分自身がドラッグされている場合
|
||||
if (isDragging.value) {
|
||||
// 自分自身にはドロップさせない
|
||||
ev.dataTransfer.dropEffect = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const isFile = ev.dataTransfer.items[0].kind === "file";
|
||||
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
||||
const isDriveFolder =
|
||||
ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
|
||||
|
||||
if (isFile || isDriveFile || isDriveFolder) {
|
||||
ev.dataTransfer.dropEffect =
|
||||
ev.dataTransfer.effectAllowed === "all" ? "copy" : "move";
|
||||
} else {
|
||||
ev.dataTransfer.dropEffect = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function onDragenter() {
|
||||
if (!isDragging.value) draghover.value = true;
|
||||
}
|
||||
|
||||
function onDragleave() {
|
||||
draghover.value = false;
|
||||
}
|
||||
|
||||
function onDrop(ev: DragEvent) {
|
||||
draghover.value = false;
|
||||
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
// ファイルだったら
|
||||
if (ev.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(ev.dataTransfer.files)) {
|
||||
emit("upload", file, props.folder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile !== "") {
|
||||
const file = JSON.parse(driveFile);
|
||||
emit("removeFile", file.id);
|
||||
os.api("drive/files/update", {
|
||||
fileId: file.id,
|
||||
folderId: props.folder.id,
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ドライブのフォルダ
|
||||
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
|
||||
if (driveFolder != null && driveFolder !== "") {
|
||||
const folder = JSON.parse(driveFolder);
|
||||
|
||||
// 移動先が自分自身ならreject
|
||||
if (folder.id === props.folder.id) return;
|
||||
|
||||
emit("removeFolder", folder.id);
|
||||
os.api("drive/folders/update", {
|
||||
folderId: folder.id,
|
||||
parentId: props.folder.id,
|
||||
})
|
||||
.then(() => {
|
||||
// noop
|
||||
})
|
||||
.catch((err) => {
|
||||
switch (err) {
|
||||
case "detected-circular-definition":
|
||||
os.alert({
|
||||
title: i18n.ts.unableToProcess,
|
||||
text: i18n.ts.circularReferenceFolder,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
os.alert({
|
||||
type: "error",
|
||||
text: i18n.ts.somethingHappened,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
function onDragstart(ev: DragEvent) {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
ev.dataTransfer.setData(
|
||||
_DATA_TRANSFER_DRIVE_FOLDER_,
|
||||
JSON.stringify(props.folder),
|
||||
);
|
||||
isDragging.value = true;
|
||||
|
||||
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
|
||||
// (=あなたの子供が、ドラッグを開始しましたよ)
|
||||
emit("dragstart");
|
||||
}
|
||||
|
||||
function onDragend() {
|
||||
isDragging.value = false;
|
||||
emit("dragend");
|
||||
}
|
||||
|
||||
function go() {
|
||||
emit("move", props.folder.id);
|
||||
}
|
||||
|
||||
function rename() {
|
||||
os.inputText({
|
||||
title: i18n.ts.renameFolder,
|
||||
placeholder: i18n.ts.inputNewFolderName,
|
||||
default: props.folder.name,
|
||||
}).then(({ canceled, result: name }) => {
|
||||
if (canceled) return;
|
||||
os.api("drive/folders/update", {
|
||||
folderId: props.folder.id,
|
||||
name: name,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteFolder() {
|
||||
os.api("drive/folders/delete", {
|
||||
folderId: props.folder.id,
|
||||
})
|
||||
.then(() => {
|
||||
if (defaultStore.state.uploadFolder === props.folder.id) {
|
||||
defaultStore.set("uploadFolder", null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
switch (err.id) {
|
||||
case "b0fc8a17-963c-405d-bfbc-859a487295e1":
|
||||
os.alert({
|
||||
type: "error",
|
||||
title: i18n.ts.unableToDelete,
|
||||
text: i18n.ts.hasChildFilesOrFolders,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
os.alert({
|
||||
type: "error",
|
||||
text: i18n.ts.unableToDelete,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setAsUploadFolder() {
|
||||
defaultStore.set("uploadFolder", props.folder.id);
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
os.contextMenu(
|
||||
[
|
||||
{
|
||||
text: i18n.ts.openInWindow,
|
||||
icon: "ph-copy ph-bold ph-lg",
|
||||
action: () => {
|
||||
os.popup(
|
||||
defineAsyncComponent(
|
||||
() => import("@/components/MkDriveWindow.vue"),
|
||||
),
|
||||
{
|
||||
initialFolder: props.folder,
|
||||
},
|
||||
{},
|
||||
"closed",
|
||||
);
|
||||
},
|
||||
},
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rghtznwe {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
height: 64px;
|
||||
background: var(--driveFolderBg);
|
||||
border-radius: 4px;
|
||||
|
||||
&,
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
*:not(.checkbox) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> .checkbox {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border: solid 1px #000;
|
||||
|
||||
&.checked {
|
||||
background: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
&.draghover {
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
border: 2px dashed var(--focus);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .name {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: var(--desktopDriveFolderFg);
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
margin-left: 2px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
> .upload {
|
||||
margin: 4px 4px;
|
||||
font-size: 0.8em;
|
||||
text-align: right;
|
||||
color: var(--desktopDriveFolderFg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div
|
||||
class="drylbebk"
|
||||
:class="{ draghover }"
|
||||
@click="onClick"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@dragenter="onDragenter"
|
||||
@dragleave="onDragleave"
|
||||
@drop.stop="onDrop"
|
||||
>
|
||||
<i v-if="folder == null" class="ph-cloud ph-bold ph-lg"></i>
|
||||
<span>{{ folder == null ? i18n.ts.drive : folder.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import * as Misskey from "iceshrimp-sdk";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
folder?: Misskey.entities.DriveFolder;
|
||||
parentFolder: Misskey.entities.DriveFolder | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "move", v?: Misskey.entities.DriveFolder): void;
|
||||
(
|
||||
ev: "upload",
|
||||
file: File,
|
||||
folder?: Misskey.entities.DriveFolder | null,
|
||||
): void;
|
||||
(ev: "removeFile", v: Misskey.entities.DriveFile["id"]): void;
|
||||
(ev: "removeFolder", v: Misskey.entities.DriveFolder["id"]): void;
|
||||
}>();
|
||||
|
||||
const hover = ref(false);
|
||||
const draghover = ref(false);
|
||||
|
||||
function onClick() {
|
||||
emit("move", props.folder);
|
||||
}
|
||||
|
||||
function onMouseover() {
|
||||
hover.value = true;
|
||||
}
|
||||
|
||||
function onMouseout() {
|
||||
hover.value = false;
|
||||
}
|
||||
|
||||
function onDragover(ev: DragEvent) {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
// このフォルダがルートかつカレントディレクトリならドロップ禁止
|
||||
if (props.folder == null && props.parentFolder == null) {
|
||||
ev.dataTransfer.dropEffect = "none";
|
||||
}
|
||||
|
||||
const isFile = ev.dataTransfer.items[0].kind === "file";
|
||||
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
||||
const isDriveFolder =
|
||||
ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
|
||||
|
||||
if (isFile || isDriveFile || isDriveFolder) {
|
||||
ev.dataTransfer.dropEffect =
|
||||
ev.dataTransfer.effectAllowed === "all" ? "copy" : "move";
|
||||
} else {
|
||||
ev.dataTransfer.dropEffect = "none";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function onDragenter() {
|
||||
if (props.folder || props.parentFolder) draghover.value = true;
|
||||
}
|
||||
|
||||
function onDragleave() {
|
||||
if (props.folder || props.parentFolder) draghover.value = false;
|
||||
}
|
||||
|
||||
function onDrop(ev: DragEvent) {
|
||||
draghover.value = false;
|
||||
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
// ファイルだったら
|
||||
if (ev.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(ev.dataTransfer.files)) {
|
||||
emit("upload", file, props.folder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile !== "") {
|
||||
const file = JSON.parse(driveFile);
|
||||
emit("removeFile", file.id);
|
||||
os.api("drive/files/update", {
|
||||
fileId: file.id,
|
||||
folderId: props.folder ? props.folder.id : null,
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ドライブのフォルダ
|
||||
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
|
||||
if (driveFolder != null && driveFolder !== "") {
|
||||
const folder = JSON.parse(driveFolder);
|
||||
// 移動先が自分自身ならreject
|
||||
if (props.folder && folder.id === props.folder.id) return;
|
||||
emit("removeFolder", folder.id);
|
||||
os.api("drive/folders/update", {
|
||||
folderId: folder.id,
|
||||
parentId: props.folder ? props.folder.id : null,
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drylbebk {
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.draghover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,915 @@
|
||||
<template>
|
||||
<div class="yfudmmck">
|
||||
<nav>
|
||||
<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
|
||||
: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 v-if="draghover" class="dropzone"></div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="*/*"
|
||||
multiple
|
||||
tabindex="-1"
|
||||
@change="onChangeFileInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
markRaw,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import * as Misskey from "iceshrimp-sdk";
|
||||
import MkButton from "./MkButton.vue";
|
||||
import XNavFolder from "@/components/MkDrive.navFolder.vue";
|
||||
import XFolder from "@/components/MkDrive.folder.vue";
|
||||
import XFile from "@/components/MkDrive.file.vue";
|
||||
import * as os from "@/os";
|
||||
import { stream } from "@/stream";
|
||||
import { defaultStore } from "@/store";
|
||||
import { i18n } from "@/i18n";
|
||||
import { uploadFile, uploads } from "@/scripts/upload";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
initialFolder?: Misskey.entities.DriveFolder;
|
||||
type?: string;
|
||||
multiple?: boolean;
|
||||
select?: "file" | "folder" | null;
|
||||
}>(),
|
||||
{
|
||||
multiple: false,
|
||||
select: null,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
ev: "selected",
|
||||
v: Misskey.entities.DriveFile | Misskey.entities.DriveFolder,
|
||||
): void;
|
||||
(
|
||||
ev: "change-selection",
|
||||
v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[],
|
||||
): void;
|
||||
(ev: "move-root"): void;
|
||||
(ev: "cd", v: Misskey.entities.DriveFolder | null): void;
|
||||
(ev: "open-folder", v: Misskey.entities.DriveFolder): void;
|
||||
}>();
|
||||
|
||||
const loadMoreFiles = ref<InstanceType<typeof MkButton>>();
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
|
||||
const folder = ref<Misskey.entities.DriveFolder | null>(null);
|
||||
const files = ref<Misskey.entities.DriveFile[]>([]);
|
||||
const folders = ref<Misskey.entities.DriveFolder[]>([]);
|
||||
const moreFiles = ref(false);
|
||||
const moreFolders = ref(false);
|
||||
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
||||
const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
|
||||
const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
||||
const uploadings = uploads;
|
||||
const connection = stream.useChannel("drive");
|
||||
const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい
|
||||
|
||||
// ドロップされようとしているか
|
||||
const draghover = ref(false);
|
||||
|
||||
// 自身の所有するアイテムがドラッグをスタートさせたか
|
||||
// (自分自身の階層にドロップできないようにするためのフラグ)
|
||||
const isDragSource = ref(false);
|
||||
|
||||
const fetching = ref(true);
|
||||
|
||||
const ilFilesObserver = new IntersectionObserver(
|
||||
(entries) =>
|
||||
entries.some((entry) => entry.isIntersecting) &&
|
||||
!fetching.value &&
|
||||
moreFiles.value &&
|
||||
fetchMoreFiles(),
|
||||
);
|
||||
|
||||
watch(folder, () => emit("cd", folder.value));
|
||||
|
||||
function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) {
|
||||
addFile(file, true);
|
||||
}
|
||||
|
||||
function onStreamDriveFileUpdated(file: Misskey.entities.DriveFile) {
|
||||
const current = folder.value ? folder.value.id : null;
|
||||
if (current !== file.folderId) {
|
||||
removeFile(file);
|
||||
} else {
|
||||
addFile(file, true);
|
||||
}
|
||||
}
|
||||
|
||||
function onStreamDriveFileDeleted(fileId: string) {
|
||||
removeFile(fileId);
|
||||
}
|
||||
|
||||
function onStreamDriveFolderCreated(
|
||||
createdFolder: Misskey.entities.DriveFolder,
|
||||
) {
|
||||
addFolder(createdFolder, true);
|
||||
}
|
||||
|
||||
function onStreamDriveFolderUpdated(
|
||||
updatedFolder: Misskey.entities.DriveFolder,
|
||||
) {
|
||||
const current = folder.value ? folder.value.id : null;
|
||||
if (current !== updatedFolder.parentId) {
|
||||
removeFolder(updatedFolder);
|
||||
} else {
|
||||
addFolder(updatedFolder, true);
|
||||
}
|
||||
}
|
||||
|
||||
function onStreamDriveFolderDeleted(folderId: string) {
|
||||
removeFolder(folderId);
|
||||
}
|
||||
|
||||
function onDragover(ev: DragEvent): any {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
// ドラッグ元が自分自身の所有するアイテムだったら
|
||||
if (isDragSource.value) {
|
||||
// 自分自身にはドロップさせない
|
||||
ev.dataTransfer.dropEffect = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const isFile = ev.dataTransfer.items[0].kind === "file";
|
||||
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
||||
const isDriveFolder =
|
||||
ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
|
||||
if (isFile || isDriveFile || isDriveFolder) {
|
||||
ev.dataTransfer.dropEffect =
|
||||
ev.dataTransfer.effectAllowed === "all" ? "copy" : "move";
|
||||
} else {
|
||||
ev.dataTransfer.dropEffect = "none";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function onDragenter() {
|
||||
if (!isDragSource.value) draghover.value = true;
|
||||
}
|
||||
|
||||
function onDragleave() {
|
||||
draghover.value = false;
|
||||
}
|
||||
|
||||
function onDrop(ev: DragEvent): any {
|
||||
draghover.value = false;
|
||||
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
// ドロップされてきたものがファイルだったら
|
||||
if (ev.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(ev.dataTransfer.files)) {
|
||||
upload(file, folder.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile !== "") {
|
||||
const file = JSON.parse(driveFile);
|
||||
if (files.value.some((f) => f.id === file.id)) return;
|
||||
removeFile(file.id);
|
||||
os.api("drive/files/update", {
|
||||
fileId: file.id,
|
||||
folderId: folder.value ? folder.value.id : null,
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ドライブのフォルダ
|
||||
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
|
||||
if (driveFolder != null && driveFolder !== "") {
|
||||
const droppedFolder = JSON.parse(driveFolder);
|
||||
|
||||
// 移動先が自分自身ならreject
|
||||
if (folder.value && droppedFolder.id === folder.value.id) return false;
|
||||
if (folders.value.some((f) => f.id === droppedFolder.id)) return false;
|
||||
removeFolder(droppedFolder.id);
|
||||
os.api("drive/folders/update", {
|
||||
folderId: droppedFolder.id,
|
||||
parentId: folder.value ? folder.value.id : null,
|
||||
})
|
||||
.then(() => {
|
||||
// noop
|
||||
})
|
||||
.catch((err) => {
|
||||
switch (err) {
|
||||
case "detected-circular-definition":
|
||||
os.alert({
|
||||
title: i18n.ts.unableToProcess,
|
||||
text: i18n.ts.circularReferenceFolder,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
os.alert({
|
||||
type: "error",
|
||||
text: i18n.ts.somethingHappened,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
function selectLocalFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function urlUpload() {
|
||||
os.inputText({
|
||||
title: i18n.ts.uploadFromUrl,
|
||||
type: "url",
|
||||
placeholder: i18n.ts.uploadFromUrlDescription,
|
||||
}).then(({ canceled, result: url }) => {
|
||||
if (canceled || !url) return;
|
||||
os.api("drive/files/upload-from-url", {
|
||||
url: url,
|
||||
folderId: folder.value ? folder.value.id : undefined,
|
||||
});
|
||||
|
||||
os.alert({
|
||||
title: i18n.ts.uploadFromUrlRequested,
|
||||
text: i18n.ts.uploadFromUrlMayTakeTime,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createFolder() {
|
||||
os.inputText({
|
||||
title: i18n.ts.createFolder,
|
||||
placeholder: i18n.ts.folderName,
|
||||
}).then(({ canceled, result: name }) => {
|
||||
if (canceled) return;
|
||||
os.api("drive/folders/create", {
|
||||
name: name,
|
||||
parentId: folder.value ? folder.value.id : undefined,
|
||||
}).then((createdFolder) => {
|
||||
addFolder(createdFolder, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renameFolder(folderToRename: Misskey.entities.DriveFolder) {
|
||||
os.inputText({
|
||||
title: i18n.ts.renameFolder,
|
||||
placeholder: i18n.ts.inputNewFolderName,
|
||||
default: folderToRename.name,
|
||||
}).then(({ canceled, result: name }) => {
|
||||
if (canceled) return;
|
||||
os.api("drive/folders/update", {
|
||||
folderId: folderToRename.id,
|
||||
name: name,
|
||||
}).then((updatedFolder) => {
|
||||
// FIXME: 画面を更新するために自分自身に移動
|
||||
move(updatedFolder);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) {
|
||||
os.api("drive/folders/delete", {
|
||||
folderId: folderToDelete.id,
|
||||
})
|
||||
.then(() => {
|
||||
// 削除時に親フォルダに移動
|
||||
move(folderToDelete.parentId);
|
||||
})
|
||||
.catch((err) => {
|
||||
switch (err.id) {
|
||||
case "b0fc8a17-963c-405d-bfbc-859a487295e1":
|
||||
os.alert({
|
||||
type: "error",
|
||||
title: i18n.ts.unableToDelete,
|
||||
text: i18n.ts.hasChildFilesOrFolders,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
os.alert({
|
||||
type: "error",
|
||||
text: i18n.ts.unableToDelete,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onChangeFileInput() {
|
||||
if (!fileInput.value?.files) return;
|
||||
for (const file of Array.from(fileInput.value.files)) {
|
||||
upload(file, folder.value);
|
||||
}
|
||||
}
|
||||
|
||||
function upload(
|
||||
file: File,
|
||||
folderToUpload?: Misskey.entities.DriveFolder | null,
|
||||
) {
|
||||
uploadFile(
|
||||
file,
|
||||
folderToUpload && typeof folderToUpload === "object"
|
||||
? folderToUpload.id
|
||||
: null,
|
||||
undefined,
|
||||
keepOriginal.value,
|
||||
).then((res) => {
|
||||
addFile(res, true);
|
||||
});
|
||||
}
|
||||
|
||||
function chooseFile(file: Misskey.entities.DriveFile) {
|
||||
const isAlreadySelected = selectedFiles.value.some((f) => f.id === file.id);
|
||||
if (props.multiple) {
|
||||
if (isAlreadySelected) {
|
||||
selectedFiles.value = selectedFiles.value.filter(
|
||||
(f) => f.id !== file.id,
|
||||
);
|
||||
} else {
|
||||
selectedFiles.value.push(file);
|
||||
}
|
||||
emit("change-selection", selectedFiles.value);
|
||||
} else {
|
||||
if (isAlreadySelected) {
|
||||
emit("selected", file);
|
||||
} else {
|
||||
selectedFiles.value = [file];
|
||||
emit("change-selection", [file]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) {
|
||||
const isAlreadySelected = selectedFolders.value.some(
|
||||
(f) => f.id === folderToChoose.id,
|
||||
);
|
||||
if (props.multiple) {
|
||||
if (isAlreadySelected) {
|
||||
selectedFolders.value = selectedFolders.value.filter(
|
||||
(f) => f.id !== folderToChoose.id,
|
||||
);
|
||||
} else {
|
||||
selectedFolders.value.push(folderToChoose);
|
||||
}
|
||||
emit("change-selection", selectedFolders.value);
|
||||
} else {
|
||||
if (isAlreadySelected) {
|
||||
emit("selected", folderToChoose);
|
||||
} else {
|
||||
selectedFolders.value = [folderToChoose];
|
||||
emit("change-selection", [folderToChoose]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function move(target?: Misskey.entities.DriveFolder) {
|
||||
if (!target) {
|
||||
goRoot();
|
||||
return;
|
||||
} else if (typeof target === "object") {
|
||||
target = target.id;
|
||||
}
|
||||
|
||||
fetching.value = true;
|
||||
|
||||
os.api("drive/folders/show", {
|
||||
folderId: target,
|
||||
}).then((folderToMove) => {
|
||||
folder.value = folderToMove;
|
||||
hierarchyFolders.value = [];
|
||||
|
||||
const dive = (folderToDive) => {
|
||||
hierarchyFolders.value.unshift(folderToDive);
|
||||
if (folderToDive.parent) dive(folderToDive.parent);
|
||||
};
|
||||
|
||||
if (folderToMove.parent) dive(folderToMove.parent);
|
||||
|
||||
emit("open-folder", folderToMove);
|
||||
fetch();
|
||||
});
|
||||
}
|
||||
|
||||
function addFolder(folderToAdd: Misskey.entities.DriveFolder, unshift = false) {
|
||||
const current = folder.value ? folder.value.id : null;
|
||||
if (current !== folderToAdd.parentId) return;
|
||||
|
||||
if (folders.value.some((f) => f.id === folderToAdd.id)) {
|
||||
const exist = folders.value.map((f) => f.id).indexOf(folderToAdd.id);
|
||||
folders.value[exist] = folderToAdd;
|
||||
return;
|
||||
}
|
||||
|
||||
if (unshift) {
|
||||
folders.value.unshift(folderToAdd);
|
||||
} else {
|
||||
folders.value.push(folderToAdd);
|
||||
}
|
||||
}
|
||||
|
||||
function addFile(fileToAdd: Misskey.entities.DriveFile, unshift = false) {
|
||||
const current = folder.value ? folder.value.id : null;
|
||||
if (current !== fileToAdd.folderId) return;
|
||||
|
||||
if (files.value.some((f) => f.id === fileToAdd.id)) {
|
||||
const exist = files.value.map((f) => f.id).indexOf(fileToAdd.id);
|
||||
files.value[exist] = fileToAdd;
|
||||
return;
|
||||
}
|
||||
|
||||
if (unshift) {
|
||||
files.value.unshift(fileToAdd);
|
||||
} else {
|
||||
files.value.push(fileToAdd);
|
||||
}
|
||||
}
|
||||
|
||||
function removeFolder(folderToRemove: Misskey.entities.DriveFolder | string) {
|
||||
const folderIdToRemove =
|
||||
typeof folderToRemove === "object" ? folderToRemove.id : folderToRemove;
|
||||
folders.value = folders.value.filter((f) => f.id !== folderIdToRemove);
|
||||
}
|
||||
|
||||
function removeFile(file: Misskey.entities.DriveFile | string) {
|
||||
const fileId = typeof file === "object" ? file.id : file;
|
||||
files.value = files.value.filter((f) => f.id !== fileId);
|
||||
}
|
||||
|
||||
function appendFile(file: Misskey.entities.DriveFile) {
|
||||
addFile(file);
|
||||
}
|
||||
|
||||
function appendFolder(folderToAppend: Misskey.entities.DriveFolder) {
|
||||
addFolder(folderToAppend);
|
||||
}
|
||||
/*
|
||||
function prependFile(file: Misskey.entities.DriveFile) {
|
||||
addFile(file, true);
|
||||
}
|
||||
|
||||
function prependFolder(folderToPrepend: Misskey.entities.DriveFolder) {
|
||||
addFolder(folderToPrepend, true);
|
||||
}
|
||||
*/
|
||||
function goRoot() {
|
||||
// 既にrootにいるなら何もしない
|
||||
if (folder.value == null) return;
|
||||
|
||||
folder.value = null;
|
||||
hierarchyFolders.value = [];
|
||||
emit("move-root");
|
||||
fetch();
|
||||
}
|
||||
|
||||
async function fetch() {
|
||||
folders.value = [];
|
||||
files.value = [];
|
||||
moreFolders.value = false;
|
||||
moreFiles.value = false;
|
||||
fetching.value = true;
|
||||
|
||||
const foldersMax = 30;
|
||||
const filesMax = 30;
|
||||
|
||||
const foldersPromise = os
|
||||
.api("drive/folders", {
|
||||
folderId: folder.value ? folder.value.id : null,
|
||||
limit: foldersMax + 1,
|
||||
})
|
||||
.then((fetchedFolders) => {
|
||||
if (fetchedFolders.length === foldersMax + 1) {
|
||||
moreFolders.value = true;
|
||||
fetchedFolders.pop();
|
||||
}
|
||||
return fetchedFolders;
|
||||
});
|
||||
|
||||
const filesPromise = os
|
||||
.api("drive/files", {
|
||||
folderId: folder.value ? folder.value.id : null,
|
||||
type: props.type,
|
||||
limit: filesMax + 1,
|
||||
})
|
||||
.then((fetchedFiles) => {
|
||||
if (fetchedFiles.length === filesMax + 1) {
|
||||
moreFiles.value = true;
|
||||
fetchedFiles.pop();
|
||||
}
|
||||
return fetchedFiles;
|
||||
});
|
||||
|
||||
const [fetchedFolders, fetchedFiles] = await Promise.all([
|
||||
foldersPromise,
|
||||
filesPromise,
|
||||
]);
|
||||
|
||||
for (const x of fetchedFolders) appendFolder(x);
|
||||
for (const x of fetchedFiles) appendFile(x);
|
||||
|
||||
fetching.value = false;
|
||||
}
|
||||
|
||||
function fetchMoreFiles() {
|
||||
fetching.value = true;
|
||||
|
||||
const max = 30;
|
||||
|
||||
// ファイル一覧取得
|
||||
os.api("drive/files", {
|
||||
folderId: folder.value ? folder.value.id : null,
|
||||
type: props.type,
|
||||
untilId: files.value[files.value.length - 1].id,
|
||||
limit: max + 1,
|
||||
}).then((files) => {
|
||||
if (files.length === max + 1) {
|
||||
moreFiles.value = true;
|
||||
files.pop();
|
||||
} else {
|
||||
moreFiles.value = false;
|
||||
}
|
||||
for (const x of files) appendFile(x);
|
||||
fetching.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function getMenu() {
|
||||
return [
|
||||
{
|
||||
type: "switch",
|
||||
text: i18n.ts.keepOriginalUploading,
|
||||
ref: keepOriginal,
|
||||
},
|
||||
null,
|
||||
{
|
||||
text: i18n.ts.addFile,
|
||||
type: "label",
|
||||
},
|
||||
{
|
||||
text: i18n.ts.upload,
|
||||
icon: "ph-upload-simple ph-bold ph-lg",
|
||||
action: () => {
|
||||
selectLocalFile();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: i18n.ts.fromUrl,
|
||||
icon: "ph-link-simple ph-bold ph-lg",
|
||||
action: () => {
|
||||
urlUpload();
|
||||
},
|
||||
},
|
||||
null,
|
||||
{
|
||||
text: folder.value ? folder.value.name : i18n.ts.drive,
|
||||
type: "label",
|
||||
},
|
||||
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) {
|
||||
os.popupMenu(
|
||||
getMenu(),
|
||||
(ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
os.contextMenu(getMenu(), ev);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) {
|
||||
nextTick(() => {
|
||||
ilFilesObserver.observe(loadMoreFiles.value?.$el);
|
||||
});
|
||||
}
|
||||
|
||||
connection.on("fileCreated", onStreamDriveFileCreated);
|
||||
connection.on("fileUpdated", onStreamDriveFileUpdated);
|
||||
connection.on("fileDeleted", onStreamDriveFileDeleted);
|
||||
connection.on("folderCreated", onStreamDriveFolderCreated);
|
||||
connection.on("folderUpdated", onStreamDriveFolderUpdated);
|
||||
connection.on("folderDeleted", onStreamDriveFolderDeleted);
|
||||
|
||||
if (props.initialFolder) {
|
||||
move(props.initialFolder);
|
||||
} else {
|
||||
fetch();
|
||||
}
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (defaultStore.state.enableInfiniteScroll) {
|
||||
nextTick(() => {
|
||||
ilFilesObserver.observe(loadMoreFiles.value?.$el);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
connection.dispose();
|
||||
ilFilesObserver.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.yfudmmck {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
> nav {
|
||||
display: flex;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
font-size: 0.9em;
|
||||
box-shadow: 0 1px 0 var(--divider);
|
||||
|
||||
&,
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> .path {
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
line-height: 42px;
|
||||
white-space: nowrap;
|
||||
|
||||
> * {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
line-height: 42px;
|
||||
cursor: pointer;
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.current {
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.separator {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
|
||||
> i {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .menu {
|
||||
margin-left: auto;
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
> .main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--margin);
|
||||
|
||||
&,
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&.fetching {
|
||||
cursor: wait !important;
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> .contents {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
height: calc(100% - 38px - 100px);
|
||||
}
|
||||
|
||||
> .contents {
|
||||
> .folders,
|
||||
> .files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> .folder,
|
||||
> .file {
|
||||
flex-grow: 1;
|
||||
width: 128px;
|
||||
margin: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
> .padding {
|
||||
flex-grow: 1;
|
||||
pointer-events: none;
|
||||
width: 128px + 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .dropzone {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 38px;
|
||||
width: 100%;
|
||||
height: calc(100% - 38px);
|
||||
border: dashed 2px var(--focus);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<button ref="thumbnail" class="zdjebgpv">
|
||||
<ImgWithBlurhash
|
||||
v-if="isThumbnailAvailable"
|
||||
:hash="file.blurhash"
|
||||
:src="file.thumbnailUrl"
|
||||
:alt="file.name"
|
||||
:title="file.name"
|
||||
:cover="fit !== 'contain'"
|
||||
/>
|
||||
<i
|
||||
v-else-if="is === 'image'"
|
||||
class="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 === 'epub'"
|
||||
class="ph-book-open-text 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>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import type * as Misskey from "iceshrimp-sdk";
|
||||
import ImgWithBlurhash from "@/components/MkImgWithBlurhash.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
fit: string;
|
||||
}>();
|
||||
|
||||
const is = computed(() => {
|
||||
if (props.file.type.startsWith("image/")) return "image";
|
||||
if (props.file.type.startsWith("video/")) return "video";
|
||||
if (props.file.type === "audio/midi") return "midi";
|
||||
if (props.file.type.startsWith("audio/")) return "audio";
|
||||
if (props.file.type.endsWith("/csv")) return "csv";
|
||||
if (props.file.type.endsWith("/pdf")) return "pdf";
|
||||
if (
|
||||
props.file.type === "application/epub+zip" ||
|
||||
props.file.name.toLowerCase().endsWith(".epub")
|
||||
)
|
||||
return "epub";
|
||||
if (props.file.type.startsWith("text/")) return "textfile";
|
||||
if (
|
||||
[
|
||||
"application/zip",
|
||||
"application/x-cpio",
|
||||
"application/x-bzip",
|
||||
"application/x-bzip2",
|
||||
"application/java-archive",
|
||||
"application/x-rar-compressed",
|
||||
"application/x-tar",
|
||||
"application/gzip",
|
||||
"application/x-7z-compressed",
|
||||
].some((archiveType) => archiveType === props.file.type)
|
||||
)
|
||||
return "archive";
|
||||
return "unknown";
|
||||
});
|
||||
|
||||
const isThumbnailAvailable = computed(() => {
|
||||
return props.file.thumbnailUrl
|
||||
? is.value === ("image" as const) || is.value === "video"
|
||||
: false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.zdjebgpv {
|
||||
position: relative;
|
||||
display: flex;
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
overflow: clip;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
|
||||
> .icon-sub {
|
||||
position: absolute;
|
||||
width: 30%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
right: 4%;
|
||||
bottom: 4%;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
pointer-events: none;
|
||||
margin: auto;
|
||||
font-size: 32px;
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<XModalWindow
|
||||
ref="dialog"
|
||||
:width="800"
|
||||
:height="500"
|
||||
:with-ok-button="true"
|
||||
:ok-button-disabled="type === 'file' && selected.length === 0"
|
||||
@click="cancel()"
|
||||
@close="cancel()"
|
||||
@ok="ok()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
{{
|
||||
multiple
|
||||
? type === "file"
|
||||
? i18n.ts.selectFiles
|
||||
: i18n.ts.selectFolders
|
||||
: type === "file"
|
||||
? i18n.ts.selectFile
|
||||
: i18n.ts.selectFolder
|
||||
}}
|
||||
<span
|
||||
v-if="selected.length > 0"
|
||||
style="margin-left: 8px; opacity: 0.5"
|
||||
>({{ number(selected.length) }})</span
|
||||
>
|
||||
</template>
|
||||
<XDrive
|
||||
:multiple="multiple"
|
||||
:select="type"
|
||||
@changeSelection="onChangeSelection"
|
||||
@selected="ok()"
|
||||
/>
|
||||
</XModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import * as Misskey from "iceshrimp-sdk";
|
||||
import XDrive from "@/components/MkDrive.vue";
|
||||
import XModalWindow from "@/components/MkModalWindow.vue";
|
||||
import number from "@/filters/number";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
type?: "file" | "folder";
|
||||
multiple: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: "file",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "done", r?: Misskey.entities.DriveFile[]): void;
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
const dialog = ref<InstanceType<typeof XModalWindow>>();
|
||||
|
||||
const selected = ref<Misskey.entities.DriveFile[]>([]);
|
||||
|
||||
function ok() {
|
||||
emit("done", selected.value);
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit("done");
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function onChangeSelection(files: Misskey.entities.DriveFile[]) {
|
||||
selected.value = files;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<XWindow
|
||||
ref="window"
|
||||
:initial-width="800"
|
||||
:initial-height="500"
|
||||
:can-resize="true"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
{{ i18n.ts.drive }}
|
||||
</template>
|
||||
<XDrive :initial-folder="initialFolder" />
|
||||
</XWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {} from "vue";
|
||||
import * as Misskey from "iceshrimp-sdk";
|
||||
import XDrive from "@/components/MkDrive.vue";
|
||||
import XWindow from "@/components/MkWindow.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
defineProps<{
|
||||
initialFolder?: Misskey.entities.DriveFolder;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<section>
|
||||
<header class="_acrylic" @click="shown = !shown">
|
||||
<i
|
||||
class="toggle ph-fw ph-lg"
|
||||
:class="
|
||||
shown
|
||||
? 'ph-caret-down ph-bold ph-lg'
|
||||
: 'ph-caret-up ph-bold ph-lg'
|
||||
"
|
||||
></i>
|
||||
<slot></slot> ({{ emojis.length }})
|
||||
<span v-if="props.skinToneSelector && props.skinTones">
|
||||
<button
|
||||
v-for="skinTone in props.skinTones"
|
||||
class="_button"
|
||||
@click.stop="
|
||||
applyUnicodeSkinTone(
|
||||
props.skinTones.indexOf(skinTone) + 1,
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="ph-circle ph-fill ph-fw ph-lg"
|
||||
:style="{ color: skinTone + ' !important' }"
|
||||
:aria-label="
|
||||
props.skinToneLabels
|
||||
? props.skinToneLabels[
|
||||
props.skinTones.indexOf(skinTone)
|
||||
]
|
||||
: ''
|
||||
"
|
||||
></i>
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
<div v-if="shown" class="body">
|
||||
<button
|
||||
v-for="emoji in localEmojis"
|
||||
:key="emoji"
|
||||
class="_button item"
|
||||
@click="emit('chosen', emoji, $event)"
|
||||
>
|
||||
<MkEmoji class="emoji" :emoji="emoji" :normal="true" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { addSkinTone } from "@/scripts/emojilist";
|
||||
|
||||
const props = defineProps<{
|
||||
emojis: string[];
|
||||
initialShown?: boolean;
|
||||
skinToneSelector?: boolean;
|
||||
skinTones?: string[];
|
||||
skinToneLabels?: string[];
|
||||
}>();
|
||||
|
||||
const localEmojis = ref([...props.emojis]);
|
||||
|
||||
function applyUnicodeSkinTone(custom?: number) {
|
||||
for (let i = 0; i < localEmojis.value.length; i++) {
|
||||
localEmojis.value[i] = addSkinTone(localEmojis.value[i], custom);
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "chosen", v: string, event: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
const shown = ref(!!props.initialShown);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.skinToneSelector) {
|
||||
applyUnicodeSkinTone();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.emojis,
|
||||
(newVal) => {
|
||||
localEmojis.value = [...newVal];
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,711 @@
|
||||
<template>
|
||||
<div
|
||||
class="omfetrab"
|
||||
:class="['s' + size, 'w' + width, 'h' + height, { asDrawer }]"
|
||||
:style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }"
|
||||
tabindex="-1"
|
||||
>
|
||||
<input
|
||||
ref="search"
|
||||
v-model.trim="q"
|
||||
class="search"
|
||||
data-prevent-emoji-insert
|
||||
:class="{ filled: q != null && q != '' }"
|
||||
:placeholder="i18n.ts.search"
|
||||
type="search"
|
||||
@paste.stop="paste"
|
||||
@keyup.enter="done()"
|
||||
/>
|
||||
<div ref="emojis" class="emojis">
|
||||
<section class="result">
|
||||
<div v-if="searchResultCustom.length > 0" class="body">
|
||||
<button
|
||||
v-for="emoji in searchResultCustom"
|
||||
:key="emoji.id"
|
||||
class="_button item"
|
||||
:title="emoji.name"
|
||||
tabindex="0"
|
||||
@click="chosen(emoji, $event)"
|
||||
>
|
||||
<!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>-->
|
||||
<img
|
||||
class="emoji"
|
||||
:src="
|
||||
disableShowingAnimatedImages
|
||||
? getStaticImageUrl(emoji.url)
|
||||
: emoji.url
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="searchResultUnicode.length > 0" class="body">
|
||||
<button
|
||||
v-for="emoji in searchResultUnicode"
|
||||
:key="emoji.slug"
|
||||
class="_button item"
|
||||
:title="emoji.slug"
|
||||
tabindex="0"
|
||||
@click="chosen(emoji, $event)"
|
||||
>
|
||||
<MkEmoji class="emoji" :emoji="emoji.emoji" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="tab === 'index'" class="group index">
|
||||
<section v-if="showPinned">
|
||||
<div class="body">
|
||||
<button
|
||||
v-for="emoji in pinned"
|
||||
:key="emoji"
|
||||
class="_button item"
|
||||
tabindex="0"
|
||||
@click="chosen(emoji, $event)"
|
||||
>
|
||||
<MkEmoji
|
||||
class="emoji"
|
||||
:emoji="emoji"
|
||||
:normal="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<header class="_acrylic">
|
||||
<i class="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 unicodeEmojiCategories"
|
||||
:key="category"
|
||||
:skin-tone-selector="category === 'people'"
|
||||
:skin-tones="unicodeEmojiSkinTones"
|
||||
:skin-tone-labels="unicodeEmojiSkinToneLabels"
|
||||
:emojis="
|
||||
emojilist
|
||||
.filter((e) => e.category === category)
|
||||
.map((e) => e.emoji)
|
||||
"
|
||||
@chosen="chosen"
|
||||
>{{
|
||||
getNicelyLabeledCategory(category) || category
|
||||
}}</XSection
|
||||
>
|
||||
</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>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import * as Misskey from "iceshrimp-sdk";
|
||||
import XSection from "@/components/MkEmojiPicker.section.vue";
|
||||
import {
|
||||
emojilist,
|
||||
unicodeEmojiCategories,
|
||||
UnicodeEmojiDef,
|
||||
getNicelyLabeledCategory,
|
||||
} from "@/scripts/emojilist";
|
||||
import { getStaticImageUrl } from "@/scripts/get-static-image-url";
|
||||
import Ripple from "@/components/MkRipple.vue";
|
||||
import * as os from "@/os";
|
||||
import { isTouchUsing } from "@/scripts/touch";
|
||||
import { deviceKind } from "@/scripts/device-kind";
|
||||
import { emojiCategories, instance } from "@/instance";
|
||||
import { i18n } from "@/i18n";
|
||||
import { defaultStore } from "@/store";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showPinned?: boolean;
|
||||
asReactionPicker?: boolean;
|
||||
maxHeight?: number;
|
||||
asDrawer?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showPinned: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "chosen", v: string, ev: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
const search = ref<HTMLInputElement>();
|
||||
const emojis = ref<HTMLDivElement>();
|
||||
|
||||
const {
|
||||
reactions: pinned,
|
||||
reactionPickerSize,
|
||||
reactionPickerWidth,
|
||||
reactionPickerHeight,
|
||||
disableShowingAnimatedImages,
|
||||
recentlyUsedEmojis,
|
||||
} = defaultStore.reactiveState;
|
||||
|
||||
const unicodeEmojiSkinTones = [
|
||||
"#FFDC5E",
|
||||
"#F7DFCF",
|
||||
"#F3D3A3",
|
||||
"#D6AE89",
|
||||
"#B17F56",
|
||||
"#7D523C",
|
||||
];
|
||||
|
||||
const unicodeEmojiSkinToneLabels = [
|
||||
i18n.ts._skinTones?.yellow ?? "Yellow",
|
||||
i18n.ts._skinTones?.light ?? "Light",
|
||||
i18n.ts._skinTones?.mediumLight ?? "Medium Light",
|
||||
i18n.ts._skinTones?.medium ?? "Medium",
|
||||
i18n.ts._skinTones?.mediumDark ?? "Medium Dark",
|
||||
i18n.ts._skinTones?.dark ?? "Dark",
|
||||
];
|
||||
|
||||
const size = computed(() =>
|
||||
props.asReactionPicker ? reactionPickerSize.value : 1,
|
||||
);
|
||||
const width = computed(() =>
|
||||
props.asReactionPicker ? reactionPickerWidth.value : 3,
|
||||
);
|
||||
const height = computed(() =>
|
||||
props.asReactionPicker ? reactionPickerHeight.value : 2,
|
||||
);
|
||||
const customEmojiCategories = emojiCategories;
|
||||
const customEmojis = instance.emojis;
|
||||
const q = ref<string | null>(null);
|
||||
const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]);
|
||||
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
|
||||
const tab = ref<"index" | "custom" | "unicode" | "tags">("index");
|
||||
|
||||
watch(q, () => {
|
||||
if (emojis.value) emojis.value.scrollTop = 0;
|
||||
|
||||
if (q.value == null || q.value === "") {
|
||||
searchResultCustom.value = [];
|
||||
searchResultUnicode.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const newQ = q.value.replace(/:/g, "").toLowerCase();
|
||||
|
||||
const searchCustom = () => {
|
||||
const max = 16;
|
||||
const emojis: {
|
||||
lowercased: string;
|
||||
lowercasedAliases: string[];
|
||||
emoji: Misskey.entities.CustomEmoji;
|
||||
}[] = customEmojis.map(emoji => ({
|
||||
lowercased: emoji.name.toLowerCase(),
|
||||
lowercasedAliases: emoji.aliases.map(alias => alias.toLowerCase()),
|
||||
emoji,
|
||||
}));
|
||||
const matches = new Set<Misskey.entities.CustomEmoji>();
|
||||
|
||||
const exactMatch = emojis.find((emoji) => emoji.lowercased === newQ);
|
||||
if (exactMatch) matches.add(exactMatch.emoji);
|
||||
|
||||
if (newQ.includes(" ")) {
|
||||
// AND検索
|
||||
const keywords = newQ.split(" ");
|
||||
|
||||
// 名前にキーワードが含まれている
|
||||
for (const { emoji, lowercased } of emojis) {
|
||||
if (keywords.every((keyword) => lowercased.includes(keyword))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
// 名前またはエイリアスにキーワードが含まれている
|
||||
for (const { lowercased, lowercasedAliases, emoji } of emojis) {
|
||||
if (
|
||||
keywords.every(
|
||||
(keyword) =>
|
||||
lowercased.includes(keyword) ||
|
||||
lowercasedAliases.some((alias) =>
|
||||
alias.includes(keyword),
|
||||
),
|
||||
)
|
||||
) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const { lowercased, emoji } of emojis) {
|
||||
if (lowercased.startsWith(newQ)) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
for (const { lowercasedAliases, emoji } of emojis) {
|
||||
if (lowercasedAliases.some((alias) => alias.startsWith(newQ))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
for (const { lowercased, emoji } of emojis) {
|
||||
if (lowercased.includes(newQ)) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
for (const { lowercasedAliases, emoji } of emojis) {
|
||||
if (lowercasedAliases.some((alias) => alias.includes(newQ))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
const searchUnicode = () => {
|
||||
const max = 32;
|
||||
const emojis = emojilist;
|
||||
const matches = new Set<UnicodeEmojiDef>();
|
||||
|
||||
const exactMatch = emojis.find((emoji) => emoji.slug === newQ);
|
||||
if (exactMatch) matches.add(exactMatch);
|
||||
|
||||
if (newQ.includes(" ")) {
|
||||
// AND検索
|
||||
const keywords = newQ.split(" ");
|
||||
|
||||
// 名前にキーワードが含まれている
|
||||
for (const emoji of emojis) {
|
||||
if (keywords.every((keyword) => emoji.slug.includes(keyword))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
// 名前またはエイリアスにキーワードが含まれている
|
||||
for (const emoji of emojis) {
|
||||
if (
|
||||
keywords.every(
|
||||
(keyword) =>
|
||||
emoji.slug.includes(keyword) ||
|
||||
emoji.keywords?.some((alias) =>
|
||||
alias.includes(keyword),
|
||||
),
|
||||
)
|
||||
) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const emoji of emojis) {
|
||||
if (emoji.slug.startsWith(newQ)) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
for (const emoji of emojis) {
|
||||
if (
|
||||
emoji.keywords?.some((keyword) => keyword.startsWith(newQ))
|
||||
) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
for (const emoji of emojis) {
|
||||
if (emoji.slug.includes(newQ)) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
for (const emoji of emojis) {
|
||||
if (emoji.keywords?.some((keyword) => keyword.includes(newQ))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
searchResultCustom.value = Array.from(searchCustom());
|
||||
searchResultUnicode.value = Array.from(searchUnicode());
|
||||
});
|
||||
|
||||
function focus() {
|
||||
if (!["smartphone", "tablet"].includes(deviceKind) && !isTouchUsing) {
|
||||
search.value?.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (emojis.value) emojis.value.scrollTop = 0;
|
||||
q.value = "";
|
||||
}
|
||||
|
||||
function getKey(
|
||||
emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef,
|
||||
): string {
|
||||
return typeof emoji === "string" ? emoji : emoji.emoji || `:${emoji.name}:`;
|
||||
}
|
||||
|
||||
function chosen(emoji: any, ev?: MouseEvent) {
|
||||
const el =
|
||||
ev &&
|
||||
((ev.currentTarget ?? ev.target) as HTMLElement | null | undefined);
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + el.offsetWidth / 2;
|
||||
const y = rect.top + el.offsetHeight / 2;
|
||||
os.popup(Ripple, { x, y }, {}, "end");
|
||||
}
|
||||
|
||||
const key = getKey(emoji);
|
||||
emit("chosen", key, ev);
|
||||
|
||||
// 最近使った絵文字更新
|
||||
if (!pinned.value.includes(key)) {
|
||||
let recents = defaultStore.state.recentlyUsedEmojis;
|
||||
recents = recents.filter((emoji: any) => emoji !== key);
|
||||
recents.unshift(key);
|
||||
defaultStore.set("recentlyUsedEmojis", recents.splice(0, 32));
|
||||
}
|
||||
}
|
||||
|
||||
function paste(event: ClipboardEvent) {
|
||||
const paste = (event.clipboardData || window.clipboardData).getData("text");
|
||||
if (done(paste)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function done(query?: any): boolean | void {
|
||||
if (query == null) query = q.value;
|
||||
if (query == null || typeof query !== "string") return;
|
||||
|
||||
const q2 = query.replaceAll(":", "");
|
||||
const exactMatchCustom = customEmojis.find((emoji) => emoji.name === q2);
|
||||
if (exactMatchCustom) {
|
||||
chosen(exactMatchCustom);
|
||||
return true;
|
||||
}
|
||||
const exactMatchUnicode = emojilist.find(
|
||||
(emoji) => emoji.emoji === q2 || emoji.slug === q2,
|
||||
);
|
||||
if (exactMatchUnicode) {
|
||||
chosen(exactMatchUnicode);
|
||||
return true;
|
||||
}
|
||||
if (searchResultCustom.value.length > 0) {
|
||||
chosen(searchResultCustom.value[0]);
|
||||
return true;
|
||||
}
|
||||
if (searchResultUnicode.value.length > 0) {
|
||||
chosen(searchResultUnicode.value[0]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
focus();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
focus,
|
||||
reset,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.omfetrab {
|
||||
$pad: 8px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.s1 {
|
||||
--eachSize: 40px;
|
||||
}
|
||||
|
||||
&.s2 {
|
||||
--eachSize: 45px;
|
||||
}
|
||||
|
||||
&.s3 {
|
||||
--eachSize: 50px;
|
||||
}
|
||||
|
||||
&.w1 {
|
||||
width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
&.w2 {
|
||||
width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
&.w3 {
|
||||
width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
&.w4 {
|
||||
width: calc((var(--eachSize) * 8) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
&.w5 {
|
||||
width: calc((var(--eachSize) * 9) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
&.h1 {
|
||||
height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
&.h2 {
|
||||
height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
&.h3 {
|
||||
height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
&.h4 {
|
||||
height: calc((var(--eachSize) * 10) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
&.asDrawer {
|
||||
width: 100% !important;
|
||||
|
||||
> .emojis {
|
||||
::v-deep(section) {
|
||||
> header {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--columns);
|
||||
font-size: 30px;
|
||||
|
||||
> .item {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .search {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1em;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
|
||||
&:not(.filled) {
|
||||
order: 1;
|
||||
z-index: 2;
|
||||
box-shadow: 0px -1px 0 0px var(--divider);
|
||||
}
|
||||
}
|
||||
|
||||
> .tabs {
|
||||
display: flex;
|
||||
display: none;
|
||||
|
||||
> .tab {
|
||||
flex: 1;
|
||||
height: 38px;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
|
||||
&.active {
|
||||
border-top: solid 1px var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .emojis {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .group {
|
||||
&:not(.index) {
|
||||
padding: 4px 0 8px 0;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
|
||||
> header {
|
||||
/*position: sticky;
|
||||
top: 0;
|
||||
left: 0;*/
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
z-index: 2;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep(section) {
|
||||
> header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
z-index: 1;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
> .body {
|
||||
position: relative;
|
||||
padding: $pad;
|
||||
|
||||
> .item {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
width: var(--eachSize);
|
||||
height: var(--eachSize);
|
||||
contain: strict;
|
||||
border-radius: 4px;
|
||||
font-size: 24px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: solid 2px var(--focus);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accent);
|
||||
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
|
||||
}
|
||||
|
||||
> .emoji {
|
||||
height: 1.25em;
|
||||
vertical-align: -0.25em;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.result {
|
||||
border-bottom: solid 0.5px var(--divider);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<MkModal
|
||||
ref="modal"
|
||||
v-slot="{ type, maxHeight }"
|
||||
:z-priority="'middle'"
|
||||
:prefer-type="
|
||||
asReactionPicker &&
|
||||
defaultStore.state.reactionPickerUseDrawerForMobile === false
|
||||
? 'popup'
|
||||
: 'auto'
|
||||
"
|
||||
:transparent-bg="true"
|
||||
:manual-showing="manualShowing"
|
||||
:src="src"
|
||||
@click="checkForShift"
|
||||
@opening="opening"
|
||||
@close="emit('close')"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<MkEmojiPicker
|
||||
ref="picker"
|
||||
class="ryghynhb _popup _shadow"
|
||||
:class="{ drawer: type === 'drawer' }"
|
||||
:show-pinned="showPinned"
|
||||
:as-reaction-picker="asReactionPicker"
|
||||
:as-drawer="type === 'drawer'"
|
||||
:max-height="maxHeight"
|
||||
@chosen="chosen"
|
||||
/>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import MkModal from "@/components/MkModal.vue";
|
||||
import MkEmojiPicker from "@/components/MkEmojiPicker.vue";
|
||||
import { defaultStore } from "@/store";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
manualShowing?: boolean | null;
|
||||
src?: HTMLElement;
|
||||
showPinned?: boolean;
|
||||
asReactionPicker?: boolean;
|
||||
}>(),
|
||||
{
|
||||
manualShowing: null,
|
||||
showPinned: true,
|
||||
asReactionPicker: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "done", v: any): void;
|
||||
(ev: "close"): void;
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<InstanceType<typeof MkModal>>();
|
||||
const picker = ref<InstanceType<typeof MkEmojiPicker>>();
|
||||
|
||||
function checkForShift(ev?: MouseEvent) {
|
||||
if (ev?.shiftKey) return;
|
||||
modal.value?.close(ev);
|
||||
}
|
||||
|
||||
function chosen(emoji: any, ev: MouseEvent) {
|
||||
emit("done", emoji);
|
||||
checkForShift(ev);
|
||||
}
|
||||
|
||||
function opening() {
|
||||
try {
|
||||
picker.value?.reset();
|
||||
} catch (e) {
|
||||
console.error("Something's wrong with resetting the emoji picker", e);
|
||||
}
|
||||
picker.value?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ryghynhb {
|
||||
&.drawer {
|
||||
border-radius: 24px;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="windowEl"
|
||||
:width="980"
|
||||
:height="760"
|
||||
:scroll="false"
|
||||
@close="close"
|
||||
@closed="$emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
<span class="title">{{ file.name }}</span>
|
||||
</template>
|
||||
<div class="epubViewer">
|
||||
<div class="toolbar">
|
||||
<button class="_button control" type="button" @click="go('prev')">
|
||||
<i class="ph-caret-left ph-bold ph-lg"></i>
|
||||
</button>
|
||||
<div class="status">
|
||||
<span>{{ status }}</span>
|
||||
<progress v-if="progress != null" :value="progress" max="100"></progress>
|
||||
</div>
|
||||
<button class="_button control" type="button" @click="go('next')">
|
||||
<i class="ph-caret-right ph-bold ph-lg"></i>
|
||||
</button>
|
||||
<a
|
||||
v-if="file.allowDownload"
|
||||
class="_button control"
|
||||
:href="file.url"
|
||||
:download="file.name"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<i class="ph-download-simple ph-bold ph-lg"></i>
|
||||
</a>
|
||||
<a
|
||||
class="_button control"
|
||||
:href="file.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<i class="ph-arrow-square-out ph-bold ph-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
<iframe
|
||||
ref="frameEl"
|
||||
class="frame"
|
||||
:title="file.name"
|
||||
:sandbox="sandbox"
|
||||
:srcdoc="frameHtml"
|
||||
></iframe>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from "vue";
|
||||
import type * as misskey from "iceshrimp-sdk";
|
||||
import MkModalWindow from "@/components/MkModalWindow.vue";
|
||||
|
||||
const EPUBJS_CDN =
|
||||
"https://cdn.jsdelivr.net/npm/epubjs@0.3.93/dist/epub.min.js";
|
||||
|
||||
const props = defineProps<{
|
||||
file: misskey.entities.DriveFile;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(event: "closed"): void;
|
||||
}>();
|
||||
|
||||
const windowEl = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
const frameEl = ref<HTMLIFrameElement | null>(null);
|
||||
const status = ref("読み込み中");
|
||||
const progress = ref<number | null>(null);
|
||||
const loading = ref(false);
|
||||
const controller = new AbortController();
|
||||
const sandbox = [
|
||||
"allow-scripts",
|
||||
"allow-forms",
|
||||
"allow-popups",
|
||||
"allow-modals",
|
||||
"allow-downloads",
|
||||
].join(" ");
|
||||
|
||||
const frameHtml = computed(() => `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
html, body, #viewer {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
}
|
||||
#viewer {
|
||||
box-sizing: border-box;
|
||||
padding: 0 12px;
|
||||
}
|
||||
#message {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
font: 14px/1.6 system-ui, sans-serif;
|
||||
color: #555;
|
||||
background: #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
#message[hidden] {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message">EPUBビューアを読み込み中</div>
|
||||
<div id="viewer"></div>
|
||||
${"<scr" + "ipt"} src="${EPUBJS_CDN}">${"</scr" + "ipt"}
|
||||
${"<scr" + "ipt"}
|
||||
(() => {
|
||||
const message = document.getElementById("message");
|
||||
const viewer = document.getElementById("viewer");
|
||||
let rendition;
|
||||
let book;
|
||||
|
||||
function post(type, payload = {}) {
|
||||
parent.postMessage({ source: "mk-epub-viewer", type, ...payload }, "*");
|
||||
}
|
||||
|
||||
function show(text) {
|
||||
message.hidden = false;
|
||||
message.textContent = text;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
message.hidden = true;
|
||||
}
|
||||
|
||||
function reportProgress(location) {
|
||||
const cfi = location?.start?.cfi;
|
||||
if (!book?.locations || !cfi) return;
|
||||
const percentage = book.locations.percentageFromCfi(cfi);
|
||||
if (Number.isFinite(percentage)) {
|
||||
post("progress", { progress: Math.round(percentage * 1000) / 10 });
|
||||
}
|
||||
}
|
||||
|
||||
async function load(buffer) {
|
||||
try {
|
||||
if (!window.ePub) throw new Error("epub.js の読み込みに失敗しました");
|
||||
show("EPUBを開いています");
|
||||
book = window.ePub();
|
||||
await book.open(buffer, "binary");
|
||||
rendition = book.renderTo(viewer, {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
spread: "auto",
|
||||
flow: "paginated",
|
||||
allowScriptedContent: true,
|
||||
});
|
||||
rendition.on("relocated", reportProgress);
|
||||
await rendition.display();
|
||||
hide();
|
||||
post("loaded");
|
||||
book.ready
|
||||
.then(() => book.locations.generate(1200))
|
||||
.then(() => reportProgress(rendition.currentLocation()))
|
||||
.catch(() => {});
|
||||
} catch (err) {
|
||||
show(err?.message || String(err));
|
||||
post("error", { message: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
const data = event.data || {};
|
||||
if (data.source !== "mk-epub-viewer-host") return;
|
||||
if (data.type === "load") load(data.buffer);
|
||||
if (data.type === "next") rendition?.next();
|
||||
if (data.type === "prev") rendition?.prev();
|
||||
});
|
||||
|
||||
if (document.readyState === "complete") {
|
||||
post("ready");
|
||||
} else {
|
||||
window.addEventListener("load", () => post("ready"), { once: true });
|
||||
}
|
||||
})();
|
||||
${"</scr" + "ipt"}
|
||||
</body>
|
||||
</html>`);
|
||||
|
||||
async function loadBook() {
|
||||
if (loading.value) return;
|
||||
const frame = frameEl.value?.contentWindow;
|
||||
if (!frame) return;
|
||||
loading.value = true;
|
||||
status.value = "EPUBを取得中";
|
||||
try {
|
||||
const res = await fetch(props.file.url, {
|
||||
credentials: "omit",
|
||||
cache: "default",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
const buffer = await res.arrayBuffer();
|
||||
status.value = "EPUBを開いています";
|
||||
frame.postMessage(
|
||||
{
|
||||
source: "mk-epub-viewer-host",
|
||||
type: "load",
|
||||
buffer,
|
||||
},
|
||||
"*",
|
||||
[buffer],
|
||||
);
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
status.value = `読み込みに失敗しました: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent) {
|
||||
const data = event.data || {};
|
||||
if (data.source !== "mk-epub-viewer") return;
|
||||
if (data.type === "ready") loadBook();
|
||||
if (data.type === "loaded") status.value = "EPUB";
|
||||
if (data.type === "progress") {
|
||||
progress.value = data.progress;
|
||||
status.value = `EPUB ${data.progress}%`;
|
||||
}
|
||||
if (data.type === "error") status.value = `表示に失敗しました: ${data.message}`;
|
||||
}
|
||||
|
||||
function go(direction: "prev" | "next") {
|
||||
frameEl.value?.contentWindow?.postMessage(
|
||||
{
|
||||
source: "mk-epub-viewer-host",
|
||||
type: direction,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
|
||||
function close() {
|
||||
windowEl.value?.close();
|
||||
}
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
controller.abort();
|
||||
window.removeEventListener("message", onMessage);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.epubViewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr) 38px 38px 38px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.control {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 6px;
|
||||
background: var(--buttonBg);
|
||||
color: var(--fg);
|
||||
|
||||
&:hover {
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
font-size: 0.9em;
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
> progress {
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.frame {
|
||||
display: block;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<button
|
||||
v-tooltip.noDelay.bottom="isFavorited ? i18n.ts.unfavorite : i18n.ts.favorite"
|
||||
class="button _button"
|
||||
:class="{ favorited: isFavorited }"
|
||||
:disabled="busy"
|
||||
@click.stop="toggleFavorite()"
|
||||
>
|
||||
<i
|
||||
class="ph-bookmark-simple ph-bold ph-lg"
|
||||
:class="{ 'ph-fill': isFavorited }"
|
||||
></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import type { Note } from "iceshrimp-sdk/built/entities";
|
||||
import { $i } from "@/account";
|
||||
import { i18n } from "@/i18n";
|
||||
import * as os from "@/os";
|
||||
import { pleaseLogin } from "@/scripts/please-login";
|
||||
|
||||
const props = defineProps<{
|
||||
note: Note;
|
||||
}>();
|
||||
|
||||
const isFavorited = ref(false);
|
||||
const busy = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!$i) return;
|
||||
|
||||
const state = await os.api("notes/state", {
|
||||
noteId: props.note.id,
|
||||
});
|
||||
|
||||
isFavorited.value = state.isFavorited;
|
||||
});
|
||||
|
||||
async function toggleFavorite(): Promise<void> {
|
||||
pleaseLogin();
|
||||
if (busy.value) return;
|
||||
|
||||
busy.value = true;
|
||||
const favorite = !isFavorited.value;
|
||||
|
||||
try {
|
||||
await os.apiWithDialog(
|
||||
favorite ? "notes/favorites/create" : "notes/favorites/delete",
|
||||
{
|
||||
noteId: props.note.id,
|
||||
},
|
||||
);
|
||||
isFavorited.value = favorite;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.button {
|
||||
&.favorited {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="meta"
|
||||
class="xfbouadm"
|
||||
:style="{ backgroundImage: `url(${meta.backgroundImageUrl})` }"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import * as Misskey from "iceshrimp-sdk";
|
||||
import * as os from "@/os";
|
||||
|
||||
const meta = ref<Misskey.entities.DetailedInstanceMetadata>();
|
||||
|
||||
os.api("meta", { detail: true }).then((gotMeta) => {
|
||||
meta.value = gotMeta;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.xfbouadm {
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div>
|
||||
<MkPagination
|
||||
v-slot="{ items }"
|
||||
:pagination="pagination"
|
||||
class="urempief"
|
||||
:class="{ grid: viewMode === 'grid' }"
|
||||
>
|
||||
<MkA
|
||||
v-for="file in items"
|
||||
:key="file.id"
|
||||
v-tooltip.mfm="
|
||||
`${file.type}\n${bytes(file.size)}\n${new Date(
|
||||
file.createdAt,
|
||||
).toLocaleString()}\nby ${
|
||||
file.user ? '@' + Acct.toString(file.user) : 'system'
|
||||
}`
|
||||
"
|
||||
:to="`/admin/file/${file.id}`"
|
||||
class="file _button"
|
||||
>
|
||||
<div v-if="file.isSensitive" class="sensitive-label">
|
||||
{{ i18n.ts.sensitive }}
|
||||
</div>
|
||||
<MkDriveFileThumbnail
|
||||
class="thumbnail"
|
||||
:file="file"
|
||||
fit="contain"
|
||||
/>
|
||||
<div v-if="viewMode === 'list'" class="body">
|
||||
<div>
|
||||
<small style="opacity: 0.7">{{ file.name }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<MkAcct v-if="file.user" :user="file.user" />
|
||||
<div v-else>{{ i18n.ts.system }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="margin-right: 1em">{{ file.type }}</span>
|
||||
<span>{{ bytes(file.size) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
>{{ i18n.ts.registeredDate }}:
|
||||
<MkTime :time="file.createdAt" mode="detail"
|
||||
/></span>
|
||||
</div>
|
||||
</div>
|
||||
</MkA>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import * as Acct from "iceshrimp-sdk/built/acct";
|
||||
import MkSwitch from "@/components/ui/switch.vue";
|
||||
import MkPagination from "@/components/MkPagination.vue";
|
||||
import MkDriveFileThumbnail from "@/components/MkDriveFileThumbnail.vue";
|
||||
import bytes from "@/filters/bytes";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
pagination: any;
|
||||
viewMode: "grid" | "list";
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@keyframes sensitive-blink {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.urempief {
|
||||
margin-top: var(--margin);
|
||||
|
||||
&.list {
|
||||
> .file {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
margin-left: 0.3em;
|
||||
padding: 8px;
|
||||
flex: 1;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
grid-gap: 12px;
|
||||
margin: var(--margin) 0;
|
||||
|
||||
> .file {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
|
||||
> .thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
> .sensitive-label {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 2px 4px;
|
||||
background: #ff0000bf;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 85%;
|
||||
animation: sensitive-blink 1s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<span class="mk-file-type-icon">
|
||||
<template v-if="kind == 'image'"
|
||||
><i class="ph-file-image ph-bold ph-lg"></i
|
||||
></template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
type: string;
|
||||
}>();
|
||||
|
||||
const kind = computed(() => props.type.split("/")[0]);
|
||||
</script>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<section class="ssazuxis">
|
||||
<header class="_button" @click="showBody = !showBody">
|
||||
<div class="title"><slot name="header"></slot></div>
|
||||
<div class="divider"></div>
|
||||
<button
|
||||
class="_button"
|
||||
:aria-expanded="showBody"
|
||||
:aria-controls="bodyId"
|
||||
>
|
||||
<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>
|
||||
</header>
|
||||
<transition
|
||||
:name="$store.state.animation ? 'folder-toggle' : ''"
|
||||
@enter="enter"
|
||||
@after-enter="afterEnter"
|
||||
@leave="leave"
|
||||
@after-leave="afterLeave"
|
||||
>
|
||||
<div v-show="showBody" :id="bodyId">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { getUniqueId } from "@/os";
|
||||
|
||||
const localStoragePrefix = "ui:folder:";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
persistKey: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bodyId: getUniqueId(),
|
||||
showBody:
|
||||
this.persistKey &&
|
||||
localStorage.getItem(localStoragePrefix + this.persistKey)
|
||||
? localStorage.getItem(
|
||||
localStoragePrefix + this.persistKey,
|
||||
) === "t"
|
||||
: this.expanded,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
showBody() {
|
||||
if (this.persistKey) {
|
||||
localStorage.setItem(
|
||||
localStoragePrefix + this.persistKey,
|
||||
this.showBody ? "t" : "f",
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleContent(show: boolean) {
|
||||
this.showBody = show;
|
||||
},
|
||||
|
||||
enter(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = 0;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = elementHeight + "px";
|
||||
},
|
||||
afterEnter(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
leave(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = elementHeight + "px";
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = 0;
|
||||
},
|
||||
afterLeave(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.folder-toggle-enter-active,
|
||||
.folder-toggle-leave-active {
|
||||
overflow-y: hidden;
|
||||
transition:
|
||||
opacity 0.5s,
|
||||
height 0.5s !important;
|
||||
}
|
||||
.folder-toggle-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.folder-toggle-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ssazuxis {
|
||||
position: relative;
|
||||
|
||||
> header {
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
position: sticky;
|
||||
top: var(--stickyTop, 0px);
|
||||
padding: var(--x-padding);
|
||||
-webkit-backdrop-filter: var(--blur, blur(8px));
|
||||
backdrop-filter: var(--blur, blur(20px));
|
||||
margin-inline: -12px;
|
||||
padding-inline: 12px;
|
||||
mask: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 12px calc(100% - 12px),
|
||||
transparent
|
||||
);
|
||||
-webkit-mask: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 12px calc(100% - 12px),
|
||||
transparent
|
||||
);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg);
|
||||
opacity: 0.85;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
> .title {
|
||||
margin: 0;
|
||||
padding: 12px 16px 12px 0;
|
||||
|
||||
> i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .divider {
|
||||
flex: 1;
|
||||
margin: auto;
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
> button {
|
||||
padding: 12px 0 12px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="!hideMenu"
|
||||
class="_buttonIcon _button"
|
||||
@click.stop="menu"
|
||||
v-tooltip="i18n.ts.menu"
|
||||
>
|
||||
<i class="ph-dots-three-outline ph-bold ph-lg"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="$i != null && $i.id != user.id"
|
||||
class="kpoogebi _button follow-button"
|
||||
:class="{
|
||||
wait,
|
||||
active: isFollowing || hasPendingFollowRequestFromYou,
|
||||
full,
|
||||
large,
|
||||
blocking: isBlocking,
|
||||
}"
|
||||
:disabled="wait"
|
||||
@click.stop="onClick"
|
||||
:aria-label="`${state} ${user.name || user.username}`"
|
||||
v-tooltip="full ? null : `${state} ${user.name || user.username}`"
|
||||
>
|
||||
<template v-if="!wait">
|
||||
<template v-if="isBlocking">
|
||||
<span>{{ (state = i18n.ts.blocked) }}</span
|
||||
><i class="ph-prohibit ph-bold ph-lg"></i>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="hasPendingFollowRequestFromYou && user.isLocked"
|
||||
>
|
||||
<span>{{ (state = i18n.ts.followRequestPending) }}</span
|
||||
><i class="ph-hourglass-medium ph-bold ph-lg"></i>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"
|
||||
>
|
||||
<!-- つまりリモートフォローの場合。 -->
|
||||
<span>{{ (state = i18n.ts.processing) }}</span
|
||||
><i class="ph-circle-notch ph-bold ph-lg fa-pulse"></i>
|
||||
</template>
|
||||
<template v-else-if="isFollowing">
|
||||
<span>{{ (state = i18n.ts.unfollow) }}</span
|
||||
><i class="ph-minus ph-bold ph-lg"></i>
|
||||
</template>
|
||||
<template v-else-if="!isFollowing && user.isLocked">
|
||||
<span>{{ (state = i18n.ts.followRequest) }}</span
|
||||
><i class="ph-lock-open ph-bold ph-lg"></i>
|
||||
</template>
|
||||
<template v-else-if="!isFollowing && !user.isLocked">
|
||||
<span>{{ (state = i18n.ts.follow) }}</span
|
||||
><i class="ph-plus ph-bold ph-lg"></i>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ (state = i18n.ts.processing) }}</span
|
||||
><i class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"></i>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import type * as Misskey from "iceshrimp-sdk";
|
||||
import * as os from "@/os";
|
||||
import { stream } from "@/stream";
|
||||
import { i18n } from "@/i18n";
|
||||
import { $i } from "@/account";
|
||||
import { getUserMenu } from "@/scripts/get-user-menu";
|
||||
import { useRouter } from "@/router";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const emit = defineEmits(["refresh"]);
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
user: Misskey.entities.UserDetailed;
|
||||
full?: boolean;
|
||||
large?: boolean;
|
||||
hideMenu?: boolean;
|
||||
}>(),
|
||||
{
|
||||
full: false,
|
||||
large: false,
|
||||
},
|
||||
);
|
||||
|
||||
const isBlocking = computed(() => props.user.isBlocking);
|
||||
|
||||
let state = ref(i18n.ts.processing);
|
||||
|
||||
let isFollowing = ref(props.user.isFollowing);
|
||||
let hasPendingFollowRequestFromYou = ref(
|
||||
props.user.hasPendingFollowRequestFromYou,
|
||||
);
|
||||
let wait = ref(false);
|
||||
const connection = stream.useChannel("main");
|
||||
|
||||
if (props.user.isFollowing == null) {
|
||||
os.api("users/show", {
|
||||
userId: props.user.id,
|
||||
}).then(onFollowChange);
|
||||
}
|
||||
|
||||
function onFollowChange(user: Misskey.entities.UserDetailed) {
|
||||
if (user.id === props.user.id) {
|
||||
isFollowing.value = user.isFollowing;
|
||||
hasPendingFollowRequestFromYou.value = user.hasPendingFollowRequestFromYou;
|
||||
}
|
||||
}
|
||||
|
||||
async function onClick() {
|
||||
wait.value = true;
|
||||
|
||||
try {
|
||||
if (isBlocking.value) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: "warning",
|
||||
text: i18n.t("unblockConfirm"),
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
await os.api("blocking/delete", {
|
||||
userId: props.user.id,
|
||||
});
|
||||
if (props.user.isMuted) {
|
||||
await os.api("mute/delete", {
|
||||
userId: props.user.id,
|
||||
});
|
||||
}
|
||||
emit("refresh");
|
||||
} else if (isFollowing.value) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: "warning",
|
||||
text: i18n.t("unfollowConfirm", {
|
||||
name: props.user.name || props.user.username,
|
||||
}),
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
await os.api("following/delete", {
|
||||
userId: props.user.id,
|
||||
});
|
||||
} else {
|
||||
if (hasPendingFollowRequestFromYou.value) {
|
||||
await os.api("following/requests/cancel", {
|
||||
userId: props.user.id,
|
||||
});
|
||||
hasPendingFollowRequestFromYou.value = false;
|
||||
} else {
|
||||
await os.api("following/create", {
|
||||
userId: props.user.id,
|
||||
});
|
||||
hasPendingFollowRequestFromYou.value = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
wait.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function menu(ev) {
|
||||
os.popupMenu(
|
||||
getUserMenu(props.user, router),
|
||||
ev.currentTarget ?? ev.target,
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connection.on("follow", onFollowChange);
|
||||
connection.on("unfollow", onFollowChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
connection.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.follow-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
border: solid 1px var(--accent);
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
border-radius: 100px;
|
||||
background: var(--bg);
|
||||
vertical-align: middle;
|
||||
margin-left: 0.5em;
|
||||
|
||||
&.full {
|
||||
padding: 0.2em 0.7em;
|
||||
width: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&.large {
|
||||
font-size: 16px;
|
||||
height: 38px;
|
||||
padding: 0 12px 0 16px;
|
||||
}
|
||||
|
||||
&:not(.full) {
|
||||
width: 31px;
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
border: 2px solid var(--focus);
|
||||
border-radius: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
//background: mix($primary, #fff, 20);
|
||||
}
|
||||
|
||||
&:active {
|
||||
//background: mix($primary, #fff, 40);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--fgOnAccent);
|
||||
background: var(--accent);
|
||||
|
||||
&:hover {
|
||||
background: var(--accentLighten);
|
||||
border-color: var(--accentLighten);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken);
|
||||
border-color: var(--accentDarken);
|
||||
}
|
||||
}
|
||||
|
||||
&.wait {
|
||||
cursor: wait !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> span {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.blocking {
|
||||
background-color: var(--bg) !important;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<XModalWindow
|
||||
ref="dialog"
|
||||
:width="370"
|
||||
:height="400"
|
||||
@close="dialog.close()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.forgotPassword }}</template>
|
||||
|
||||
<form
|
||||
v-if="instance.enableEmail"
|
||||
class="bafeceda"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<div class="main _formRoot">
|
||||
<MkInput
|
||||
v-model="username"
|
||||
class="_formBlock"
|
||||
type="text"
|
||||
pattern="^[a-zA-Z0-9_]+$"
|
||||
:spellcheck="false"
|
||||
autofocus
|
||||
required
|
||||
>
|
||||
<template #label>{{ i18n.ts.username }}</template>
|
||||
<template #prefix>@</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput
|
||||
v-model="email"
|
||||
class="_formBlock"
|
||||
type="email"
|
||||
:spellcheck="false"
|
||||
required
|
||||
>
|
||||
<template #label>{{ i18n.ts.emailAddress }}</template>
|
||||
<template #caption>{{
|
||||
i18n.ts._forgotPassword.enterEmail
|
||||
}}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkButton
|
||||
class="_formBlock"
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
primary
|
||||
style="margin: 0 auto"
|
||||
>{{ i18n.ts.send }}</MkButton
|
||||
>
|
||||
</div>
|
||||
<div class="sub">
|
||||
<MkA to="/about" class="_link">{{
|
||||
i18n.ts._forgotPassword.ifNoEmail
|
||||
}}</MkA>
|
||||
</div>
|
||||
</form>
|
||||
<div v-else class="bafecedb">
|
||||
{{ i18n.ts._forgotPassword.contactAdmin }}
|
||||
</div>
|
||||
</XModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {} from "vue";
|
||||
import XModalWindow from "@/components/MkModalWindow.vue";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import MkInput from "@/components/form/input.vue";
|
||||
import * as os from "@/os";
|
||||
import { instance } from "@/instance";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "done"): void;
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
let dialog: InstanceType<typeof XModalWindow> = ref();
|
||||
|
||||
let username = ref("");
|
||||
let email = ref("");
|
||||
let processing = ref(false);
|
||||
|
||||
async function onSubmit() {
|
||||
processing.value = true;
|
||||
await os.apiWithDialog("request-reset-password", {
|
||||
username: username.value,
|
||||
email: email.value,
|
||||
});
|
||||
emit("done");
|
||||
dialog.value.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bafeceda {
|
||||
> .main {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
> .sub {
|
||||
border-top: solid 0.5px var(--divider);
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.bafecedb {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<XModalWindow
|
||||
ref="dialog"
|
||||
:width="450"
|
||||
:can-close="false"
|
||||
:with-ok-button="true"
|
||||
:ok-button-disabled="false"
|
||||
@click="cancel()"
|
||||
@ok="ok()"
|
||||
@close="cancel()"
|
||||
@closed="$emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
{{ title }}
|
||||
</template>
|
||||
|
||||
<MkSpacer :margin-min="20" :margin-max="32">
|
||||
<div class="xkpnjxcv _formRoot">
|
||||
<template
|
||||
v-for="item in Object.keys(form).filter(
|
||||
(item) => !form[item].hidden,
|
||||
)"
|
||||
>
|
||||
<FormInput
|
||||
v-if="form[item].type === 'number'"
|
||||
v-model="values[item]"
|
||||
type="number"
|
||||
:step="form[item].step || 1"
|
||||
class="_formBlock"
|
||||
>
|
||||
<template #label
|
||||
><span v-text="form[item].label || item"></span
|
||||
><span v-if="form[item].required === false">
|
||||
({{ i18n.ts.optional }})</span
|
||||
></template
|
||||
>
|
||||
<template v-if="form[item].description" #caption>{{
|
||||
form[item].description
|
||||
}}</template>
|
||||
</FormInput>
|
||||
<FormInput
|
||||
v-else-if="
|
||||
form[item].type === 'string' &&
|
||||
!form[item].multiline
|
||||
"
|
||||
v-model="values[item]"
|
||||
type="text"
|
||||
class="_formBlock"
|
||||
>
|
||||
<template #label
|
||||
><span v-text="form[item].label || item"></span
|
||||
><span v-if="form[item].required === false">
|
||||
({{ i18n.ts.optional }})</span
|
||||
></template
|
||||
>
|
||||
<template v-if="form[item].description" #caption>{{
|
||||
form[item].description
|
||||
}}</template>
|
||||
</FormInput>
|
||||
<FormTextarea
|
||||
v-else-if="
|
||||
form[item].type === 'string' && form[item].multiline
|
||||
"
|
||||
v-model="values[item]"
|
||||
class="_formBlock"
|
||||
>
|
||||
<template #label
|
||||
><span v-text="form[item].label || item"></span
|
||||
><span v-if="form[item].required === false">
|
||||
({{ 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>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import FormInput from "./form/input.vue";
|
||||
import FormTextarea from "./form/textarea.vue";
|
||||
import FormSwitch from "./form/switch.vue";
|
||||
import FormSelect from "./form/select.vue";
|
||||
import FormRange from "./form/range.vue";
|
||||
import MkButton from "./MkButton.vue";
|
||||
import FormRadios from "./form/radios.vue";
|
||||
import XModalWindow from "@/components/MkModalWindow.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
XModalWindow,
|
||||
FormInput,
|
||||
FormTextarea,
|
||||
FormSwitch,
|
||||
FormSelect,
|
||||
FormRange,
|
||||
MkButton,
|
||||
FormRadios,
|
||||
},
|
||||
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["done"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
values: {},
|
||||
i18n,
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
for (const item in this.form) {
|
||||
this.values[item] = this.form[item].default ?? null;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
ok() {
|
||||
this.$emit("done", {
|
||||
result: this.values,
|
||||
});
|
||||
this.$refs.dialog.close();
|
||||
},
|
||||
|
||||
cancel() {
|
||||
this.$emit("done", {
|
||||
canceled: true,
|
||||
});
|
||||
this.$refs.dialog.close();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.xkpnjxcv {
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<XFormula :formula="formula" :block="block" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, defineAsyncComponent } from "vue";
|
||||
import * as os from "@/os";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
XFormula: defineAsyncComponent(
|
||||
() => import("@/components/MkFormulaCore.vue"),
|
||||
),
|
||||
},
|
||||
props: {
|
||||
formula: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div v-if="block" v-html="compiledFormula"></div>
|
||||
<span v-else v-html="compiledFormula"></span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import katex from "katex";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
formula: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
compiledFormula(): any {
|
||||
const katexString = katex.renderToString(this.formula, {
|
||||
throwOnError: false,
|
||||
} as any);
|
||||
return this.block
|
||||
? `<div style="text-align:center">${katexString}</div>`
|
||||
: katexString;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "katex/dist/katex.min.css";
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel">
|
||||
<div class="thumbnail">
|
||||
<ImgWithBlurhash
|
||||
class="img"
|
||||
:src="post.files[0].thumbnailUrl"
|
||||
:hash="post.files[0].blurhash"
|
||||
/>
|
||||
</div>
|
||||
<article>
|
||||
<header>
|
||||
<MkAvatar :user="post.user" class="avatar" />
|
||||
</header>
|
||||
<footer>
|
||||
<span class="title">{{ post.title }}</span>
|
||||
</footer>
|
||||
</article>
|
||||
</MkA>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {} from "vue";
|
||||
import { userName } from "@/filters/user";
|
||||
import ImgWithBlurhash from "@/components/MkImgWithBlurhash.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
post: any;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ttasepnz {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 200px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
|
||||
> .thumbnail {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
> article {
|
||||
> footer {
|
||||
&:before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
transition: all 0.5s ease;
|
||||
|
||||
> .img {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
> article {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
|
||||
> .avatar {
|
||||
margin-left: auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 8px var(--shadow);
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(rgba(0, 0, 0, 0.4), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
> .title {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="mk-google" @click.stop>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { i18n } from "@/i18n";
|
||||
import { searchEngine } from "@/config"
|
||||
|
||||
const props = defineProps<{
|
||||
q: string;
|
||||
}>();
|
||||
|
||||
const query = ref(props.q);
|
||||
|
||||
const search = () => {
|
||||
window.open(
|
||||
`${searchEngine}${query.value}`,
|
||||
"_blank",
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-google {
|
||||
display: flex;
|
||||
margin: 8px 0;
|
||||
|
||||
> input {
|
||||
flex-shrink: 1;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
border: solid 1px var(--divider);
|
||||
border-radius: 4px 0 0 4px;
|
||||
-webkit-appearance: none;
|
||||
-webkit-border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
> button {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
border: solid 1px var(--divider);
|
||||
border-left: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 2px 4px rgba(#000, 0.15) inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<MkA class="mention" :to="`/@@${username}`" @click.stop>
|
||||
<img
|
||||
v-if="group?.iconUrl"
|
||||
class="icon"
|
||||
:src="group.iconUrl"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span v-else class="icon placeholder">
|
||||
<i class="ph-users-three ph-bold"></i>
|
||||
</span>
|
||||
<span class="main">
|
||||
<span class="username">@@{{ username }}</span>
|
||||
</span>
|
||||
</MkA>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import MkA from "@/components/global/MkA.vue";
|
||||
import * as os from "@/os";
|
||||
|
||||
const props = defineProps<{
|
||||
username: string;
|
||||
}>();
|
||||
|
||||
const group = ref<any>(null);
|
||||
|
||||
async function fetchGroup() {
|
||||
group.value = null;
|
||||
try {
|
||||
group.value = await os.api("users/groups/show-by-username", {
|
||||
username: props.username,
|
||||
});
|
||||
} catch {
|
||||
group.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.username, fetchGroup);
|
||||
onMounted(fetchGroup);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mention {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 2px 8px 2px 2px;
|
||||
margin-block: 2px;
|
||||
border-radius: 999px;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--mention);
|
||||
isolation: isolate;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--mention);
|
||||
opacity: 0.1;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
object-fit: cover;
|
||||
margin: 0 0.2em 0 0;
|
||||
vertical-align: bottom;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
> .placeholder {
|
||||
background: var(--panelHighlight);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div ref="rootEl">
|
||||
<MkLoading v-if="fetching" />
|
||||
<div v-else>
|
||||
<canvas ref="chartEl"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick, watch, shallowRef, ref } from 'vue';
|
||||
import { Chart } from "chart.js";
|
||||
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";
|
||||
import { $i } from "@/account";
|
||||
|
||||
initChart();
|
||||
|
||||
const props = defineProps<{
|
||||
src: string;
|
||||
}>();
|
||||
|
||||
const rootEl = shallowRef<HTMLDivElement>(null);
|
||||
const chartEl = shallowRef<HTMLCanvasElement>(null);
|
||||
const now = new Date();
|
||||
let chartInstance: Chart = null;
|
||||
let fetching = ref(true);
|
||||
|
||||
const { handler: externalTooltipHandler } = useChartTooltip({
|
||||
position: "middle",
|
||||
});
|
||||
|
||||
const addArrays = (arr1: number[], arr2: number[], arr3: number[]) =>
|
||||
arr1.length === arr2.length && arr2.length === arr3.length
|
||||
? arr1.map((val, i) => val + arr2[i] + arr3[i])
|
||||
: null;
|
||||
|
||||
async function renderChart() {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
}
|
||||
|
||||
const wide = rootEl.value.offsetWidth > 700;
|
||||
const narrow = rootEl.value.offsetWidth < 400;
|
||||
|
||||
const weeks = wide ? 50 : narrow ? 10 : 25;
|
||||
const chartLimit = 7 * weeks;
|
||||
|
||||
const getDate = (ago: number) => {
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth();
|
||||
const d = now.getDate();
|
||||
|
||||
return new Date(y, m, d - ago);
|
||||
};
|
||||
|
||||
const format = (arr) => {
|
||||
return arr.map((v, i) => {
|
||||
const dt = getDate(i);
|
||||
const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, "0")}-${dt.getDate().toString().padStart(2, "0")}`;
|
||||
return {
|
||||
x: iso,
|
||||
y: dt.getDay(),
|
||||
d: iso,
|
||||
v,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
let values;
|
||||
|
||||
if (props.src === "active-users") {
|
||||
const raw = await os.api("charts/active-users", {
|
||||
limit: chartLimit,
|
||||
span: "day",
|
||||
});
|
||||
values = raw.readWrite;
|
||||
} else if (props.src === "notes") {
|
||||
const raw = await os.api("charts/notes", {
|
||||
limit: chartLimit,
|
||||
span: "day",
|
||||
});
|
||||
values = raw.local.inc;
|
||||
} else if (props.src === "ap-requests-inbox-received") {
|
||||
const raw = await os.api("charts/ap-request", {
|
||||
limit: chartLimit,
|
||||
span: "day",
|
||||
});
|
||||
values = raw.inboxReceived;
|
||||
} else if (props.src === "ap-requests-deliver-succeeded") {
|
||||
const raw = await os.api("charts/ap-request", {
|
||||
limit: chartLimit,
|
||||
span: "day",
|
||||
});
|
||||
values = raw.deliverSucceeded;
|
||||
} else if (props.src === "ap-requests-deliver-failed") {
|
||||
const raw = await os.api("charts/ap-request", {
|
||||
limit: chartLimit,
|
||||
span: "day",
|
||||
});
|
||||
values = raw.deliverFailed;
|
||||
} else if (props.src === "my-notes") {
|
||||
const raw = await os.api("charts/user/notes", {
|
||||
limit: chartLimit,
|
||||
span: "day",
|
||||
userId: $i.id,
|
||||
});
|
||||
values = addArrays(raw.diffs.normal, raw.diffs.reply, raw.diffs.renote);
|
||||
}
|
||||
|
||||
fetching.value = false;
|
||||
|
||||
await nextTick();
|
||||
|
||||
const color = defaultStore.state.darkMode ? "#ebbcba" : "#d7827e";
|
||||
|
||||
// 視覚上の分かりやすさのため上から最も大きい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 marginEachCell = 4;
|
||||
|
||||
chartInstance = new Chart(chartEl.value, {
|
||||
type: "matrix",
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: "Read & Write",
|
||||
data: format(values),
|
||||
pointRadius: 0,
|
||||
borderWidth: 0,
|
||||
borderJoinStyle: "round",
|
||||
borderRadius: 3,
|
||||
backgroundColor(c) {
|
||||
const value = c.dataset.data[c.dataIndex].v;
|
||||
let a = (value - min) / max;
|
||||
if (value !== 0) {
|
||||
// 0でない限りは完全に不可視にはしない
|
||||
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;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
aspectRatio: wide ? 6 : narrow ? 1.8 : 3.2,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 8,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
offset: true,
|
||||
position: "bottom",
|
||||
time: {
|
||||
unit: "week",
|
||||
round: "week",
|
||||
isoWeekday: 0,
|
||||
displayFormats: {
|
||||
day: "M/d",
|
||||
month: "Y/M",
|
||||
week: "M/d",
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
display: true,
|
||||
maxRotation: 0,
|
||||
autoSkipPadding: 8,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
offset: true,
|
||||
reverse: true,
|
||||
position: "right",
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
padding: 1,
|
||||
font: {
|
||||
size: 9,
|
||||
},
|
||||
callback: (value, index, values) =>
|
||||
["", "Mon", "", "Wed", "", "Fri", ""][value],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
callbacks: {
|
||||
title(context) {
|
||||
const v =
|
||||
context[0].dataset.data[context[0].dataIndex];
|
||||
return v.d;
|
||||
},
|
||||
label(context) {
|
||||
const v = context.dataset.data[context.dataIndex];
|
||||
return ["Active: " + v.v];
|
||||
},
|
||||
},
|
||||
//mode: 'index',
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
external: externalTooltipHandler,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
fetching.value = true;
|
||||
renderChart();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
renderChart();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<MkModal
|
||||
ref="modal"
|
||||
:z-priority="'middle'"
|
||||
@click="modal.close()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<div class="xubzgfga">
|
||||
<header>{{ image.name }}</header>
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="image.comment"
|
||||
:title="image.comment"
|
||||
@click="modal.close()"
|
||||
/>
|
||||
<footer>
|
||||
<span>{{ image.type }}</span>
|
||||
<span>{{ bytes(image.size) }}</span>
|
||||
<span v-if="image.properties && image.properties.width"
|
||||
>{{ number(image.properties.width) }}px ×
|
||||
{{ number(image.properties.height) }}px</span
|
||||
>
|
||||
</footer>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {} from "vue";
|
||||
import type * as misskey from "iceshrimp-sdk";
|
||||
import bytes from "@/filters/bytes";
|
||||
import number from "@/filters/number";
|
||||
import MkModal from "@/components/MkModal.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
image: misskey.entities.DriveFile;
|
||||
}>(),
|
||||
{},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<InstanceType<typeof MkModal>>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.xubzgfga {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
> header,
|
||||
> footer {
|
||||
align-self: center;
|
||||
display: inline-block;
|
||||
padding: 6px 9px;
|
||||
font-size: 90%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> header {
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
> img {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
cursor: zoom-out;
|
||||
image-orientation: from-image;
|
||||
}
|
||||
|
||||
> footer {
|
||||
margin-top: 8px;
|
||||
opacity: 0.8;
|
||||
|
||||
> span + span {
|
||||
margin-left: 0.5em;
|
||||
padding-left: 0.5em;
|
||||
border-left: solid 1px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<canvas
|
||||
v-if="!loaded"
|
||||
ref="canvas"
|
||||
:width="size"
|
||||
:height="size"
|
||||
:title="title"
|
||||
/>
|
||||
<img
|
||||
v-if="src"
|
||||
:src="src"
|
||||
:title="title"
|
||||
:type="type"
|
||||
:alt="alt"
|
||||
:class="{ cover }"
|
||||
:style="{ 'object-fit': cover ? 'cover' : null }"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@load="onLoad"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { decodeBlurHash } from "fast-blurhash";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src?: string | null;
|
||||
hash?: string;
|
||||
alt?: string;
|
||||
type?: string | null;
|
||||
title?: string | null;
|
||||
size?: number;
|
||||
cover?: boolean;
|
||||
}>(),
|
||||
{
|
||||
src: null,
|
||||
type: null,
|
||||
alt: "",
|
||||
title: null,
|
||||
size: 64,
|
||||
cover: true,
|
||||
},
|
||||
);
|
||||
|
||||
const canvas = ref<HTMLCanvasElement>();
|
||||
let loaded = ref(false);
|
||||
|
||||
function draw() {
|
||||
if (props.hash == null || canvas.value == null) return;
|
||||
const pixels = decodeBlurHash(props.hash, props.size, props.size);
|
||||
const ctx = canvas.value.getContext("2d");
|
||||
const imageData = ctx!.createImageData(props.size, props.size);
|
||||
imageData.data.set(pixels);
|
||||
ctx!.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function onLoad() {
|
||||
loaded.value = true;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
draw();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
canvas,
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div v-if="visible" class="info" :class="{ warn, card }">
|
||||
<i v-if="warn" class="ph-warning ph-bold ph-lg"></i>
|
||||
<i
|
||||
v-else
|
||||
class="ph-bold ph-lg"
|
||||
:class="icon ? `ph-${icon}` : 'ph-info'"
|
||||
></i>
|
||||
<slot></slot>
|
||||
<button
|
||||
v-if="closeable"
|
||||
v-tooltip="i18n.ts.close"
|
||||
class="_buttonIcon close"
|
||||
@click.stop="close"
|
||||
:aria-label="i18n.t('close')"
|
||||
>
|
||||
<i class="ph-x ph-bold ph-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const visible = ref(true);
|
||||
|
||||
defineProps<{
|
||||
icon?: string;
|
||||
warn?: boolean;
|
||||
card?: boolean;
|
||||
closeable?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "close"): void;
|
||||
}>();
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
emit("close");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.info {
|
||||
padding: 16px;
|
||||
font-size: 90%;
|
||||
background: var(--infoBg);
|
||||
color: var(--infoFg);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
|
||||
&.warn {
|
||||
background: var(--infoWarnBg);
|
||||
color: var(--infoWarnFg);
|
||||
}
|
||||
|
||||
&.card {
|
||||
display: block;
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
padding: 48px;
|
||||
font-size: 1em;
|
||||
text-align: center;
|
||||
> i {
|
||||
display: block;
|
||||
font-size: 4em;
|
||||
margin: 0;
|
||||
}
|
||||
> :deep(*) {
|
||||
margin-inline: auto;
|
||||
}
|
||||
> :deep(:not(:last-child)) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
> :deep(p) {
|
||||
max-width: 30em;
|
||||
}
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
> .close {
|
||||
margin-left: auto;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
$style.root,
|
||||
{
|
||||
yellow: instance.isNotResponding,
|
||||
red: instance.isBlocked,
|
||||
purple: instance.isSilenced,
|
||||
gray: instance.isSuspended,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<img class="icon" :src="getInstanceIcon(instance)" alt="" />
|
||||
<div class="body">
|
||||
<span class="host">{{ instance.name ?? instance.host }}</span>
|
||||
<span class="sub _monospace"
|
||||
><b>{{ instance.host }}</b> /
|
||||
{{ instance.softwareName || "?" }}
|
||||
{{ instance.softwareVersion }}</span
|
||||
>
|
||||
</div>
|
||||
<MkMiniChart v-if="chartValues" class="chart" :src="chartValues" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import * as iceshrimp from "iceshrimp-sdk";
|
||||
import MkMiniChart from "@/components/MkMiniChart.vue";
|
||||
import * as os from "@/os";
|
||||
import { getProxiedImageUrlNullable } from "@/scripts/media-proxy";
|
||||
|
||||
const props = defineProps<{
|
||||
instance: iceshrimp.entities.Instance;
|
||||
}>();
|
||||
|
||||
let chartValues = ref<number[] | null>(null);
|
||||
|
||||
os.apiGet("charts/instance", {
|
||||
host: props.instance.host,
|
||||
limit: 16 + 1,
|
||||
span: "day",
|
||||
}).then((res) => {
|
||||
// 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く
|
||||
res.requests.received.splice(0, 1);
|
||||
chartValues.value = res.requests.received;
|
||||
});
|
||||
|
||||
function getInstanceIcon(instance): string {
|
||||
return (
|
||||
getProxiedImageUrlNullable(instance.iconUrl, "preview") ??
|
||||
getProxiedImageUrlNullable(instance.faviconUrl, "preview") ??
|
||||
"/client-assets/dummy.png"
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
$bodyTitleHieght: 18px;
|
||||
$bodyInfoHieght: 16px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
|
||||
> :global(.icon) {
|
||||
display: block;
|
||||
width: ($bodyTitleHieght + $bodyInfoHieght);
|
||||
height: ($bodyTitleHieght + $bodyInfoHieght);
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
> :global(.body) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
font-size: 0.9em;
|
||||
color: var(--fg);
|
||||
padding-right: 8px;
|
||||
|
||||
> :global(.host) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: $bodyTitleHieght;
|
||||
}
|
||||
|
||||
> :global(.sub) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
line-height: $bodyInfoHieght;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
> :global(.chart) {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
&:global(.yellow) {
|
||||
--c: rgb(255 196 0 / 15%);
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
var(--c) 16.67%,
|
||||
transparent 16.67%,
|
||||
transparent 50%,
|
||||
var(--c) 50%,
|
||||
var(--c) 66.67%,
|
||||
transparent 66.67%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
&:global(.red) {
|
||||
--c: rgb(255 0 0 / 15%);
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
var(--c) 16.67%,
|
||||
transparent 16.67%,
|
||||
transparent 50%,
|
||||
var(--c) 50%,
|
||||
var(--c) 66.67%,
|
||||
transparent 66.67%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
&:global(.purple) {
|
||||
--c: rgba(196, 0, 255, 0.15);
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
var(--c) 16.67%,
|
||||
transparent 16.67%,
|
||||
transparent 50%,
|
||||
var(--c) 50%,
|
||||
var(--c) 66.67%,
|
||||
transparent 66.67%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
&:global(.gray) {
|
||||
--c: var(--bg);
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
var(--c) 16.67%,
|
||||
transparent 16.67%,
|
||||
transparent 50%,
|
||||
var(--c) 50%,
|
||||
var(--c) 66.67%,
|
||||
transparent 66.67%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<XModalWindow
|
||||
ref="dialogEl"
|
||||
:with-ok-button="true"
|
||||
:ok-button-disabled="selected == null"
|
||||
@click="cancel()"
|
||||
@close="cancel()"
|
||||
@ok="ok()"
|
||||
@closed="$emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.selectInstance }}</template>
|
||||
<div class="mehkoush">
|
||||
<div class="form">
|
||||
<MkInput
|
||||
v-model="hostname"
|
||||
:autofocus="true"
|
||||
@update:modelValue="search"
|
||||
>
|
||||
<template #label>{{ i18n.ts.instance }}</template>
|
||||
</MkInput>
|
||||
</div>
|
||||
<div
|
||||
v-if="hostname != ''"
|
||||
class="result"
|
||||
:class="{ hit: instances.length > 0 }"
|
||||
>
|
||||
<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 v-else class="empty">
|
||||
<span>{{ i18n.ts.noInstances }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</XModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MkInput from "@/components/form/input.vue";
|
||||
import XModalWindow from "@/components/MkModalWindow.vue";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
import { Instance } from "iceshrimp-sdk/built/entities";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "ok", selected: Instance): void;
|
||||
(ev: "cancel"): void;
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
let hostname = ref("");
|
||||
let instances: Instance[] = ref([]);
|
||||
let selected: Instance | null = ref(null);
|
||||
let dialogEl = ref<InstanceType<typeof XModalWindow>>();
|
||||
|
||||
let searchOrderLatch = 0;
|
||||
const search = () => {
|
||||
if (hostname.value === "") {
|
||||
instances.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const searchId = ++searchOrderLatch;
|
||||
os.api("federation/instances", {
|
||||
host: hostname.value,
|
||||
limit: 10,
|
||||
blocked: false,
|
||||
suspended: false,
|
||||
sort: "+pubSub",
|
||||
}).then((_instances) => {
|
||||
if (searchId !== searchOrderLatch) return;
|
||||
instances.value = _instances.map(
|
||||
(x) =>
|
||||
({
|
||||
id: x.id,
|
||||
host: x.host,
|
||||
iconUrl: x.iconUrl,
|
||||
}) as Instance,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const ok = () => {
|
||||
if (selected.value == null) return;
|
||||
emit("ok", selected.value);
|
||||
dialogEl.value?.close();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit("cancel");
|
||||
dialogEl.value?.close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mehkoush {
|
||||
margin: var(--marginFull) 0;
|
||||
|
||||
> .form {
|
||||
padding: 0 var(--root-margin);
|
||||
}
|
||||
|
||||
> .result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
|
||||
&.result.hit {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
> .instances {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px 0;
|
||||
|
||||
> .instance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px var(--root-margin);
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: var(--interactiveElementHovered);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> .body {
|
||||
padding: 0 8px;
|
||||
width: 100%;
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px;
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .empty {
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkFolder class="item">
|
||||
<template #header>Chart</template>
|
||||
<div :class="$style.chart">
|
||||
<div class="selects">
|
||||
<MkSelect v-model="chartSrc" style="margin: 0; flex: 1">
|
||||
<optgroup :label="i18n.ts.federation">
|
||||
<option value="federation">
|
||||
{{ i18n.ts._charts.federation }}
|
||||
</option>
|
||||
<option value="ap-request">
|
||||
{{ i18n.ts._charts.apRequest }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup :label="i18n.ts.users">
|
||||
<option value="users">
|
||||
{{ i18n.ts._charts.usersIncDec }}
|
||||
</option>
|
||||
<option value="users-total">
|
||||
{{ i18n.ts._charts.usersTotal }}
|
||||
</option>
|
||||
<option value="active-users">
|
||||
{{ i18n.ts._charts.activeUsers }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup :label="i18n.ts.notes">
|
||||
<option value="notes">
|
||||
{{ i18n.ts._charts.notesIncDec }}
|
||||
</option>
|
||||
<option value="local-notes">
|
||||
{{ i18n.ts._charts.localNotesIncDec }}
|
||||
</option>
|
||||
<option value="remote-notes">
|
||||
{{ i18n.ts._charts.remoteNotesIncDec }}
|
||||
</option>
|
||||
<option value="notes-total">
|
||||
{{ i18n.ts._charts.notesTotal }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup :label="i18n.ts.drive">
|
||||
<option value="drive-files">
|
||||
{{ i18n.ts._charts.filesIncDec }}
|
||||
</option>
|
||||
<option value="drive">
|
||||
{{ i18n.ts._charts.storageUsageIncDec }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</MkSelect>
|
||||
<MkSelect v-model="chartSpan" style="margin: 0 0 0 10px">
|
||||
<option value="hour">{{ i18n.ts.perHour }}</option>
|
||||
<option value="day">{{ i18n.ts.perDay }}</option>
|
||||
</MkSelect>
|
||||
</div>
|
||||
<div class="chart _panel">
|
||||
<MkChart
|
||||
:src="chartSrc"
|
||||
:span="chartSpan"
|
||||
:limit="chartLimit"
|
||||
:detailed="true"
|
||||
></MkChart>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder class="item">
|
||||
<template #header>Active users heatmap</template>
|
||||
<MkSelect v-model="heatmapSrc" style="margin: 0 0 12px 0">
|
||||
<option value="active-users">Active users</option>
|
||||
<option value="notes">Posts</option>
|
||||
<option value="ap-requests-inbox-received">
|
||||
Fediverse Requests: inboxReceived
|
||||
</option>
|
||||
<option value="ap-requests-deliver-succeeded">
|
||||
Fediverse Requests: deliverSucceeded
|
||||
</option>
|
||||
<option value="ap-requests-deliver-failed">
|
||||
Fediverse Requests: deliverFailed
|
||||
</option>
|
||||
</MkSelect>
|
||||
<div class="_panel" :class="$style.heatmap">
|
||||
<MkHeatmap :src="heatmapSrc" />
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder class="item">
|
||||
<template #header>Federation</template>
|
||||
<div :class="$style.federation">
|
||||
<div class="pies">
|
||||
<div class="sub">
|
||||
<div class="title">Sub</div>
|
||||
<canvas ref="subDoughnutEl"></canvas>
|
||||
</div>
|
||||
<div class="pub">
|
||||
<div class="title">Pub</div>
|
||||
<canvas ref="pubDoughnutEl"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, shallowRef } from 'vue';
|
||||
import { Chart } from "chart.js";
|
||||
import MkSelect from "@/components/form/select.vue";
|
||||
import MkChart from "@/components/MkChart.vue";
|
||||
import { useChartTooltip } from "@/scripts/use-chart-tooltip";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
import MkHeatmap from "@/components/MkHeatmap.vue";
|
||||
import MkFolder from "@/components/MkFolder.vue";
|
||||
import { initChart } from "@/scripts/init-chart";
|
||||
|
||||
initChart();
|
||||
|
||||
const chartLimit = 500;
|
||||
let chartSpan = ref<"hour" | "day">("hour");
|
||||
let chartSrc = ref("active-users");
|
||||
let heatmapSrc = ref("active-users");
|
||||
let subDoughnutEl = shallowRef<HTMLCanvasElement>();
|
||||
let pubDoughnutEl = shallowRef<HTMLCanvasElement>();
|
||||
|
||||
const { handler: externalTooltipHandler1 } = useChartTooltip({
|
||||
position: "middle",
|
||||
});
|
||||
const { handler: externalTooltipHandler2 } = useChartTooltip({
|
||||
position: "middle",
|
||||
});
|
||||
|
||||
function createDoughnut(chartEl, tooltip, data) {
|
||||
const chartInstance = new Chart(chartEl, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: data.map((x) => x.name),
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: data.map((x) => x.color),
|
||||
borderColor: getComputedStyle(
|
||||
document.documentElement,
|
||||
).getPropertyValue("--panel"),
|
||||
borderWidth: 2,
|
||||
hoverOffset: 0,
|
||||
data: data.map((x) => x.value),
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
},
|
||||
},
|
||||
onClick: (ev) => {
|
||||
const hit = chartInstance.getElementsAtEventForMode(
|
||||
ev,
|
||||
"nearest",
|
||||
{ intersect: true },
|
||||
false,
|
||||
)[0];
|
||||
if (hit && data[hit.index].onClick) {
|
||||
data[hit.index].onClick();
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
mode: "index",
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
external: tooltip,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return chartInstance;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
os.apiGet("federation/stats", { limit: 30 }).then((fedStats) => {
|
||||
createDoughnut(
|
||||
subDoughnutEl.value,
|
||||
externalTooltipHandler1,
|
||||
fedStats.topSubInstances
|
||||
.map((x) => ({
|
||||
name: x.host,
|
||||
color: x.themeColor,
|
||||
value: x.followersCount,
|
||||
onClick: () => {
|
||||
os.pageWindow(`/instance-info/${x.host}`);
|
||||
},
|
||||
}))
|
||||
.concat([
|
||||
{
|
||||
name: "(other)",
|
||||
color: "#80808080",
|
||||
value: fedStats.otherFollowersCount,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
createDoughnut(
|
||||
pubDoughnutEl.value,
|
||||
externalTooltipHandler2,
|
||||
fedStats.topPubInstances
|
||||
.map((x) => ({
|
||||
name: x.host,
|
||||
color: x.themeColor,
|
||||
value: x.followingCount,
|
||||
onClick: () => {
|
||||
os.pageWindow(`/instance-info/${x.host}`);
|
||||
},
|
||||
}))
|
||||
.concat([
|
||||
{
|
||||
name: "(other)",
|
||||
color: "#80808080",
|
||||
value: fedStats.otherFollowingCount,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
&:global {
|
||||
> .item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart {
|
||||
&:global {
|
||||
> .selects {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
> .chart {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap {
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.retention {
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.federation {
|
||||
&:global {
|
||||
> .pies {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
> .sub,
|
||||
> .pub {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
max-height: 300px;
|
||||
|
||||
> .title {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div
|
||||
class="hpaizdrt"
|
||||
v-tooltip="capitalize(instance.softwareName)"
|
||||
ref="ticker"
|
||||
:style="bg"
|
||||
>
|
||||
<img class="icon" :src="getInstanceIcon(instance)" aria-hidden="true" />
|
||||
<span class="name">{{ instance.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { instanceName } from "@/config";
|
||||
import { instance as Instance } from "@/instance";
|
||||
import { getProxiedImageUrlNullable } from "@/scripts/media-proxy";
|
||||
|
||||
const props = defineProps<{
|
||||
instance?: {
|
||||
faviconUrl?: string;
|
||||
name: string;
|
||||
themeColor?: string;
|
||||
softwareName?: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
let ticker = ref<HTMLElement | null>(null);
|
||||
|
||||
// if no instance data is given, this is for the local instance
|
||||
const instance = props.instance ?? {
|
||||
faviconUrl: Instance.iconUrl || Instance.faviconUrl || "/favicon.ico",
|
||||
name: instanceName,
|
||||
themeColor: (
|
||||
document.querySelector(
|
||||
'meta[name="theme-color-orig"]',
|
||||
) as HTMLMetaElement
|
||||
)?.content,
|
||||
softwareName: Instance.softwareName || "FrozenFriendsYume",
|
||||
};
|
||||
|
||||
const capitalize = (s: string) => s && s[0].toUpperCase() + s.slice(1);
|
||||
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
const themeColor =
|
||||
instance.themeColor ?? computedStyle.getPropertyValue("--bg");
|
||||
|
||||
const bg = {
|
||||
background: `${themeColor}B3`,
|
||||
};
|
||||
|
||||
function getInstanceIcon(instance): string {
|
||||
return (
|
||||
getProxiedImageUrlNullable(instance.iconUrl, "preview") ??
|
||||
getProxiedImageUrlNullable(instance.faviconUrl, "preview") ??
|
||||
"/client-assets/dummy.png"
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hpaizdrt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1.1em;
|
||||
justify-self: flex-end;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 100px;
|
||||
font-size: 0.8em;
|
||||
text-shadow: 0 2px 2px var(--shadow);
|
||||
overflow: hidden;
|
||||
.header > .body & {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
height: 100%;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
> .name {
|
||||
display: none;
|
||||
color: #fff;
|
||||
margin-left: 4px;
|
||||
font-size: 0.85em;
|
||||
vertical-align: top;
|
||||
font-weight: bold;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-shadow:
|
||||
-1px -1px 0 #000,
|
||||
1px -1px 0 #000,
|
||||
-1px 1px 0 #000,
|
||||
1px 1px 0 #000;
|
||||
.article > .main &,
|
||||
.header > .body & {
|
||||
display: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="alqyeyti" :class="{ oneline }">
|
||||
<div class="key">
|
||||
<slot name="key"></slot>
|
||||
</div>
|
||||
<div class="value">
|
||||
<slot name="value"></slot>
|
||||
<button
|
||||
v-if="copy"
|
||||
v-tooltip="i18n.ts.copy"
|
||||
class="_textButton"
|
||||
style="margin-left: 0.5em"
|
||||
@click="copy_"
|
||||
>
|
||||
<i class="ph-clipboard-text ph-bold"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {} from "vue";
|
||||
import copyToClipboard from "@/scripts/copy-to-clipboard";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
copy?: string | null;
|
||||
oneline?: boolean;
|
||||
}>(),
|
||||
{
|
||||
copy: null,
|
||||
oneline: false,
|
||||
},
|
||||
);
|
||||
|
||||
const copy_ = async () => {
|
||||
await copyToClipboard(props.copy ?? '');
|
||||
await os.success();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.alqyeyti {
|
||||
> .key {
|
||||
font-size: 0.85em;
|
||||
padding: 0 0 0.25em 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&.oneline {
|
||||
display: flex;
|
||||
|
||||
> .key {
|
||||
width: 30%;
|
||||
font-size: 1em;
|
||||
padding: 0 8px 0 0;
|
||||
}
|
||||
|
||||
> .value {
|
||||
width: 70%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<MkModal
|
||||
ref="modal"
|
||||
v-slot="{ type, maxHeight }"
|
||||
:prefer-type="preferedModalType"
|
||||
:anchor="anchor"
|
||||
:transparent-bg="true"
|
||||
:src="src"
|
||||
@click="modal.close()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<div
|
||||
class="szkkfdyq _popup _shadow"
|
||||
:class="{ asDrawer: type === 'drawer' }"
|
||||
:style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }"
|
||||
>
|
||||
<div class="main">
|
||||
<template v-for="item in items">
|
||||
<button
|
||||
v-if="item.action"
|
||||
v-click-anime
|
||||
class="_button"
|
||||
@click="
|
||||
($event) => {
|
||||
item.action($event);
|
||||
close();
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon" :class="item.icon"></i>
|
||||
<div class="text">{{ item.text }}</div>
|
||||
<span
|
||||
v-if="item.indicate"
|
||||
class="indicator"
|
||||
:class="{
|
||||
animateIndicator: $store.state.animation,
|
||||
}"
|
||||
><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"
|
||||
:class="{
|
||||
animateIndicator: $store.state.animation,
|
||||
}"
|
||||
><i class="ph-circle ph-fill"></i
|
||||
></span>
|
||||
</MkA>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {} from "vue";
|
||||
import MkModal from "@/components/MkModal.vue";
|
||||
import { navbarItemDef } from "@/navbar";
|
||||
import { instanceName } from "@/config";
|
||||
import { defaultStore } from "@/store";
|
||||
import { i18n } from "@/i18n";
|
||||
import { deviceKind } from "@/scripts/device-kind";
|
||||
import * as os from "@/os";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src?: HTMLElement;
|
||||
anchor?: { x: string; y: string };
|
||||
}>(),
|
||||
{
|
||||
anchor: () => ({ x: "right", y: "center" }),
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
const preferedModalType =
|
||||
deviceKind === "desktop" && props.src != null
|
||||
? "popup"
|
||||
: deviceKind === "smartphone"
|
||||
? "drawer"
|
||||
: "dialog";
|
||||
|
||||
const modal = ref<InstanceType<typeof MkModal>>();
|
||||
|
||||
const menu = normalizeMenu(defaultStore.state.menu);
|
||||
|
||||
const titleFallbacks: Record<string, string> = {
|
||||
reversi: "リバーシ",
|
||||
shogi: "将棋",
|
||||
yumeFortune: "ゆめくじ",
|
||||
};
|
||||
|
||||
const items = orderLaunchPadKeys(Object.keys(navbarItemDef))
|
||||
.filter((k) => !menu.includes(k))
|
||||
.map((k) => ({ key: k, def: navbarItemDef[k] }))
|
||||
.filter(({ def }) => (def.show == null ? true : def.show))
|
||||
.map(({ key, def }) => ({
|
||||
type: def.to ? "link" : "button",
|
||||
text: i18n.ts[def.title] ?? titleFallbacks[key] ?? def.title,
|
||||
icon: def.icon,
|
||||
to: def.to,
|
||||
action: def.action,
|
||||
indicate: def.indicated,
|
||||
}));
|
||||
|
||||
function normalizeMenu(source: string[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (const item of source) {
|
||||
if (item === "reversi") continue;
|
||||
if (item === "shogi") continue;
|
||||
if (item === "yumeFortune") continue;
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function orderLaunchPadKeys(keys: string[]): string[] {
|
||||
const ordered = keys.filter((key) => key !== "shogi" && key !== "yumeFortune");
|
||||
const reversiIndex = ordered.indexOf("reversi");
|
||||
if (reversiIndex >= 0) {
|
||||
ordered.splice(reversiIndex + 1, 0, "shogi", "yumeFortune");
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function close() {
|
||||
modal.value.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.szkkfdyq {
|
||||
max-height: 100%;
|
||||
width: min(460px, 100vw);
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
text-align: left;
|
||||
border-radius: 16px;
|
||||
|
||||
&.asDrawer {
|
||||
width: 100%;
|
||||
padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 16px) 16px;
|
||||
border-radius: 24px;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
> .main,
|
||||
> .sub {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
|
||||
> a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: bottom;
|
||||
height: 100px;
|
||||
border-radius: 10px;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--accent);
|
||||
background: var(--accentedBg);
|
||||
}
|
||||
|
||||
> .icon {
|
||||
font-size: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
> .text {
|
||||
margin-top: 12px;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
> .indicator {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: 32px;
|
||||
color: var(--indicator);
|
||||
font-size: 8px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
> .animateIndicator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .sub {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<component
|
||||
:is="self ? 'MkA' : 'a'"
|
||||
ref="el"
|
||||
class="xlcxczvw _link"
|
||||
:[attr]="maybeRelativeUrl"
|
||||
: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>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, ref } from 'vue';
|
||||
import { url as local } from "@/config";
|
||||
import { useTooltip } from "@/scripts/use-tooltip";
|
||||
import * as os from "@/os";
|
||||
import { maybeMakeRelative } from "@/scripts/url";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
url: string;
|
||||
rel?: null | string;
|
||||
}>(),
|
||||
{},
|
||||
);
|
||||
|
||||
const maybeRelativeUrl = maybeMakeRelative(props.url, local);
|
||||
const self = maybeRelativeUrl !== props.url;
|
||||
const attr = self ? "to" : "href";
|
||||
const target = self ? null : "_blank";
|
||||
|
||||
const el = ref();
|
||||
|
||||
useTooltip((el), (showing) => {
|
||||
os.popup(
|
||||
defineAsyncComponent(
|
||||
() => import("@/components/MkUrlPreviewPopup.vue"),
|
||||
),
|
||||
{
|
||||
showing,
|
||||
url: props.url,
|
||||
source: el.value,
|
||||
},
|
||||
{},
|
||||
"closed",
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.xlcxczvw {
|
||||
word-break: break-all;
|
||||
|
||||
> .icon {
|
||||
padding-left: 2px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<MkModal ref="modal" :z-priority="'middle'" @closed="$emit('closed')">
|
||||
<div :class="$style.root">
|
||||
<p :class="$style.title">
|
||||
{{ i18n.ts.youHaveUnreadAnnouncements }}
|
||||
</p>
|
||||
<MkButton
|
||||
:class="$style.gotIt"
|
||||
primary
|
||||
full
|
||||
@click="checkAnnouncements()"
|
||||
>{{ i18n.ts.gotIt }}</MkButton
|
||||
>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef } from "vue";
|
||||
import MkModal from "@/components/MkModal.vue";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
import * as os from "@/os";
|
||||
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
const checkAnnouncements = () => {
|
||||
modal.value.close();
|
||||
location.href = "/announcements";
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
margin: auto;
|
||||
position: relative;
|
||||
padding: 32px;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gotIt {
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { h, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
|
||||
export default {
|
||||
name: "MarqueeText",
|
||||
props: {
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 15,
|
||||
},
|
||||
repeat: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
paused: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
reverse: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const contentEl = ref();
|
||||
|
||||
function calc() {
|
||||
const eachLength = contentEl.value.offsetWidth / props.repeat;
|
||||
const factor = 3000;
|
||||
const duration = props.duration / ((1 / eachLength) * factor);
|
||||
|
||||
contentEl.value.style.animationDuration = `${duration}s`;
|
||||
}
|
||||
|
||||
watch(() => props.duration, calc);
|
||||
|
||||
onMounted(() => {
|
||||
calc();
|
||||
});
|
||||
|
||||
onUnmounted(() => {});
|
||||
|
||||
return {
|
||||
contentEl,
|
||||
};
|
||||
},
|
||||
render({ $slots, $style, $props: { duration, repeat, paused, reverse } }) {
|
||||
return h("div", { class: [$style.wrap] }, [
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
ref: "contentEl",
|
||||
class: [paused ? $style.paused : undefined, $style.content],
|
||||
},
|
||||
Array(repeat).fill(
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class: $style.text,
|
||||
style: {
|
||||
animationDirection: reverse
|
||||
? "reverse"
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
$slots.default(),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrap {
|
||||
overflow: clip;
|
||||
animation-play-state: running;
|
||||
|
||||
&:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
.content {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
animation-play-state: inherit;
|
||||
}
|
||||
.text {
|
||||
display: inline-block;
|
||||
animation-name: marquee;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
animation-duration: inherit;
|
||||
animation-play-state: inherit;
|
||||
}
|
||||
.paused .text {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<div class="media" v-size="{ max: [350] }">
|
||||
<button v-if="hide" class="hidden" @click="hide = false">
|
||||
<ImgWithBlurhash
|
||||
:hash="media.blurhash"
|
||||
:title="media.comment"
|
||||
:alt="media.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>
|
||||
</button>
|
||||
<template v-else>
|
||||
<a v-if="media.type.startsWith('image')" :href="media.url">
|
||||
<ImgWithBlurhash
|
||||
:hash="media.blurhash"
|
||||
:src="url"
|
||||
:alt="media.comment"
|
||||
:type="media.type"
|
||||
:cover="false"
|
||||
/>
|
||||
<div v-if="media.type === 'image/gif'" class="gif">GIF</div>
|
||||
</a>
|
||||
<VuePlyr
|
||||
v-if="media.type.startsWith('video')"
|
||||
ref="plyr"
|
||||
:options="{
|
||||
controls: videoControls,
|
||||
disableContextMenu: !media.allowDownload,
|
||||
}"
|
||||
>
|
||||
<video
|
||||
:poster="media.thumbnailUrl"
|
||||
:aria-label="media.comment"
|
||||
preload="none"
|
||||
controls
|
||||
:controlsList="media.allowDownload ? undefined : 'nodownload'"
|
||||
@contextmenu="onMediaContextmenu"
|
||||
>
|
||||
<source :src="media.url" :type="mediaType" />
|
||||
</video>
|
||||
</VuePlyr>
|
||||
<button
|
||||
v-if="isEpub"
|
||||
class="epub"
|
||||
type="button"
|
||||
:title="media.name"
|
||||
@click.stop="openEpub"
|
||||
>
|
||||
<i class="ph-book-open-text ph-bold ph-lg"></i>
|
||||
<span>
|
||||
<b>{{ media.name }}</b>
|
||||
<small>EPUB</small>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<div class="buttons">
|
||||
<button
|
||||
v-if="media.comment"
|
||||
v-tooltip.noLabel="
|
||||
`${i18n.ts.alt}: ${
|
||||
media.comment.length > 200
|
||||
? media.comment.trim().slice(0, 200) + '...'
|
||||
: media.comment.trim()
|
||||
}`
|
||||
"
|
||||
:aria-label="i18n.ts.alt"
|
||||
class="_button"
|
||||
@click.stop="captionPopup"
|
||||
>
|
||||
<i class="ph-subtitles ph-bold ph-lg"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="!hide"
|
||||
v-tooltip="i18n.ts.hide"
|
||||
class="_button"
|
||||
@click.stop="hide = true"
|
||||
>
|
||||
<i class="ph-eye-slash ph-bold ph-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, watch, ref, computed } from "vue";
|
||||
import VuePlyr from "./VuePlyr.vue";
|
||||
import type * as misskey from "iceshrimp-sdk";
|
||||
import { getStaticImageUrl } from "@/scripts/get-static-image-url";
|
||||
import ImgWithBlurhash from "@/components/MkImgWithBlurhash.vue";
|
||||
import { defaultStore } from "@/store";
|
||||
import { i18n } from "@/i18n";
|
||||
import * as os from "@/os";
|
||||
|
||||
const XEpubViewer = defineAsyncComponent(
|
||||
() => import("@/components/MkEpubViewer.vue"),
|
||||
);
|
||||
|
||||
const props = defineProps<{
|
||||
media: misskey.entities.DriveFile;
|
||||
raw?: boolean;
|
||||
}>();
|
||||
|
||||
let hide = ref(true);
|
||||
|
||||
const url =
|
||||
props.raw || defaultStore.state.loadRawImages
|
||||
? props.media.url
|
||||
: defaultStore.state.disableShowingAnimatedImages &&
|
||||
props.media.type.startsWith("image")
|
||||
? getStaticImageUrl(props.media.thumbnailUrl)
|
||||
: props.media.thumbnailUrl;
|
||||
|
||||
const mediaType = computed(() => {
|
||||
return props.media.type === "video/quicktime"
|
||||
? "video/mp4"
|
||||
: props.media.type;
|
||||
});
|
||||
|
||||
const isEpub = computed(
|
||||
() =>
|
||||
props.media.type === "application/epub+zip" ||
|
||||
props.media.name.toLowerCase().endsWith(".epub"),
|
||||
);
|
||||
|
||||
const videoControls = computed(() => [
|
||||
"play-large",
|
||||
"play",
|
||||
"progress",
|
||||
"current-time",
|
||||
"mute",
|
||||
"volume",
|
||||
"pip",
|
||||
...(props.media.allowDownload ? ["download"] : []),
|
||||
"fullscreen",
|
||||
]);
|
||||
|
||||
function onMediaContextmenu(ev: MouseEvent) {
|
||||
if (!props.media.allowDownload) ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
function captionPopup() {
|
||||
os.alert({
|
||||
type: "info",
|
||||
text: props.media.comment,
|
||||
});
|
||||
}
|
||||
|
||||
function openEpub() {
|
||||
os.popup(
|
||||
XEpubViewer,
|
||||
{
|
||||
file: props.media,
|
||||
},
|
||||
{},
|
||||
"closed",
|
||||
);
|
||||
}
|
||||
|
||||
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
|
||||
watch(
|
||||
() => props.media,
|
||||
() => {
|
||||
hide.value =
|
||||
defaultStore.state.nsfw === "force"
|
||||
? true
|
||||
: props.media.isSensitive &&
|
||||
defaultStore.state.nsfw !== "ignore";
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hidden {
|
||||
all: unset;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> .text {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 30px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
> .wrapper {
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
border: 2px solid var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.media {
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
|
||||
--plyr-color-main: var(--accent);
|
||||
|
||||
> .buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
> * {
|
||||
background-color: var(--accentedBg);
|
||||
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
color: var(--accent);
|
||||
font-size: 0.8em;
|
||||
padding: 6px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
> a {
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
box-sizing: border-box;
|
||||
&:focus-visible {
|
||||
border: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
> .gif {
|
||||
background-color: var(--fg);
|
||||
border-radius: 6px;
|
||||
color: var(--accentLighten);
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
left: 12px;
|
||||
opacity: 0.5;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
top: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .epub {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 150px;
|
||||
padding: 22px;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--fg);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.12), transparent),
|
||||
var(--panel);
|
||||
|
||||
> i {
|
||||
font-size: 42px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
> span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
b,
|
||||
small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--fgTransparentWeak);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.plyr__controls) {
|
||||
contain: strict;
|
||||
height: 24px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
:deep(.plyr__volume) {
|
||||
display: flex;
|
||||
min-width: max-content;
|
||||
width: 110px;
|
||||
transition: width 0.2s cubic-bezier(0, 0, 0, 1);
|
||||
[data-plyr="volume"] {
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
transition:
|
||||
margin 0.3s,
|
||||
opacity 0.2s 0.2s;
|
||||
}
|
||||
&:not(:hover):not(:focus-within) {
|
||||
width: 0px;
|
||||
transition: width 0.2s;
|
||||
[data-plyr="volume"] {
|
||||
margin-inline: 0px;
|
||||
opacity: 0;
|
||||
transition:
|
||||
margin 0.3s,
|
||||
opacity 0.1s;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.max-width_350px {
|
||||
:deep(.plyr:not(:fullscreen)) {
|
||||
min-width: unset !important;
|
||||
.plyr__control--overlaid,
|
||||
.plyr__progress__container,
|
||||
.plyr__volume,
|
||||
[data-plyr="download"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
:deep(.plyr__time) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="mk-media-banner" @click.stop>
|
||||
<div
|
||||
v-if="media.isSensitive && hide"
|
||||
class="sensitive"
|
||||
@click="hide = false"
|
||||
>
|
||||
<span class="icon"><i class="ph-warning ph-bold ph-lg"></i></span>
|
||||
<b>{{ i18n.ts.sensitive }}</b>
|
||||
<span>{{ i18n.ts.clickToShow }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
media.type.startsWith('audio') && media.type !== 'audio/midi'
|
||||
"
|
||||
class="audio"
|
||||
>
|
||||
<VuePlyr
|
||||
:options="{
|
||||
controls: audioControls,
|
||||
disableContextMenu: !media.allowDownload,
|
||||
}"
|
||||
>
|
||||
<audio
|
||||
ref="audioEl"
|
||||
class="audio"
|
||||
:src="media.url"
|
||||
:title="media.name"
|
||||
controls
|
||||
:controlsList="media.allowDownload ? undefined : 'nodownload'"
|
||||
preload="metadata"
|
||||
@contextmenu="onMediaContextmenu"
|
||||
@volumechange="volumechange"
|
||||
/>
|
||||
</VuePlyr>
|
||||
</div>
|
||||
<a
|
||||
v-else-if="media.allowDownload"
|
||||
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 v-else class="download disabled" :title="media.name">
|
||||
<span class="icon"
|
||||
><i class="ph-lock-simple ph-bold ph-lg"></i
|
||||
></span>
|
||||
<b>{{ media.name }}</b>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import VuePlyr from "./VuePlyr.vue";
|
||||
import type * as misskey from "iceshrimp-sdk";
|
||||
import { ColdDeviceStorage } from "@/store";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
media: misskey.entities.DriveFile;
|
||||
}>(),
|
||||
{},
|
||||
);
|
||||
|
||||
const audioEl = ref<HTMLAudioElement | null>();
|
||||
let hide = ref(true);
|
||||
|
||||
const audioControls = computed(() => [
|
||||
"play-large",
|
||||
"play",
|
||||
"progress",
|
||||
"current-time",
|
||||
"mute",
|
||||
"volume",
|
||||
...(props.media.allowDownload ? ["download"] : []),
|
||||
]);
|
||||
|
||||
function volumechange() {
|
||||
if (audioEl.value) ColdDeviceStorage.set("mediaVolume", audioEl.value.volume);
|
||||
}
|
||||
|
||||
function onMediaContextmenu(ev: MouseEvent) {
|
||||
if (!props.media.allowDownload) ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (audioEl.value) audioEl.value.volume = ColdDeviceStorage.get("mediaVolume");
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-media-banner {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
--plyr-color-main: var(--accent);
|
||||
--plyr-audio-controls-background: var(--panelHighlight);
|
||||
--plyr-audio-controls-background-hover: var(--accentedBg);
|
||||
--plyr-audio-control-color: var(--navFg);
|
||||
|
||||
> .download,
|
||||
> .sensitive {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
> * {
|
||||
display: block;
|
||||
}
|
||||
|
||||
> b {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
}
|
||||
|
||||
> .download {
|
||||
background: var(--noteAttachedFile);
|
||||
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
> .sensitive {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> .audio {
|
||||
.audio {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<MkModal ref="modal" @click="done(true)" @closed="$emit('closed')">
|
||||
<div class="container">
|
||||
<div class="fullwidth top-caption">
|
||||
<div class="mk-dialog">
|
||||
<header>
|
||||
<Mfm v-if="title" class="title" :text="title" />
|
||||
<span
|
||||
class="text-count"
|
||||
:class="{ over: remainingLength < 0 }"
|
||||
>{{ remainingLength }}</span
|
||||
>
|
||||
<br />
|
||||
</header>
|
||||
<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 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>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import insertTextAtCursor from "insert-text-at-cursor";
|
||||
import { length } from "stringz";
|
||||
import * as os from "@/os";
|
||||
import MkModal from "@/components/MkModal.vue";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import bytes from "@/filters/bytes";
|
||||
import number from "@/filters/number";
|
||||
import { i18n } from "@/i18n";
|
||||
import { instance } from "@/instance";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkModal,
|
||||
MkButton,
|
||||
},
|
||||
|
||||
props: {
|
||||
image: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
input: {
|
||||
required: true,
|
||||
},
|
||||
showOkButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showCaptionButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showCancelButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
cancelableByBgClick: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["done", "closed"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
inputValue: this.input.default ? this.input.default : null,
|
||||
i18n,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
remainingLength(): number {
|
||||
const maxCaptionLength = instance.maxCaptionTextLength ?? 512;
|
||||
if (typeof this.inputValue !== "string") return maxCaptionLength;
|
||||
return maxCaptionLength - length(this.inputValue);
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
document.addEventListener("keydown", this.onKeydown);
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
document.removeEventListener("keydown", this.onKeydown);
|
||||
},
|
||||
|
||||
methods: {
|
||||
bytes,
|
||||
number,
|
||||
|
||||
done(canceled, result?) {
|
||||
this.$emit("done", { canceled, result });
|
||||
this.$refs.modal.close();
|
||||
},
|
||||
|
||||
async ok() {
|
||||
if (!this.showOkButton) return;
|
||||
|
||||
const result = this.inputValue;
|
||||
this.done(false, result);
|
||||
},
|
||||
|
||||
cancel() {
|
||||
this.done(true);
|
||||
},
|
||||
|
||||
onBgClick() {
|
||||
if (this.cancelableByBgClick) {
|
||||
this.cancel();
|
||||
}
|
||||
},
|
||||
|
||||
onKeydown(evt) {
|
||||
if (evt.which === 27) {
|
||||
// ESC
|
||||
this.cancel();
|
||||
}
|
||||
},
|
||||
|
||||
onInputKeydown(evt) {
|
||||
if (evt.which === 13) {
|
||||
// Enter
|
||||
if (evt.ctrlKey) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
this.ok();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
caption() {
|
||||
const img = document.getElementById(
|
||||
"imgtocaption",
|
||||
) as HTMLImageElement;
|
||||
const ta = document.getElementById(
|
||||
"captioninput",
|
||||
) as HTMLTextAreaElement;
|
||||
os.api("drive/files/caption-image", {
|
||||
url: img.src,
|
||||
}).then((text) => {
|
||||
insertTextAtCursor(ta, text.slice(0, 512 - ta.value.length));
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: row;
|
||||
overflow: scroll;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
@media (max-width: 850px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
}
|
||||
.top-caption {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
}
|
||||
.fullwidth {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
.mk-dialog {
|
||||
position: relative;
|
||||
padding: 32px;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
margin: auto;
|
||||
|
||||
> header {
|
||||
margin: 0 0 8px 0;
|
||||
position: relative;
|
||||
|
||||
> .title {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
> .text-count {
|
||||
opacity: 0.7;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .buttons {
|
||||
margin-top: 16px;
|
||||
|
||||
> * {
|
||||
margin: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
padding: 0 24px;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 90px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
.hdrwpsaf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
> header,
|
||||
> footer {
|
||||
align-self: center;
|
||||
display: inline-block;
|
||||
padding: 6px 9px;
|
||||
font-size: 90%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> header {
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
> img {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
cursor: zoom-out;
|
||||
image-orientation: from-image;
|
||||
}
|
||||
|
||||
> footer {
|
||||
margin-top: 8px;
|
||||
opacity: 0.8;
|
||||
|
||||
> span + span {
|
||||
margin-left: 0.5em;
|
||||
padding-left: 0.5em;
|
||||
border-left: solid 1px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div class="hoawjimk files">
|
||||
<XBanner
|
||||
v-for="media in mediaList.filter((media) => !previewable(media))"
|
||||
:key="media.id"
|
||||
:media="media"
|
||||
/>
|
||||
<div
|
||||
v-if="previewableCount > 0"
|
||||
class="grid-container"
|
||||
:data-count="previewableCount < 5 ? previewableCount : null"
|
||||
:class="{ dmWidth: inDm }"
|
||||
>
|
||||
<div ref="gallery" @click.stop>
|
||||
<XMedia
|
||||
v-for="media in mediaList.filter((media) =>
|
||||
previewable(media),
|
||||
)"
|
||||
:key="media.id"
|
||||
:class="{ image: media.type.startsWith('image') }"
|
||||
:data-id="media.id"
|
||||
:media="media"
|
||||
:raw="raw"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import * as misskey from "iceshrimp-sdk";
|
||||
import PhotoSwipeLightbox from "photoswipe/lightbox";
|
||||
import PhotoSwipe from "photoswipe";
|
||||
import "photoswipe/style.css";
|
||||
import XBanner from "@/components/MkMediaBanner.vue";
|
||||
import XMedia from "@/components/MkMedia.vue";
|
||||
import * as os from "@/os";
|
||||
import { FILE_TYPE_BROWSERSAFE } from "@/const";
|
||||
import { defaultStore } from "@/store";
|
||||
|
||||
const props = defineProps<{
|
||||
mediaList: misskey.entities.DriveFile[];
|
||||
raw?: boolean;
|
||||
inDm?: boolean;
|
||||
}>();
|
||||
|
||||
const gallery = ref(null);
|
||||
const pswpZIndex = os.claimZIndex("middle");
|
||||
|
||||
onMounted(() => {
|
||||
const lightbox = new PhotoSwipeLightbox({
|
||||
dataSource: props.mediaList
|
||||
.filter((media) => {
|
||||
if (media.type === "image/svg+xml") return true; // svgのwebpublicはpngなのでtrue
|
||||
return (
|
||||
media.type.startsWith("image") &&
|
||||
FILE_TYPE_BROWSERSAFE.includes(media.type)
|
||||
);
|
||||
})
|
||||
.map((media) => {
|
||||
const item = {
|
||||
src: media.url,
|
||||
w: media.properties.width,
|
||||
h: media.properties.height,
|
||||
alt: media.comment,
|
||||
};
|
||||
if (
|
||||
media.properties.orientation != null &&
|
||||
media.properties.orientation >= 5
|
||||
) {
|
||||
[item.w, item.h] = [item.h, item.w];
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
gallery: gallery.value,
|
||||
children: ".image",
|
||||
thumbSelector: ".image",
|
||||
loop: false,
|
||||
padding:
|
||||
window.innerWidth > 500
|
||||
? {
|
||||
top: 32,
|
||||
bottom: 32,
|
||||
left: 32,
|
||||
right: 32,
|
||||
}
|
||||
: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
imageClickAction: "close",
|
||||
tapAction: "toggle-controls",
|
||||
preloadFirstSlide: false,
|
||||
pswpModule: PhotoSwipe,
|
||||
});
|
||||
|
||||
lightbox.on("itemData", (ev) => {
|
||||
const { itemData } = ev;
|
||||
|
||||
// element is children
|
||||
const { element } = itemData;
|
||||
|
||||
const id = element.dataset.id;
|
||||
const file = props.mediaList.find((media) => media.id === id);
|
||||
|
||||
itemData.src = file.url;
|
||||
itemData.w = Number(file.properties.width);
|
||||
itemData.h = Number(file.properties.height);
|
||||
if (
|
||||
file.properties.orientation != null &&
|
||||
file.properties.orientation >= 5
|
||||
) {
|
||||
[itemData.w, itemData.h] = [itemData.h, itemData.w];
|
||||
}
|
||||
itemData.msrc = file.thumbnailUrl;
|
||||
itemData.alt = file.comment;
|
||||
itemData.thumbCropped = true;
|
||||
});
|
||||
|
||||
lightbox.on("uiRegister", () => {
|
||||
lightbox.pswp.ui.registerElement({
|
||||
name: "altText",
|
||||
className: "pwsp__alt-text-container",
|
||||
appendTo: "wrapper",
|
||||
onInit: (el, pwsp) => {
|
||||
let textBox = document.createElement("p");
|
||||
textBox.className = "pwsp__alt-text";
|
||||
el.appendChild(textBox);
|
||||
|
||||
let preventProp = function (ev: Event): void {
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
// Allow scrolling/text selection
|
||||
el.onwheel = preventProp;
|
||||
el.onclick = preventProp;
|
||||
el.onpointerdown = preventProp;
|
||||
el.onpointercancel = preventProp;
|
||||
el.onpointermove = preventProp;
|
||||
|
||||
pwsp.on("change", () => {
|
||||
textBox.textContent = pwsp.currSlide.data.alt?.trim();
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
lightbox.on("afterInit", () => {
|
||||
history.pushState(null, "", location.href);
|
||||
addEventListener("popstate", close);
|
||||
// This is a workaround. Not sure why, but when clicking to open, it doesn't move focus to the photoswipe. Preventing using esc to close. However when using keyboard to open it already focuses the lightbox fine.
|
||||
lightbox.pswp.element.focus();
|
||||
});
|
||||
lightbox.on("close", () => {
|
||||
removeEventListener("popstate", close);
|
||||
history.back();
|
||||
});
|
||||
|
||||
lightbox.init();
|
||||
|
||||
function close() {
|
||||
removeEventListener("popstate", close);
|
||||
history.forward();
|
||||
lightbox.pswp.close();
|
||||
}
|
||||
});
|
||||
|
||||
const previewable = (file: misskey.entities.DriveFile): boolean => {
|
||||
if (file.type === "image/svg+xml") return true; // svgのwebpublic/thumbnailはpngなのでtrue
|
||||
if (file.type === "application/epub+zip") return true;
|
||||
if (file.name.toLowerCase().endsWith(".epub")) return true;
|
||||
// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
|
||||
return (
|
||||
(file.type.startsWith("video") || file.type.startsWith("image")) &&
|
||||
FILE_TYPE_BROWSERSAFE.includes(file.type)
|
||||
);
|
||||
};
|
||||
const previewableCount = props.mediaList.filter((media) =>
|
||||
previewable(media),
|
||||
).length;
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hoawjimk {
|
||||
> .dmWidth {
|
||||
min-width: 20rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
> .grid-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
&[data-count] {
|
||||
padding-top: 56.25%; // 16:9;
|
||||
> div {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-count="1"] > div {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
&[data-count="2"] > div {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
&[data-count="3"] > div {
|
||||
grid-template-columns: 1fr 0.5fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
|
||||
> *:nth-child(1) {
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
|
||||
> *:nth-child(3) {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-count="4"] > div {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
|
||||
&:not([data-count]) > div > div {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
grid-gap: 8px;
|
||||
|
||||
> div,
|
||||
> button {
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
pointer-events: all;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
> :nth-child(1) {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
> :nth-child(2) {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
> :nth-child(3) {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
> :nth-child(4) {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.pswp {
|
||||
// なぜか機能しない
|
||||
//z-index: v-bind(pswpZIndex);
|
||||
z-index: 2000000;
|
||||
}
|
||||
.pwsp__alt-text-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.pwsp__alt-text {
|
||||
color: white;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 5px;
|
||||
|
||||
max-height: 10vh;
|
||||
overflow-x: clip;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.pwsp__alt-text:empty {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="memorietTimeline">
|
||||
<MkLoading v-if="loading && items.length === 0" />
|
||||
<div v-else-if="items.length === 0" class="_fullinfo">
|
||||
<div>表示できるMemorietはありません</div>
|
||||
</div>
|
||||
<div v-else class="items">
|
||||
<article v-for="item in items" :key="item.id" class="item">
|
||||
<div class="meta">
|
||||
<span>
|
||||
<i class="ph-timer ph-bold"></i>
|
||||
{{ item.expiresAt ? `${formatDate(item.expiresAt)}に削除` : "無期限公開" }}
|
||||
</span>
|
||||
<button v-if="item.viewerCount != null" class="_button footprintButton" @click="toggleViewers(item)">
|
||||
<i class="ph-footprints ph-bold"></i>
|
||||
足跡 {{ item.viewerCount }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="storyPreview">
|
||||
<img v-if="itemImage(item)" :src="itemImage(item)?.url" :alt="itemImage(item)?.name" />
|
||||
<div v-else class="previewEmpty">画像なし</div>
|
||||
<div
|
||||
v-for="layer in item.textLayers"
|
||||
:key="layer.id"
|
||||
class="storyText"
|
||||
:style="layerStyle(layer)"
|
||||
>
|
||||
{{ layer.text }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="item.viewers" class="viewerList">
|
||||
<div v-if="item.viewers.length === 0">まだ閲覧者はいません</div>
|
||||
<template v-else>
|
||||
<div v-for="view in item.viewers" :key="`${item.id}-${view.user.id}`" class="viewerRow">
|
||||
<MkAvatar :user="view.user" class="avatar" />
|
||||
<span class="viewerName">{{ view.user.name || view.user.username }}</span>
|
||||
<span class="viewedAt">{{ formatDate(view.viewedAt) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<MkNote :note="item.note" />
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="items.length > 0" class="actions">
|
||||
<MkButton :disabled="loading" @click="loadMore">
|
||||
<i class="ph-arrow-down ph-bold"></i>
|
||||
さらに読み込む
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import * as misskey from "iceshrimp-sdk";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import MkNote from "@/components/MkNote.vue";
|
||||
import MkAvatar from "@/components/global/MkAvatar.vue";
|
||||
import * as os from "@/os";
|
||||
|
||||
type MemorietTextLayer = {
|
||||
id: string;
|
||||
text: string;
|
||||
color: string;
|
||||
backgroundColor: string | null;
|
||||
backgroundWidth: number | null;
|
||||
backgroundHeight: number | null;
|
||||
fontSize: number;
|
||||
x: number;
|
||||
y: number;
|
||||
rotate: number;
|
||||
};
|
||||
|
||||
type MemorietView = {
|
||||
viewedAt: string;
|
||||
user: misskey.entities.User;
|
||||
};
|
||||
|
||||
type MemorietItem = {
|
||||
id: string;
|
||||
expiresAt: string | null;
|
||||
textLayers: MemorietTextLayer[];
|
||||
viewerCount: number | null;
|
||||
viewers?: MemorietView[];
|
||||
note: misskey.entities.Note;
|
||||
};
|
||||
|
||||
const items = ref<MemorietItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const limit = 20;
|
||||
|
||||
function layerStyle(layer: MemorietTextLayer) {
|
||||
const backgroundColor = layer.backgroundColor;
|
||||
return {
|
||||
color: layer.color,
|
||||
backgroundColor: backgroundColor ?? "transparent",
|
||||
fontSize: `${layer.fontSize}px`,
|
||||
left: `${layer.x}%`,
|
||||
top: `${layer.y}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${layer.rotate}deg)`,
|
||||
padding: backgroundColor == null ? "0" : "0.25em 0.45em",
|
||||
width: layer.backgroundWidth != null && layer.backgroundWidth > 0 ? `${layer.backgroundWidth}%` : "auto",
|
||||
height: layer.backgroundHeight != null && layer.backgroundHeight > 0 ? `${layer.backgroundHeight}%` : "auto",
|
||||
};
|
||||
}
|
||||
|
||||
function itemImage(item: MemorietItem): misskey.entities.DriveFile | null {
|
||||
return item.note.files?.find((file) => file.type?.startsWith("image/")) ?? null;
|
||||
}
|
||||
|
||||
async function load(offset = 0) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const next = (await (os.api as any)("memoriet/list", { limit, offset })) as MemorietItem[];
|
||||
items.value = offset === 0 ? next : [...items.value, ...next];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
await load(items.value.length);
|
||||
}
|
||||
|
||||
async function toggleViewers(item: MemorietItem) {
|
||||
if (item.viewers) {
|
||||
item.viewers = undefined;
|
||||
return;
|
||||
}
|
||||
item.viewers = (await (os.api as any)("memoriet/viewers", { memorietId: item.id, limit: 100 })) as MemorietView[];
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.memorietTimeline {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
border: solid 1px var(--divider);
|
||||
}
|
||||
|
||||
.meta,
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--fgTransparentWeak);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.meta span,
|
||||
.footprintButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.footprintButton {
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--buttonBg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.storyPreview {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 9 / 16;
|
||||
width: min(100%, 420px);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
border: solid 1px var(--divider);
|
||||
}
|
||||
|
||||
.storyPreview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.previewEmpty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--fgTransparentWeak);
|
||||
}
|
||||
|
||||
.storyText {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 92%;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
white-space: pre-wrap;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewerList {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
border: solid 1px var(--divider);
|
||||
}
|
||||
|
||||
.viewerRow {
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.viewerName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewedAt {
|
||||
color: var(--fgTransparentWeak);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.viewerRow {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.viewedAt {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<MkA
|
||||
v-if="url.startsWith('/')"
|
||||
v-user-preview="canonical"
|
||||
class="mention"
|
||||
:class="{ isMe }"
|
||||
:to="url"
|
||||
@click.stop
|
||||
>
|
||||
<img class="icon" :src="`/avatar/@${username}@${host}`" alt="" loading="lazy" decoding="async" />
|
||||
<span class="main">
|
||||
<span class="username">@{{ username }}</span>
|
||||
<span
|
||||
v-if="host != localHost || $store.state.showFullAcct"
|
||||
class="host"
|
||||
>@{{ toUnicode(host) }}</span
|
||||
>
|
||||
</span>
|
||||
</MkA>
|
||||
<a
|
||||
v-else
|
||||
class="mention"
|
||||
: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>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toUnicode } from "punycode/";
|
||||
import {} from "vue";
|
||||
import { host as localHost } from "@/config";
|
||||
import { $i } from "@/account";
|
||||
|
||||
const props = defineProps<{
|
||||
username: string;
|
||||
host: string;
|
||||
}>();
|
||||
|
||||
const canonical =
|
||||
props.host === localHost
|
||||
? `@${props.username}`
|
||||
: `@${props.username}@${toUnicode(props.host)}`;
|
||||
|
||||
const url = `/${canonical}`;
|
||||
|
||||
const isMe =
|
||||
$i &&
|
||||
`@${props.username}@${toUnicode(props.host)}` ===
|
||||
`@${$i.username}@${toUnicode(localHost)}`.toLowerCase();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mention {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 2px 8px 2px 2px;
|
||||
margin-block: 2px;
|
||||
border-radius: 999px;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--mention);
|
||||
isolation: isolate;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--mention);
|
||||
opacity: 0.1;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&.isMe {
|
||||
--mention: var(--mentionMe);
|
||||
}
|
||||
|
||||
> .icon {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
object-fit: cover;
|
||||
margin: 0 0.2em 0 0;
|
||||
vertical-align: bottom;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
> .main > .host {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div ref="el" class="sfhdhdhr" tabindex="-1">
|
||||
<MkMenu
|
||||
ref="menu"
|
||||
:items="items"
|
||||
:align="align"
|
||||
:width="width"
|
||||
:as-drawer="false"
|
||||
@close="onChildClosed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { on } from "events";
|
||||
import {
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import MkMenu from "./MkMenu.vue";
|
||||
import { MenuItem } from "@/types/menu";
|
||||
|
||||
const props = defineProps<{
|
||||
items: MenuItem[];
|
||||
targetElement: HTMLElement;
|
||||
rootElement: HTMLElement;
|
||||
width?: number;
|
||||
viaKeyboard?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "closed"): void;
|
||||
(ev: "actioned"): void;
|
||||
}>();
|
||||
|
||||
const el = ref<HTMLElement>();
|
||||
const align = "left";
|
||||
|
||||
function setPosition() {
|
||||
const rootRect = props.rootElement.getBoundingClientRect();
|
||||
const rect = props.targetElement.getBoundingClientRect();
|
||||
const left = props.targetElement.offsetWidth;
|
||||
const top = rect.top - rootRect.top - 8;
|
||||
el.value.style.left = left + "px";
|
||||
el.value.style.top = top + "px";
|
||||
}
|
||||
|
||||
function onChildClosed(actioned?: boolean) {
|
||||
if (actioned) {
|
||||
emit("actioned");
|
||||
} else {
|
||||
emit("closed");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setPosition();
|
||||
nextTick(() => {
|
||||
setPosition();
|
||||
});
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
checkHit: (ev: MouseEvent) => {
|
||||
return ev.target === el.value || el.value.contains(ev.target);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sfhdhdhr {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<FocusTrap
|
||||
ref="focusTrap"
|
||||
v-model:active="isActive"
|
||||
:return-focus-on-deactivate="!noReturnFocus"
|
||||
@deactivate="emit('close')"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
ref="itemsEl"
|
||||
class="rrevdjwt _popup _shadow"
|
||||
:class="{ center: align === 'center', asDrawer }"
|
||||
:style="{
|
||||
width: width && !asDrawer ? width + 'px' : '',
|
||||
maxHeight: maxHeight ? maxHeight + 'px' : '',
|
||||
}"
|
||||
@contextmenu.self="(e) => e.preventDefault()"
|
||||
tabindex="-1"
|
||||
>
|
||||
<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'"
|
||||
class="pending item"
|
||||
>
|
||||
<span><MkEllipsis /></span>
|
||||
</span>
|
||||
<MkA
|
||||
v-else-if="item.type === 'link'"
|
||||
:to="item.to"
|
||||
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>
|
||||
<MkAvatar
|
||||
v-if="item.avatar"
|
||||
:user="item.avatar"
|
||||
class="avatar"
|
||||
disableLink
|
||||
/>
|
||||
<span :style="item.textStyle || ''">{{
|
||||
item.text
|
||||
}}</span>
|
||||
<span
|
||||
v-if="item.indicate"
|
||||
class="indicator"
|
||||
:class="{
|
||||
animateIndicator: $store.state.animation,
|
||||
}"
|
||||
><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"
|
||||
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 :style="item.textStyle || ''">{{
|
||||
item.text
|
||||
}}</span>
|
||||
<span
|
||||
v-if="item.indicate"
|
||||
class="indicator"
|
||||
:class="{
|
||||
animateIndicator: $store.state.animation,
|
||||
}"
|
||||
><i class="ph-circle ph-fill"></i
|
||||
></span>
|
||||
</a>
|
||||
<button
|
||||
v-else-if="item.type === 'user' && !items.hidden"
|
||||
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"
|
||||
disableLink
|
||||
/><MkUserName :user="item.user" />
|
||||
<span
|
||||
v-if="item.indicate"
|
||||
class="indicator"
|
||||
:class="{
|
||||
animateIndicator: $store.state.animation,
|
||||
}"
|
||||
><i class="ph-circle ph-fill"></i
|
||||
></span>
|
||||
</button>
|
||||
<span
|
||||
v-else-if="item.type === 'switch'"
|
||||
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'"
|
||||
class="_button item parent"
|
||||
:class="{ childShowing: childShowingItem === item }"
|
||||
@mouseenter="showChildren(item, $event)"
|
||||
@click.stop="showChildren(item, $event)"
|
||||
>
|
||||
<i
|
||||
v-if="item.icon"
|
||||
class="ph-fw ph-lg"
|
||||
:class="item.icon"
|
||||
></i>
|
||||
<span :style="item.textStyle || ''">{{
|
||||
item.text
|
||||
}}</span>
|
||||
<i
|
||||
class="ph-caret-right ph-bold ph-lg ph-fw ph-lg"
|
||||
style="margin-left: auto;
|
||||
margin-right: unset;"
|
||||
></i
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!item.hidden"
|
||||
class="_button item"
|
||||
:class="{
|
||||
danger: item.danger,
|
||||
accent: item.accent,
|
||||
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>
|
||||
<MkAvatar
|
||||
v-if="item.avatar"
|
||||
:user="item.avatar"
|
||||
class="avatar"
|
||||
disableLink
|
||||
/>
|
||||
<span :style="item.textStyle || ''">{{
|
||||
item.text
|
||||
}}</span>
|
||||
<span
|
||||
v-if="item.indicate"
|
||||
class="indicator"
|
||||
:class="{
|
||||
animateIndicator: $store.state.animation,
|
||||
}"
|
||||
><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 v-if="childMenu" class="child">
|
||||
<XChild
|
||||
ref="child"
|
||||
:items="childMenu"
|
||||
:target-element="childTarget"
|
||||
:root-element="itemsEl"
|
||||
showing
|
||||
@actioned="childActioned"
|
||||
@closed="closeChild"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
computed,
|
||||
menu,
|
||||
defineAsyncComponent,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
Ref,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import { focusPrev, focusNext } from "@/scripts/focus";
|
||||
import FormSwitch from "@/components/form/switch.vue";
|
||||
import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from "@/types/menu";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
import { FocusTrap } from "focus-trap-vue";
|
||||
|
||||
const XChild = defineAsyncComponent(() => import("./MkMenu.child.vue"));
|
||||
const focusTrap = ref();
|
||||
const isActive = ref(true);
|
||||
|
||||
const props = defineProps<{
|
||||
items: MenuItem[];
|
||||
viaKeyboard?: boolean;
|
||||
asDrawer?: boolean;
|
||||
align?: "center" | string;
|
||||
width?: number;
|
||||
maxHeight?: number;
|
||||
noReturnFocus?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "close", actioned?: boolean): void;
|
||||
}>();
|
||||
|
||||
let itemsEl = ref<HTMLDivElement>();
|
||||
|
||||
let items2: InnerMenuItem[] = ref([]);
|
||||
|
||||
let child = ref<InstanceType<typeof XChild>>();
|
||||
|
||||
let childShowingItem = ref<MenuItem | null>();
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
const items: (MenuItem | MenuPending)[] = [...props.items].filter(
|
||||
(item) => item !== undefined,
|
||||
);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
if (item && "then" in item) {
|
||||
// if item is Promise
|
||||
items[i] = { type: "pending" };
|
||||
item.then((actualItem) => {
|
||||
items2.value[i] = actualItem;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items2.value = items as InnerMenuItem[];
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
let childMenu = ref<MenuItem[] | null>();
|
||||
let childTarget = ref<HTMLElement | null>();
|
||||
|
||||
function closeChild() {
|
||||
childMenu.value = null;
|
||||
childShowingItem.value = null;
|
||||
}
|
||||
|
||||
function childActioned() {
|
||||
closeChild();
|
||||
close(true);
|
||||
}
|
||||
|
||||
function onGlobalMousedown(event: MouseEvent) {
|
||||
if (
|
||||
childTarget.value &&
|
||||
(event.target === childTarget.value || childTarget.value.contains(event.target))
|
||||
)
|
||||
return;
|
||||
if (child.value && child.value.checkHit(event)) return;
|
||||
closeChild();
|
||||
}
|
||||
|
||||
let childCloseTimer: null | number = null;
|
||||
function onItemMouseEnter(item) {
|
||||
childCloseTimer = window.setTimeout(() => {
|
||||
closeChild();
|
||||
}, 300);
|
||||
}
|
||||
function onItemMouseLeave(item) {
|
||||
if (childCloseTimer) window.clearTimeout(childCloseTimer);
|
||||
}
|
||||
|
||||
async function showChildren(item: MenuItem, ev: MouseEvent) {
|
||||
if (props.asDrawer) {
|
||||
os.popupMenu(item.children, ev.currentTarget ?? ev.target);
|
||||
close();
|
||||
} else {
|
||||
childTarget.value = ev.currentTarget ?? ev.target;
|
||||
childMenu.value = item.children;
|
||||
childShowingItem.value = item;
|
||||
}
|
||||
}
|
||||
|
||||
function clicked(fn: MenuAction, ev: MouseEvent) {
|
||||
fn(ev);
|
||||
close(true);
|
||||
}
|
||||
|
||||
function close(actioned = false) {
|
||||
emit("close", actioned);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("mousedown", onGlobalMousedown, {
|
||||
passive: true,
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("mousedown", onGlobalMousedown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rrevdjwt {
|
||||
padding: 8px 0;
|
||||
box-sizing: border-box;
|
||||
min-width: 200px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&.center {
|
||||
> .item {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
> .item {
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
padding: 6px 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9em;
|
||||
line-height: 20px;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
width: calc(100% - 16px);
|
||||
margin-bottom: 0.2rem;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover,
|
||||
&:focus-visible {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
|
||||
&:before {
|
||||
background: var(--accentedBg);
|
||||
}
|
||||
}
|
||||
&:focus-visible:before {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #eb6f92;
|
||||
|
||||
&:hover {
|
||||
color: #e0def4;
|
||||
|
||||
&:before {
|
||||
background: #eb6f92;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: #e0def4;
|
||||
|
||||
&:before {
|
||||
background: #b4637a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.accent {
|
||||
color: var(--accent);
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
|
||||
&:before {
|
||||
background: var(--accentedBg);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--fgOnAccent);
|
||||
|
||||
&:before {
|
||||
background: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--fgOnAccent);
|
||||
opacity: 1;
|
||||
|
||||
&:before {
|
||||
background: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:active):focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus) inset;
|
||||
}
|
||||
|
||||
&.label {
|
||||
pointer-events: none;
|
||||
font-size: 0.7em;
|
||||
padding-bottom: 4px;
|
||||
|
||||
> span {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
&.pending {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.none {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.parent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
|
||||
&.childShowing {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
|
||||
&:before {
|
||||
background: var(--accentedBg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 5px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
margin-right: 5px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
> .indicator {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 13px;
|
||||
color: var(--indicator);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
> .animateIndicator {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
> .divider {
|
||||
margin: 8px 0;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
|
||||
&.asDrawer {
|
||||
padding: 12px 0 calc(env(safe-area-inset-bottom, 0px) + 12px) 0;
|
||||
width: 100%;
|
||||
border-radius: 24px;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
|
||||
> .item {
|
||||
font-size: 1em;
|
||||
padding: 12px 24px;
|
||||
|
||||
&:before {
|
||||
width: calc(100% - 24px);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
> i,
|
||||
.avatar {
|
||||
margin-right: 14px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
> .divider {
|
||||
margin: 12px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<svg :viewBox="`0 0 ${viewBoxX} ${viewBoxY}`" style="overflow: visible">
|
||||
<defs>
|
||||
<linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0">
|
||||
<stop offset="0%" :stop-color="color" stop-opacity="0"></stop>
|
||||
<stop
|
||||
offset="100%"
|
||||
:stop-color="color"
|
||||
stop-opacity="0.65"
|
||||
></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon
|
||||
:points="polygonPoints"
|
||||
:style="`stroke: none; fill: url(#${gradientId});`"
|
||||
/>
|
||||
<polyline
|
||||
:points="polylinePoints"
|
||||
fill="none"
|
||||
:stroke="color"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle :cx="headX" :cy="headY" r="3" :fill="color" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, watch, ref } from 'vue';
|
||||
import { v4 as uuid } from "uuid";
|
||||
import tinycolor from "tinycolor2";
|
||||
import { useInterval } from "@/scripts/use-interval";
|
||||
|
||||
const props = defineProps<{
|
||||
src: number[];
|
||||
}>();
|
||||
|
||||
const viewBoxX = 50;
|
||||
const viewBoxY = 50;
|
||||
const gradientId = uuid();
|
||||
let polylinePoints = ref("");
|
||||
let polygonPoints = ref("");
|
||||
let headX = ref<number | null>(null);
|
||||
let headY = ref<number | null>(null);
|
||||
let clock = ref<number | null>(null);
|
||||
const accent = tinycolor(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--accent"),
|
||||
);
|
||||
const color = accent.toRgbString();
|
||||
|
||||
function draw(): void {
|
||||
const stats = props.src.slice().reverse();
|
||||
const peak = Math.max.apply(null, stats) || 1;
|
||||
|
||||
const _polylinePoints = stats.map((n, i) => [
|
||||
i * (viewBoxX / (stats.length - 1)),
|
||||
(1 - n / peak) * viewBoxY,
|
||||
]);
|
||||
|
||||
polylinePoints.value = _polylinePoints.map((xy) => `${xy[0]},${xy[1]}`).join(" ");
|
||||
|
||||
polygonPoints.value = `0,${viewBoxY} ${polylinePoints.value} ${viewBoxX},${viewBoxY}`;
|
||||
|
||||
headX.value = _polylinePoints[_polylinePoints.length - 1][0];
|
||||
headY.value = _polylinePoints[_polylinePoints.length - 1][1];
|
||||
}
|
||||
|
||||
watch(() => props.src, draw, { immediate: true });
|
||||
|
||||
// Vueが何故かWatchを発動させない場合があるので
|
||||
useInterval(draw, 1000, {
|
||||
immediate: false,
|
||||
afterMounted: true,
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,620 @@
|
||||
<template>
|
||||
<Transition
|
||||
:name="transitionName"
|
||||
:enter-active-class="
|
||||
$style['transition_' + transitionName + '_enterActive']
|
||||
"
|
||||
:leave-active-class="
|
||||
$style['transition_' + transitionName + '_leaveActive']
|
||||
"
|
||||
:enter-from-class="
|
||||
$style['transition_' + transitionName + '_enterFrom']
|
||||
"
|
||||
:leave-to-class="$style['transition_' + transitionName + '_leaveTo']"
|
||||
:duration="transitionDuration"
|
||||
appear
|
||||
@after-leave="emit('closed')"
|
||||
@keyup.esc="emit('click')"
|
||||
@enter="emit('opening')"
|
||||
@after-enter="onOpened"
|
||||
>
|
||||
<FocusTrap
|
||||
v-model:active="isActive"
|
||||
:return-focus-on-deactivate="!noReturnFocus"
|
||||
>
|
||||
<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,
|
||||
}"
|
||||
tabindex="-1"
|
||||
v-focus
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, watch, provide, onUnmounted, ref, shallowRef, computed } from 'vue';
|
||||
import * as os from "@/os";
|
||||
import { isTouchUsing } from "@/scripts/touch";
|
||||
import { defaultStore } from "@/store";
|
||||
import { deviceKind } from "@/scripts/device-kind";
|
||||
import { FocusTrap } from "focus-trap-vue";
|
||||
|
||||
function getFixedContainer(el: Element | null): Element | null {
|
||||
if (el == null || el.tagName === "BODY") return null;
|
||||
const position = window.getComputedStyle(el).getPropertyValue("position");
|
||||
if (position === "fixed") {
|
||||
return el;
|
||||
} else {
|
||||
return getFixedContainer(el.parentElement);
|
||||
}
|
||||
}
|
||||
|
||||
type ModalTypes = "popup" | "dialog" | "dialog:top" | "drawer";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
manualShowing?: boolean | null;
|
||||
anchor?: { x: string; y: string };
|
||||
src?: HTMLElement;
|
||||
preferType?: ModalTypes | "auto";
|
||||
zPriority?: "low" | "middle" | "high";
|
||||
noOverlap?: boolean;
|
||||
transparentBg?: boolean;
|
||||
noReturnFocus?: boolean;
|
||||
}>(),
|
||||
{
|
||||
manualShowing: null,
|
||||
src: null,
|
||||
anchor: () => ({ x: "center", y: "bottom" }),
|
||||
preferType: "auto",
|
||||
zPriority: "low",
|
||||
noOverlap: true,
|
||||
transparentBg: false,
|
||||
noReturnFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "opening"): void;
|
||||
(ev: "opened"): void;
|
||||
(ev: "click"): void;
|
||||
(ev: "esc"): void;
|
||||
(ev: "close"): void;
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
provide("modal", true);
|
||||
|
||||
let maxHeight = ref<number>();
|
||||
let fixed = ref(false);
|
||||
let transformOrigin = ref("center");
|
||||
let showing = ref(true);
|
||||
let content = shallowRef<HTMLElement>();
|
||||
const zIndex = os.claimZIndex(props.zPriority);
|
||||
let useSendAnime = ref(false);
|
||||
const isActive = computed(
|
||||
() => (props.manualShowing != null ? props.manualShowing : showing.value),
|
||||
);
|
||||
const type = computed<ModalTypes>(() => {
|
||||
if (props.preferType === "auto") {
|
||||
if (
|
||||
!defaultStore.state.disableDrawer &&
|
||||
isTouchUsing &&
|
||||
deviceKind === "smartphone"
|
||||
) {
|
||||
return "drawer";
|
||||
} else {
|
||||
return props.src != null ? "popup" : "dialog";
|
||||
}
|
||||
} else {
|
||||
return props.preferType!;
|
||||
}
|
||||
});
|
||||
const isEnableBgTransparent = computed(
|
||||
() => props.transparentBg && type.value === "popup",
|
||||
);
|
||||
let transitionName = computed(() =>
|
||||
defaultStore.state.animation
|
||||
? useSendAnime.value
|
||||
? "send"
|
||||
: type.value === "drawer"
|
||||
? "modal-drawer"
|
||||
: type.value === "popup"
|
||||
? "modal-popup"
|
||||
: "modal"
|
||||
: "",
|
||||
);
|
||||
let transitionDuration = computed(() =>
|
||||
transitionName.value === "send"
|
||||
? 400
|
||||
: transitionName.value === "modal-popup"
|
||||
? 100
|
||||
: transitionName.value === "modal"
|
||||
? 200
|
||||
: transitionName.value === "modal-drawer"
|
||||
? 200
|
||||
: 0,
|
||||
);
|
||||
|
||||
let contentClicking = false;
|
||||
|
||||
const focusedElement = document.activeElement;
|
||||
function close(ev, opts: { useSendAnimation?: boolean } = {}) {
|
||||
// removeEventListener("popstate", close);
|
||||
// if (props.preferType == "dialog") {
|
||||
// history.forward();
|
||||
// }
|
||||
if (opts.useSendAnimation) {
|
||||
useSendAnime.value = true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
if (props.src) props.src.style.pointerEvents = "auto";
|
||||
showing.value = false;
|
||||
emit("close");
|
||||
if (!props.noReturnFocus) {
|
||||
focusedElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function onBgClick() {
|
||||
if (contentClicking) return;
|
||||
emit("click");
|
||||
}
|
||||
|
||||
if (type.value === "drawer") {
|
||||
maxHeight.value = window.innerHeight / 1.5;
|
||||
}
|
||||
|
||||
const keymap = {
|
||||
esc: () => emit("esc"),
|
||||
};
|
||||
|
||||
const MARGIN = 16;
|
||||
|
||||
const align = () => {
|
||||
if (props.src == null) return;
|
||||
if (type.value === "drawer") return;
|
||||
if (type.value === "dialog") return;
|
||||
|
||||
if (content.value == null) return;
|
||||
|
||||
const srcRect = props.src.getBoundingClientRect();
|
||||
|
||||
const width = content.value!.offsetWidth;
|
||||
const height = content.value!.offsetHeight;
|
||||
|
||||
let left;
|
||||
let top;
|
||||
|
||||
const x = srcRect.left + (fixed.value ? 0 : window.pageXOffset);
|
||||
const y = srcRect.top + (fixed.value ? 0 : window.pageYOffset);
|
||||
|
||||
if (props.anchor.x === "center") {
|
||||
left = x + props.src.offsetWidth / 2 - width / 2;
|
||||
} else if (props.anchor.x === "left") {
|
||||
// TODO
|
||||
} else if (props.anchor.x === "right") {
|
||||
left = x + props.src.offsetWidth;
|
||||
}
|
||||
|
||||
if (props.anchor.y === "center") {
|
||||
top = y - height / 2;
|
||||
} else if (props.anchor.y === "top") {
|
||||
// TODO
|
||||
} else if (props.anchor.y === "bottom") {
|
||||
top = y + props.src.offsetHeight;
|
||||
}
|
||||
|
||||
if (fixed.value) {
|
||||
// 画面から横にはみ出る場合
|
||||
if (left + width > window.innerWidth) {
|
||||
left = window.innerWidth - width;
|
||||
}
|
||||
|
||||
const underSpace = window.innerHeight - MARGIN - top;
|
||||
const upperSpace = srcRect.top - MARGIN;
|
||||
|
||||
// 画面から縦にはみ出る場合
|
||||
if (top + height > window.innerHeight - MARGIN) {
|
||||
if (props.noOverlap && props.anchor.x === "center") {
|
||||
if (underSpace >= upperSpace / 3) {
|
||||
maxHeight.value = underSpace;
|
||||
} else {
|
||||
maxHeight.value = upperSpace;
|
||||
top = upperSpace + MARGIN - height;
|
||||
}
|
||||
} else {
|
||||
top = window.innerHeight - MARGIN - height;
|
||||
}
|
||||
} else {
|
||||
maxHeight.value = underSpace;
|
||||
}
|
||||
} else {
|
||||
// 画面から横にはみ出る場合
|
||||
if (left + width - window.scrollX > window.innerWidth) {
|
||||
left = window.innerWidth - width + window.scrollX - 1;
|
||||
}
|
||||
|
||||
const underSpace =
|
||||
window.innerHeight - MARGIN - (top - window.pageYOffset);
|
||||
const upperSpace = srcRect.top - MARGIN;
|
||||
|
||||
// 画面から縦にはみ出る場合
|
||||
if (top + height - window.scrollY > window.innerHeight - MARGIN) {
|
||||
if (props.noOverlap && props.anchor.x === "center") {
|
||||
if (underSpace >= upperSpace / 3) {
|
||||
maxHeight.value = underSpace;
|
||||
} else {
|
||||
maxHeight.value = upperSpace;
|
||||
top = window.scrollY + (upperSpace + MARGIN - height);
|
||||
}
|
||||
} else {
|
||||
top =
|
||||
window.innerHeight -
|
||||
MARGIN -
|
||||
height +
|
||||
window.pageYOffset -
|
||||
1;
|
||||
}
|
||||
} else {
|
||||
maxHeight.value = underSpace;
|
||||
}
|
||||
}
|
||||
|
||||
if (top < 0) {
|
||||
top = MARGIN;
|
||||
}
|
||||
|
||||
if (left < 0) {
|
||||
left = 0;
|
||||
}
|
||||
|
||||
let transformOriginX = "center";
|
||||
let transformOriginY = "center";
|
||||
|
||||
if (
|
||||
top >=
|
||||
srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.pageYOffset)
|
||||
) {
|
||||
transformOriginY = "top";
|
||||
} else if (top + height <= srcRect.top + (fixed.value ? 0 : window.pageYOffset)) {
|
||||
transformOriginY = "bottom";
|
||||
}
|
||||
|
||||
if (
|
||||
left >=
|
||||
srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.pageXOffset)
|
||||
) {
|
||||
transformOriginX = "left";
|
||||
} else if (
|
||||
left + width <=
|
||||
srcRect.left + (fixed.value ? 0 : window.pageXOffset)
|
||||
) {
|
||||
transformOriginX = "right";
|
||||
}
|
||||
|
||||
transformOrigin.value = `${transformOriginX} ${transformOriginY}`;
|
||||
|
||||
content.value.style.left = left + "px";
|
||||
content.value.style.top = top + "px";
|
||||
};
|
||||
|
||||
const onOpened = () => {
|
||||
emit("opened");
|
||||
|
||||
// モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
|
||||
const el = content.value!.children[0];
|
||||
el.addEventListener(
|
||||
"mousedown",
|
||||
(ev) => {
|
||||
contentClicking = true;
|
||||
window.addEventListener(
|
||||
"mouseup",
|
||||
(ev) => {
|
||||
// click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
|
||||
window.setTimeout(() => {
|
||||
contentClicking = false;
|
||||
}, 100);
|
||||
},
|
||||
{ passive: true, once: true },
|
||||
);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
// if (props.preferType == "dialog") {
|
||||
// history.pushState(null, "", location.href);
|
||||
// }
|
||||
// addEventListener("popstate", close);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
watch(
|
||||
() => props.src,
|
||||
async () => {
|
||||
if (props.src) {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
props.src.style.pointerEvents = "none";
|
||||
}
|
||||
fixed.value = type.value === "drawer" || getFixedContainer(props.src) != null;
|
||||
|
||||
await nextTick();
|
||||
|
||||
align();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
nextTick(() => {
|
||||
new ResizeObserver((entries, observer) => {
|
||||
align();
|
||||
}).observe(content.value!);
|
||||
});
|
||||
});
|
||||
onUnmounted(() => {
|
||||
// removeEventListener("popstate", close);
|
||||
// if (props.preferType == "dialog") {
|
||||
// history.back();
|
||||
// }
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.transition_send_enterActive,
|
||||
.transition_send_leaveActive {
|
||||
> .bg {
|
||||
transition: opacity 0.3s !important;
|
||||
}
|
||||
|
||||
> .content {
|
||||
transform: translateY(0px);
|
||||
transition:
|
||||
opacity 0.3s ease-in,
|
||||
transform 0.3s cubic-bezier(0.5, -0.5, 1, 0.5) !important;
|
||||
}
|
||||
}
|
||||
.transition_send_enterFrom,
|
||||
.transition_send_leaveTo {
|
||||
> .bg {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
> .content {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(-300px);
|
||||
}
|
||||
}
|
||||
|
||||
.transition_modal_enterActive,
|
||||
.transition_modal_leaveActive {
|
||||
> .bg {
|
||||
transition: opacity 0.2s !important;
|
||||
}
|
||||
|
||||
> .content {
|
||||
transform-origin: var(--transformOrigin);
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
transform 0.2s !important;
|
||||
}
|
||||
}
|
||||
.transition_modal_enterFrom,
|
||||
.transition_modal_leaveTo {
|
||||
> .bg {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
> .content {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform-origin: var(--transformOrigin);
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.transition_modal-popup_enterActive,
|
||||
.transition_modal-popup_leaveActive {
|
||||
> .bg {
|
||||
transition: opacity 0.2s !important;
|
||||
}
|
||||
|
||||
> .content {
|
||||
transform-origin: var(--transformOrigin);
|
||||
transition:
|
||||
opacity 0.2s cubic-bezier(0, 0, 0.2, 1),
|
||||
transform 0.2s cubic-bezier(0, 0, 0.2, 1) !important;
|
||||
}
|
||||
}
|
||||
.transition_modal-popup_enterFrom,
|
||||
.transition_modal-popup_leaveTo {
|
||||
> .bg {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
> .content {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform-origin: var(--transformOrigin);
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.transition_modal-drawer_enterActive {
|
||||
> .bg {
|
||||
transition: opacity 0.2s !important;
|
||||
}
|
||||
|
||||
> .content {
|
||||
transition: transform 0.2s cubic-bezier(0, 0.5, 0, 1) !important;
|
||||
}
|
||||
}
|
||||
.transition_modal-drawer_leaveActive {
|
||||
> .bg {
|
||||
transition: opacity 0.2s !important;
|
||||
}
|
||||
|
||||
> .content {
|
||||
transition: transform 0.2s cubic-bezier(0, 0.5, 0, 1) !important;
|
||||
}
|
||||
}
|
||||
.transition_modal-drawer_enterFrom,
|
||||
.transition_modal-drawer_leaveTo {
|
||||
> .bg {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
> .content {
|
||||
pointer-events: none;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.root {
|
||||
outline: none;
|
||||
&.dialog {
|
||||
> .content {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
padding: 32px;
|
||||
// TODO: mask-imageはiOSだとやたら重い。なんとかしたい
|
||||
-webkit-mask-image: linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 1) 32px,
|
||||
rgba(0, 0, 0, 1) calc(100% - 32px),
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 1) 32px,
|
||||
rgba(0, 0, 0, 1) calc(100% - 32px),
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 16px;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 1) 16px,
|
||||
rgba(0, 0, 0, 1) calc(100% - 16px),
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 1) 16px,
|
||||
rgba(0, 0, 0, 1) calc(100% - 16px),
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
> ::v-deep(*) {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&.top {
|
||||
> ::v-deep(*) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.popup {
|
||||
> .content {
|
||||
position: absolute;
|
||||
|
||||
&.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: clip;
|
||||
|
||||
> .content {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
|
||||
> ::v-deep(*) {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bg {
|
||||
&.bgTransparent {
|
||||
background: transparent;
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="hrmcaedk _narrow_"
|
||||
:style="{
|
||||
width: `${width}px`,
|
||||
height: height ? `min(${height}px, 100%)` : '100%',
|
||||
}"
|
||||
>
|
||||
<div class="header" @contextmenu="onContextmenu">
|
||||
<button
|
||||
v-if="history.length > 0"
|
||||
v-tooltip="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()"
|
||||
:aria-label="i18n.t('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>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ComputedRef, provide, ref, computed } from 'vue';
|
||||
import MkModal from "@/components/MkModal.vue";
|
||||
import { popout as _popout } from "@/scripts/popout";
|
||||
import copyToClipboard from "@/scripts/copy-to-clipboard";
|
||||
import { url } from "@/config";
|
||||
import * as os from "@/os";
|
||||
import { mainRouter, routes } from "@/router";
|
||||
import { i18n } from "@/i18n";
|
||||
import {
|
||||
PageMetadata,
|
||||
provideMetadataReceiver,
|
||||
setPageMetadata,
|
||||
} from "@/scripts/page-metadata";
|
||||
import { Router } from "@/nirax";
|
||||
|
||||
const props = defineProps<{
|
||||
initialPath: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(ev: "closed"): void;
|
||||
(ev: "click"): void;
|
||||
}>();
|
||||
|
||||
const router = new Router(routes, props.initialPath);
|
||||
|
||||
router.addListener("push", (ctx) => {});
|
||||
|
||||
let pageMetadata = ref<null | ComputedRef<PageMetadata>>();
|
||||
let rootEl = ref();
|
||||
let modal = ref<InstanceType<typeof MkModal>>();
|
||||
let path = ref(props.initialPath);
|
||||
let width = ref(860);
|
||||
let height = ref(660);
|
||||
const history = [];
|
||||
|
||||
provide("router", router);
|
||||
provideMetadataReceiver((info) => {
|
||||
pageMetadata.value = info;
|
||||
});
|
||||
provide("shouldOmitHeaderTitle", true);
|
||||
provide("shouldHeaderThin", true);
|
||||
|
||||
const pageUrl = computed(() => url + path.value);
|
||||
const contextmenu = computed(() => {
|
||||
return [
|
||||
{
|
||||
type: "label",
|
||||
text: path.value,
|
||||
},
|
||||
{
|
||||
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.value, "_blank");
|
||||
modal.value.close();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "ph-link-simple ph-bold ph-lg",
|
||||
text: i18n.ts.copyLink,
|
||||
action: async () => {
|
||||
await copyToClipboard(pageUrl.value);
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function navigate(path, record = true) {
|
||||
if (record) history.push(router.getCurrentPath());
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
function back() {
|
||||
navigate(history.pop(), false);
|
||||
}
|
||||
|
||||
function expand() {
|
||||
mainRouter.push(path.value);
|
||||
modal.value.close();
|
||||
}
|
||||
|
||||
function popout() {
|
||||
_popout(path.value, rootEl.value);
|
||||
modal.value.close();
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
os.contextMenu(contextmenu.value, ev);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hrmcaedk {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
contain: content;
|
||||
border-radius: var(--radius);
|
||||
margin: auto;
|
||||
|
||||
--root-margin: 24px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
--root-margin: 16px;
|
||||
}
|
||||
|
||||
> .header {
|
||||
$height: 52px;
|
||||
$height-narrow: 42px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: $height;
|
||||
line-height: $height;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background: var(--windowHeader);
|
||||
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
|
||||
> button {
|
||||
height: $height;
|
||||
width: $height;
|
||||
|
||||
&:hover {
|
||||
color: var(--fgHighlighted);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
height: $height-narrow;
|
||||
line-height: $height-narrow;
|
||||
padding-left: 16px;
|
||||
|
||||
> button {
|
||||
height: $height-narrow;
|
||||
width: $height-narrow;
|
||||
}
|
||||
}
|
||||
|
||||
> .title {
|
||||
flex: 1;
|
||||
|
||||
> .icon {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .body {
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<MkModal
|
||||
ref="modal"
|
||||
:prefer-type="'dialog'"
|
||||
@click="onBgClick"
|
||||
@keyup.esc="$emit('close')"
|
||||
@closed="$emit('closed')"
|
||||
>
|
||||
<FocusTrap v-model:active="isActive">
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="ebkgoccj"
|
||||
:style="{
|
||||
width: `${props.width}px`,
|
||||
height: scroll
|
||||
? height
|
||||
? `${props.height}px`
|
||||
: null
|
||||
: height
|
||||
? `min(${props.height}px, 100%)`
|
||||
: '100%',
|
||||
}"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div ref="headerEl" class="header">
|
||||
<button
|
||||
v-if="props.withOkButton"
|
||||
:aria-label="i18n.t('close')"
|
||||
class="_button"
|
||||
@click="$emit('close')"
|
||||
v-tooltip="i18n.ts.close"
|
||||
>
|
||||
<i class="ph-x ph-bold ph-lg"></i>
|
||||
</button>
|
||||
<span class="title">
|
||||
<slot name="header"></slot>
|
||||
</span>
|
||||
<button
|
||||
v-if="!props.withOkButton"
|
||||
:aria-label="i18n.t('close')"
|
||||
class="_button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="ph-x ph-bold ph-lg"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="props.withOkButton"
|
||||
:aria-label="i18n.t('ok')"
|
||||
class="_button"
|
||||
:disabled="props.okButtonDisabled"
|
||||
@click="$emit('ok')"
|
||||
>
|
||||
<i class="ph-check ph-bold ph-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import { FocusTrap } from "focus-trap-vue";
|
||||
import MkModal from "./MkModal.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
withOkButton: boolean;
|
||||
okButtonDisabled: boolean;
|
||||
width: number;
|
||||
height: number | null;
|
||||
scroll: boolean;
|
||||
}>(),
|
||||
{
|
||||
withOkButton: false,
|
||||
okButtonDisabled: false,
|
||||
width: 400,
|
||||
height: null,
|
||||
scroll: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "click"): void;
|
||||
(event: "close"): void;
|
||||
(event: "closed"): void;
|
||||
(event: "ok"): void;
|
||||
}>();
|
||||
|
||||
let modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
let rootEl = shallowRef<HTMLElement>();
|
||||
let headerEl = shallowRef<HTMLElement>();
|
||||
const isActive = shallowRef(true);
|
||||
|
||||
const close = (ev) => {
|
||||
modal.value?.close(ev);
|
||||
};
|
||||
|
||||
const onBgClick = () => {
|
||||
emit("click");
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ebkgoccj {
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
contain: content;
|
||||
container-type: inline-size;
|
||||
border-radius: var(--radius);
|
||||
|
||||
--root-margin: 24px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
--root-margin: 16px;
|
||||
}
|
||||
|
||||
> .header {
|
||||
$height: 46px;
|
||||
$height-narrow: 42px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
background: var(--windowHeader);
|
||||
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
|
||||
> button {
|
||||
height: $height;
|
||||
width: $height;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
height: $height-narrow;
|
||||
width: $height-narrow;
|
||||
}
|
||||
}
|
||||
|
||||
> .title {
|
||||
flex: 1;
|
||||
line-height: $height;
|
||||
padding-left: 32px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
line-height: $height-narrow;
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
> button + .title {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--panel);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="msjugskd _block">
|
||||
<i
|
||||
class="ph-airplane-takeoff ph-bold ph-lg"
|
||||
style="margin-right: 8px"
|
||||
/>
|
||||
{{ i18n.ts.accountMoved }}
|
||||
<MkMention class="link" :username="acct" :host="host" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MkMention from "./MkMention.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
defineProps<{
|
||||
acct: string;
|
||||
host: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.msjugskd {
|
||||
padding: 16px;
|
||||
font-size: 90%;
|
||||
background: var(--infoWarnBg);
|
||||
color: var(--error);
|
||||
border-radius: var(--radius);
|
||||
|
||||
> .link {
|
||||
margin-left: 4px;
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,682 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!muted.muted"
|
||||
v-show="!isDeleted"
|
||||
ref="el"
|
||||
v-hotkey="keymap"
|
||||
v-size="{ max: [500, 350, 300] }"
|
||||
class="lxwezrsl _block"
|
||||
:tabindex="!isDeleted ? '-1' : null"
|
||||
:class="{ renote: isRenote }"
|
||||
>
|
||||
<MkNoteSub
|
||||
v-if="conversation"
|
||||
v-for="note in conversation"
|
||||
:key="note.id"
|
||||
class="reply-to"
|
||||
:note="note"
|
||||
:detailedView="true"
|
||||
:forceExpandCw="props.expandAllCws"
|
||||
/>
|
||||
<MkLoading v-else-if="note.reply" mini />
|
||||
<MkNoteSub
|
||||
v-if="note.reply"
|
||||
:note="note.reply"
|
||||
class="reply-to"
|
||||
:detailedView="true"
|
||||
:forceExpandCw="props.expandAllCws"
|
||||
/>
|
||||
|
||||
<MkNote
|
||||
ref="noteEl"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
tabindex="-1"
|
||||
:note="note"
|
||||
detailedView
|
||||
:forceExpandCw="props.expandAllCws"
|
||||
></MkNote>
|
||||
|
||||
<MkTab v-model="tab" :style="'underline'" @update:modelValue="loadTab">
|
||||
<option value="replies">
|
||||
<!-- <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> -->
|
||||
<span v-if="note.repliesCount > 0" class="count">{{
|
||||
note.repliesCount
|
||||
}}</span>
|
||||
{{ i18n.ts._notification._types.reply }}
|
||||
</option>
|
||||
<option value="renotes" v-if="note.renoteCount > 0">
|
||||
<!-- <i class="ph-repeat ph-bold ph-lg"></i> -->
|
||||
<span class="count">{{ note.renoteCount }}</span>
|
||||
{{ i18n.ts._notification._types.renote }}
|
||||
</option>
|
||||
<option value="views" disabled v-if="note.viewCount != null">
|
||||
<span class="count">{{ note.viewCount }}</span>
|
||||
Views
|
||||
</option>
|
||||
<option value="reactions" v-if="reactionsCount > 0">
|
||||
<!-- <i class="ph-smiley ph-bold ph-lg"></i> -->
|
||||
<span class="count">{{ reactionsCount }}</span>
|
||||
{{ i18n.ts.reaction }}
|
||||
</option>
|
||||
<option value="quotes" v-if="directQuotes?.length > 0">
|
||||
<!-- <i class="ph-quotes ph-bold ph-lg"></i> -->
|
||||
<span class="count">{{ directQuotes.length }}</span>
|
||||
{{ i18n.ts._notification._types.quote }}
|
||||
</option>
|
||||
<option value="clips" v-if="clips?.length > 0">
|
||||
<!-- <i class="ph-paperclip ph-bold ph-lg"></i> -->
|
||||
<span class="count">{{ clips.length }}</span>
|
||||
{{ i18n.ts.clips }}
|
||||
</option>
|
||||
</MkTab>
|
||||
|
||||
<MkNoteSub
|
||||
v-if="directReplies && tab === 'replies'"
|
||||
v-for="note in directReplies"
|
||||
:key="note.id"
|
||||
:note="note"
|
||||
class="reply"
|
||||
:conversation="replies"
|
||||
:detailedView="true"
|
||||
:parentId="note.id"
|
||||
:forceExpandCw="props.expandAllCws"
|
||||
/>
|
||||
<MkLoading v-else-if="tab === 'replies' && note.repliesCount > 0" />
|
||||
|
||||
<MkNoteSub
|
||||
v-if="directQuotes && tab === 'quotes'"
|
||||
v-for="note in directQuotes"
|
||||
:key="note.id"
|
||||
:note="note"
|
||||
class="reply"
|
||||
:conversation="replies"
|
||||
:detailedView="true"
|
||||
:parentId="note.id"
|
||||
:forceExpandCw="props.expandAllCws"
|
||||
/>
|
||||
<MkLoading v-else-if="tab === 'quotes' && directQuotes.length > 0" />
|
||||
|
||||
<!-- <MkPagination
|
||||
v-if="tab === 'renotes'"
|
||||
v-slot="{ items }"
|
||||
ref="pagingComponent"
|
||||
:pagination="pagination"
|
||||
> -->
|
||||
<MkUserCardMini
|
||||
v-if="tab === 'renotes' && renotes"
|
||||
v-for="item in renotes"
|
||||
:key="item.user.id"
|
||||
:user="item.user"
|
||||
:with-chart="false"
|
||||
/>
|
||||
<!-- </MkPagination> -->
|
||||
<MkLoading v-else-if="tab === 'renotes' && note.renoteCount > 0" />
|
||||
|
||||
<div v-if="tab === 'clips' && clips.length > 0" class="_content clips">
|
||||
<MkA
|
||||
v-for="item in clips"
|
||||
:key="item.id"
|
||||
:to="`/clips/${item.id}`"
|
||||
class="item _panel"
|
||||
>
|
||||
<b>{{ item.name }}</b>
|
||||
<div v-if="item.description" class="description">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
<div class="user">
|
||||
<MkAvatar
|
||||
:user="item.user"
|
||||
class="avatar"
|
||||
:show-indicator="true"
|
||||
/>
|
||||
<MkUserName :user="item.user" :nowrap="false" />
|
||||
</div>
|
||||
</MkA>
|
||||
</div>
|
||||
<MkLoading v-else-if="tab === 'clips' && clips.length > 0" />
|
||||
|
||||
<MkReactedUsers
|
||||
v-if="tab === 'reactions' && reactionsCount > 0"
|
||||
:note-id="note.id"
|
||||
></MkReactedUsers>
|
||||
</div>
|
||||
<div v-else class="_panel muted" @click="muted.muted = false">
|
||||
<I18n :src="softMuteReasonI18nSrc(muted.what)" tag="small">
|
||||
<template #name>
|
||||
<MkA
|
||||
v-user-preview="note.userId"
|
||||
class="name"
|
||||
:to="userPage(note.user)"
|
||||
>
|
||||
<MkUserName :user="note.user" />
|
||||
</MkA>
|
||||
</template>
|
||||
<template #reason>
|
||||
<b class="_blur_text">{{ muted.matched.join(", ") }}</b>
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
onUpdated,
|
||||
reactive,
|
||||
ref,
|
||||
} from "vue";
|
||||
import * as misskey from "iceshrimp-sdk";
|
||||
import MkTab from "@/components/MkTab.vue";
|
||||
import MkNote from "@/components/MkNote.vue";
|
||||
import MkNoteSub from "@/components/MkNoteSub.vue";
|
||||
import XStarButton from "@/components/MkStarButton.vue";
|
||||
import XRenoteButton from "@/components/MkRenoteButton.vue";
|
||||
import MkPagination from "@/components/MkPagination.vue";
|
||||
import MkUserCardMini from "@/components/MkUserCardMini.vue";
|
||||
import MkReactedUsers from "@/components/MkReactedUsers.vue";
|
||||
import { pleaseLogin } from "@/scripts/please-login";
|
||||
import { getWordSoftMute } from "@/scripts/check-word-mute";
|
||||
import { userPage } from "@/filters/user";
|
||||
import * as os from "@/os";
|
||||
import { defaultStore, noteViewInterruptors } from "@/store";
|
||||
import { reactionPicker } from "@/scripts/reaction-picker";
|
||||
import { $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 "iceshrimp-sdk/src/streaming.types";
|
||||
import appear from "@/directives/appear";
|
||||
|
||||
const props = defineProps<{
|
||||
note: misskey.entities.Note;
|
||||
pinned?: boolean;
|
||||
expandAllCws?: boolean;
|
||||
}>();
|
||||
|
||||
let tab = ref("replies");
|
||||
|
||||
let note = ref(deepClone(props.note));
|
||||
|
||||
const softMuteReasonI18nSrc = (what?: string) => {
|
||||
if (what === "note") return i18n.ts.userSaysSomethingReason;
|
||||
if (what === "reply") return i18n.ts.userSaysSomethingReasonReply;
|
||||
if (what === "renote") return i18n.ts.userSaysSomethingReasonRenote;
|
||||
if (what === "quote") return i18n.ts.userSaysSomethingReasonQuote;
|
||||
|
||||
// I don't think here is reachable, but just in case
|
||||
return i18n.ts.userSaysSomething;
|
||||
};
|
||||
|
||||
// plugin
|
||||
if (noteViewInterruptors.length > 0) {
|
||||
onMounted(async () => {
|
||||
let result = deepClone(note.value);
|
||||
for (const interruptor of noteViewInterruptors) {
|
||||
result = await interruptor.handler(result);
|
||||
}
|
||||
note.value = result;
|
||||
});
|
||||
}
|
||||
|
||||
const el = ref<HTMLElement>();
|
||||
const noteEl = ref();
|
||||
const menuButton = ref<HTMLElement>();
|
||||
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
|
||||
const reactButton = ref<HTMLElement>();
|
||||
const showContent = ref(defaultStore.state.alwaysExpandCws);
|
||||
const isDeleted = ref(false);
|
||||
const muted = ref(getWordSoftMute(note.value, $i, defaultStore.state.mutedWords));
|
||||
const translation = ref(null);
|
||||
const translating = ref(false);
|
||||
let conversation = ref<null | misskey.entities.Note[]>([]);
|
||||
const replies = ref<misskey.entities.Note[]>([]);
|
||||
let directReplies = ref<null | misskey.entities.Note[]>([]);
|
||||
let directQuotes = ref<null | misskey.entities.Note[]>([]);
|
||||
let clips = ref();
|
||||
let renotes = ref();
|
||||
let isScrolling;
|
||||
|
||||
const reactionsCount = Object.values(props.note.reactions).reduce(
|
||||
(x, y) => x + y,
|
||||
0,
|
||||
);
|
||||
|
||||
const keymap = {
|
||||
r: () => reply(true),
|
||||
"e|a|plus": () => react(true),
|
||||
q: () => renoteButton.value.renote(true),
|
||||
esc: blur,
|
||||
"m|o": () => menu(true),
|
||||
s: () => showContent.value !== showContent.value,
|
||||
};
|
||||
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
note: (note),
|
||||
isDeletedRef: isDeleted,
|
||||
});
|
||||
|
||||
function reply(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
os.post({
|
||||
reply: note.value,
|
||||
animation: !viaKeyboard,
|
||||
}).then(() => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
function react(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
blur();
|
||||
reactionPicker.show(
|
||||
reactButton.value,
|
||||
(reaction) => {
|
||||
os.api("notes/reactions/create", {
|
||||
noteId: note.value.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
focus();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function undoReact(note): void {
|
||||
const oldReaction = note.myReaction;
|
||||
if (!oldReaction) return;
|
||||
os.api("notes/reactions/delete", {
|
||||
noteId: note.id,
|
||||
});
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent): void {
|
||||
const isLink = (el: HTMLElement) => {
|
||||
if (el.tagName === "A") return true;
|
||||
if (el.parentElement) {
|
||||
return isLink(el.parentElement);
|
||||
}
|
||||
};
|
||||
if (isLink(ev.target)) return;
|
||||
if (window.getSelection().toString() !== "") return;
|
||||
|
||||
if (defaultStore.state.useReactionPickerForContextMenu) {
|
||||
ev.preventDefault();
|
||||
react();
|
||||
} else {
|
||||
os.contextMenu(
|
||||
getNoteMenu({
|
||||
note: note.value,
|
||||
translating,
|
||||
translation,
|
||||
menuButton,
|
||||
isDeleted,
|
||||
}),
|
||||
ev,
|
||||
).then(focus);
|
||||
}
|
||||
}
|
||||
|
||||
function menu(viaKeyboard = false): void {
|
||||
os.popupMenu(
|
||||
getNoteMenu({
|
||||
note: note.value,
|
||||
translating,
|
||||
translation,
|
||||
menuButton,
|
||||
isDeleted,
|
||||
}),
|
||||
menuButton.value,
|
||||
{
|
||||
viaKeyboard,
|
||||
},
|
||||
).then(focus);
|
||||
}
|
||||
|
||||
function focus() {
|
||||
noteEl.value.focus();
|
||||
}
|
||||
|
||||
function blur() {
|
||||
noteEl.value.blur();
|
||||
}
|
||||
|
||||
directReplies.value = null;
|
||||
os.api("notes/children", {
|
||||
noteId: note.value.id,
|
||||
limit: 30,
|
||||
depth: 12,
|
||||
}).then((res) => {
|
||||
res = res.reduce((acc, resNote) => {
|
||||
if (resNote.userId == note.value.userId) {
|
||||
return [...acc, resNote];
|
||||
}
|
||||
return [resNote, ...acc];
|
||||
}, []);
|
||||
replies.value = res;
|
||||
directReplies.value = res
|
||||
.filter((resNote) => resNote.replyId === note.value.id)
|
||||
.reverse();
|
||||
directQuotes.value = res.filter((resNote) => resNote.renoteId === note.value.id);
|
||||
});
|
||||
|
||||
conversation.value = null;
|
||||
if (note.value.replyId) {
|
||||
os.api("notes/conversation", {
|
||||
noteId: note.value.replyId,
|
||||
limit: 30,
|
||||
}).then((res) => {
|
||||
conversation.value = res.reverse();
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
clips.value = null;
|
||||
os.api("notes/clips", {
|
||||
noteId: note.value.id,
|
||||
}).then((res) => {
|
||||
clips.value = res;
|
||||
});
|
||||
|
||||
// const pagination = {
|
||||
// endpoint: "notes/renotes",
|
||||
// noteId: note.id,
|
||||
// limit: 10,
|
||||
// };
|
||||
|
||||
// const pagingComponent = $ref<InstanceType<typeof MkPagination>>();
|
||||
|
||||
renotes.value = null;
|
||||
function loadTab() {
|
||||
if (tab.value === "renotes" && !renotes.value) {
|
||||
os.api("notes/renotes", {
|
||||
noteId: note.value.id,
|
||||
limit: 100,
|
||||
}).then((res) => {
|
||||
renotes.value = res;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function onNoteUpdated(noteData: NoteUpdatedEvent): Promise<void> {
|
||||
const { type, id, body } = noteData;
|
||||
|
||||
switch (type) {
|
||||
case "replied":
|
||||
const createdId = body.id;
|
||||
const replyNote = await os.api("notes/show", {
|
||||
noteId: createdId,
|
||||
});
|
||||
delete replyNote.reply;
|
||||
replies.value.unshift(replyNote);
|
||||
if (id === note.value.id) {
|
||||
directReplies.value?.push(replyNote);
|
||||
}
|
||||
break;
|
||||
|
||||
case "deleted":
|
||||
if (id === note.value.id) {
|
||||
isDeleted.value = true;
|
||||
} else {
|
||||
replies.value.splice(replies.value.findIndex(p => p.id == id), 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("wheel", () => {
|
||||
isScrolling = true;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
stream.on("noteUpdated", onNoteUpdated);
|
||||
isScrolling = false;
|
||||
noteEl.value.scrollIntoView();
|
||||
});
|
||||
|
||||
onUpdated(() => {
|
||||
if (!isScrolling) {
|
||||
noteEl.value.scrollIntoView();
|
||||
if (location.hash) {
|
||||
location.replace(location.hash); // Jump to highlighted reply
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stream.off("noteUpdated", onNoteUpdated);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.lxwezrsl {
|
||||
font-size: 1.05em;
|
||||
position: relative;
|
||||
transition: box-shadow 0.1s ease;
|
||||
contain: content;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
width: calc(100% - 8px);
|
||||
height: calc(100% - 8px);
|
||||
border: solid 1px var(--focus);
|
||||
border-radius: var(--radius);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
> .reply-to {
|
||||
margin-bottom: -16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
> :deep(.note-container) {
|
||||
padding-block: 28px 0;
|
||||
padding-top: 12px;
|
||||
font-size: 1.1rem;
|
||||
overflow: clip;
|
||||
outline: none;
|
||||
scroll-margin-top: calc(var(--stickyTop) + 20vh);
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--divider);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.article {
|
||||
cursor: unset;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
&:first-child {
|
||||
padding-top: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
> :deep(.chips) {
|
||||
padding-block: 6px 12px;
|
||||
padding-left: 32px;
|
||||
&:last-child {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
> :deep(.user-card-mini),
|
||||
> :deep(.reacted-users > *) {
|
||||
padding-inline: 32px;
|
||||
border-top: 1px solid var(--divider);
|
||||
border-radius: 0;
|
||||
}
|
||||
> :deep(.reacted-users > div) {
|
||||
padding-block: 12px;
|
||||
}
|
||||
|
||||
> .reply {
|
||||
border-top: solid 0.5px var(--divider);
|
||||
padding-top: 24px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
// Hover
|
||||
:deep(.reply > .main),
|
||||
.reply-to,
|
||||
:deep(.more) {
|
||||
position: relative;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -12px -24px;
|
||||
bottom: -0px;
|
||||
background: var(--panelHighlight);
|
||||
border-radius: var(--radius);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
background 0.2s;
|
||||
z-index: -1;
|
||||
}
|
||||
&.reply-to {
|
||||
&::before {
|
||||
inset: 0px 8px;
|
||||
}
|
||||
&:not(.max-width_500px)::before {
|
||||
bottom: 16px;
|
||||
}
|
||||
&:first-of-type::before {
|
||||
top: 12px;
|
||||
}
|
||||
&.reply.max-width_500px:first-of-type::before {
|
||||
top: 4px;
|
||||
}
|
||||
}
|
||||
// &::after {
|
||||
// content: "";
|
||||
// position: absolute;
|
||||
// inset: -9999px;
|
||||
// background: var(--modalBg);
|
||||
// opacity: 0;
|
||||
// z-index: -2;
|
||||
// pointer-events: none;
|
||||
// transition: opacity .2s;
|
||||
// }
|
||||
&.more::before {
|
||||
inset: 0 !important;
|
||||
}
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
--panel: var(--panelHighlight);
|
||||
&::before {
|
||||
opacity: 1;
|
||||
background: var(--panelHighlight) !important;
|
||||
}
|
||||
}
|
||||
// @media (pointer: coarse) {
|
||||
// &:has(.button:focus-within) {
|
||||
// z-index: 2;
|
||||
// --dividerHovered: transparent;
|
||||
// &::after {
|
||||
// opacity: 1;
|
||||
// backdrop-filter: var(--modalBgFilter);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
:deep(.reply:target > .main),
|
||||
:deep(.reply-to:target) {
|
||||
z-index: 2;
|
||||
&::before {
|
||||
outline: auto;
|
||||
opacity: 1;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
&.max-width_500px {
|
||||
font-size: 0.975em;
|
||||
> .reply-to {
|
||||
&::before {
|
||||
inset-inline: -24px;
|
||||
}
|
||||
&:first-child {
|
||||
padding-top: 14px;
|
||||
&::before {
|
||||
top: -24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> :deep(.note-container) {
|
||||
padding: 12px 0 0 0;
|
||||
font-size: 1.05rem;
|
||||
> .header > .body {
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
> .clips,
|
||||
> :deep(.user-card-mini),
|
||||
> :deep(.reacted-users > *) {
|
||||
padding-inline: 16px !important;
|
||||
}
|
||||
> :deep(.underline) {
|
||||
padding-left: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_300px {
|
||||
font-size: 0.825em;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.clips {
|
||||
// want to redesign at some point
|
||||
padding: 24px 32px;
|
||||
padding-top: 0;
|
||||
> .item {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
// background: var(--buttonBg);
|
||||
border: 1px solid var(--divider);
|
||||
margin-bottom: var(--margin);
|
||||
transition: background 0.2s;
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
background: var(--panelHighlight);
|
||||
}
|
||||
|
||||
> .description {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
> .user {
|
||||
$height: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
line-height: $height;
|
||||
|
||||
> .avatar {
|
||||
width: $height;
|
||||
height: $height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<header class="kkwtjztg">
|
||||
<div class="user-info">
|
||||
<div>
|
||||
<MkA
|
||||
v-user-preview="note.user.id"
|
||||
class="name"
|
||||
:to="userPage(note.user)"
|
||||
@click.stop
|
||||
>
|
||||
<MkUserName :user="note.user" class="mkusername">
|
||||
<span v-if="note.user.isBot" class="is-bot">bot</span>
|
||||
</MkUserName>
|
||||
</MkA>
|
||||
<div class="username"><MkAcct :user="note.user" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="info">
|
||||
<MkA class="created-at" :to="notePage(note)">
|
||||
<MkTime :time="note.createdAt" />
|
||||
<i
|
||||
v-if="note.updatedAt"
|
||||
v-tooltip.noDelay="
|
||||
i18n.t('edited', {
|
||||
date: new Date(
|
||||
note.updatedAt,
|
||||
).toLocaleDateString(),
|
||||
time: new Date(
|
||||
note.updatedAt,
|
||||
).toLocaleTimeString(),
|
||||
})
|
||||
"
|
||||
class="ph-pencil ph-bold"
|
||||
style="margin-left: 0.4rem"
|
||||
></i>
|
||||
</MkA>
|
||||
<MkVisibility :note="note" />
|
||||
</div>
|
||||
<MkInstanceTicker
|
||||
v-if="showTicker"
|
||||
class="ticker"
|
||||
:instance="note.user.instance"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {} from "vue";
|
||||
import type * as misskey from "iceshrimp-sdk";
|
||||
import { defaultStore, noteViewInterruptors } from "@/store";
|
||||
import MkVisibility from "@/components/MkVisibility.vue";
|
||||
import MkInstanceTicker from "@/components/MkInstanceTicker.vue";
|
||||
import { notePage } from "@/filters/note";
|
||||
import { userPage } from "@/filters/user";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
note: misskey.entities.Note;
|
||||
pinned?: boolean;
|
||||
}>();
|
||||
|
||||
let note = ref(props.note);
|
||||
|
||||
const showTicker =
|
||||
defaultStore.state.instanceTicker === "always" ||
|
||||
(defaultStore.state.instanceTicker === "remote" && note.value.user.instance);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.kkwtjztg {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
justify-self: flex-end;
|
||||
border-radius: 100px;
|
||||
font-size: 0.8em;
|
||||
width: 100%;
|
||||
> .avatar {
|
||||
width: 3.7em;
|
||||
height: 3.7em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
> .user-info {
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
font-size: 1.2em;
|
||||
> div {
|
||||
&:first-child {
|
||||
flex-grow: 1;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
gap: 0.1em 0;
|
||||
}
|
||||
&:last-child {
|
||||
max-width: 50%;
|
||||
gap: 0.3em 0.5em;
|
||||
}
|
||||
.article > .main & {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
&:last-child {
|
||||
align-items: flex-end;
|
||||
}
|
||||
> * {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.name {
|
||||
// flex: 1 1 0px;
|
||||
display: inline;
|
||||
margin: 0 0.5em 0 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.mkusername > .is-bot {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
margin: 0 0.5em 0 0;
|
||||
padding: 1px 6px;
|
||||
font-size: 80%;
|
||||
border: solid 0.5px var(--divider);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.username {
|
||||
display: inline;
|
||||
margin: 0 0.5em 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
align-self: flex-start;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.5em;
|
||||
font-size: 0.9em;
|
||||
.created-at {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.ticker {
|
||||
display: inline-flex;
|
||||
margin-left: 0.5em;
|
||||
vertical-align: middle;
|
||||
> .name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div v-size="{ min: [350, 500] }" class="fefdfafb">
|
||||
<MkAvatar class="avatar" :user="$i" disableLink />
|
||||
<div class="main">
|
||||
<div class="header">
|
||||
<MkUserName :user="$i" />
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="content">
|
||||
<Mfm
|
||||
:text="preprocess(text).trim()"
|
||||
:author="$i"
|
||||
:i="$i"
|
||||
advancedMfm
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {} from "vue";
|
||||
import { preprocess } from "@/scripts/preprocess";
|
||||
|
||||
const props = defineProps<{
|
||||
text: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fefdfafb {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: clip;
|
||||
font-size: 0.95em;
|
||||
|
||||
&.min-width_350px {
|
||||
> .avatar {
|
||||
margin: 0 10px 0 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
&.min-width_500px {
|
||||
> .avatar {
|
||||
margin: 0 12px 0 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
margin: 0 10px 0 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> .main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
> .header {
|
||||
margin-bottom: 2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> .body {
|
||||
> .cw {
|
||||
cursor: default;
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
> .text {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
> .text {
|
||||
cursor: default;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div v-size="{ min: [350, 500] }" class="yohlumlk">
|
||||
<MkAvatar class="avatar" :user="note.user" />
|
||||
<div class="main">
|
||||
<XNoteHeader class="header" :note="note" :mini="true" />
|
||||
<div class="body">
|
||||
<MkSubNoteContent class="text" :note="note" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {} from "vue";
|
||||
import * as misskey from "iceshrimp-sdk";
|
||||
import XNoteHeader from "@/components/MkNoteHeader.vue";
|
||||
import MkSubNoteContent from "@/components/MkSubNoteContent.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
note: misskey.entities.Note;
|
||||
pinned?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.yohlumlk {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: clip;
|
||||
font-size: 0.95em;
|
||||
|
||||
&.min-width_350px {
|
||||
> .avatar {
|
||||
margin: 0 10px 0 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
&.min-width_500px {
|
||||
> .avatar {
|
||||
margin: 0 12px 0 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
margin: 0 10px 0 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
> .main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
> .header {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,795 @@
|
||||
<template>
|
||||
<article
|
||||
v-if="!muted.muted || muted.what === 'reply'"
|
||||
ref="el"
|
||||
v-size="{ max: [450, 500] }"
|
||||
class="wrpstxzv"
|
||||
:id="detailedView ? appearNote.id : null"
|
||||
tabindex="-1"
|
||||
:class="{
|
||||
children: depth > 1,
|
||||
singleStart: replies.length == 1,
|
||||
firstColumn: depth == 1 && conversation,
|
||||
}"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
>
|
||||
<div v-if="conversation && depth > 1" class="line"></div>
|
||||
<div
|
||||
class="main"
|
||||
@click="noteClick"
|
||||
:style="{ cursor: expandOnNoteClick ? 'pointer' : '' }"
|
||||
>
|
||||
<div class="avatar-container">
|
||||
<MkAvatar class="avatar" :user="appearNote.user" />
|
||||
<div
|
||||
v-if="!conversation || replies.length > 0"
|
||||
class="line"
|
||||
></div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<XNoteHeader class="header" :note="note" :mini="true" />
|
||||
<div class="body">
|
||||
<MkSubNoteContent
|
||||
class="text"
|
||||
:note="note"
|
||||
:parentId="parentId"
|
||||
:conversation="conversation"
|
||||
:detailedView="detailedView"
|
||||
:forceExpandCw="props.forceExpandCw"
|
||||
@focusfooter="footerEl.focus()"
|
||||
/>
|
||||
<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>
|
||||
<footer ref="footerEl" class="footer" tabindex="-1">
|
||||
<span v-if="appearNote.viewCount != null" class="views">
|
||||
<i class="ph-eye ph-bold"></i>
|
||||
{{ appearNote.viewCount }}
|
||||
</span>
|
||||
<XReactionsViewer
|
||||
v-if="enableEmojiReactions"
|
||||
ref="reactionsViewer"
|
||||
:note="appearNote"
|
||||
/>
|
||||
<button
|
||||
v-tooltip.noDelay.bottom="i18n.ts.reply"
|
||||
class="button _button"
|
||||
@click.stop="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"
|
||||
/>
|
||||
<XStarButtonNoEmoji
|
||||
v-if="!enableEmojiReactions"
|
||||
class="button"
|
||||
:note="appearNote"
|
||||
:count="
|
||||
Object.values(appearNote.reactions).reduce(
|
||||
(partialSum, val) => partialSum + val,
|
||||
0,
|
||||
)
|
||||
"
|
||||
:reacted="appearNote.myReaction != null"
|
||||
/>
|
||||
<XStarButton
|
||||
v-if="
|
||||
enableEmojiReactions &&
|
||||
appearNote.myReaction == null
|
||||
"
|
||||
ref="starButton"
|
||||
class="button"
|
||||
:note="appearNote"
|
||||
/>
|
||||
<button
|
||||
v-if="
|
||||
enableEmojiReactions &&
|
||||
appearNote.myReaction == null
|
||||
"
|
||||
ref="reactButton"
|
||||
v-tooltip.noDelay.bottom="i18n.ts.reaction"
|
||||
class="button _button"
|
||||
@click.stop="react()"
|
||||
>
|
||||
<i class="ph-smiley ph-bold ph-lg"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="
|
||||
enableEmojiReactions &&
|
||||
appearNote.myReaction != null
|
||||
"
|
||||
ref="reactButton"
|
||||
class="button _button reacted"
|
||||
@click.stop="undoReact(appearNote)"
|
||||
v-tooltip.noDelay.bottom="i18n.ts.removeReaction"
|
||||
>
|
||||
<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.stop="menu()"
|
||||
>
|
||||
<i class="ph-dots-three-outline ph-bold ph-lg"></i>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="conversation">
|
||||
<MkNoteSub
|
||||
v-if="replyLevel < 11 && depth < 5"
|
||||
v-for="reply in replies"
|
||||
:key="reply.id"
|
||||
:note="reply"
|
||||
class="reply"
|
||||
:class="{ single: replies.length == 1 }"
|
||||
:conversation="conversation"
|
||||
:depth="replies.length == 1 ? depth : depth + 1"
|
||||
:replyLevel="replyLevel + 1"
|
||||
:parentId="appearNote.id"
|
||||
:detailedView="detailedView"
|
||||
:forceExpandCw="props.forceExpandCw"
|
||||
/>
|
||||
<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>
|
||||
</article>
|
||||
<div v-else class="muted" @click="muted.muted = false">
|
||||
<I18n :src="softMuteReasonI18nSrc(muted.what)" tag="small">
|
||||
<template #name>
|
||||
<MkA
|
||||
v-user-preview="note.userId"
|
||||
class="name"
|
||||
:to="userPage(note.user)"
|
||||
>
|
||||
<MkUserName :user="note.user" />
|
||||
</MkA>
|
||||
</template>
|
||||
<template #reason>
|
||||
<b class="_blur_text">{{ muted.matched.join(", ") }}</b>
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { inject, ref, computed } from 'vue';
|
||||
import type { Ref } from "vue";
|
||||
import * as misskey from "iceshrimp-sdk";
|
||||
import XNoteHeader from "@/components/MkNoteHeader.vue";
|
||||
import MkSubNoteContent from "@/components/MkSubNoteContent.vue";
|
||||
import XReactionsViewer from "@/components/MkReactionsViewer.vue";
|
||||
import XStarButton from "@/components/MkStarButton.vue";
|
||||
import XStarButtonNoEmoji from "@/components/MkStarButtonNoEmoji.vue";
|
||||
import XRenoteButton from "@/components/MkRenoteButton.vue";
|
||||
import XQuoteButton from "@/components/MkQuoteButton.vue";
|
||||
import copyToClipboard from "@/scripts/copy-to-clipboard";
|
||||
import { url } from "@/config";
|
||||
import { pleaseLogin } from "@/scripts/please-login";
|
||||
import { getNoteMenu } from "@/scripts/get-note-menu";
|
||||
import { getWordSoftMute } from "@/scripts/check-word-mute";
|
||||
import { notePage } from "@/filters/note";
|
||||
import { useRouter } from "@/router";
|
||||
import { userPage } from "@/filters/user";
|
||||
import * as os from "@/os";
|
||||
import { reactionPicker } from "@/scripts/reaction-picker";
|
||||
import { $i } from "@/account";
|
||||
import { i18n } from "@/i18n";
|
||||
import { useNoteCapture } from "@/scripts/use-note-capture";
|
||||
import { defaultStore } from "@/store";
|
||||
import { deepClone } from "@/scripts/clone";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
note: misskey.entities.Note;
|
||||
conversation?: misskey.entities.Note[];
|
||||
parentId?;
|
||||
detailedView?;
|
||||
forceExpandCw?: boolean;
|
||||
|
||||
// how many notes are in between this one and the note being viewed in detail
|
||||
depth?: number;
|
||||
// the actual reply level of this note within the conversation thread
|
||||
replyLevel?: number;
|
||||
}>(),
|
||||
{
|
||||
depth: 1,
|
||||
replyLevel: 1,
|
||||
},
|
||||
);
|
||||
|
||||
let note = ref(deepClone(props.note));
|
||||
|
||||
const softMuteReasonI18nSrc = (what?: string) => {
|
||||
if (what === "note") return i18n.ts.userSaysSomethingReason;
|
||||
if (what === "reply") return i18n.ts.userSaysSomethingReasonReply;
|
||||
if (what === "renote") return i18n.ts.userSaysSomethingReasonRenote;
|
||||
if (what === "quote") return i18n.ts.userSaysSomethingReasonQuote;
|
||||
|
||||
// I don't think here is reachable, but just in case
|
||||
return i18n.ts.userSaysSomething;
|
||||
};
|
||||
|
||||
const isRenote =
|
||||
note.value.renote != null &&
|
||||
note.value.text == null &&
|
||||
note.value.fileIds.length === 0 &&
|
||||
note.value.poll == null;
|
||||
|
||||
const el = ref<HTMLElement>();
|
||||
const footerEl = ref<HTMLElement>();
|
||||
const menuButton = ref<HTMLElement>();
|
||||
const starButton = ref<InstanceType<typeof XStarButton>>();
|
||||
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
|
||||
const reactButton = ref<HTMLElement>();
|
||||
let appearNote = computed(() =>
|
||||
isRenote ? (note.value.renote as misskey.entities.Note) : note.value,
|
||||
);
|
||||
const isDeleted = ref(false);
|
||||
const muted = ref(getWordSoftMute(note.value, $i, defaultStore.state.mutedWords));
|
||||
const translation = ref(null);
|
||||
const translating = ref(false);
|
||||
const replies: misskey.entities.Note[] =
|
||||
computed(() => props.conversation
|
||||
?.filter(
|
||||
(item) =>
|
||||
item.replyId === props.note.id ||
|
||||
item.renoteId === props.note.id,
|
||||
)
|
||||
.reverse() ?? []);
|
||||
const enableEmojiReactions = defaultStore.state.enableEmojiReactions;
|
||||
const expandOnNoteClick = defaultStore.state.expandOnNoteClick;
|
||||
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
note: (appearNote),
|
||||
isDeletedRef: isDeleted,
|
||||
});
|
||||
|
||||
function reply(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
os.post({
|
||||
reply: appearNote.value,
|
||||
animation: !viaKeyboard,
|
||||
}).then(() => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
function react(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
blur();
|
||||
reactionPicker.show(
|
||||
reactButton.value,
|
||||
(reaction) => {
|
||||
os.api("notes/reactions/create", {
|
||||
noteId: appearNote.value.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
focus();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function undoReact(note): void {
|
||||
const oldReaction = note.myReaction;
|
||||
if (!oldReaction) return;
|
||||
os.api("notes/reactions/delete", {
|
||||
noteId: note.id,
|
||||
});
|
||||
}
|
||||
|
||||
const currentClipPage = inject<Ref<misskey.entities.Clip> | null>(
|
||||
"currentClipPage",
|
||||
null,
|
||||
);
|
||||
|
||||
function menu(viaKeyboard = false): void {
|
||||
os.popupMenu(
|
||||
getNoteMenu({
|
||||
note: note.value,
|
||||
translating,
|
||||
translation,
|
||||
menuButton,
|
||||
isDeleted,
|
||||
currentClipPage,
|
||||
}),
|
||||
menuButton.value,
|
||||
{
|
||||
viaKeyboard,
|
||||
},
|
||||
).then(focus);
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent): void {
|
||||
const isLink = (el: HTMLElement) => {
|
||||
if (el.tagName === "A") return true;
|
||||
if (el.parentElement) {
|
||||
return isLink(el.parentElement);
|
||||
}
|
||||
};
|
||||
if (isLink(ev.target)) return;
|
||||
if (window.getSelection().toString() !== "") return;
|
||||
|
||||
if (defaultStore.state.useReactionPickerForContextMenu) {
|
||||
ev.preventDefault();
|
||||
react();
|
||||
} else {
|
||||
os.contextMenu(
|
||||
[
|
||||
{
|
||||
type: "label",
|
||||
text: notePage(appearNote.value),
|
||||
},
|
||||
{
|
||||
icon: "ph-browser ph-bold ph-lg",
|
||||
text: i18n.ts.openInWindow,
|
||||
action: () => {
|
||||
os.pageWindow(notePage(appearNote.value));
|
||||
},
|
||||
},
|
||||
notePage(appearNote.value) != location.pathname
|
||||
? {
|
||||
icon: "ph-arrows-out-simple ph-bold ph-lg",
|
||||
text: i18n.ts.showInPage,
|
||||
action: () => {
|
||||
router.push(notePage(appearNote.value), "forcePage");
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
null,
|
||||
{
|
||||
type: "a",
|
||||
icon: "ph-arrow-square-out ph-bold ph-lg",
|
||||
text: i18n.ts.openInNewTab,
|
||||
href: notePage(appearNote.value),
|
||||
target: "_blank",
|
||||
},
|
||||
{
|
||||
icon: "ph-link-simple ph-bold ph-lg",
|
||||
text: i18n.ts.copyLink,
|
||||
action: async () => {
|
||||
await copyToClipboard(`${url}${notePage(appearNote.value)}`);
|
||||
},
|
||||
},
|
||||
note.value.user.host != null
|
||||
? {
|
||||
type: "a",
|
||||
icon: "ph-arrow-square-up-right ph-bold ph-lg",
|
||||
text: i18n.ts.showOnRemote,
|
||||
href: note.value.url ?? note.value.uri ?? "",
|
||||
target: "_blank",
|
||||
}
|
||||
: undefined,
|
||||
],
|
||||
ev,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function focus() {
|
||||
el.value.focus();
|
||||
}
|
||||
|
||||
function blur() {
|
||||
el.value.blur();
|
||||
}
|
||||
|
||||
function noteClick(e) {
|
||||
if (document.getSelection().type === "Range" || !expandOnNoteClick) {
|
||||
e.stopPropagation();
|
||||
} else {
|
||||
router.push(notePage(props.note));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wrpstxzv {
|
||||
padding: 16px 32px;
|
||||
outline: none;
|
||||
&.children {
|
||||
padding: 10px 0 0 var(--indent);
|
||||
padding-left: var(--indent) !important;
|
||||
font-size: 1em;
|
||||
cursor: auto;
|
||||
|
||||
&.max-width_500px {
|
||||
padding: 10px 0 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .main {
|
||||
display: flex;
|
||||
|
||||
> .avatar-container {
|
||||
margin-right: 8px;
|
||||
z-index: 2;
|
||||
> .avatar {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .body {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0 -200px;
|
||||
padding: 0 200px;
|
||||
overflow: clip;
|
||||
@media (pointer: coarse) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
> .header {
|
||||
margin-bottom: 2px;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
> .body {
|
||||
> .translation {
|
||||
border: solid 0.5px var(--divider);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
> .footer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> :deep(.button) {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
opacity: 0.7;
|
||||
&:disabled {
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
flex-grow: 1;
|
||||
max-width: 3.5em;
|
||||
width: max-content;
|
||||
min-width: max-content;
|
||||
height: auto;
|
||||
transition: opacity 0.2s;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
bottom: 2px;
|
||||
background: var(--panel);
|
||||
z-index: -1;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
&:first-of-type {
|
||||
margin-left: -0.5em;
|
||||
&::before {
|
||||
border-radius: 100px 0 0 100px;
|
||||
}
|
||||
}
|
||||
&:last-of-type {
|
||||
&::before {
|
||||
border-radius: 0 100px 100px 0;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
color: var(--fgHighlighted);
|
||||
}
|
||||
|
||||
> i {
|
||||
display: inline !important;
|
||||
}
|
||||
|
||||
> .count {
|
||||
display: inline;
|
||||
margin: 0 0 0 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.reacted {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.reply > .main {
|
||||
margin-inline: -200px;
|
||||
padding-inline: 200px;
|
||||
&::before {
|
||||
inset-inline: 176px !important;
|
||||
}
|
||||
}
|
||||
&.reply,
|
||||
&.reply-to {
|
||||
> .main > .body {
|
||||
margin-right: -24px;
|
||||
padding-right: 24px;
|
||||
margin-top: -12px;
|
||||
padding-top: 12px;
|
||||
margin-left: calc(0px - var(--avatarSize) - 32px);
|
||||
padding-left: calc(var(--avatarSize) + 32px);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
}
|
||||
&.reply-to {
|
||||
> .main > .body {
|
||||
margin-left: calc(0px - var(--avatarSize) - 38px);
|
||||
padding-left: calc(var(--avatarSize) + 38px);
|
||||
margin-top: -16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
}
|
||||
&.reply {
|
||||
--avatarSize: 38px;
|
||||
.avatar-container {
|
||||
margin-right: 8px !important;
|
||||
}
|
||||
}
|
||||
> .reply,
|
||||
> .more {
|
||||
margin-top: 10px;
|
||||
&.single {
|
||||
padding: 0 !important;
|
||||
> .line {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .more {
|
||||
display: flex;
|
||||
padding-block: 10px;
|
||||
font-weight: 600;
|
||||
> .line {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-grow: 0 !important;
|
||||
margin-top: -10px !important;
|
||||
margin-bottom: 10px !important;
|
||||
margin-right: 10px !important;
|
||||
&::before {
|
||||
border-left-style: dashed !important;
|
||||
border-bottom-left-radius: 100px !important;
|
||||
}
|
||||
}
|
||||
i {
|
||||
font-size: 1em !important;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
a {
|
||||
position: static;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
&::after {
|
||||
content: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.reply,
|
||||
&.reply-to,
|
||||
&.reply-to-more {
|
||||
> .main:hover,
|
||||
> .main:focus-within {
|
||||
:deep(.footer .button) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.reply-to,
|
||||
&.reply-to-more {
|
||||
padding-bottom: 0;
|
||||
&:first-child {
|
||||
padding-top: 24px;
|
||||
}
|
||||
.line::before {
|
||||
margin-bottom: -16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Reply Lines
|
||||
&.reply,
|
||||
&.reply-to,
|
||||
&.reply-to-more {
|
||||
--indent: calc(var(--avatarSize) - 5px);
|
||||
> .main {
|
||||
> .avatar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-right: 14px;
|
||||
width: var(--avatarSize);
|
||||
> .avatar {
|
||||
width: var(--avatarSize);
|
||||
height: var(--avatarSize);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.line {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: var(--avatarSize);
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
margin-bottom: -10px;
|
||||
pointer-events: none;
|
||||
opacity: 0.25;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-left: 2px solid currentColor;
|
||||
margin-left: calc((var(--avatarSize) / 2) - 1px);
|
||||
width: calc(var(--indent) / 2);
|
||||
inset-block: 0;
|
||||
min-height: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.reply-to,
|
||||
&.reply-to-more {
|
||||
> .main > .avatar-container > .line {
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
}
|
||||
&.single,
|
||||
&.singleStart {
|
||||
> .main > .avatar-container > .line {
|
||||
margin-bottom: -10px !important;
|
||||
}
|
||||
}
|
||||
.reply.children:not(:last-child) {
|
||||
// Line that goes through multiple replies
|
||||
position: relative;
|
||||
> .line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
// Reply line connectors
|
||||
.reply.children:not(.single) {
|
||||
position: relative;
|
||||
> .line {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: 0;
|
||||
top: 0;
|
||||
opacity: 0.25;
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-left: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
margin-left: calc((var(--avatarSize) / 2) - 1px);
|
||||
width: calc(var(--indent) / 2);
|
||||
height: calc((var(--avatarSize) / 2));
|
||||
border-bottom-left-radius: calc(var(--indent) / 2);
|
||||
top: 8px;
|
||||
}
|
||||
}
|
||||
&:not(:last-child) > .line::after {
|
||||
mask: linear-gradient(to right, transparent 2px, black 2px);
|
||||
-webkit-mask: linear-gradient(to right, transparent 2px, black 2px);
|
||||
}
|
||||
}
|
||||
// End Reply Divider
|
||||
.children > .main:last-child {
|
||||
padding-bottom: 1em;
|
||||
&::before {
|
||||
bottom: 1em;
|
||||
}
|
||||
// &::after {
|
||||
// content: "";
|
||||
// border-top: 1px solid var(--dividerHovered);
|
||||
// position: absolute;
|
||||
// bottom: 0;
|
||||
// margin-left: calc(var(--avatarSize) + 12px);
|
||||
// inset-inline: 0;
|
||||
// }
|
||||
}
|
||||
&.firstColumn > .children:last-child > .main {
|
||||
padding-bottom: 0 !important;
|
||||
&::before {
|
||||
bottom: 0 !important;
|
||||
}
|
||||
// &::after { content: unset }
|
||||
}
|
||||
|
||||
&.max-width_500px {
|
||||
padding: 14px 16px;
|
||||
:not(.reply) > & {
|
||||
.reply {
|
||||
--avatarSize: 24px;
|
||||
--indent: calc(var(--avatarSize) - 4px);
|
||||
}
|
||||
}
|
||||
&.firstColumn {
|
||||
> .main,
|
||||
> .line,
|
||||
> .children:not(.single) > .line {
|
||||
--avatarSize: 35px;
|
||||
--indent: 35px;
|
||||
}
|
||||
> .children:not(.single) {
|
||||
padding-left: 28px !important;
|
||||
}
|
||||
}
|
||||
&.reply-to {
|
||||
--avatarSize: 46px;
|
||||
padding: 14px 16px;
|
||||
padding-top: 14px !important;
|
||||
padding-bottom: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
> .main > .avatar-container {
|
||||
margin-right: 10px;
|
||||
}
|
||||
&:first-child > .main > .body {
|
||||
margin-top: -20px;
|
||||
padding-top: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.views {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--fgTransparentWeak);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<MkPagination ref="pagingComponent" :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img
|
||||
:src="instance.images.info"
|
||||
class="_ghost"
|
||||
alt="Info"
|
||||
/>
|
||||
<div>{{ i18n.ts.noNotes }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items: notes }">
|
||||
<div class="giivymft" :class="{ noGap }" ref="tlEl">
|
||||
<XList
|
||||
ref="notes"
|
||||
v-slot="{ item: note }"
|
||||
:items="notes"
|
||||
:direction="pagination.reversed ? 'up' : 'down'"
|
||||
:reversed="pagination.reversed"
|
||||
:no-gap="noGap"
|
||||
class="notes"
|
||||
>
|
||||
<XNote
|
||||
:key="note._featuredId_ || note._prId_ || note.id"
|
||||
class="qtqtichx"
|
||||
:note="note"
|
||||
/>
|
||||
</XList>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from "vue";
|
||||
import type { Paging } from "@/components/MkPagination.vue";
|
||||
import XNote from "@/components/MkNote.vue";
|
||||
import XList from "@/components/MkDateSeparatedList.vue";
|
||||
import MkPagination from "@/components/MkPagination.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
import { scroll } from "@/scripts/scroll";
|
||||
import {instance} from "@/instance";
|
||||
import { defaultStore } from "@/store.js";
|
||||
|
||||
const tlEl = ref<HTMLElement>();
|
||||
|
||||
const props = defineProps<{
|
||||
pagination: Paging;
|
||||
noGap?: boolean;
|
||||
}>();
|
||||
|
||||
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
|
||||
|
||||
const interval = ref<NodeJS.Timer>();
|
||||
const lastFetchScrollTop = ref(document.documentElement.clientHeight * -0.5);
|
||||
|
||||
function scrollTop() {
|
||||
if (!tlEl.value) return;
|
||||
scroll(tlEl.value, { top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function setTimer() {
|
||||
if (interval.value || !defaultStore.state.enableInfiniteScroll) return;
|
||||
interval.value = setInterval(() => {
|
||||
const viewport = document.documentElement.clientHeight;
|
||||
const left = document.documentElement.scrollHeight - document.documentElement.scrollTop;
|
||||
if (left > Math.max(viewport * 3, viewport + 4000) || document.documentElement.scrollTop - lastFetchScrollTop.value < viewport) return;
|
||||
pagingComponent.value.prefetchMore();
|
||||
lastFetchScrollTop.value = document.documentElement.scrollTop;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function clearTimer() {
|
||||
if (!interval.value) return;
|
||||
clearInterval(interval.value);
|
||||
interval.value = undefined;
|
||||
}
|
||||
|
||||
onMounted(setTimer);
|
||||
onActivated(setTimer);
|
||||
onUnmounted(clearTimer);
|
||||
onDeactivated(clearTimer);
|
||||
|
||||
defineExpose({
|
||||
pagingComponent,
|
||||
scrollTop,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.giivymft {
|
||||
&.noGap {
|
||||
> .notes {
|
||||
background: var(--panel) !important;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
}
|
||||
&:not(.noGap) {
|
||||
> .notes {
|
||||
.qtqtichx {
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,585 @@
|
||||
<template>
|
||||
<div
|
||||
ref="elRef"
|
||||
v-size="{ max: [500, 450] }"
|
||||
class="qglefbjs notification"
|
||||
:class="notification.type"
|
||||
>
|
||||
<div class="head">
|
||||
<MkAvatar
|
||||
v-if="notification.type === 'pollEnded'"
|
||||
class="icon"
|
||||
:user="notification.note.user"
|
||||
/>
|
||||
<MkAvatar
|
||||
v-else-if="notification.user"
|
||||
class="icon"
|
||||
:user="notification.user"
|
||||
/>
|
||||
<img
|
||||
v-else-if="notification.icon"
|
||||
class="icon"
|
||||
:src="notification.icon"
|
||||
alt=""
|
||||
/>
|
||||
<div class="sub-icon" :class="notification.type">
|
||||
<i
|
||||
v-if="notification.type === 'follow'"
|
||||
class="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="
|
||||
showEmojiReactions && notification.type === 'reaction'
|
||||
"
|
||||
ref="reactionRef"
|
||||
:reaction="
|
||||
notification.reaction
|
||||
? notification.reaction.replace(
|
||||
/^:(\w+):$/,
|
||||
':$1@.:',
|
||||
)
|
||||
: notification.reaction
|
||||
"
|
||||
:custom-emojis="notification.note.emojis"
|
||||
:no-style="true"
|
||||
/>
|
||||
<XReactionIcon
|
||||
v-else-if="
|
||||
!showEmojiReactions && notification.type === 'reaction'
|
||||
"
|
||||
:reaction="defaultReaction"
|
||||
:no-style="true"
|
||||
/>
|
||||
<i
|
||||
v-else-if="notification.type === 'bite'"
|
||||
class="ph-tooth ph-bold"
|
||||
></i>
|
||||
</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)"
|
||||
>
|
||||
<span>{{ i18n.ts._notification.reacted }}: </span>
|
||||
<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)"
|
||||
>
|
||||
<span>{{ i18n.ts._notification.renoted }}: </span>
|
||||
<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)"
|
||||
>
|
||||
<span>{{ i18n.ts._notification.voted }}: </span>
|
||||
<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.7"
|
||||
>{{ i18n.ts.youGotNewFollower }}
|
||||
<div v-if="full">
|
||||
<MkFollowButton
|
||||
:user="notification.user"
|
||||
:full="true"
|
||||
:hideMenu="true"
|
||||
/></div
|
||||
></span>
|
||||
<MkA
|
||||
v-if="notification.type === 'bite' && notification.bite.targetType === 'note'"
|
||||
class="text"
|
||||
:to="notePage(notification.bite.target)"
|
||||
:title="getNoteSummary(notification.bite.target)"
|
||||
>
|
||||
<span>{{ i18n.ts.bitYourNote }}: </span>
|
||||
<i class="ph-quotes ph-fill ph-lg"></i>
|
||||
<Mfm
|
||||
:text="getNoteSummary(notification.bite.target)"
|
||||
:plain="true"
|
||||
:nowrap="!full"
|
||||
:custom-emojis="notification.bite.target.emojis"
|
||||
/>
|
||||
<i class="ph-quotes ph-fill ph-lg"></i>
|
||||
</MkA>
|
||||
<div v-if="notification.type === 'bite' && notification.bite.targetType === 'note' && full">
|
||||
<MkBiteButton
|
||||
:user="notification.user"
|
||||
:bite="notification.bite"
|
||||
:full="true"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="notification.type === 'bite' && notification.bite.targetType !== 'note'"
|
||||
class="text"
|
||||
style="opacity: 0.7">
|
||||
<span>{{
|
||||
notification.bite.targetType === 'user'
|
||||
? i18n.ts.bitYou
|
||||
: i18n.ts.bitYouBack
|
||||
}}</span>
|
||||
<div v-if="full">
|
||||
<MkBiteButton
|
||||
:user="notification.user"
|
||||
:bite="notification.bite"
|
||||
:full="true"
|
||||
/></div
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
v-if="notification.type === 'followRequestAccepted'"
|
||||
class="text"
|
||||
style="opacity: 0.7"
|
||||
>{{ i18n.ts.followRequestAccepted }}</span
|
||||
>
|
||||
<span
|
||||
v-if="notification.type === 'receiveFollowRequest'"
|
||||
class="text"
|
||||
style="opacity: 0.7"
|
||||
>{{ 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.7"
|
||||
>{{ i18n.ts.groupInvited }}:
|
||||
<b>{{ notification.invitation.group.name }}</b>
|
||||
<div v-if="full && !groupInviteDone">
|
||||
<button
|
||||
class="_textButton"
|
||||
@click="acceptGroupInvitation()"
|
||||
>
|
||||
{{ i18n.ts.accept }}
|
||||
</button>
|
||||
|
|
||||
<button
|
||||
class="_textButton"
|
||||
@click="rejectGroupInvitation()"
|
||||
>
|
||||
{{ i18n.ts.reject }}
|
||||
</button>
|
||||
</div></span
|
||||
>
|
||||
<span v-if="notification.type === 'app'" class="text">
|
||||
<Mfm :text="notification.body" :nowrap="!full" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from "vue";
|
||||
import * as misskey from "iceshrimp-sdk";
|
||||
import XReactionIcon from "@/components/MkReactionIcon.vue";
|
||||
import MkFollowButton from "@/components/MkFollowButton.vue";
|
||||
import MkBiteButton from "@/components/MkBiteButton.vue";
|
||||
import XReactionTooltip from "@/components/MkReactionTooltip.vue";
|
||||
import { getNoteSummary } from "@/scripts/get-note-summary";
|
||||
import { notePage } from "@/filters/note";
|
||||
import { userPage } from "@/filters/user";
|
||||
import { i18n } from "@/i18n";
|
||||
import * as os from "@/os";
|
||||
import { stream } from "@/stream";
|
||||
import { useTooltip } from "@/scripts/use-tooltip";
|
||||
import { defaultStore } from "@/store";
|
||||
import { instance } from "@/instance";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
notification: misskey.entities.Notification;
|
||||
withTime?: boolean;
|
||||
full?: boolean;
|
||||
}>(),
|
||||
{
|
||||
withTime: false,
|
||||
full: false,
|
||||
},
|
||||
);
|
||||
|
||||
const elRef = ref<HTMLElement>(null);
|
||||
const reactionRef = ref(null);
|
||||
|
||||
const showEmojiReactions =
|
||||
defaultStore.state.enableEmojiReactions ||
|
||||
defaultStore.state.showEmojisInReactionNotifications;
|
||||
const defaultReaction = ["⭐", "👍", "❤️"].includes(instance.defaultReaction)
|
||||
? instance.defaultReaction
|
||||
: "⭐";
|
||||
|
||||
let readObserver: IntersectionObserver | undefined;
|
||||
let connection;
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.notification.isRead) {
|
||||
readObserver = new IntersectionObserver((entries, observer) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
stream.send("readNotification", {
|
||||
id: props.notification.id,
|
||||
});
|
||||
observer.disconnect();
|
||||
});
|
||||
|
||||
readObserver.observe(elRef.value);
|
||||
|
||||
connection = stream.useChannel("main");
|
||||
connection.on("readAllNotifications", () => readObserver.disconnect());
|
||||
|
||||
watch(props.notification.isRead, () => {
|
||||
readObserver.disconnect();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (readObserver) readObserver.disconnect();
|
||||
if (connection) connection.dispose();
|
||||
});
|
||||
|
||||
const followRequestDone = ref(false);
|
||||
const groupInviteDone = ref(false);
|
||||
|
||||
const acceptFollowRequest = () => {
|
||||
followRequestDone.value = true;
|
||||
os.api("following/requests/accept", { userId: props.notification.user.id });
|
||||
};
|
||||
|
||||
const rejectFollowRequest = () => {
|
||||
followRequestDone.value = true;
|
||||
os.api("following/requests/reject", { userId: props.notification.user.id });
|
||||
};
|
||||
|
||||
const acceptGroupInvitation = () => {
|
||||
groupInviteDone.value = true;
|
||||
os.apiWithDialog("users/groups/invitations/accept", {
|
||||
invitationId: props.notification.invitation.id,
|
||||
});
|
||||
};
|
||||
|
||||
const rejectGroupInvitation = () => {
|
||||
groupInviteDone.value = true;
|
||||
os.api("users/groups/invitations/reject", {
|
||||
invitationId: props.notification.invitation.id,
|
||||
});
|
||||
};
|
||||
|
||||
useTooltip(reactionRef, (showing) => {
|
||||
os.popup(
|
||||
XReactionTooltip,
|
||||
{
|
||||
showing,
|
||||
reaction: props.notification.reaction
|
||||
? props.notification.reaction.replace(/^:(\w+):$/, ":$1@.:")
|
||||
: props.notification.reaction,
|
||||
emojis: props.notification.note.emojis,
|
||||
targetElement: reactionRef.value.$el,
|
||||
},
|
||||
{},
|
||||
"closed",
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.qglefbjs {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 24px 32px;
|
||||
font-size: 0.9em;
|
||||
overflow-wrap: break-word;
|
||||
display: flex;
|
||||
contain: content;
|
||||
|
||||
&.max-width_500px {
|
||||
padding-block: 16px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
&.max-width_450px {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
> .head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
flex-shrink: 0;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-right: 8px;
|
||||
|
||||
> .icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
> .sub-icon {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 100%;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 0 0 3px var(--panel);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> * {
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.follow,
|
||||
&.followRequestAccepted,
|
||||
&.receiveFollowRequest,
|
||||
&.groupInvited {
|
||||
padding: 3px;
|
||||
background: #31748f;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.renote {
|
||||
padding: 3px;
|
||||
background: #31748f;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.quote {
|
||||
padding: 3px;
|
||||
background: #31748f;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.reply {
|
||||
padding: 3px;
|
||||
background: #c4a7e7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.mention {
|
||||
padding: 3px;
|
||||
background: #908caa;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.pollVote {
|
||||
padding: 3px;
|
||||
background: #908caa;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.pollEnded {
|
||||
padding: 3px;
|
||||
background: #908caa;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .tail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
> header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
|
||||
> .name {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
> .time {
|
||||
margin-left: auto;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
> .text {
|
||||
white-space: nowrap;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
> span:first-child {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> i {
|
||||
vertical-align: super;
|
||||
font-size: 50%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
> i:first-child {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
> i:last-child {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user