Initial commit

This commit is contained in:
2026-07-26 12:29:43 +09:00
parent 5f290dc465
commit 50bfaeafdf
2253 changed files with 318636 additions and 196 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"extends": ["@eslint-sets/vue3", "@eslint-sets/vue3-ts"],
"plugins": ["file-progress", "prettier"],
"rules": {
"file-progress/activate": 1
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"tabWidth": 4,
"useTabs": true,
"singleQuote": false,
"vueIndentScriptAndStyle": false,
"overrides": [
{
"files": "*.vue",
"options": {
"parser": "vue"
}
}
]
}
+15
View File
@@ -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
}
}
+10
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
declare module "@/themes/*.json5" {
import { Theme } from "@/scripts/theme";
const theme: Theme;
export default theme;
}
+7
View File
@@ -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.
+85
View File
@@ -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
View File
@@ -0,0 +1 @@
declare module "fengari";
+298
View File
@@ -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>
+254
View File
@@ -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>
+17
View File
@@ -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>
+410
View File
@@ -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>
+915
View File
@@ -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>
+177
View File
@@ -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>
+92
View File
@@ -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>
+67
View File
@@ -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>
+371
View File
@@ -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>
+549
View File
@@ -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>
+620
View File
@@ -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>
+109
View File
@@ -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