Compare commits

..

No commits in common. "80a29f771f723ddf8b64609725e97b626663dff3" and "f06c74c85ccc5c5941f87c4c834547bdd96e2bbb" have entirely different histories.

97 changed files with 10757 additions and 36 deletions

View File

@ -23,6 +23,7 @@ import {
NoteReaction,
Notification,
OriginType,
Page,
ServerInfo,
Signin,
Stats,
@ -630,6 +631,8 @@ export type Endpoints = {
};
res: Notification[];
};
"i/page-likes": { req: TODO; res: TODO };
"i/pages": { req: TODO; res: TODO };
"i/pin": { req: { noteId: Note["id"] }; res: MeDetailed };
"i/read-all-messaging-messages": { req: TODO; res: TODO };
"i/read-all-unread-notes": { req: TODO; res: TODO };
@ -883,6 +886,24 @@ export type Endpoints = {
res: null;
};
// page-push
"page-push": {
req: { pageId: Page["id"]; event: string; var?: any };
res: null;
};
// pages
"pages/create": { req: TODO; res: Page };
"pages/delete": { req: { pageId: Page["id"] }; res: null };
"pages/featured": { req: NoParams; res: Page[] };
"pages/like": { req: { pageId: Page["id"] }; res: null };
"pages/show": {
req: { pageId?: Page["id"]; name?: string; username?: string };
res: Page;
};
"pages/unlike": { req: { pageId: Page["id"] }; res: null };
"pages/update": { req: TODO; res: null };
// ping
ping: { req: NoParams; res: { pong: number } };
@ -979,6 +1000,7 @@ export type Endpoints = {
};
res: Note[];
};
"users/pages": { req: TODO; res: TODO };
"users/recommendation": { req: TODO; res: TODO };
"users/relation": { req: TODO; res: TODO };
"users/report-abuse": { req: TODO; res: TODO };

View File

@ -64,6 +64,8 @@ export type UserDetailed = UserLite & {
notesCount: number;
pinnedNoteIds: ID[];
pinnedNotes: Note[];
pinnedPage: Page | null;
pinnedPageId: string | null;
publicReactions: boolean;
securityKeys: boolean;
twoFactorEnabled: boolean;
@ -306,6 +308,36 @@ export type Stats = {
driveUsageRemote: number;
};
export type Page = {
id: ID;
createdAt: DateString;
updatedAt: DateString;
userId: User["id"];
user: User;
content: Record<string, any>[];
variables: Record<string, any>[];
title: string;
name: string;
summary: string | null;
hideTitleWhenPinned: boolean;
alignCenter: boolean;
font: string;
script: string;
eyeCatchingImageId: DriveFile["id"] | null;
eyeCatchingImage: DriveFile | null;
attachedFiles: any;
likedCount: number;
isLiked?: boolean;
};
export type PageEvent = {
pageId: Page["id"];
event: string;
var: any;
userId: User["id"];
user: User;
};
export type Announcement = {
id: ID;
createdAt: DateString;

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@
"@rollup/plugin-alias": "3.1.9",
"@rollup/plugin-json": "4.1.0",
"@rollup/pluginutils": "^4.2.1",
"@syuilo/aiscript": "0.11.1",
"@types/escape-regexp": "0.0.1",
"@types/glob": "8.1.0",
"@types/gulp": "4.0.11",
@ -89,6 +90,7 @@
"vue": "3.3.4",
"vue-isyourpasswordsafe": "^2.0.0",
"vue-plyr": "^7.0.0",
"vue-prism-editor": "2.0.0-alpha.2",
"vue3-otp-input": "^0.4.1",
"vuedraggable": "4.1.0"
}

View File

@ -218,7 +218,7 @@
<script lang="ts" setup>
import type { Ref } from "vue";
import { computed, inject, ref, toRaw } from "vue";
import { computed, inject, onMounted, ref, toRaw } from "vue";
import * as mfm from "mfm-js";
import type * as misskey from "calckey-js";
import XNoteSub from "@/components/MagNoteSub.vue";
@ -238,7 +238,7 @@ import { getWordSoftMute } from "@/scripts/check-word-mute";
import { useRouter } from "@/router";
import { userPage } from "@/filters/user";
import * as os from "@/os";
import { defaultStore } from "@/store";
import { defaultStore, noteViewInterruptors } from "@/store";
import { reactionPicker } from "@/scripts/reaction-picker";
import { $i } from "@/account";
import { i18n } from "@/i18n";
@ -276,6 +276,17 @@ const softMuteReasonI18nSrc = (what?: string) => {
return i18n.ts.userSaysSomething;
};
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result = structuredClone(toRaw(note));
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
}
note = result;
});
}
const el = ref<HTMLElement | null>(null);
const footerEl = ref<HTMLElement | null>(null);
const menuButton = ref<HTMLElement | null>(null);

View File

@ -164,7 +164,7 @@ 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 } from "@/store";
import { defaultStore, noteViewInterruptors } from "@/store";
import { reactionPicker } from "@/scripts/reaction-picker";
import { $i } from "@/account";
import { i18n } from "@/i18n";
@ -200,6 +200,17 @@ const softMuteReasonI18nSrc = (what?: string) => {
return i18n.ts.userSaysSomething;
};
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result = structuredClone(toRaw(note));
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
}
note = result;
});
}
const el = ref<HTMLElement | null>(null);
const noteEl = ref<HTMLElement | null>(null);
const menuButton = ref<HTMLElement | null>(null);

View File

@ -18,6 +18,7 @@
<template v-for="item in items">
<button
v-if="item.action"
v-click-anime
class="_button"
@click="
($event) => {
@ -32,7 +33,12 @@
><i class="ph-circle ph-fill"></i
></span>
</button>
<MkA v-else :to="item.to" @click.passive="close()">
<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"
@ -46,11 +52,14 @@
</template>
<script lang="ts" setup>
import {} from "vue";
import MkModal from "@/components/MkModal.vue";
import { navbarItemDef } from "@/navbar";
import { instanceName } from "@/config";
import { defaultStore } from "@/store";
import { i18n } from "@/i18n";
import { deviceKind } from "@/scripts/device-kind";
import * as os from "@/os";
const props = withDefaults(
defineProps<{

View File

@ -0,0 +1,171 @@
<template>
<MkA
:to="`/@${page.user.username}/pages/${page.name}`"
class="vhpxefrj _block"
tabindex="-1"
:behavior="`${ui === 'deck' ? 'window' : null}`"
>
<div
v-if="page.eyeCatchingImage"
class="thumbnail"
:style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"
></div>
<article>
<header>
<h1 :title="page.title">{{ page.title }}</h1>
</header>
<p v-if="page.summary" :title="page.summary">
{{
page.summary.length > 85
? page.summary.slice(0, 85) + "…"
: page.summary
}}
</p>
<footer>
<img
class="icon"
:src="page.user.avatarUrl"
aria-label="none"
/>
<p>{{ userName(page.user) }}</p>
</footer>
</article>
</MkA>
</template>
<script lang="ts" setup>
import { userName } from "@/filters/user";
import { ui } from "@/config";
defineProps<{
page: any;
}>();
</script>
<style lang="scss" scoped>
.vhpxefrj {
display: block;
&:hover {
text-decoration: none;
color: var(--accent);
}
> .thumbnail {
width: 100%;
height: 200px;
background-position: center;
background-size: cover;
display: flex;
justify-content: center;
align-items: center;
> button {
font-size: 3.5em;
opacity: 0.7;
&:hover {
font-size: 4em;
opacity: 0.9;
}
}
& + article {
left: 100px;
width: calc(100% - 100px);
}
}
> article {
padding: 16px;
> header {
margin-bottom: 8px;
> h1 {
margin: 0;
font-size: 1em;
color: var(--urlPreviewTitle);
}
}
> p {
margin: 0;
color: var(--urlPreviewText);
font-size: 0.8em;
}
> footer {
margin-top: 8px;
height: 16px;
> img {
display: inline-block;
width: 16px;
height: 16px;
margin-right: 4px;
vertical-align: top;
}
> p {
display: inline-block;
margin: 0;
color: var(--urlPreviewInfo);
font-size: 0.8em;
line-height: 16px;
vertical-align: top;
}
}
}
@media (max-width: 700px) {
> .thumbnail {
position: relative;
width: 100%;
height: 100px;
& + article {
left: 0;
width: 100%;
}
}
}
@media (max-width: 550px) {
font-size: 12px;
> .thumbnail {
height: 80px;
}
> article {
padding: 12px;
}
}
@media (max-width: 500px) {
font-size: 10px;
> .thumbnail {
height: 70px;
}
> article {
padding: 8px;
> header {
margin-bottom: 4px;
}
> footer {
margin-top: 4px;
> img {
width: 12px;
height: 12px;
}
}
}
}
}
</style>

View File

@ -15,6 +15,7 @@
</button>
<button
v-if="$props.editId == null"
v-click-anime
v-tooltip="i18n.ts.switchAccount"
class="account _button"
@click="openAccountMenu"
@ -197,6 +198,14 @@
>
<i class="ph-smiley ph-bold ph-lg"></i>
</button>
<button
v-if="postFormActions.length > 0"
v-tooltip="i18n.ts.plugin"
class="_button"
@click="showActions"
>
<i class="ph-plug ph-bold ph-lg"></i>
</button>
<!-- v-if="showMfmCheatsheet" -->
<button
v-tooltip="i18n.ts._mfm.cheatSheet"
@ -236,7 +245,7 @@ import { formatTimeString } from "@/scripts/format-time-string";
import { Autocomplete } from "@/scripts/autocomplete";
import * as os from "@/os";
import { selectFiles } from "@/scripts/select-file";
import { defaultStore } from "@/store";
import { defaultStore, notePostInterruptors, postFormActions } from "@/store";
import MkInfo from "@/components/MkInfo.vue";
import { i18n } from "@/i18n";
import { instance } from "@/instance";
@ -838,6 +847,13 @@ async function post() {
: hashtags_;
}
// plugin
if (notePostInterruptors.length > 0) {
for (const interruptor of notePostInterruptors) {
postData = await interruptor.handler(structuredClone(postData));
}
}
let token = undefined;
if (postAccount) {
@ -896,6 +912,27 @@ async function openCheatSheet(ev: MouseEvent) {
os.popup(XCheatSheet, {}, {}, "closed");
}
function showActions(ev) {
os.popupMenu(
postFormActions.map((action) => ({
text: action.title,
action: () => {
action.handler(
{
text: text,
},
(key, value) => {
if (key === "text") {
text = value;
}
}
);
},
})),
ev.currentTarget ?? ev.target
);
}
let postAccount = $ref<misskey.entities.UserDetailed | null>(null);
function openAccountMenu(ev: MouseEvent) {

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { defineComponent, h } from "vue";
import { defineComponent, h, resolveDirective, withDirectives } from "vue";
export default defineComponent({
props: {
@ -24,6 +24,7 @@ export default defineComponent({
role: "tablist",
},
options.map((option) =>
withDirectives(
h(
"button",
{
@ -42,6 +43,8 @@ export default defineComponent({
},
},
option.children
),
[[resolveDirective("click-anime")]]
)
)
);

View File

@ -0,0 +1,117 @@
<template>
<div ref="rootEl" class="meijqfqm">
<canvas
:id="idForCanvas"
ref="canvasEl"
class="canvas"
:width="width"
height="300"
@contextmenu.prevent="() => {}"
></canvas>
<div :id="idForTags" ref="tagsEl" class="tags">
<ul>
<slot></slot>
</ul>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, watch, PropType, onBeforeUnmount } from "vue";
import tinycolor from "tinycolor2";
const loaded = !!window.TagCanvas;
const SAFE_FOR_HTML_ID = "abcdefghijklmnopqrstuvwxyz";
const computedStyle = getComputedStyle(document.documentElement);
const idForCanvas = Array.from(Array(16))
.map(
() =>
SAFE_FOR_HTML_ID[
Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)
]
)
.join("");
const idForTags = Array.from(Array(16))
.map(
() =>
SAFE_FOR_HTML_ID[
Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)
]
)
.join("");
let available = $ref(false);
let rootEl = $ref<HTMLElement | null>(null);
let canvasEl = $ref<HTMLCanvasElement | null>(null);
let tagsEl = $ref<HTMLElement | null>(null);
let width = $ref(300);
watch($$(available), () => {
try {
window.TagCanvas.Start(idForCanvas, idForTags, {
textColour: "#ffffff",
outlineColour: tinycolor(
computedStyle.getPropertyValue("--accent")
).toHexString(),
outlineRadius: 10,
initial: [-0.03, -0.01],
frontSelect: true,
imageRadius: 8,
//dragControl: true,
dragThreshold: 3,
wheelZoom: false,
reverse: true,
depth: 0.5,
maxSpeed: 0.2,
minSpeed: 0.003,
stretchX: 0.8,
stretchY: 0.8,
});
} catch (err) {}
});
onMounted(() => {
width = rootEl.offsetWidth;
if (loaded) {
available = true;
} else {
document.head
.appendChild(
Object.assign(document.createElement("script"), {
async: true,
src: "/client-assets/tagcanvas.min.js",
})
)
.addEventListener("load", () => (available = true));
}
});
onBeforeUnmount(() => {
if (window.TagCanvas) window.TagCanvas.Delete(idForCanvas);
});
defineExpose({
update: () => {
window.TagCanvas.Update(idForCanvas);
},
});
</script>
<style lang="scss" scoped>
.meijqfqm {
position: relative;
overflow: clip;
display: grid;
place-items: center;
> .canvas {
display: block;
}
> .tags {
position: absolute;
top: 999px;
left: 999px;
}
}
</style>

View File

@ -0,0 +1,64 @@
<template>
<component
:is="'x-' + block.type"
:key="block.id"
:block="block"
:hpml="hpml"
:h="h"
/>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import XText from "./page.text.vue";
import XSection from "./page.section.vue";
import XImage from "./page.image.vue";
import XButton from "./page.button.vue";
import XNumberInput from "./page.number-input.vue";
import XTextInput from "./page.text-input.vue";
import XTextareaInput from "./page.textarea-input.vue";
import XSwitch from "./page.switch.vue";
import XIf from "./page.if.vue";
import XTextarea from "./page.textarea.vue";
import XPost from "./page.post.vue";
import XCounter from "./page.counter.vue";
import XRadioButton from "./page.radio-button.vue";
import XCanvas from "./page.canvas.vue";
import XNote from "./page.note.vue";
import { Hpml } from "@/scripts/hpml/evaluator";
import { Block } from "@/scripts/hpml/block";
export default defineComponent({
components: {
XText,
XSection,
XImage,
XButton,
XNumberInput,
XTextInput,
XTextareaInput,
XTextarea,
XPost,
XSwitch,
XIf,
XCounter,
XRadioButton,
XCanvas,
XNote,
},
props: {
block: {
type: Object as PropType<Block>,
required: true,
},
hpml: {
type: Object as PropType<Hpml>,
required: true,
},
h: {
type: Number,
required: true,
},
},
});
</script>

View File

@ -0,0 +1,70 @@
<template>
<div>
<MkButton class="kudkigyw" :primary="block.primary" @click="click()">{{
hpml.interpolate(block.text)
}}</MkButton>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, unref } from "vue";
import MkButton from "../MkButton.vue";
import * as os from "@/os";
import { ButtonBlock } from "@/scripts/hpml/block";
import { Hpml } from "@/scripts/hpml/evaluator";
export default defineComponent({
components: {
MkButton,
},
props: {
block: {
type: Object as PropType<ButtonBlock>,
required: true,
},
hpml: {
type: Object as PropType<Hpml>,
required: true,
},
},
methods: {
click() {
if (this.block.action === "dialog") {
this.hpml.eval();
os.alert({
text: this.hpml.interpolate(this.block.content),
});
} else if (this.block.action === "resetRandom") {
this.hpml.updateRandomSeed(Math.random());
this.hpml.eval();
} else if (this.block.action === "pushEvent") {
os.api("page-push", {
pageId: this.hpml.page.id,
event: this.block.event,
...(this.block.var
? {
var: unref(this.hpml.vars)[this.block.var],
}
: {}),
});
os.alert({
type: "success",
text: this.hpml.interpolate(this.block.message),
});
} else if (this.block.action === "callAiScript") {
this.hpml.callAiScript(this.block.fn);
}
},
},
});
</script>
<style lang="scss" scoped>
.kudkigyw {
display: inline-block;
min-width: 200px;
max-width: 450px;
margin: 8px 0;
}
</style>

View File

@ -0,0 +1,49 @@
<template>
<div class="ysrxegms">
<canvas ref="canvas" :width="block.width" :height="block.height" />
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, PropType, Ref, ref } from "vue";
import * as os from "@/os";
import { CanvasBlock } from "@/scripts/hpml/block";
import { Hpml } from "@/scripts/hpml/evaluator";
export default defineComponent({
props: {
block: {
type: Object as PropType<CanvasBlock>,
required: true,
},
hpml: {
type: Object as PropType<Hpml>,
required: true,
},
},
setup(props, ctx) {
const canvas: Ref<any> = ref(null);
onMounted(() => {
props.hpml.registerCanvas(props.block.name, canvas.value);
});
return {
canvas,
};
},
});
</script>
<style lang="scss" scoped>
.ysrxegms {
display: inline-block;
vertical-align: bottom;
overflow: auto;
max-width: 100%;
> canvas {
display: block;
}
}
</style>

View File

@ -0,0 +1,56 @@
<template>
<div>
<MkButton class="llumlmnx" @click="click()">{{
hpml.interpolate(block.text)
}}</MkButton>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from "vue";
import MkButton from "../MkButton.vue";
import { CounterVarBlock } from "@/scripts/hpml/block";
import { Hpml } from "@/scripts/hpml/evaluator";
export default defineComponent({
components: {
MkButton,
},
props: {
block: {
type: Object as PropType<CounterVarBlock>,
required: true,
},
hpml: {
type: Object as PropType<Hpml>,
required: true,
},
},
setup(props, ctx) {
const value = computed(() => {
return props.hpml.vars.value[props.block.name];
});
function click() {
props.hpml.updatePageVar(
props.block.name,
value.value + (props.block.inc || 1)
);
props.hpml.eval();
}
return {
click,
};
},
});
</script>
<style lang="scss" scoped>
.llumlmnx {
display: inline-block;
min-width: 300px;
max-width: 450px;
margin: 8px 0;
}
</style>

View File

@ -0,0 +1,37 @@
<template>
<div v-show="hpml.vars.value[block.var]">
<XBlock
v-for="child in block.children"
:key="child.id"
:block="child"
:hpml="hpml"
:h="h"
/>
</div>
</template>
<script lang="ts">
import { IfBlock } from "@/scripts/hpml/block";
import { Hpml } from "@/scripts/hpml/evaluator";
import { defineComponent, defineAsyncComponent, PropType } from "vue";
export default defineComponent({
components: {
XBlock: defineAsyncComponent(() => import("./page.block.vue")),
},
props: {
block: {
type: Object as PropType<IfBlock>,
required: true,
},
hpml: {
type: Object as PropType<Hpml>,
required: true,
},
h: {
type: Number,
required: true,
},
},
});
</script>

View File

@ -0,0 +1,37 @@
<template>
<div class="lzyxtsnt">
<ImgWithBlurhash
v-if="image"
:hash="image.blurhash"
:src="image.url"
:alt="image.comment"
:title="image.comment"
:cover="false"
/>
</div>
</template>
<script lang="ts" setup>
import { defineComponent, PropType } from "vue";
import ImgWithBlurhash from "@/components/MkImgWithBlurhash.vue";
import * as os from "@/os";
import { ImageBlock } from "@/scripts/hpml/block";
import { Hpml } from "@/scripts/hpml/evaluator";
const props = defineProps<{
block: PropType<ImageBlock>;
hpml: PropType<Hpml>;
}>();
const image = props.hpml.page.attachedFiles.find(
(x) => x.id === props.block.fileId
);
</script>
<style lang="scss" scoped>
.lzyxtsnt {
> img {
max-width: 100%;
}
}
</style>

View File

@ -0,0 +1,61 @@
<template>
<div class="voxdxuby">
<XNote
v-if="note && !block.detailed"
:key="note.id + ':normal'"
v-model:note="note"
/>
<XNoteDetailed
v-if="note && block.detailed"
:key="note.id + ':detail'"
v-model:note="note"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, PropType, Ref, ref } from "vue";
import XNote from "@/components/MagNote.vue";
import XNoteDetailed from "@/components/MagNoteDetailed.vue";
import * as os from "@/os";
import { NoteBlock } from "@/scripts/hpml/block";
import { endpoints, packed } from "magnetar-common";
export default defineComponent({
components: {
XNote,
XNoteDetailed,
},
props: {
block: {
type: Object as PropType<NoteBlock>,
required: true,
},
},
setup(props, ctx) {
const note: Ref<packed.PackNoteMaybeFull | null> = ref(null);
onMounted(() => {
if (!props.block.note) return;
os.magApi(
endpoints.GetNoteById,
{ context: props.block.detailed, attachments: true },
{ id: props.block.note }
).then((result) => {
note.value = result;
});
});
return {
note,
};
},
});
</script>
<style lang="scss" scoped>
.voxdxuby {
margin: 1em 0;
}
</style>

View File

@ -0,0 +1,60 @@
<template>
<div>
<MkInput
class="kudkigyw"
:model-value="value"
type="number"
@update:modelValue="updateValue($event)"
>
<template #label>{{ hpml.interpolate(block.text) }}</template>
</MkInput>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from "vue";
import MkInput from "../form/input.vue";
import * as os from "@/os";
import { Hpml } from "@/scripts/hpml/evaluator";
import { NumberInputVarBlock } from "@/scripts/hpml/block";
export default defineComponent({
components: {
MkInput,
},
props: {
block: {
type: Object as PropType<NumberInputVarBlock>,
required: true,
},
hpml: {
type: Object as PropType<Hpml>,
required: true,
},
},
setup(props, ctx) {
const value = computed(() => {
return props.hpml.vars.value[props.block.name];
});
function updateValue(newValue) {
props.hpml.updatePageVar(props.block.name, newValue);
props.hpml.eval();
}
return {
value,
updateValue,
};
},
});
</script>
<style lang="scss" scoped>
.kudkigyw {
display: inline-block;
min-width: 300px;
max-width: 450px;
margin: 8px 0;
}
</style>

View File

@ -0,0 +1,121 @@
<template>
<div class="ngbfujlo">
<MkTextarea :model-value="text" readonly style="margin: 0"></MkTextarea>
<MkButton
class="button"
primary
:disabled="posting || posted"
@click="post()"
>
<i v-if="posted" class="ph-check ph-bold ph-lg"></i>
<i v-else class="ph-paper-plane-tilt ph-bold ph-lg"></i>
</MkButton>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import MkTextarea from "../form/textarea.vue";
import MkButton from "../MkButton.vue";
import { apiUrl } from "@/config";
import * as os from "@/os";
import { PostBlock } from "@/scripts/hpml/block";
import { Hpml } from "@/scripts/hpml/evaluator";
export default defineComponent({
components: {
MkTextarea,
MkButton,
},
props: {
block: {
type: Object as PropType<PostBlock>,
required: true,
},
hpml: {
type: Object as PropType<Hpml>,
required: true,
},
},
data() {
return {
text: this.hpml.interpolate(this.block.text),
posted: false,
posting: false,
};
},
watch: {
"hpml.vars": {
handler() {
this.text = this.hpml.interpolate(this.block.text);
},
deep: true,
},
},
methods: {
upload() {
const promise = new Promise((ok) => {
const canvas = this.hpml.canvases[this.block.canvasId];
canvas.toBlob((blob) => {
const formData = new FormData();
formData.append("file", blob);
if (this.$store.state.uploadFolder) {
formData.append(
"folderId",
this.$store.state.uploadFolder
);
}
fetch(apiUrl + "/drive/files/create", {
method: "POST",
body: formData,
headers: {
authorization: `Bearer ${this.$i.token}`,
},
})
.then((response) => response.json())
.then((f) => {
ok(f);
});
});
});
os.promiseDialog(promise);
return promise;
},
async post() {
this.posting = true;
const file = this.block.attachCanvasImage
? await this.upload()
: null;
os.apiWithDialog("notes/create", {
text: this.text === "" ? null : this.text,
fileIds: file ? [file.id] : undefined,
}).then(() => {
this.posted = true;
});
},
},
});
</script>
<style lang="scss" scoped>
.ngbfujlo {
position: relative;
padding: 32px;
border-radius: 6px;
box-shadow: 0 2px 8px var(--shadow);
z-index: 1;
> .button {
margin-top: 32px;
}
@media (max-width: 600px) {
padding: 16px;
> .button {
margin-top: 16px;
}
}
}
</style>

View File

@ -0,0 +1,52 @@
<template>
<div>
<div>{{ hpml.interpolate(block.title) }}</div>
<MkRadio
v-for="item in block.values"
:key="item"
:modelValue="value"
:value="item"
@update:modelValue="updateValue($event)"
>{{ item }}</MkRadio
>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from "vue";
import MkRadio from "../form/radio.vue";
import * as os from "@/os";
import { Hpml } from "@/scripts/hpml/evaluator";
import { RadioButtonVarBlock } from "@/scripts/hpml/block";
export default defineComponent({
components: {
MkRadio,
},
props: {
block: {
type: Object as PropType<RadioButtonVarBlock>,
required: true,
},
hpml: {
type: Object as PropType<Hpml>,
required: true,
},
},
setup(props, ctx) {
const value = computed(() => {
return props.hpml.vars.value[props.block.name];
});
function updateValue(newValue: string) {
props.hpml.updatePageVar(props.block.name, newValue);
props.hpml.eval();
}
return {
value,
updateValue,
};
},
});
</script>

View File

@ -0,0 +1,66 @@
<template>
<section class="sdgxphyu">
<component :is="'h' + h">{{ block.title }}</component>
<div class="children">
<XBlock
v-for="child in block.children"
:key="child.id"
:block="child"
:hpml="hpml"
:h="h + 1"
/>
</div>
</section>
</template>
<script lang="ts">
import { defineComponent, defineAsyncComponent, PropType } from "vue";
import * as os from "@/os";
import { SectionBlock } from "@/scripts/hpml/block";
import { Hpml } from "@/scripts/hpml/evaluator";
export default defineComponent({
components: {
XBlock: defineAsyncComponent(() => import("./page.block.vue")),
},
props: {
block: {
type: Object as PropType<SectionBlock>,
required: true,
},
hpml: {
type: Object as PropType<Hpml>,
required: true,
},
h: {
required: true,
},
},
});
</script>
<style lang="scss" scoped>
.sdgxphyu {
margin: 1.5em 0;
> h2 {
font-size: 1.35em;
margin: 0 0 0.5em 0;
}
> h3 {
font-size: 1em;
margin: 0 0 0.5em 0;
}
> h4 {
font-size: 1em;
margin: 0 0 0.5em 0;
}
> .children {
//padding 16px
}
}
</style>

View File

@ -0,0 +1,59 @@
<template>
<div class="hkcxmtwj">
<MkSwitch
:model-value="value"
@update:modelValue="updateValue($event)"
>{{ hpml.interpolate(block.text) }}</MkSwitch
>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from "vue";
import MkSwitch from "../form/switch.vue";
import * as os from "@/os";
import { Hpml } from "@/scripts/hpml/evaluator";
import { SwitchVarBlock } from "@/scripts/hpml/block";
export default defineComponent({
components: {
MkSwitch,
},
props: {
block: {
type: Object as PropType<SwitchVarBlock>,
required: true,
},
hpml: {
type: Object as PropType<Hpml>,
required: true,
},
},
setup(props, ctx) {
const value = computed(() => {
return props.hpml.vars.value[props.block.name];
});
function updateValue(newValue: boolean) {
props.hpml.updatePageVar(props.block.name, newValue);
props.hpml.eval();
}
return {
value,
updateValue,
};
},
});
</script>
<style lang="scss" scoped>
.hkcxmtwj {
display: inline-block;
margin: 16px auto;
& + .hkcxmtwj {
margin-left: 16px;
}
}
</style>

View File

@ -0,0 +1,60 @@
<template>
<div>
<MkInput
class="kudkigyw"
:model-value="value"
type="text"
@update:modelValue="updateValue($event)"
>
<template #label>{{ hpml.interpolate(block.text) }}</template>
</MkInput>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from "vue";
import MkInput from "../form/input.vue";
import * as os from "@/os";
import { Hpml } from "@/scripts/hpml/evaluator";
import { TextInputVarBlock } from "@/scripts/hpml/block";
export default defineComponent({
components: {
MkInput,
},
props: {
block: {
type: Object as PropType<TextInputVarBlock>,
required: true,
},
hpml: {
type: Object as PropType<Hpml>,
required: true,
},
},
setup(props, ctx) {
const value = computed(() => {
return props.hpml.vars.value[props.block.name];
});
function updateValue(newValue) {
props.hpml.updatePageVar(props.block.name, newValue);
props.hpml.eval();
}
return {
value,
updateValue,
};
},
});
</script>
<style lang="scss" scoped>
.kudkigyw {
display: inline-block;
min-width: 300px;
max-width: 450px;
margin: 8px 0;
}
</style>

View File

@ -0,0 +1,67 @@
<template>
<div class="mrdgzndn">
<Mfm :key="text" :text="text" :is-note="false" :i="$i" />
<MkUrlPreview v-for="url in urls" :key="url" :url="url" class="url" />
</div>
</template>
<script lang="ts">
import { TextBlock } from "@/scripts/hpml/block";
import { Hpml } from "@/scripts/hpml/evaluator";
import { defineAsyncComponent, defineComponent, PropType } from "vue";
import * as mfm from "mfm-js";
import { extractUrlFromMfm } from "@/scripts/extract-url-from-mfm";
export default defineComponent({
components: {
MkUrlPreview: defineAsyncComponent(
() => import("@/components/MkUrlPreview.vue")
),
},
props: {
block: {
type: Object as PropType<TextBlock>,
required: true,
},
hpml: {
type: Object as PropType<Hpml>,
required: true,
},
},
data() {
return {
text: this.hpml.interpolate(this.block.text),
};
},
computed: {
urls(): string[] {
if (this.text) {
return extractUrlFromMfm(mfm.parse(this.text));
} else {
return [];
}
},
},
watch: {
"hpml.vars": {
handler() {
this.text = this.hpml.interpolate(this.block.text);
},
deep: true,
},
},
});
</script>
<style lang="scss" scoped>
.mrdgzndn {
&:not(:first-child) {
margin-top: 0.5em;
}
&:not(:last-child) {
margin-bottom: 0.5em;
}
> .url {
margin: 0.5em 0;
}
}
</style>

View File

@ -0,0 +1,50 @@
<template>
<div>
<MkTextarea
:model-value="value"
@update:modelValue="updateValue($event)"
>
<template #label>{{ hpml.interpolate(block.text) }}</template>
</MkTextarea>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from "vue";
import MkTextarea from "../form/textarea.vue";
import * as os from "@/os";
import { Hpml } from "@/scripts/hpml/evaluator";
import { HpmlTextInput } from "@/scripts/hpml";
import { TextInputVarBlock } from "@/scripts/hpml/block";
export default defineComponent({
components: {
MkTextarea,
},
props: {
block: {
type: Object as PropType<TextInputVarBlock>,
required: true,
},
hpml: {
type: Object as PropType<Hpml>,
required: true,
},
},
setup(props, ctx) {
const value = computed(() => {
return props.hpml.vars.value[props.block.name];
});
function updateValue(newValue) {
props.hpml.updatePageVar(props.block.name, newValue);
props.hpml.eval();
}
return {
value,
updateValue,
};
},
});
</script>

View File

@ -0,0 +1,28 @@
<template>
<MkTextarea :model-value="text" readonly></MkTextarea>
</template>
<script lang="ts" setup>
import { watch } from "vue";
import MkTextarea from "../form/textarea.vue";
import { TextBlock } from "@/scripts/hpml/block";
import { Hpml } from "@/scripts/hpml/evaluator";
const props = defineProps<{
block: TextBlock;
hpml: Hpml;
}>();
let text = $ref("");
watch(
props.hpml.vars,
() => {
text = props.hpml.interpolate(props.block.text) as string;
},
{
deep: true,
immediate: true,
}
);
</script>

View File

@ -0,0 +1,104 @@
<template>
<div
v-if="hpml"
class="iroscrza"
:class="{ center: page.alignCenter, serif: page.font === 'serif' }"
>
<XBlock
v-for="child in page.content"
:key="child.id"
:block="child"
:hpml="hpml"
:h="2"
/>
</div>
</template>
<script lang="ts">
import {
defineComponent,
onMounted,
nextTick,
onUnmounted,
PropType,
} from "vue";
import { parse } from "@syuilo/aiscript";
import XBlock from "./page.block.vue";
import { Hpml } from "@/scripts/hpml/evaluator";
import { url } from "@/config";
import { $i } from "@/account";
import { defaultStore } from "@/store";
export default defineComponent({
components: {
XBlock,
},
props: {
page: {
type: Object as PropType<Record<string, any>>,
required: true,
},
},
setup(props, ctx) {
const hpml = new Hpml(props.page, {
randomSeed: Math.random(),
visitor: $i,
url: url,
enableAiScript: !defaultStore.state.disablePagesScript,
});
onMounted(() => {
nextTick(() => {
if (props.page.script && hpml.aiscript) {
let ast;
try {
ast = parse(props.page.script);
} catch (err) {
console.error(err);
/*os.alert({
type: 'error',
text: 'Syntax error :('
});*/
return;
}
hpml.aiscript
.exec(ast)
.then(() => {
hpml.eval();
})
.catch((err) => {
console.error(err);
/*os.alert({
type: 'error',
text: err
});*/
});
} else {
hpml.eval();
}
});
onUnmounted(() => {
if (hpml.aiscript) hpml.aiscript.abort();
});
});
return {
hpml,
};
},
});
</script>
<style lang="scss" scoped>
.iroscrza {
&.serif {
> div {
font-family: serif;
}
}
&.center {
text-align: center;
}
}
</style>

View File

@ -0,0 +1,31 @@
import { Directive } from "vue";
import { defaultStore } from "@/store";
export default {
mounted(el, binding, vn) {
/*
if (!defaultStore.state.animation) return;
el.classList.add('_anime_bounce_standBy');
el.addEventListener('mousedown', () => {
el.classList.add('_anime_bounce_standBy');
el.classList.add('_anime_bounce_ready');
el.addEventListener('mouseleave', () => {
el.classList.remove('_anime_bounce_ready');
});
});
el.addEventListener('click', () => {
el.classList.add('_anime_bounce');
});
el.addEventListener('animationend', () => {
el.classList.remove('_anime_bounce_ready');
el.classList.remove('_anime_bounce');
el.classList.add('_anime_bounce_standBy');
});
*/
},
} as Directive;

View File

@ -8,6 +8,7 @@ import tooltip from "./tooltip";
import hotkey from "./hotkey";
import appear from "./appear";
import anim from "./anim";
import clickAnime from "./click-anime";
import panel from "./panel";
import adaptiveBorder from "./adaptive-border";
import focus from "./focus";
@ -22,6 +23,7 @@ export default function (app: App) {
app.directive("hotkey", hotkey);
app.directive("appear", appear);
app.directive("anim", anim);
app.directive("click-anime", clickAnime);
app.directive("panel", panel);
app.directive("adaptive-border", adaptiveBorder);
app.directive("focus", focus);

View File

@ -437,6 +437,14 @@ function checkForSplash() {
//store.commit('instance/set', );
});
for (const plugin of ColdDeviceStorage.get("plugins").filter(
(p) => p.active
)) {
import("./plugin").then(({ install }) => {
install(plugin);
});
}
const hotkeys = {
d: (): void => {
defaultStore.set("darkMode", !defaultStore.state.darkMode);

View File

@ -63,6 +63,11 @@ export const navbarItemDef = reactive({
show: computed(() => $i != null),
to: "/my/favorites",
},
pages: {
title: "pages",
icon: "ph-file-text ph-bold ph-lg",
to: "/pages",
},
gallery: {
title: "gallery",
icon: "ph-image-square ph-bold ph-lg",

View File

@ -60,7 +60,13 @@
</template>
<script lang="ts" setup>
import { markRaw, nextTick, onBeforeUnmount, onMounted } from "vue";
import {
markRaw,
version as vueVersion,
onMounted,
onBeforeUnmount,
nextTick,
} from "vue";
import XFederation from "./overview.federation.vue";
import XInstances from "./overview.instances.vue";
import XQueue from "./overview.queue.vue";
@ -71,10 +77,14 @@ import XStats from "./overview.stats.vue";
import XModerators from "./overview.moderators.vue";
import XHeatmap from "./overview.heatmap.vue";
// import XMetrics from "./overview.metrics.vue";
import MkTagCloud from "@/components/MkTagCloud.vue";
import { version, url } from "@/config";
import * as os from "@/os";
import { stream } from "@/stream";
import { i18n } from "@/i18n";
import { definePageMetadata } from "@/scripts/page-metadata";
import { defaultStore } from "@/store";
import MkFileListForAdmin from "@/components/MkFileListForAdmin.vue";
import MkFolder from "@/components/MkFolder.vue";
const rootEl = $shallowRef<HTMLElement>();

View File

@ -60,6 +60,7 @@
<button
v-if="$i && $i.id === post.user.id"
v-tooltip="i18n.ts.edit"
v-click-anime
class="_button"
@click="edit"
>
@ -69,6 +70,7 @@
</button>
<button
v-tooltip="i18n.ts.shareWithNote"
v-click-anime
class="_button"
@click="shareWithNote"
>
@ -79,6 +81,7 @@
<button
v-if="shareAvailable()"
v-tooltip="i18n.ts.share"
v-click-anime
class="_button"
@click="share"
>

View File

@ -0,0 +1,130 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-lightning ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.button }}</template
>
<section class="xfhsjczc">
<MkInput v-model="value.text"
><template #label>{{
i18n.ts._pages.blocks._button.text
}}</template></MkInput
>
<MkSwitch v-model="value.primary"
><span>{{
i18n.ts._pages.blocks._button.colored
}}</span></MkSwitch
>
<MkSelect v-model="value.action">
<template #label>{{
i18n.ts._pages.blocks._button.action
}}</template>
<option value="dialog">
{{ i18n.ts._pages.blocks._button._action.dialog }}
</option>
<option value="resetRandom">
{{ i18n.ts._pages.blocks._button._action.resetRandom }}
</option>
<option value="pushEvent">
{{ i18n.ts._pages.blocks._button._action.pushEvent }}
</option>
<option value="callAiScript">
{{ i18n.ts._pages.blocks._button._action.callAiScript }}
</option>
</MkSelect>
<template v-if="value.action === 'dialog'">
<MkInput v-model="value.content"
><template #label>{{
i18n.ts._pages.blocks._button._action._dialog.content
}}</template></MkInput
>
</template>
<template v-else-if="value.action === 'pushEvent'">
<MkInput v-model="value.event"
><template #label>{{
i18n.ts._pages.blocks._button._action._pushEvent.event
}}</template></MkInput
>
<MkInput v-model="value.message"
><template #label>{{
i18n.ts._pages.blocks._button._action._pushEvent.message
}}</template></MkInput
>
<MkSelect v-model="value.var">
<template #label>{{
i18n.ts._pages.blocks._button._action._pushEvent
.variable
}}</template>
<option :value="null">
{{
i18n.t(
"_pages.blocks._button._action._pushEvent.no-variable"
)
}}
</option>
<option v-for="v in hpml.getVarsByType()" :value="v.name">
{{ v.name }}
</option>
<optgroup :label="i18n.ts._pages.script.pageVariables">
<option
v-for="v in hpml.getPageVarsByType()"
:value="v"
>
{{ v }}
</option>
</optgroup>
<optgroup
:label="i18n.ts._pages.script.enviromentVariables"
>
<option v-for="v in hpml.getEnvVarsByType()" :value="v">
{{ v }}
</option>
</optgroup>
</MkSelect>
</template>
<template v-else-if="value.action === 'callAiScript'">
<MkInput v-model="value.fn"
><template #label>{{
i18n.ts._pages.blocks._button._action._callAiScript
.functionName
}}</template></MkInput
>
</template>
</section>
</XContainer>
</template>
<script lang="ts" setup>
import {} from "vue";
import XContainer from "../page-editor.container.vue";
import MkSelect from "@/components/form/select.vue";
import MkInput from "@/components/form/input.vue";
import MkSwitch from "@/components/form/switch.vue";
import { i18n } from "@/i18n";
withDefaults(
defineProps<{
value: any;
hpml: any;
}>(),
{
value: {
text: "",
action: "dialog",
content: null,
event: null,
message: null,
primary: false,
var: null,
fn: null,
},
}
);
</script>
<style lang="scss" scoped>
.xfhsjczc {
padding: 0 16px 0 16px;
}
</style>

View File

@ -0,0 +1,51 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-paint-brush-household ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.canvas }}</template
>
<section style="padding: 0 16px 0 16px">
<MkInput v-model="value.name">
<template #prefix
><i class="ph-magic-wand ph-bold ph-lg"></i
></template>
<template #label>{{
i18n.ts._pages.blocks._canvas.id
}}</template>
</MkInput>
<MkInput v-model="value.width" type="number">
<template #label>{{
i18n.ts._pages.blocks._canvas.width
}}</template>
<template #suffix>px</template>
</MkInput>
<MkInput v-model="value.height" type="number">
<template #label>{{
i18n.ts._pages.blocks._canvas.height
}}</template>
<template #suffix>px</template>
</MkInput>
</section>
</XContainer>
</template>
<script lang="ts" setup>
import {} from "vue";
import XContainer from "../page-editor.container.vue";
import MkInput from "@/components/form/input.vue";
import { i18n } from "@/i18n";
withDefaults(
defineProps<{
value: any;
}>(),
{
value: {
name: "",
width: 300,
height: 200,
},
}
);
</script>

View File

@ -0,0 +1,47 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-lightning ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.counter }}</template
>
<section style="padding: 0 16px 0 16px">
<MkInput v-model="value.name">
<template #prefix
><i class="ph-magic-wand ph-bold ph-lg"></i
></template>
<template #label>{{
i18n.ts._pages.blocks._counter.name
}}</template>
</MkInput>
<MkInput v-model="value.text">
<template #label>{{
i18n.ts._pages.blocks._counter.text
}}</template>
</MkInput>
<MkInput v-model="value.inc" type="number">
<template #label>{{
i18n.ts._pages.blocks._counter.inc
}}</template>
</MkInput>
</section>
</XContainer>
</template>
<script lang="ts" setup>
import {} from "vue";
import XContainer from "../page-editor.container.vue";
import MkInput from "@/components/form/input.vue";
import { i18n } from "@/i18n";
withDefaults(
defineProps<{
value: any;
}>(),
{
value: {
name: "",
},
}
);
</script>

View File

@ -0,0 +1,88 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-question ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.if }}</template
>
<template #func>
<button class="_button" @click="add()">
<i class="ph-plus ph-bold ph-lg"></i>
</button>
</template>
<section class="romcojzs">
<MkSelect v-model="value.var">
<template #label>{{
i18n.ts._pages.blocks._if.variable
}}</template>
<option
v-for="v in hpml.getVarsByType('boolean')"
:value="v.name"
>
{{ v.name }}
</option>
<optgroup :label="i18n.ts._pages.script.pageVariables">
<option
v-for="v in hpml.getPageVarsByType('boolean')"
:value="v"
>
{{ v }}
</option>
</optgroup>
<optgroup :label="i18n.ts._pages.script.enviromentVariables">
<option
v-for="v in hpml.getEnvVarsByType('boolean')"
:value="v"
>
{{ v }}
</option>
</optgroup>
</MkSelect>
<XBlocks v-model="value.children" class="children" :hpml="hpml" />
</section>
</XContainer>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, inject } from "vue";
import { v4 as uuid } from "uuid";
import XContainer from "../page-editor.container.vue";
import MkSelect from "@/components/form/select.vue";
import * as os from "@/os";
import { i18n } from "@/i18n";
const XBlocks = defineAsyncComponent(() => import("../page-editor.blocks.vue"));
const props = withDefaults(
defineProps<{
value: any;
hpml: any;
}>(),
{
value: {
children: [],
var: null,
},
}
);
const getPageBlockList = inject<(any) => any>("getPageBlockList");
async function add() {
const { canceled, result: type } = await os.select({
title: i18n.ts._pages.chooseBlock,
groupedItems: getPageBlockList(),
});
if (canceled) return;
const id = uuid();
props.value.children.push({ id, type });
}
</script>
<style lang="scss" scoped>
.romcojzs {
padding: 0 16px 16px 16px;
}
</style>

View File

@ -0,0 +1,71 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-image ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.image }}</template
>
<template #func>
<button @click="choose()">
<i class="ph-folder-notch-open ph-bold ph-lg"></i>
</button>
</template>
<section class="oyyftmcf">
<MkDriveFileThumbnail
v-if="file"
class="preview"
:file="file"
fit="contain"
@click="choose()"
/>
</section>
</XContainer>
</template>
<script lang="ts" setup>
import { onMounted } from "vue";
import XContainer from "../page-editor.container.vue";
import MkDriveFileThumbnail from "@/components/MkDriveFileThumbnail.vue";
import * as os from "@/os";
import { i18n } from "@/i18n";
const props = withDefaults(
defineProps<{
value: any;
}>(),
{
value: {
fileId: null,
},
}
);
let file: any = $ref(null);
async function choose() {
os.selectDriveFile(false).then((fileResponse: any) => {
file = fileResponse;
props.value.fileId = fileResponse.id;
});
}
onMounted(async () => {
if (props.value.fileId == null) {
await choose();
} else {
os.api("drive/files/show", {
fileId: props.value.fileId,
}).then((fileResponse) => {
file = fileResponse;
});
}
});
</script>
<style lang="scss" scoped>
.oyyftmcf {
> .preview {
height: 150px;
}
}
</style>

View File

@ -0,0 +1,91 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-sticker ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.note }}</template
>
<section style="padding: 0 16px 0 16px">
<MkInput v-model="id">
<template #label>{{ i18n.ts._pages.blocks._note.id }}</template>
<template #caption>{{
i18n.ts._pages.blocks._note.idDescription
}}</template>
</MkInput>
<MkSwitch v-model="value.detailed"
><span>{{
i18n.ts._pages.blocks._note.detailed
}}</span></MkSwitch
>
<XNote
v-if="note && !value.detailed"
:key="note.id + ':normal'"
v-model:note="note"
style="margin-bottom: 16px"
/>
<XNoteDetailed
v-if="note && value.detailed"
:key="note.id + ':detail'"
v-model:note="note"
style="margin-bottom: 16px"
/>
</section>
</XContainer>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
import XContainer from "../page-editor.container.vue";
import MkInput from "@/components/form/input.vue";
import MkSwitch from "@/components/form/switch.vue";
import XNote from "@/components/MagNote.vue";
import XNoteDetailed from "@/components/MagNoteDetailed.vue";
import * as os from "@/os";
import { i18n } from "@/i18n";
import { endpoints, packed } from "magnetar-common";
const props = withDefaults(
defineProps<{
value: any;
}>(),
{
value: {
note: null,
detailed: false,
},
}
);
let id = ref<string | null>(props.value.note);
let note = ref<packed.PackNoteMaybeFull | null>(null);
watch(
id,
async () => {
if (
id.value &&
(id.value.startsWith("http://") || id.value.startsWith("https://"))
) {
props.value.note = (
id.value.endsWith("/") ? id.value.slice(0, -1) : id.value
)
.split("/")
.pop();
} else {
props.value.note = id.value;
}
if (props.value.note) return;
note.value = await os.magApi(
endpoints.GetNoteById,
{ context: true, attachments: true },
{ id: props.value.note }
);
},
{
immediate: true,
}
);
</script>

View File

@ -0,0 +1,47 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-lightning ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.numberInput }}</template
>
<section style="padding: 0 16px 0 16px">
<MkInput v-model="value.name">
<template #prefix
><i class="ph-magic-wand ph-bold ph-lg"></i
></template>
<template #label>{{
i18n.ts._pages.blocks._numberInput.name
}}</template>
</MkInput>
<MkInput v-model="value.text">
<template #label>{{
i18n.ts._pages.blocks._numberInput.text
}}</template>
</MkInput>
<MkInput v-model="value.default" type="number">
<template #label>{{
i18n.ts._pages.blocks._numberInput.default
}}</template>
</MkInput>
</section>
</XContainer>
</template>
<script lang="ts" setup>
import {} from "vue";
import XContainer from "../page-editor.container.vue";
import MkInput from "@/components/form/input.vue";
import { i18n } from "@/i18n";
withDefaults(
defineProps<{
value: any;
}>(),
{
value: {
name: "",
},
}
);
</script>

View File

@ -0,0 +1,48 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-paper-plane-tilt ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.post }}</template
>
<section style="padding: 16px">
<MkTextarea v-model="value.text"
><template #label>{{
i18n.ts._pages.blocks._post.text
}}</template></MkTextarea
>
<MkSwitch v-model="value.attachCanvasImage"
><span>{{
i18n.ts._pages.blocks._post.attachCanvasImage
}}</span></MkSwitch
>
<MkInput v-if="value.attachCanvasImage" v-model="value.canvasId"
><template #label>{{
i18n.ts._pages.blocks._post.canvasId
}}</template></MkInput
>
</section>
</XContainer>
</template>
<script lang="ts" setup>
import {} from "vue";
import XContainer from "../page-editor.container.vue";
import MkTextarea from "@/components/form/textarea.vue";
import MkInput from "@/components/form/input.vue";
import MkSwitch from "@/components/form/switch.vue";
import { i18n } from "@/i18n";
withDefaults(
defineProps<{
value: any;
}>(),
{
value: {
text: "",
attachCanvasImage: false,
canvasId: "",
},
}
);
</script>

View File

@ -0,0 +1,66 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-lightning ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.radioButton }}</template
>
<section style="padding: 0 16px 16px 16px">
<MkInput v-model="value.name"
><template #prefix
><i class="ph-magic-wand ph-bold ph-lg"></i></template
><template #label>{{
i18n.ts._pages.blocks._radioButton.name
}}</template></MkInput
>
<MkInput v-model="value.title"
><template #label>{{
i18n.ts._pages.blocks._radioButton.title
}}</template></MkInput
>
<MkTextarea v-model="values"
><template #label>{{
i18n.ts._pages.blocks._radioButton.values
}}</template></MkTextarea
>
<MkInput v-model="value.default"
><template #label>{{
i18n.ts._pages.blocks._radioButton.default
}}</template></MkInput
>
</section>
</XContainer>
</template>
<script lang="ts" setup>
import { watch } from "vue";
import XContainer from "../page-editor.container.vue";
import MkTextarea from "@/components/form/textarea.vue";
import MkInput from "@/components/form/input.vue";
import { i18n } from "@/i18n";
const props = withDefaults(
defineProps<{
value: any;
}>(),
{
value: {
name: "",
title: "",
values: [],
},
}
);
let values: string = $ref(props.value.values.join("\n"));
watch(
values,
() => {
props.value.values = values.split("\n");
},
{
deep: true,
}
);
</script>

View File

@ -0,0 +1,79 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-sticker ph-bold ph-lg"></i>
{{ value.title }}</template
>
<template #func>
<button class="_button" @click="rename()">
<i class="ph-pencil ph-bold ph-lg"></i>
</button>
<button class="_button" @click="add()">
<i class="ph-plus ph-bold ph-lg"></i>
</button>
</template>
<section class="ilrvjyvi">
<XBlocks v-model="value.children" class="children" :hpml="hpml" />
</section>
</XContainer>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, inject, onMounted } from "vue";
import { v4 as uuid } from "uuid";
import XContainer from "../page-editor.container.vue";
import * as os from "@/os";
import { i18n } from "@/i18n";
const XBlocks = defineAsyncComponent(() => import("../page-editor.blocks.vue"));
const props = withDefaults(
defineProps<{
value: any;
hpml: any;
}>(),
{
value: {
title: null,
children: [],
},
}
);
const getPageBlockList = inject<(any) => any>("getPageBlockList");
async function rename() {
const { canceled, result: title } = await os.inputText({
title: "Enter title",
default: props.value.title,
});
if (canceled) return;
props.value.title = title;
}
async function add() {
const { canceled, result: type } = await os.select({
title: i18n.ts._pages.chooseBlock,
groupedItems: getPageBlockList(),
});
if (canceled) return;
const id = uuid();
props.value.children.push({ id, type });
}
onMounted(() => {
if (props.value.title == null) {
rename();
}
});
</script>
<style lang="scss" scoped>
.ilrvjyvi {
> .children {
padding: 16px;
}
}
</style>

View File

@ -0,0 +1,53 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-lightning ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.switch }}</template
>
<section class="kjuadyyj">
<MkInput v-model="value.name"
><template #prefix
><i class="ph-magic-wand ph-bold ph-lg"></i></template
><template #label>{{
i18n.ts._pages.blocks._switch.name
}}</template></MkInput
>
<MkInput v-model="value.text"
><template #label>{{
i18n.ts._pages.blocks._switch.text
}}</template></MkInput
>
<MkSwitch v-model="value.default"
><span>{{
i18n.ts._pages.blocks._switch.default
}}</span></MkSwitch
>
</section>
</XContainer>
</template>
<script lang="ts" setup>
import {} from "vue";
import XContainer from "../page-editor.container.vue";
import MkSwitch from "@/components/form/switch.vue";
import MkInput from "@/components/form/input.vue";
import { i18n } from "@/i18n";
withDefaults(
defineProps<{
value: any;
}>(),
{
value: {
name: "",
},
}
);
</script>
<style lang="scss" scoped>
.kjuadyyj {
padding: 0 16px 16px 16px;
}
</style>

View File

@ -0,0 +1,46 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-lightning ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.textInput }}</template
>
<section style="padding: 0 16px 0 16px">
<MkInput v-model="value.name"
><template #prefix
><i class="ph-magic-wand ph-bold ph-lg"></i></template
><template #label>{{
i18n.ts._pages.blocks._textInput.name
}}</template></MkInput
>
<MkInput v-model="value.text"
><template #label>{{
i18n.ts._pages.blocks._textInput.text
}}</template></MkInput
>
<MkInput v-model="value.default" type="text"
><template #label>{{
i18n.ts._pages.blocks._textInput.default
}}</template></MkInput
>
</section>
</XContainer>
</template>
<script lang="ts" setup>
import {} from "vue";
import XContainer from "../page-editor.container.vue";
import MkInput from "@/components/form/input.vue";
import { i18n } from "@/i18n";
withDefaults(
defineProps<{
value: any;
}>(),
{
value: {
name: "",
},
}
);
</script>

View File

@ -0,0 +1,50 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-align-left ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.text }}</template
>
<section class="vckmsadr">
<textarea v-model="value.text"></textarea>
</section>
</XContainer>
</template>
<script lang="ts" setup>
import {} from "vue";
import XContainer from "../page-editor.container.vue";
import { i18n } from "@/i18n";
withDefaults(
defineProps<{
value: any;
}>(),
{
value: {
text: "",
},
}
);
</script>
<style lang="scss" scoped>
.vckmsadr {
> textarea {
display: block;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 100%;
min-width: 100%;
min-height: 150px;
border: none;
box-shadow: none;
padding: 16px;
background: transparent;
color: var(--fg);
font-size: 14px;
box-sizing: border-box;
}
}
</style>

View File

@ -0,0 +1,47 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-lightning ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.textareaInput }}</template
>
<section style="padding: 0 16px 16px 16px">
<MkInput v-model="value.name"
><template #prefix
><i class="ph-magic-wand ph-bold ph-lg"></i></template
><template #label>{{
i18n.ts._pages.blocks._textareaInput.name
}}</template></MkInput
>
<MkInput v-model="value.text"
><template #label>{{
i18n.ts._pages.blocks._textareaInput.text
}}</template></MkInput
>
<MkTextarea v-model="value.default"
><template #label>{{
i18n.ts._pages.blocks._textareaInput.default
}}</template></MkTextarea
>
</section>
</XContainer>
</template>
<script lang="ts" setup>
import {} from "vue";
import XContainer from "../page-editor.container.vue";
import MkTextarea from "@/components/form/textarea.vue";
import MkInput from "@/components/form/input.vue";
import { i18n } from "@/i18n";
withDefaults(
defineProps<{
value: any;
}>(),
{
value: {
name: "",
},
}
);
</script>

View File

@ -0,0 +1,50 @@
<template>
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header
><i class="ph-align-left ph-bold ph-lg"></i>
{{ i18n.ts._pages.blocks.textarea }}</template
>
<section class="ihymsbbe">
<textarea v-model="value.text"></textarea>
</section>
</XContainer>
</template>
<script lang="ts" setup>
import {} from "vue";
import XContainer from "../page-editor.container.vue";
import { i18n } from "@/i18n";
withDefaults(
defineProps<{
value: any;
}>(),
{
value: {
text: "",
},
}
);
</script>
<style lang="scss" scoped>
.ihymsbbe {
> textarea {
display: block;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 100%;
min-width: 100%;
min-height: 150px;
border: none;
box-shadow: none;
padding: 16px;
background: transparent;
color: var(--fg);
font-size: 14px;
box-sizing: border-box;
}
}
</style>

View File

@ -0,0 +1,108 @@
<template>
<XDraggable
v-model="blocks"
tag="div"
item-key="id"
handle=".drag-handle"
:group="{ name: 'blocks' }"
animation="150"
swap-threshold="0.5"
>
<template #item="{ element }">
<component
:is="'x-' + element.type"
:value="element"
:hpml="hpml"
@update:value="updateItem"
@remove="() => removeItem(element)"
/>
</template>
</XDraggable>
</template>
<script lang="ts">
import { defineComponent, defineAsyncComponent } from "vue";
import XSection from "./els/page-editor.el.section.vue";
import XText from "./els/page-editor.el.text.vue";
import XTextarea from "./els/page-editor.el.textarea.vue";
import XImage from "./els/page-editor.el.image.vue";
import XButton from "./els/page-editor.el.button.vue";
import XTextInput from "./els/page-editor.el.text-input.vue";
import XTextareaInput from "./els/page-editor.el.textarea-input.vue";
import XNumberInput from "./els/page-editor.el.number-input.vue";
import XSwitch from "./els/page-editor.el.switch.vue";
import XIf from "./els/page-editor.el.if.vue";
import XPost from "./els/page-editor.el.post.vue";
import XCounter from "./els/page-editor.el.counter.vue";
import XRadioButton from "./els/page-editor.el.radio-button.vue";
import XCanvas from "./els/page-editor.el.canvas.vue";
import XNote from "./els/page-editor.el.note.vue";
import * as os from "@/os";
export default defineComponent({
components: {
XDraggable: defineAsyncComponent(() =>
import("vuedraggable").then((x) => x.default)
),
XSection,
XText,
XImage,
XButton,
XTextarea,
XTextInput,
XTextareaInput,
XNumberInput,
XSwitch,
XIf,
XPost,
XCounter,
XRadioButton,
XCanvas,
XNote,
},
props: {
modelValue: {
type: Array,
required: true,
},
hpml: {
required: true,
},
},
emits: ["update:modelValue"],
computed: {
blocks: {
get() {
return this.modelValue;
},
set(value) {
this.$emit("update:modelValue", value);
},
},
},
methods: {
updateItem(v) {
const i = this.blocks.findIndex((x) => x.id === v.id);
const newValue = [
...this.blocks.slice(0, i),
v,
...this.blocks.slice(i + 1),
];
this.$emit("update:modelValue", newValue);
},
removeItem(el) {
const i = this.blocks.findIndex((x) => x.id === el.id);
const newValue = [
...this.blocks.slice(0, i),
...this.blocks.slice(i + 1),
];
this.$emit("update:modelValue", newValue);
},
},
});
</script>

View File

@ -0,0 +1,180 @@
<template>
<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }">
<header>
<div class="title"><slot name="header"></slot></div>
<div class="buttons">
<slot name="func"></slot>
<button v-if="removable" class="_button" @click="remove()">
<i class="ph-trash ph-bold ph-lg"></i>
</button>
<button v-if="draggable" class="drag-handle _button">
<i class="ph-list ph-bold ph-lg"></i>
</button>
<button class="_button" @click="toggleContent(!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>
<p v-show="showBody" v-if="error != null" class="error">
{{
i18n.t("_pages.script.typeError", {
slot: error.arg + 1,
expect: i18n.t(`script.types.${error.expect}`),
actual: i18n.t(`script.types.${error.actual}`),
})
}}
</p>
<p v-show="showBody" v-if="warn != null" class="warn">
{{
i18n.t("_pages.script.thereIsEmptySlot", {
slot: warn.slot + 1,
})
}}
</p>
<div v-show="showBody" class="body">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { i18n } from "@/i18n";
export default defineComponent({
props: {
expanded: {
type: Boolean,
default: true,
},
removable: {
type: Boolean,
default: true,
},
draggable: {
type: Boolean,
default: false,
},
error: {
required: false,
default: null,
},
warn: {
required: false,
default: null,
},
},
emits: ["toggle", "remove"],
data() {
return {
showBody: this.expanded,
i18n,
};
},
methods: {
toggleContent(show: boolean) {
this.showBody = show;
this.$emit("toggle", show);
},
remove() {
this.$emit("remove");
},
},
});
</script>
<style lang="scss" scoped>
.cpjygsrt {
position: relative;
overflow: hidden;
background: var(--panel);
border: solid 2px var(--X12);
border-radius: 6px;
&:hover {
border: solid 2px var(--X13);
}
&.warn {
border: solid 2px #f6c177;
}
&.error {
border: solid 2px #eb6f92;
}
& + .cpjygsrt {
margin-top: 16px;
}
> header {
> .title {
z-index: 1;
margin: 0;
padding: 0 16px;
line-height: 42px;
font-size: 0.9em;
font-weight: bold;
box-shadow: 0 1px rgba(#000, 0.07);
> i {
margin-right: 6px;
}
&:empty {
display: none;
}
}
> .buttons {
position: absolute;
z-index: 2;
top: 0;
right: 0;
> button {
padding: 0;
width: 42px;
font-size: 0.9em;
line-height: 42px;
}
.drag-handle {
cursor: move;
}
}
}
> .warn {
color: #ea9d34;
margin: 0;
padding: 16px 16px 0 16px;
font-size: 14px;
}
> .error {
color: #b4637a;
margin: 0;
padding: 16px 16px 0 16px;
font-size: 14px;
}
> .body {
::v-deep(.juejbjww),
::v-deep(.eiipwacr) {
&:not(.inline):first-child {
margin-top: 28px;
}
&:not(.inline):last-child {
margin-bottom: 20px;
}
}
}
}
</style>

View File

@ -0,0 +1,408 @@
<template>
<XContainer
:removable="removable"
:error="error"
:warn="warn"
:draggable="draggable"
@remove="() => $emit('remove')"
>
<template #header
><i v-if="icon" :class="icon"></i>
<template v-if="title"
>{{ title }}
<span v-if="typeText" class="turmquns"
>({{ typeText }})</span
></template
><template v-else-if="typeText">{{ typeText }}</template></template
>
<template #func>
<button class="_button" @click="changeType()">
<i class="ph-pencil ph-bold ph-lg"></i>
</button>
</template>
<section
v-if="modelValue.type === null"
class="pbglfege"
@click="changeType()"
>
{{ i18n.ts._pages.script.emptySlot }}
</section>
<section v-else-if="modelValue.type === 'text'" class="tbwccoaw">
<input v-model="modelValue.value" />
</section>
<section
v-else-if="modelValue.type === 'multiLineText'"
class="tbwccoaw"
>
<textarea v-model="modelValue.value"></textarea>
</section>
<section v-else-if="modelValue.type === 'textList'" class="tbwccoaw">
<textarea
v-model="modelValue.value"
:placeholder="i18n.ts._pages.script.blocks._textList.info"
></textarea>
</section>
<section v-else-if="modelValue.type === 'number'" class="tbwccoaw">
<input v-model="modelValue.value" type="number" />
</section>
<section v-else-if="modelValue.type === 'ref'" class="hpdwcrvs">
<select v-model="modelValue.value">
<option
v-for="v in hpml
.getVarsByType(
getExpectedType ? getExpectedType() : null
)
.filter((x) => x.name !== name)"
:value="v.name"
>
{{ v.name }}
</option>
<optgroup :label="i18n.ts._pages.script.argVariables">
<option v-for="v in fnSlots" :value="v.name">
{{ v.name }}
</option>
</optgroup>
<optgroup :label="i18n.ts._pages.script.pageVariables">
<option
v-for="v in hpml.getPageVarsByType(
getExpectedType ? getExpectedType() : null
)"
:value="v"
>
{{ v }}
</option>
</optgroup>
<optgroup :label="i18n.ts._pages.script.enviromentVariables">
<option
v-for="v in hpml.getEnvVarsByType(
getExpectedType ? getExpectedType() : null
)"
:value="v"
>
{{ v }}
</option>
</optgroup>
</select>
</section>
<section v-else-if="modelValue.type === 'aiScriptVar'" class="tbwccoaw">
<input v-model="modelValue.value" />
</section>
<section
v-else-if="modelValue.type === 'fn'"
class=""
style="padding: 0 16px 16px 16px"
>
<MkTextarea v-model="slots">
<template #label>{{
i18n.ts._pages.script.blocks._fn.slots
}}</template>
<template #caption>{{
i18n.t("_pages.script.blocks._fn.slots-info")
}}</template>
</MkTextarea>
<XV
v-if="modelValue.value.expression"
v-model="modelValue.value.expression"
:title="i18n.t(`_pages.script.blocks._fn.arg1`)"
:get-expected-type="() => null"
:hpml="hpml"
:fn-slots="modelValue.value.slots"
:name="name"
/>
</section>
<section
v-else-if="modelValue.type.startsWith('fn:')"
class=""
style="padding: 16px"
>
<XV
v-for="(x, i) in modelValue.args"
:key="i"
v-model="modelValue.args[i]"
:title="
hpml.getVarByName(modelValue.type.split(':')[1]).value
.slots[i].name
"
:get-expected-type="() => null"
:hpml="hpml"
:name="name"
/>
</section>
<section v-else class="" style="padding: 16px">
<XV
v-for="(x, i) in modelValue.args"
:key="i"
v-model="modelValue.args[i]"
:title="
i18n.t(
`_pages.script.blocks._${modelValue.type}.arg${i + 1}`
)
"
:get-expected-type="() => _getExpectedType(i)"
:hpml="hpml"
:name="name"
:fn-slots="fnSlots"
/>
</section>
</XContainer>
</template>
<script lang="ts">
import { defineAsyncComponent, defineComponent } from "vue";
import { v4 as uuid } from "uuid";
import XContainer from "./page-editor.container.vue";
import MkTextarea from "@/components/form/textarea.vue";
import { blockDefs } from "@/scripts/hpml/index";
import * as os from "@/os";
import { isLiteralValue } from "@/scripts/hpml/expr";
import { funcDefs } from "@/scripts/hpml/lib";
import { i18n } from "@/i18n";
export default defineComponent({
components: {
XContainer,
MkTextarea,
XV: defineAsyncComponent(
() => import("./page-editor.script-block.vue")
),
},
inject: ["getScriptBlockList"],
props: {
getExpectedType: {
required: false,
default: null,
},
modelValue: {
required: true,
},
title: {
required: false,
},
removable: {
required: false,
default: false,
},
hpml: {
required: true,
},
name: {
required: true,
},
fnSlots: {
required: false,
},
draggable: {
required: false,
default: false,
},
},
data() {
return {
error: null,
warn: null,
slots: "",
i18n,
};
},
computed: {
icon(): any {
if (this.modelValue.type === null) return null;
if (this.modelValue.type.startsWith("fn:"))
return "ph-plug ph-bold ph-lg";
return blockDefs.find((x) => x.type === this.modelValue.type).icon;
},
typeText(): any {
if (this.modelValue.type === null) return null;
if (this.modelValue.type.startsWith("fn:"))
return this.modelValue.type.split(":")[1];
return i18n.t(`_pages.script.blocks.${this.modelValue.type}`);
},
},
watch: {
slots: {
handler() {
this.modelValue.value.slots = this.slots
.split("\n")
.map((x) => ({
name: x,
type: null,
}));
},
deep: true,
},
},
created() {
if (this.modelValue.value == null) this.modelValue.value = null;
if (this.modelValue.value && this.modelValue.value.slots)
this.slots = this.modelValue.value.slots
.map((x) => x.name)
.join("\n");
this.$watch(
() => this.modelValue.type,
(t) => {
this.warn = null;
if (this.modelValue.type === "fn") {
const id = uuid();
this.modelValue.value = {
slots: [],
expression: { id, type: null },
};
return;
}
if (
this.modelValue.type &&
this.modelValue.type.startsWith("fn:")
) {
const fnName = this.modelValue.type.split(":")[1];
const fn = this.hpml.getVarByName(fnName);
const empties = [];
for (let i = 0; i < fn.value.slots.length; i++) {
const id = uuid();
empties.push({ id, type: null });
}
this.modelValue.args = empties;
return;
}
if (isLiteralValue(this.modelValue)) return;
const empties = [];
for (
let i = 0;
i < funcDefs[this.modelValue.type].in.length;
i++
) {
const id = uuid();
empties.push({ id, type: null });
}
this.modelValue.args = empties;
for (
let i = 0;
i < funcDefs[this.modelValue.type].in.length;
i++
) {
const inType = funcDefs[this.modelValue.type].in[i];
if (typeof inType !== "number") {
if (inType === "number")
this.modelValue.args[i].type = "number";
if (inType === "string")
this.modelValue.args[i].type = "text";
}
}
}
);
this.$watch(
() => this.modelValue.args,
(args) => {
if (args == null) {
this.warn = null;
return;
}
const emptySlotIndex = args.findIndex((x) => x.type === null);
if (emptySlotIndex !== -1 && emptySlotIndex < args.length) {
this.warn = {
slot: emptySlotIndex,
};
} else {
this.warn = null;
}
},
{
deep: true,
}
);
this.$watch(
() => this.hpml.variables,
() => {
if (this.type != null && this.modelValue) {
this.error = this.hpml.typeCheck(this.modelValue);
}
},
{
deep: true,
}
);
},
methods: {
async changeType() {
const { canceled, result: type } = await os.select({
title: i18n.ts._pages.selectType,
groupedItems: this.getScriptBlockList(
this.getExpectedType ? this.getExpectedType() : null
),
});
if (canceled) return;
this.modelValue.type = type;
},
_getExpectedType(slot: number) {
return this.hpml.getExpectedType(this.modelValue, slot);
},
},
});
</script>
<style lang="scss" scoped>
.turmquns {
opacity: 0.7;
}
.pbglfege {
opacity: 0.5;
padding: 16px;
text-align: center;
cursor: pointer;
color: var(--fg);
}
.tbwccoaw {
> input,
> textarea {
display: block;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 100%;
max-width: 100%;
min-width: 100%;
border: none;
box-shadow: none;
padding: 16px;
font-size: 16px;
background: transparent;
color: var(--fg);
box-sizing: border-box;
}
> textarea {
min-height: 100px;
}
}
.hpdwcrvs {
padding: 16px;
> select {
display: block;
padding: 4px;
font-size: 16px;
width: 100%;
}
}
</style>

View File

@ -0,0 +1,645 @@
<template>
<MkStickyContainer>
<template #header
><MkPageHeader
v-model:tab="tab"
:actions="headerActions"
:tabs="headerTabs"
/></template>
<MkSpacer :content-max="700">
<div class="jqqmcavi">
<MkButton
v-if="pageId"
class="button"
inline
link
:to="`/@${author.username}/pages/${currentName}`"
><i class="ph-arrow-square-out ph-bold ph-lg"></i>
{{ i18n.ts._pages.viewPage }}</MkButton
>
<MkButton
v-if="!readonly"
inline
primary
class="button"
@click="save"
><i class="ph-floppy-disk-back ph-bold ph-lg"></i>
{{ i18n.ts.save }}</MkButton
>
<MkButton v-if="pageId" inline class="button" @click="duplicate"
><i class="ph-clipboard-text ph-bold ph-lg"></i>
{{ i18n.ts.duplicate }}</MkButton
>
<MkButton
v-if="pageId && !readonly"
inline
class="button"
danger
@click="del"
><i class="ph-trash ph-bold ph-lg"></i>
{{ i18n.ts.delete }}</MkButton
>
</div>
<div v-if="tab === 'settings'">
<div class="_formRoot">
<MkInput v-model="title" class="_formBlock">
<template #label>{{ i18n.ts._pages.title }}</template>
</MkInput>
<MkInput v-model="summary" class="_formBlock">
<template #label>{{ i18n.ts._pages.summary }}</template>
</MkInput>
<MkInput v-model="name" class="_formBlock">
<template #prefix
>{{ url }}/@{{ author.username }}/pages/</template
>
<template #label>{{ i18n.ts._pages.url }}</template>
</MkInput>
<MkSwitch v-model="isPublic" class="_formBlock">{{
i18n.ts.public
}}</MkSwitch>
<MkSwitch v-model="alignCenter" class="_formBlock">{{
i18n.ts._pages.alignCenter
}}</MkSwitch>
<MkSelect v-model="font" class="_formBlock">
<template #label>{{ i18n.ts._pages.font }}</template>
<option value="serif">
{{ i18n.ts._pages.fontSerif }}
</option>
<option value="sans-serif">
{{ i18n.ts._pages.fontSansSerif }}
</option>
</MkSelect>
<MkSwitch
v-model="hideTitleWhenPinned"
class="_formBlock"
>{{ i18n.ts._pages.hideTitleWhenPinned }}</MkSwitch
>
<div class="eyeCatch">
<MkButton
v-if="eyeCatchingImageId == null && !readonly"
@click="setEyeCatchingImage"
><i class="ph-plus ph-bold ph-lg"></i>
{{ i18n.ts._pages.eyeCatchingImageSet }}</MkButton
>
<div v-else-if="eyeCatchingImage">
<img
:src="eyeCatchingImage.url"
:alt="eyeCatchingImage.name"
style="max-width: 100%"
/>
<MkButton
v-if="!readonly"
@click="removeEyeCatchingImage()"
><i class="ph-trash ph-bold ph-lg"></i>
{{
i18n.ts._pages.eyeCatchingImageRemove
}}</MkButton
>
</div>
</div>
</div>
</div>
<div v-else-if="tab === 'contents'">
<div>
<XBlocks v-model="content" class="content" :hpml="hpml" />
<MkButton v-if="!readonly" @click="add()"
><i class="ph-plus ph-bold ph-lg"></i
></MkButton>
</div>
</div>
<div v-else-if="tab === 'variables'">
<div class="qmuvgica">
<XDraggable
v-show="variables.length > 0"
v-model="variables"
tag="div"
class="variables"
item-key="name"
handle=".drag-handle"
:group="{ name: 'variables' }"
animation="150"
swap-threshold="0.5"
>
<template #item="{ element }">
<XVariable
:model-value="element"
:removable="true"
:hpml="hpml"
:name="element.name"
:title="element.name"
:draggable="true"
@remove="() => removeVariable(element)"
/>
</template>
</XDraggable>
<MkButton
v-if="!readonly"
class="add"
@click="addVariable()"
><i class="ph-plus ph-bold ph-lg"></i
></MkButton>
</div>
</div>
<div v-else-if="tab === 'script'">
<div>
<MkTextarea v-model="script" class="_code" />
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, computed, provide, watch } from "vue";
import { v4 as uuid } from "uuid";
import XVariable from "./page-editor.script-block.vue";
import XBlocks from "./page-editor.blocks.vue";
import MkTextarea from "@/components/form/textarea.vue";
import MkButton from "@/components/MkButton.vue";
import MkSelect from "@/components/form/select.vue";
import MkSwitch from "@/components/form/switch.vue";
import MkInput from "@/components/form/input.vue";
import { blockDefs } from "@/scripts/hpml/index";
import { HpmlTypeChecker } from "@/scripts/hpml/type-checker";
import { url } from "@/config";
import { collectPageVars } from "@/scripts/collect-page-vars";
import * as os from "@/os";
import { selectFile } from "@/scripts/select-file";
import { mainRouter } from "@/router";
import { i18n } from "@/i18n";
import { definePageMetadata } from "@/scripts/page-metadata";
import { $i } from "@/account";
const XDraggable = defineAsyncComponent(() =>
import("vuedraggable").then((x) => x.default)
);
const props = defineProps<{
initPageId?: string;
initPageName?: string;
initUser?: string;
}>();
let tab = $ref("settings");
let author = $ref($i);
let readonly = $ref(false);
let page = $ref(null);
let pageId = $ref(null);
let currentName = $ref(null);
let title = $ref("");
let summary = $ref(null);
let name = $ref(Date.now().toString());
let eyeCatchingImage = $ref(null);
let eyeCatchingImageId = $ref(null);
let font = $ref("sans-serif");
let content = $ref([]);
let alignCenter = $ref(false);
let isPublic = $ref(true);
let hideTitleWhenPinned = $ref(false);
let variables = $ref([]);
let hpml = $ref(null);
let script = $ref("");
provide("readonly", readonly);
provide("getScriptBlockList", getScriptBlockList);
provide("getPageBlockList", getPageBlockList);
watch($$(eyeCatchingImageId), async () => {
if (eyeCatchingImageId == null) {
eyeCatchingImage = null;
} else {
eyeCatchingImage = await os.api("drive/files/show", {
fileId: eyeCatchingImageId,
});
}
});
function getSaveOptions() {
return {
title: title.trim(),
name: name.trim(),
summary: summary,
font: font,
script: script,
hideTitleWhenPinned: hideTitleWhenPinned,
alignCenter: alignCenter,
isPublic: isPublic,
content: content,
variables: variables,
eyeCatchingImageId: eyeCatchingImageId,
};
}
function save() {
const options = getSaveOptions();
const onError = (err) => {
if (err.id === "3d81ceae-475f-4600-b2a8-2bc116157532") {
if (err.info.param === "name") {
os.alert({
type: "error",
title: i18n.ts._pages.invalidNameTitle,
text: i18n.ts._pages.invalidNameText,
});
}
} else if (err.code === "NAME_ALREADY_EXISTS") {
os.alert({
type: "error",
text: i18n.ts._pages.nameAlreadyExists,
});
}
};
if (pageId) {
options.pageId = pageId;
os.api("pages/update", options)
.then((page) => {
currentName = name.trim();
os.alert({
type: "success",
text: i18n.ts._pages.updated,
});
})
.catch(onError);
} else {
os.api("pages/create", options)
.then((created) => {
pageId = created.id;
currentName = name.trim();
os.alert({
type: "success",
text: i18n.ts._pages.created,
});
mainRouter.push(`/pages/edit/${pageId}`);
})
.catch(onError);
}
}
function del() {
os.confirm({
type: "warning",
text: i18n.t("removeAreYouSure", { x: title.trim() }),
}).then(({ canceled }) => {
if (canceled) return;
os.api("pages/delete", {
pageId: pageId,
}).then(() => {
os.alert({
type: "success",
text: i18n.ts._pages.deleted,
});
mainRouter.push("/pages");
});
});
}
function duplicate() {
title = title + " - copy";
name = name + "-copy";
os.api("pages/create", getSaveOptions()).then((created) => {
pageId = created.id;
currentName = name.trim();
os.alert({
type: "success",
text: i18n.ts._pages.created,
});
mainRouter.push(`/pages/edit/${pageId}`);
});
}
async function add() {
const { canceled, result: type } = await os.select({
type: null,
title: i18n.ts._pages.chooseBlock,
groupedItems: getPageBlockList(),
});
if (canceled) return;
const id = uuid();
content.push({ id, type });
}
async function addVariable() {
let { canceled, result: name } = await os.inputText({
title: i18n.ts._pages.enterVariableName,
});
if (canceled) return;
name = name.trim();
if (hpml.isUsedName(name)) {
os.alert({
type: "error",
text: i18n.ts._pages.variableNameIsAlreadyUsed,
});
return;
}
const id = uuid();
variables.push({ id, name, type: null });
}
function removeVariable(v) {
variables = variables.filter((x) => x.name !== v.name);
}
function getPageBlockList() {
return [
{
label: i18n.ts._pages.contentBlocks,
items: [
{ value: "section", text: i18n.ts._pages.blocks.section },
{ value: "text", text: i18n.ts._pages.blocks.text },
{ value: "image", text: i18n.ts._pages.blocks.image },
{ value: "textarea", text: i18n.ts._pages.blocks.textarea },
{ value: "note", text: i18n.ts._pages.blocks.note },
{ value: "canvas", text: i18n.ts._pages.blocks.canvas },
],
},
{
label: i18n.ts._pages.inputBlocks,
items: [
{ value: "button", text: i18n.ts._pages.blocks.button },
{
value: "radioButton",
text: i18n.ts._pages.blocks.radioButton,
},
{ value: "textInput", text: i18n.ts._pages.blocks.textInput },
{
value: "textareaInput",
text: i18n.ts._pages.blocks.textareaInput,
},
{
value: "numberInput",
text: i18n.ts._pages.blocks.numberInput,
},
{ value: "switch", text: i18n.ts._pages.blocks.switch },
{ value: "counter", text: i18n.ts._pages.blocks.counter },
],
},
{
label: i18n.ts._pages.specialBlocks,
items: [
{ value: "if", text: i18n.ts._pages.blocks.if },
{ value: "post", text: i18n.ts._pages.blocks.post },
],
},
];
}
function getScriptBlockList(type: string = null) {
const list = [];
const blocks = blockDefs.filter(
(block) =>
type == null ||
block.out == null ||
block.out === type ||
typeof block.out === "number"
);
for (const block of blocks) {
const category = list.find((x) => x.category === block.category);
if (category) {
category.items.push({
value: block.type,
text: i18n.t(`_pages.script.blocks.${block.type}`),
});
} else {
list.push({
category: block.category,
label: i18n.t(`_pages.script.categories.${block.category}`),
items: [
{
value: block.type,
text: i18n.t(`_pages.script.blocks.${block.type}`),
},
],
});
}
}
const userFns = variables.filter((x) => x.type === "fn");
if (userFns.length > 0) {
list.unshift({
label: i18n.t("_pages.script.categories.fn"),
items: userFns.map((v) => ({
value: "fn:" + v.name,
text: v.name,
})),
});
}
return list;
}
function setEyeCatchingImage(img) {
selectFile(img.currentTarget ?? img.target, null).then((file) => {
eyeCatchingImageId = file.id;
});
}
function removeEyeCatchingImage() {
eyeCatchingImageId = null;
}
async function init() {
hpml = new HpmlTypeChecker();
watch(
$$(variables),
() => {
hpml.variables = variables;
},
{ deep: true }
);
watch(
$$(content),
() => {
hpml.pageVars = collectPageVars(content);
},
{ deep: true }
);
if (props.initPageId) {
page = await os.api("pages/show", {
pageId: props.initPageId,
});
} else if (props.initPageName && props.initUser) {
page = await os.api("pages/show", {
name: props.initPageName,
username: props.initUser,
});
readonly = true;
}
if (page) {
author = page.user;
pageId = page.id;
title = page.title;
name = page.name;
currentName = page.name;
summary = page.summary;
font = page.font;
script = page.script;
hideTitleWhenPinned = page.hideTitleWhenPinned;
alignCenter = page.alignCenter;
isPublic = page.isPublic;
content = page.content;
variables = page.variables;
eyeCatchingImageId = page.eyeCatchingImageId;
} else {
const id = uuid();
content = [
{
id,
type: "text",
text: "",
},
];
}
}
init();
const headerActions = $computed(() => []);
const headerTabs = $computed(() => [
{
key: "settings",
title: i18n.ts._pages.pageSetting,
icon: "ph-gear-six ph-bold ph-lg",
},
{
key: "contents",
title: i18n.ts._pages.contents,
icon: "ph-sticker ph-bold ph-lg",
},
{
key: "variables",
title: i18n.ts._pages.variables,
icon: "ph-magic-wand ph-bold ph-lg",
},
{
key: "script",
title: i18n.ts.script,
icon: "ph-code ph-bold ph-lg",
},
]);
definePageMetadata(
computed(() => {
let title = i18n.ts._pages.newPage;
if (props.initPageId) {
title = i18n.ts._pages.editPage;
} else if (props.initPageName && props.initUser) {
title = i18n.ts._pages.readPage;
}
return {
title: title,
icon: "ph-pencil ph-bold ph-lg",
};
})
);
</script>
<style lang="scss" scoped>
.jqqmcavi {
> .button {
& + .button {
margin: 4px;
}
}
}
.gwbmwxkm {
position: relative;
> header {
> .title {
z-index: 1;
margin: 0;
padding: 0 16px;
line-height: 42px;
font-size: 0.9em;
font-weight: bold;
box-shadow: 0 1px rgba(#000, 0.07);
> i {
margin-right: 6px;
}
&:empty {
display: none;
}
}
> .buttons {
position: absolute;
z-index: 2;
top: 0;
right: 0;
> button {
padding: 0;
width: 42px;
font-size: 0.9em;
line-height: 42px;
}
}
}
> section {
padding: 0 32px 32px 32px;
@media (max-width: 500px) {
padding: 0 16px 16px 16px;
}
> .view {
display: inline-block;
margin: 16px 0 0 0;
font-size: 14px;
}
> .content {
margin-bottom: 16px;
}
> .eyeCatch {
margin-bottom: 16px;
> div {
> img {
max-width: 100%;
}
}
}
}
}
.qmuvgica {
padding: 16px;
> .variables {
margin-bottom: 16px;
}
> .add {
margin-bottom: 16px;
}
}
</style>

View File

@ -0,0 +1,469 @@
<template>
<MkStickyContainer>
<template #header
><MkPageHeader
:actions="headerActions"
:tabs="headerTabs"
:display-back-button="true"
/></template>
<MkSpacer :content-max="800">
<transition
:name="$store.state.animation ? 'fade' : ''"
mode="out-in"
>
<div
v-if="page"
:key="page.id"
v-size="{ max: [450] }"
class="xcukqgmh"
>
<div class="footer">
<div>
<i class="ph-alarm ph-bold" />
{{ i18n.ts.createdAt }}:
<MkTime :time="page.createdAt" mode="detail" />
</div>
<div v-if="page.createdAt != page.updatedAt">
<i class="ph-alarm ph-bold"></i>
{{ i18n.ts.updatedAt }}:
<MkTime :time="page.updatedAt" mode="detail" />
</div>
</div>
<div class="_block main">
<div class="banner">
<div class="banner-image">
<div class="header">
<h1>{{ page.title }}</h1>
</div>
<div class="menu-actions">
<button
v-tooltip="i18n.ts.copyUrl"
@click="copyUrl"
class="menu _button"
>
<i
class="ph-link-simple ph-bold ph-lg"
/>
</button>
<MkA
v-tooltip="i18n.ts._pages.viewSource"
:to="`/@${username}/pages/${pageName}/view-source`"
class="menu _button"
style="transform: translateY(2px)"
><i class="ph-code ph-bold ph-lg"
/></MkA>
<template
v-if="$i && $i.id === page.userId"
>
<MkA
v-tooltip="i18n.ts._pages.editPage"
class="menu _button"
:to="`/pages/edit/${page.id}`"
style="transform: translateY(2px)"
><i class="ph-pencil ph-bold ph-lg"
/></MkA>
<button
v-if="$i.pinnedPageId === page.id"
v-tooltip="i18n.ts.unpin"
class="menu _button"
@click="pin(false)"
>
<i
class="ph-push-pin-slash ph-bold ph-lg"
/>
</button>
<button
v-else
v-tooltip="i18n.ts.pin"
class="menu _button"
@click="pin(true)"
>
<i
class="ph-push-pin ph-bold ph-lg"
/>
</button>
</template>
</div>
</div>
</div>
<div class="content">
<XPage :page="page" />
</div>
<div class="actions">
<div class="like">
<MkButton
v-if="page.isLiked"
v-tooltip="i18n.ts._pages.unlike"
class="button"
primary
@click="unlike()"
><i class="ph-heart ph-fill ph-lg"></i
><span
v-if="page.likedCount > 0"
class="count"
>{{ page.likedCount }}</span
></MkButton
>
<MkButton
v-else
v-tooltip="i18n.ts._pages.like"
class="button"
@click="like()"
><i class="ph-heart ph-bold"></i
><span
v-if="page.likedCount > 0"
class="count"
>{{ page.likedCount }}</span
></MkButton
>
</div>
<div class="other">
<button
v-tooltip="i18n.ts.shareWithNote"
v-click-anime
class="_button"
@click="shareWithNote"
>
<i
class="ph-repeat ph-bold ph-lg ph-fw ph-lg"
></i>
</button>
<button
v-if="shareAvailable()"
v-tooltip="i18n.ts.share"
v-click-anime
class="_button"
@click="share"
>
<i
class="ph-share-network ph-bold ph-lg ph-fw ph-lg"
></i>
</button>
</div>
<div class="user">
<MagAvatarResolvingProxy
:user="page.user"
class="avatar"
/>
<div class="name">
<MkUserName
:user="page.user"
style="display: block"
/>
<MkAcct :user="page.user" />
</div>
<MkFollowButton
v-if="!$i || $i.id != page.user.id"
:user="page.user"
:inline="true"
:transparent="false"
:full="true"
class="koudoku"
/>
</div>
</div>
<!-- <div class="links">
<MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA>
<template v-if="$i && $i.id === page.userId">
<MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA>
<button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button>
<button v-else class="link _textButton" @click="pin(true)">{{ i18n.ts.pin }}</button>
</template>
</div> -->
</div>
<MkContainer
:max-height="300"
:foldable="true"
:expanded="false"
class="other"
>
<template #header
><i class="ph-clock ph-bold ph-lg"></i>
{{ i18n.ts.recentPosts }}</template
>
<MkPagination
v-slot="{ items }"
:pagination="otherPostsPagination"
>
<MkPagePreview
v-for="page in items"
:key="page.id"
:page="page"
class="_gap"
/>
</MkPagination>
</MkContainer>
</div>
<MkError v-else-if="error" @retry="fetchPage()" />
<MkLoading v-else />
</transition>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, watch } from "vue";
import XPage from "@/components/page/page.vue";
import MkButton from "@/components/MkButton.vue";
import * as os from "@/os";
import { url } from "@/config";
import MkFollowButton from "@/components/MkFollowButton.vue";
import MkContainer from "@/components/MkContainer.vue";
import MkPagination from "@/components/MkPagination.vue";
import MkPagePreview from "@/components/MkPagePreview.vue";
import { i18n } from "@/i18n";
import copyToClipboard from "@/scripts/copy-to-clipboard";
import { definePageMetadata } from "@/scripts/page-metadata";
import { shareAvailable } from "@/scripts/share-available";
import { $i } from "@/account";
const props = defineProps<{
pageName: string;
username: string;
}>();
let page = $ref(null);
let bgImg = $ref(null);
let error = $ref(null);
const otherPostsPagination = {
endpoint: "users/pages" as const,
limit: 6,
params: computed(() => ({
userId: page.user.id,
})),
};
const path = $computed(() => props.username + "/" + props.pageName);
function fetchPage() {
page = null;
os.api("pages/show", {
name: props.pageName,
username: props.username,
})
.then((_page) => {
page = _page;
bgImg = getBgImg();
})
.catch((err) => {
error = err;
});
}
function copyUrl() {
copyToClipboard(window.location.href);
os.success();
}
function getBgImg(): string {
if (page.eyeCatchingImage != null) {
return `url(${page.eyeCatchingImage.url})`;
} else {
return "linear-gradient(to bottom right, #31748f, #9ccfd8)";
}
}
function share() {
navigator.share({
title: page.title ?? page.name,
text: page.summary,
url: `${url}/@${page.user.username}/pages/${page.name}`,
});
}
function shareWithNote() {
os.post({
initialText: `${page.title || page.name} ${url}/@${
page.user.username
}/pages/${page.name}`,
});
}
function like() {
os.api("pages/like", {
pageId: page.id,
}).then(() => {
page.isLiked = true;
page.likedCount++;
});
}
async function unlike() {
os.api("pages/unlike", {
pageId: page.id,
}).then(() => {
page.isLiked = false;
page.likedCount--;
});
}
function pin(pin) {
os.apiWithDialog("i/update", {
pinnedPageId: pin ? page.id : null,
});
}
watch(() => path, fetchPage, { immediate: true });
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata(
computed(() =>
page
? {
title: computed(() => page.title || page.name),
avatar: page.user,
path: `/@${page.user.username}/pages/${page.name}`,
share: {
title: page.title || page.name,
text: page.summary,
},
}
: null
)
);
</script>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.125s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.xcukqgmh {
> .main {
> * {
margin: 1rem;
}
> .banner {
margin: 0rem !important;
> .banner-image {
// TODO:
display: block;
width: 100%;
height: 150px;
background-position: center;
background-size: cover;
background-image: v-bind("bgImg");
> .header {
padding: 16px;
> h1 {
margin: 0;
color: white;
text-shadow: 0 0 8px var(--shadow);
}
}
> .menu-actions {
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px));
background: rgba(0, 0, 0, 0.2);
padding: 8px;
border-radius: 24px;
width: fit-content;
position: relative;
top: -10px;
left: 1rem;
> .menu {
vertical-align: bottom;
height: 31px;
width: 31px;
color: #fff;
text-shadow: 0 0 8px var(--shadow);
font-size: 16px;
}
> .koudoku {
margin-left: 4px;
vertical-align: bottom;
}
}
}
}
> .content {
padding: 16px 0;
}
> .actions {
display: flex;
align-items: center;
margin-top: 16px;
padding: 16px 0;
border-top: solid 0.5px var(--divider);
> .like {
> .button {
--accent: #eb6f92;
--X8: #eb6f92;
--buttonBg: rgb(216 71 106 / 5%);
--buttonHoverBg: rgb(216 71 106 / 10%);
color: #eb6f92;
::v-deep(.count) {
margin-left: 0.5em;
}
}
}
> .other {
> button {
padding: 2px;
margin: 0 8px;
&:hover {
color: var(--fgHighlighted);
}
}
}
> .user {
margin-left: auto;
display: flex;
align-items: center;
> .avatar {
width: 40px;
height: 40px;
}
> .name {
margin: 0 0 0 12px;
font-size: 90%;
}
> .koudoku {
margin-left: auto;
margin: 1rem;
}
}
}
> .links {
margin-top: 16px;
padding: 14px 0;
border-top: solid 0.5px var(--divider);
> .link {
margin-right: 2em;
}
}
}
> .footer {
margin: var(--margin) 0 var(--margin) 0;
font-size: 85%;
opacity: 0.75;
}
}
</style>

View File

@ -0,0 +1,196 @@
<template>
<MkStickyContainer>
<template #header
><MkPageHeader
v-model:tab="tab"
:actions="headerActions"
:tabs="headerTabs"
/></template>
<MkSpacer :content-max="700">
<swiper
:round-lengths="true"
:touch-angle="25"
:threshold="10"
:centeredSlides="true"
:modules="[Virtual]"
:space-between="20"
:virtual="true"
:allow-touch-move="
!(
deviceKind === 'desktop' &&
!defaultStore.state.swipeOnDesktop
)
"
@swiper="setSwiperRef"
@slide-change="onSlideChange"
>
<swiper-slide>
<div class="rknalgpo">
<MkPagination
v-slot="{ items }"
:pagination="featuredPagesPagination"
>
<MkPagePreview
v-for="page in items"
:key="page.id"
class="ckltabjg"
:page="page"
/>
</MkPagination>
</div>
</swiper-slide>
<swiper-slide>
<div class="rknalgpo liked">
<MkPagination
v-slot="{ items }"
:pagination="likedPagesPagination"
>
<MkPagePreview
v-for="like in items"
:key="like.page.id"
class="ckltabjg"
:page="like.page"
/>
</MkPagination>
</div>
</swiper-slide>
<swiper-slide>
<div class="rknalgpo my">
<div class="buttoncontainer">
<MkButton class="new primary" @click="create()"
><i class="ph-plus ph-bold ph-lg"></i>
{{ i18n.ts._pages.newPage }}</MkButton
>
</div>
<MkPagination
v-slot="{ items }"
:pagination="myPagesPagination"
>
<MkPagePreview
v-for="page in items"
:key="page.id"
class="ckltabjg"
:page="page"
/>
</MkPagination>
</div>
</swiper-slide>
</swiper>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, watch, onMounted } from "vue";
import { Virtual } from "swiper";
import { Swiper, SwiperSlide } from "swiper/vue";
import MkPagePreview from "@/components/MkPagePreview.vue";
import MkPagination from "@/components/MkPagination.vue";
import MkButton from "@/components/MkButton.vue";
import { useRouter } from "@/router";
import { i18n } from "@/i18n";
import { definePageMetadata } from "@/scripts/page-metadata";
import { deviceKind } from "@/scripts/device-kind";
import { defaultStore } from "@/store";
import "swiper/scss";
import "swiper/scss/virtual";
const router = useRouter();
let tab = $ref("featured");
const tabs = ["featured", "liked", "my"];
watch($$(tab), () => syncSlide(tabs.indexOf(tab)));
const featuredPagesPagination = {
endpoint: "pages/featured" as const,
limit: 10,
};
const likedPagesPagination = {
endpoint: "i/page-likes" as const,
limit: 10,
};
const myPagesPagination = {
endpoint: "i/pages" as const,
limit: 10,
};
function create() {
router.push("/pages/new");
}
const headerActions = $computed(() => [
{
icon: "ph-plus ph-bold ph-lg",
text: i18n.ts.create,
handler: create,
},
]);
const headerTabs = $computed(() => [
{
key: "featured",
title: i18n.ts._pages.featured,
icon: "ph-fire-simple ph-bold ph-lg",
},
{
key: "liked",
title: i18n.ts._pages.liked,
icon: "ph-heart ph-bold ph-lg",
},
{
key: "my",
title: i18n.ts._pages.my,
icon: "ph-crown-simple ph-bold ph-lg",
},
]);
definePageMetadata(
computed(() => ({
title: i18n.ts.pages,
icon: "ph-file-text ph-bold ph-lg",
}))
);
let swiperRef = null;
function setSwiperRef(swiper) {
swiperRef = swiper;
syncSlide(tabs.indexOf(tab));
}
function onSlideChange() {
tab = tabs[swiperRef.activeIndex];
}
function syncSlide(index) {
swiperRef.slideTo(index);
}
onMounted(() => {
syncSlide(tabs.indexOf(swiperRef.activeIndex));
});
</script>
<style lang="scss" scoped>
.rknalgpo {
> .buttoncontainer {
display: grid;
justify-content: center;
margin-bottom: 1rem;
}
&.my .ckltabjg:first-child {
margin-top: 16px;
}
.ckltabjg:not(:last-child) {
margin-bottom: 8px;
}
@media (min-width: 500px) {
.ckltabjg:not(:last-child) {
margin-bottom: 16px;
}
}
}
</style>

View File

@ -0,0 +1,164 @@
<template>
<div class="iltifgqe">
<div class="editor _panel _gap">
<PrismEditor
v-model="code"
class="_code code"
style="height: 30vh"
:highlight="highlighter"
:line-numbers="false"
/>
<MkButton
style="position: absolute; top: 8px; right: 8px"
primary
@click="run()"
><i class="ph-play ph-bold ph-lg"></i
></MkButton>
</div>
<MkContainer :foldable="true" class="_gap">
<template #header>{{ i18n.ts.output }}</template>
<div class="bepmlvbi">
<div
v-for="log in logs"
:key="log.id"
class="log"
:class="{ print: log.print }"
>
{{ log.text }}
</div>
</div>
</MkContainer>
<div class="_gap">
{{ i18n.ts.scratchpadDescription }}
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
import "prismjs";
import { highlight, languages } from "prismjs/components/prism-core";
import "prismjs/components/prism-clike";
import "prismjs/components/prism-javascript";
import "prismjs/themes/prism-okaidia.css";
import { PrismEditor } from "vue-prism-editor";
import "vue-prism-editor/dist/prismeditor.min.css";
import { AiScript, parse, utils } from "@syuilo/aiscript";
import MkContainer from "@/components/MkContainer.vue";
import MkButton from "@/components/MkButton.vue";
import { createAiScriptEnv } from "@/scripts/aiscript/api";
import * as os from "@/os";
import { $i } from "@/account";
import { i18n } from "@/i18n";
import { definePageMetadata } from "@/scripts/page-metadata";
const code = ref("");
const logs = ref<any[]>([]);
const saved = localStorage.getItem("scratchpad");
if (saved) {
code.value = saved;
}
watch(code, () => {
localStorage.setItem("scratchpad", code.value);
});
async function run() {
logs.value = [];
const aiscript = new AiScript(
createAiScriptEnv({
storageKey: "scratchpad",
token: $i?.token,
}),
{
in: (q) => {
return new Promise((ok) => {
os.inputText({
title: q,
}).then(({ canceled, result: a }) => {
ok(a);
});
});
},
out: (value) => {
logs.value.push({
id: Math.random(),
text:
value.type === "str"
? value.value
: utils.valToString(value),
print: true,
});
},
log: (type, params) => {
switch (type) {
case "end":
logs.value.push({
id: Math.random(),
text: utils.valToString(params.val, true),
print: false,
});
break;
default:
break;
}
},
}
);
let ast;
try {
ast = parse(code.value);
} catch (error) {
os.alert({
type: "error",
text: "Syntax error :(",
});
return;
}
try {
await aiscript.exec(ast);
} catch (error: any) {
os.alert({
type: "error",
text: error.message,
});
}
}
function highlighter(code) {
return highlight(code, languages.js, "javascript");
}
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.scratchpad,
icon: "ph-terminal-window ph-bold ph-lg",
});
</script>
<style lang="scss" scoped>
.iltifgqe {
padding: 16px;
> .editor {
position: relative;
}
}
.bepmlvbi {
padding: 16px;
> .log {
&:not(.print) {
opacity: 0.7;
}
}
}
</style>

View File

@ -6,6 +6,12 @@
<FormLink to="/settings/apps" class="_formBlock">{{
i18n.ts.manageAccessTokens
}}</FormLink>
<FormLink
to="/api-console"
:behavior="isDesktop ? 'window' : null"
class="_formBlock"
>API console</FormLink
>
</div>
</template>

View File

@ -157,6 +157,12 @@ const menuDef = computed(() => [
to: "/settings/sounds",
active: currentPage?.route.name === "sounds",
},
{
icon: "ph-plug ph-bold ph-lg",
text: i18n.ts.plugins,
to: "/settings/plugin",
active: currentPage?.route.name === "plugin",
},
],
},
{

View File

@ -0,0 +1,151 @@
<template>
<div class="_formRoot">
<FormInfo warn class="_formBlock">{{
i18n.ts._plugin.installWarn
}}</FormInfo>
<FormTextarea v-model="code" tall class="_formBlock">
<template #label>{{ i18n.ts.code }}</template>
</FormTextarea>
<div class="_formBlock">
<FormButton :disabled="code == null" primary inline @click="install"
><i class="ph-check ph-bold ph-lg"></i>
{{ i18n.ts.install }}</FormButton
>
</div>
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, nextTick, ref } from "vue";
import { AiScript, parse } from "@syuilo/aiscript";
import { serialize } from "@syuilo/aiscript/built/serializer";
import { v4 as uuid } from "uuid";
import FormTextarea from "@/components/form/textarea.vue";
import FormButton from "@/components/MkButton.vue";
import FormInfo from "@/components/MkInfo.vue";
import * as os from "@/os";
import { ColdDeviceStorage } from "@/store";
import { unisonReload } from "@/scripts/unison-reload";
import { i18n } from "@/i18n";
import { definePageMetadata } from "@/scripts/page-metadata";
const code = ref(null);
function installPlugin({ id, meta, ast, token }) {
ColdDeviceStorage.set(
"plugins",
ColdDeviceStorage.get("plugins").concat({
...meta,
id,
active: true,
configData: {},
token: token,
ast: ast,
})
);
}
async function install() {
let ast;
try {
ast = parse(code.value);
} catch (err) {
os.alert({
type: "error",
text: "Syntax error :(",
});
return;
}
const meta = AiScript.collectMetadata(ast);
if (meta == null) {
os.alert({
type: "error",
text: "No metadata found :(",
});
return;
}
const metadata = meta.get(null);
if (metadata == null) {
os.alert({
type: "error",
text: "No metadata found :(",
});
return;
}
const { name, version, author, description, permissions, config } =
metadata;
if (name == null || version == null || author == null) {
os.alert({
type: "error",
text: "Required property not found :(",
});
return;
}
const token =
permissions == null || permissions.length === 0
? null
: await new Promise((res, rej) => {
os.popup(
defineAsyncComponent(
() => import("@/components/MkTokenGenerateWindow.vue")
),
{
title: i18n.ts.tokenRequested,
information: i18n.ts.pluginTokenRequestedDescription,
initialName: name,
initialPermissions: permissions,
},
{
done: async (result) => {
const { name, permissions } = result;
const { token } = await os.api(
"miauth/gen-token",
{
session: null,
name: name,
permission: permissions,
}
);
res(token);
},
},
"closed"
);
});
installPlugin({
id: uuid(),
meta: {
name,
version,
author,
description,
permissions,
config,
},
token,
ast: serialize(ast),
});
os.success();
nextTick(() => {
unisonReload();
});
}
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts._plugin.install,
icon: "ph-download-simple ph-bold ph-lg",
});
</script>

View File

@ -0,0 +1,127 @@
<template>
<div class="_formRoot">
<FormLink to="/settings/plugin/install"
><template #icon
><i class="ph-download-simple ph-bold ph-lg"></i></template
>{{ i18n.ts._plugin.install }}</FormLink
>
<FormSection>
<template #label>{{ i18n.ts.manage }}</template>
<div
v-for="plugin in plugins"
:key="plugin.id"
class="_formBlock _panel"
style="padding: 20px"
>
<span style="display: flex"
><b>{{ plugin.name }}</b
><span style="margin-left: auto"
>v{{ plugin.version }}</span
></span
>
<FormSwitch
class="_formBlock"
:model-value="plugin.active"
@update:modelValue="changeActive(plugin, $event)"
>{{ i18n.ts.makeActive }}</FormSwitch
>
<MkKeyValue class="_formBlock">
<template #key>{{ i18n.ts.author }}</template>
<template #value>{{ plugin.author }}</template>
</MkKeyValue>
<MkKeyValue class="_formBlock">
<template #key>{{ i18n.ts.description }}</template>
<template #value>{{ plugin.description }}</template>
</MkKeyValue>
<MkKeyValue class="_formBlock">
<template #key>{{ i18n.ts.permission }}</template>
<template #value>{{ plugin.permission }}</template>
</MkKeyValue>
<div style="display: flex; gap: var(--margin); flex-wrap: wrap">
<MkButton
v-if="plugin.config"
inline
@click="config(plugin)"
><i class="ph-gear-six ph-bold ph-lg"></i>
{{ i18n.ts.settings }}</MkButton
>
<MkButton inline danger @click="uninstall(plugin)"
><i class="ph-trash ph-bold ph-lg"></i>
{{ i18n.ts.uninstall }}</MkButton
>
</div>
</div>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import { nextTick, ref } from "vue";
import FormLink from "@/components/form/link.vue";
import FormSwitch from "@/components/form/switch.vue";
import FormSection from "@/components/form/section.vue";
import MkButton from "@/components/MkButton.vue";
import MkKeyValue from "@/components/MkKeyValue.vue";
import * as os from "@/os";
import { ColdDeviceStorage } from "@/store";
import { unisonReload } from "@/scripts/unison-reload";
import { i18n } from "@/i18n";
import { definePageMetadata } from "@/scripts/page-metadata";
const plugins = ref(ColdDeviceStorage.get("plugins"));
function uninstall(plugin) {
ColdDeviceStorage.set(
"plugins",
plugins.value.filter((x) => x.id !== plugin.id)
);
os.success();
nextTick(() => {
unisonReload();
});
}
// TODO: storeactionAiScriptAPI
async function config(plugin) {
const config = plugin.config;
for (const key in plugin.configData) {
config[key].default = plugin.configData[key];
}
const { canceled, result } = await os.form(plugin.name, config);
if (canceled) return;
const coldPlugins = ColdDeviceStorage.get("plugins");
coldPlugins.find((p) => p.id === plugin.id)!.configData = result;
ColdDeviceStorage.set("plugins", coldPlugins);
nextTick(() => {
location.reload();
});
}
function changeActive(plugin, active) {
const coldPlugins = ColdDeviceStorage.get("plugins");
coldPlugins.find((p) => p.id === plugin.id)!.active = active;
ColdDeviceStorage.set("plugins", coldPlugins);
nextTick(() => {
location.reload();
});
}
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.plugins,
icon: "ph-plug ph-bold ph-lg",
});
</script>
<style lang="scss" scoped></style>

View File

@ -121,6 +121,7 @@ const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
"lightTheme",
"darkTheme",
"syncDeviceDarkMode",
"plugins",
"mediaVolume",
"sound_masterVolume",
"sound_note",

View File

@ -308,15 +308,27 @@
</dl>
</div>
<div class="status">
<MkA :to="userPage(user)">
<MkA
v-click-anime
:to="userPage(user)"
:class="{ active: page === 'index' }"
>
<b>{{ number(user.note_count) }}</b>
<span>{{ i18n.ts.notes }}</span>
</MkA>
<MkA :to="userPage(user, 'following')">
<MkA
v-click-anime
:to="userPage(user, 'following')"
:class="{ active: page === 'following' }"
>
<b>{{ number(user.following_count) }}</b>
<span>{{ i18n.ts.following }}</span>
</MkA>
<MkA :to="userPage(user, 'followers')">
<MkA
v-click-anime
:to="userPage(user, 'followers')"
:class="{ active: page === 'followers' }"
>
<b>{{ number(user.follower_count) }}</b>
<span>{{ i18n.ts.followers }}</span>
</MkA>
@ -383,6 +395,7 @@ import MkUserName from "@/components/global/MkUserName.vue";
import Mfm from "@/components/mfm.vue";
import MkTime from "@/components/global/MkTime.vue";
import MkA from "@/components/global/MkA.vue";
import page from "@/components/page/page.vue";
import { packed } from "magnetar-common";
import number from "../../filters/number";

View File

@ -18,6 +18,7 @@
/>
<XReactions v-else-if="tab === 'reactions'" :user="user" />
<XClips v-else-if="tab === 'clips'" :user="user" />
<XPages v-else-if="tab === 'pages'" :user="user" />
<XGallery v-else-if="tab === 'gallery'" :user="user" />
</div>
<MkError v-else-if="error" @retry="fetchUser()" />
@ -41,6 +42,7 @@ import * as Acct from "calckey-js/built/acct";
const XHome = defineAsyncComponent(() => import("./home.vue"));
const XReactions = defineAsyncComponent(() => import("./reactions.vue"));
const XClips = defineAsyncComponent(() => import("./clips.vue"));
const XPages = defineAsyncComponent(() => import("./pages.vue"));
const XGallery = defineAsyncComponent(() => import("./gallery.vue"));
const props = withDefaults(
@ -142,6 +144,11 @@ const headerTabs = $computed(() =>
title: i18n.ts.clips,
icon: "ph-paperclip ph-bold ph-lg",
},
{
key: "pages",
title: i18n.ts.pages,
icon: "ph-file-text ph-bold ph-lg",
},
{
key: "gallery",
title: i18n.ts.gallery,

View File

@ -0,0 +1,33 @@
<template>
<MkSpacer :content-max="800">
<MkPagination v-slot="{ items }" ref="list" :pagination="pagination">
<MkPagePreview
v-for="page in items"
:key="page.id"
:page="page"
class="_gap"
/>
</MkPagination>
</MkSpacer>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import MkPagePreview from "@/components/MkPagePreview.vue";
import MkPagination from "@/components/MkPagination.vue";
import { packed } from "magnetar-common";
const props = defineProps<{
user: packed.PackUserBase;
}>();
const pagination = {
endpoint: "users/pages" as const,
limit: 20,
params: computed(() => ({
userId: props.user.id,
})),
};
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,192 @@
import { AiScript, utils, values } from "@syuilo/aiscript";
import { deserialize } from "@syuilo/aiscript/built/serializer";
import { jsToVal } from "@syuilo/aiscript/built/interpreter/util";
import { createAiScriptEnv } from "@/scripts/aiscript/api";
import { inputText } from "@/os";
import {
noteActions,
notePostInterruptors,
noteViewInterruptors,
postFormActions,
userActions,
} from "@/store";
const pluginContexts = new Map<string, AiScript>();
export function install(plugin) {
console.info("Plugin installed:", plugin.name, `v${plugin.version}`);
const aiscript = new AiScript(
createPluginEnv({
plugin: plugin,
storageKey: `plugins:${plugin.id}`,
}),
{
in: (q) => {
return new Promise((ok) => {
inputText({
title: q,
}).then(({ canceled, result: a }) => {
ok(a);
});
});
},
out: (value) => {
console.log(value);
},
log: (type, params) => {},
}
);
initPlugin({ plugin, aiscript });
aiscript.exec(deserialize(plugin.ast));
}
function createPluginEnv(opts) {
const config = new Map();
for (const [k, v] of Object.entries(opts.plugin.config || {})) {
config.set(
k,
jsToVal(
typeof opts.plugin.configData[k] !== "undefined"
? opts.plugin.configData[k]
: v.default
)
);
}
return {
...createAiScriptEnv({ ...opts, token: opts.plugin.token }),
//#region Deprecated
"Mk:register_post_form_action": values.FN_NATIVE(([title, handler]) => {
registerPostFormAction({
pluginId: opts.plugin.id,
title: title.value,
handler,
});
}),
"Mk:register_user_action": values.FN_NATIVE(([title, handler]) => {
registerUserAction({
pluginId: opts.plugin.id,
title: title.value,
handler,
});
}),
"Mk:register_note_action": values.FN_NATIVE(([title, handler]) => {
registerNoteAction({
pluginId: opts.plugin.id,
title: title.value,
handler,
});
}),
//#endregion
"Plugin:register_post_form_action": values.FN_NATIVE(
([title, handler]) => {
registerPostFormAction({
pluginId: opts.plugin.id,
title: title.value,
handler,
});
}
),
"Plugin:register_user_action": values.FN_NATIVE(([title, handler]) => {
registerUserAction({
pluginId: opts.plugin.id,
title: title.value,
handler,
});
}),
"Plugin:register_note_action": values.FN_NATIVE(([title, handler]) => {
registerNoteAction({
pluginId: opts.plugin.id,
title: title.value,
handler,
});
}),
"Plugin:register_note_view_interruptor": values.FN_NATIVE(
([handler]) => {
registerNoteViewInterruptor({
pluginId: opts.plugin.id,
handler,
});
}
),
"Plugin:register_note_post_interruptor": values.FN_NATIVE(
([handler]) => {
registerNotePostInterruptor({
pluginId: opts.plugin.id,
handler,
});
}
),
"Plugin:open_url": values.FN_NATIVE(([url]) => {
window.open(url.value, "_blank");
}),
"Plugin:config": values.OBJ(config),
};
}
function initPlugin({ plugin, aiscript }) {
pluginContexts.set(plugin.id, aiscript);
}
function registerPostFormAction({ pluginId, title, handler }) {
postFormActions.push({
title,
handler: (form, update) => {
pluginContexts.get(pluginId)?.execFn(handler, [
utils.jsToVal(form),
values.FN_NATIVE(([key, value]) => {
update(key.value, value.value);
}),
]);
},
});
}
function registerUserAction({ pluginId, title, handler }) {
userActions.push({
title,
handler: (user) => {
pluginContexts
.get(pluginId)
?.execFn(handler, [utils.jsToVal(user)]);
},
});
}
function registerNoteAction({ pluginId, title, handler }) {
noteActions.push({
title,
handler: (note) => {
pluginContexts
.get(pluginId)
?.execFn(handler, [utils.jsToVal(note)]);
},
});
}
function registerNoteViewInterruptor({ pluginId, handler }) {
noteViewInterruptors.push({
handler: async (note) => {
return utils.valToJs(
await pluginContexts
.get(pluginId)
.execFn(handler, [utils.jsToVal(note)])
);
},
});
}
function registerNotePostInterruptor({ pluginId, handler }) {
notePostInterruptors.push({
handler: async (note) => {
return utils.valToJs(
await pluginContexts
.get(pluginId)
.execFn(handler, [utils.jsToVal(note)])
);
},
});
}

View File

@ -23,6 +23,14 @@ const page = (loader: AsyncComponentLoader<any>) =>
});
export const routes = [
{
path: "/@:initUser/pages/:initPageName/view-source",
component: page(() => import("./pages/page-editor/page-editor.vue")),
},
{
path: "/@:username/pages/:pageName",
component: page(() => import("./pages/page.vue")),
},
{
path: "/@:acct/following",
component: page(() => import("./pages/user/following.vue")),
@ -160,6 +168,18 @@ export const routes = [
name: "sounds",
component: page(() => import("./pages/settings/sounds.vue")),
},
{
path: "/plugin/install",
name: "plugin",
component: page(
() => import("./pages/settings/plugin.install.vue")
),
},
{
path: "/plugin",
name: "plugin",
component: page(() => import("./pages/settings/plugin.vue")),
},
{
path: "/import-export",
name: "import-export",
@ -338,10 +358,19 @@ export const routes = [
component: page(() => import("./pages/share.vue")),
loginRequired: true,
},
{
path: "/api-console",
component: page(() => import("./pages/api-console.vue")),
loginRequired: true,
},
{
path: "/mfm-cheat-sheet",
component: page(() => import("./pages/mfm-cheat-sheet.vue")),
},
{
path: "/scratchpad",
component: page(() => import("./pages/scratchpad.vue")),
},
{
path: "/preview",
component: page(() => import("./pages/preview.vue")),
@ -364,6 +393,20 @@ export const routes = [
path: "/tags/:tag",
component: page(() => import("./pages/tag.vue")),
},
{
path: "/pages/new",
component: page(() => import("./pages/page-editor/page-editor.vue")),
loginRequired: true,
},
{
path: "/pages/edit/:initPageId",
component: page(() => import("./pages/page-editor/page-editor.vue")),
loginRequired: true,
},
{
path: "/pages",
component: page(() => import("./pages/pages.vue")),
},
{
path: "/gallery/:postId/edit",
component: page(() => import("./pages/gallery/edit.vue")),

View File

@ -0,0 +1,61 @@
import { utils, values } from "@syuilo/aiscript";
import * as os from "@/os";
import { $i } from "@/account";
export function createAiScriptEnv(opts) {
let apiRequests = 0;
return {
USER_ID: $i ? values.STR($i.id) : values.NULL,
USER_NAME: $i ? values.STR($i.name) : values.NULL,
USER_USERNAME: $i ? values.STR($i.username) : values.NULL,
"Mk:dialog": values.FN_NATIVE(async ([title, text, type]) => {
await os.alert({
type: type ? type.value : "info",
title: title.value,
text: text.value,
});
}),
"Mk:confirm": values.FN_NATIVE(async ([title, text, type]) => {
const confirm = await os.confirm({
type: type ? type.value : "question",
title: title.value,
text: text.value,
});
return confirm.canceled ? values.FALSE : values.TRUE;
}),
"Mk:api": values.FN_NATIVE(async ([ep, param, token]) => {
if (token) {
utils.assertString(token);
// バグがあればundefinedもあり得るため念のため
if (typeof token.value !== "string")
throw new Error("invalid token");
}
apiRequests++;
if (apiRequests > 16) return values.NULL;
const res = await os.api(
ep.value,
utils.valToJs(param),
token ? token.value : opts.token || null
);
return utils.jsToVal(res);
}),
"Mk:save": values.FN_NATIVE(([key, value]) => {
utils.assertString(key);
localStorage.setItem(
`aiscript:${opts.storageKey}:${key.value}`,
JSON.stringify(utils.valToJs(value))
);
return values.NULL;
}),
"Mk:load": values.FN_NATIVE(([key]) => {
utils.assertString(key);
return utils.jsToVal(
JSON.parse(
localStorage.getItem(
`aiscript:${opts.storageKey}:${key.value}`
)
)
);
}),
};
}

View File

@ -0,0 +1,48 @@
export function collectPageVars(content) {
const pageVars = [];
const collect = (xs: any[]) => {
for (const x of xs) {
if (x.type === "textInput") {
pageVars.push({
name: x.name,
type: "string",
value: x.default || "",
});
} else if (x.type === "textareaInput") {
pageVars.push({
name: x.name,
type: "string",
value: x.default || "",
});
} else if (x.type === "numberInput") {
pageVars.push({
name: x.name,
type: "number",
value: x.default || 0,
});
} else if (x.type === "switch") {
pageVars.push({
name: x.name,
type: "boolean",
value: x.default,
});
} else if (x.type === "counter") {
pageVars.push({
name: x.name,
type: "number",
value: 0,
});
} else if (x.type === "radioButton") {
pageVars.push({
name: x.name,
type: "string",
value: x.default || "",
});
} else if (x.children) {
collect(x.children);
}
}
};
collect(content);
return pageVars;
}

View File

@ -6,6 +6,7 @@ import { instance } from "@/instance";
import * as os from "@/os";
import copyToClipboard from "@/scripts/copy-to-clipboard";
import { url } from "@/config";
import { noteActions } from "@/store";
import { shareAvailable } from "@/scripts/share-available";
import { getUserMenu } from "@/scripts/get-user-menu";
import { magEffectiveNote, magTransUsername } from "@/scripts-mag/mag-util";
@ -486,5 +487,18 @@ export function getNoteMenu(props: {
].filter((x) => x !== undefined);
}
if (noteActions.length > 0) {
menu = menu.concat([
null,
...noteActions.map((action) => ({
icon: "ph-plug ph-bold ph-lg",
text: action.title,
action: () => {
action.handler(appearNote);
},
})),
]);
}
return menu;
}

View File

@ -3,6 +3,7 @@ import { i18n } from "@/i18n";
import copyToClipboard from "@/scripts/copy-to-clipboard";
import { host } from "@/config";
import * as os from "@/os";
import { userActions } from "@/store";
import { $i, iAmModerator } from "@/account";
import { mainRouter } from "@/router";
import { Router } from "@/nirax";
@ -366,5 +367,18 @@ export function getUserMenu(
]);
}
if (userActions.length > 0) {
menu = menu.concat([
null,
...userActions.map((action) => ({
icon: "ph-plug ph-bold ph-lg",
text: action.title,
action: () => {
action.handler(user);
},
})),
]);
}
return menu;
}

View File

@ -32,6 +32,14 @@ export function openHelpMenu_(ev: MouseEvent) {
window.open(instance.tosUrl, "_blank");
},
},
{
type: "button",
text: i18n.ts.apps,
icon: "ph-device-mobile ph-bold ph-lg",
action: () => {
window.open("https://calckey.org/apps", "_blank");
},
},
{
type: "button",
action: async () => {
@ -47,7 +55,25 @@ export function openHelpMenu_(ev: MouseEvent) {
text: i18n.ts.developer,
icon: "ph-code ph-bold ph-lg",
children: [
// TODO: Magnetar developer tools
{
type: "link",
to: "/api-console",
text: "API Console",
icon: "ph-terminal-window ph-bold ph-lg",
},
{
text: i18n.ts.document,
icon: "ph-file-doc ph-bold ph-lg",
action: () => {
window.open("/api-doc", "_blank");
},
},
{
type: "link",
to: "/scratchpad",
text: "AiScript Scratchpad",
icon: "ph-scribble-loop ph-bold ph-lg",
},
],
},
],

View File

@ -0,0 +1,128 @@
// blocks
export type BlockBase = {
id: string;
type: string;
};
export type TextBlock = BlockBase & {
type: "text";
text: string;
};
export type SectionBlock = BlockBase & {
type: "section";
title: string;
children: (Block | VarBlock)[];
};
export type ImageBlock = BlockBase & {
type: "image";
fileId: string | null;
};
export type ButtonBlock = BlockBase & {
type: "button";
text: any;
primary: boolean;
action: string;
content: string;
event: string;
message: string;
var: string;
fn: string;
};
export type IfBlock = BlockBase & {
type: "if";
var: string;
children: Block[];
};
export type TextareaBlock = BlockBase & {
type: "textarea";
text: string;
};
export type PostBlock = BlockBase & {
type: "post";
text: string;
attachCanvasImage: boolean;
canvasId: string;
};
export type CanvasBlock = BlockBase & {
type: "canvas";
name: string; // canvas id
width: number;
height: number;
};
export type NoteBlock = BlockBase & {
type: "note";
detailed: boolean;
note: string | null;
};
export type Block =
| TextBlock
| SectionBlock
| ImageBlock
| ButtonBlock
| IfBlock
| TextareaBlock
| PostBlock
| CanvasBlock
| NoteBlock
| VarBlock;
// variable blocks
export type VarBlockBase = BlockBase & {
name: string;
};
export type NumberInputVarBlock = VarBlockBase & {
type: "numberInput";
text: string;
};
export type TextInputVarBlock = VarBlockBase & {
type: "textInput";
text: string;
};
export type SwitchVarBlock = VarBlockBase & {
type: "switch";
text: string;
};
export type RadioButtonVarBlock = VarBlockBase & {
type: "radioButton";
title: string;
values: string[];
};
export type CounterVarBlock = VarBlockBase & {
type: "counter";
text: string;
inc: number;
};
export type VarBlock =
| NumberInputVarBlock
| TextInputVarBlock
| SwitchVarBlock
| RadioButtonVarBlock
| CounterVarBlock;
const varBlock = [
"numberInput",
"textInput",
"switch",
"radioButton",
"counter",
];
export function isVarBlock(block: Block): block is VarBlock {
return varBlock.includes(block.type);
}

View File

@ -0,0 +1,264 @@
import autobind from "autobind-decorator";
import { PageVar, envVarsDef, Fn, HpmlScope, HpmlError } from ".";
import { version } from "@/config";
import { AiScript, utils, values } from "@syuilo/aiscript";
import { createAiScriptEnv } from "../aiscript/api";
import { collectPageVars } from "../collect-page-vars";
import { initHpmlLib, initAiLib } from "./lib";
import * as os from "@/os";
import { markRaw, ref, Ref, unref } from "vue";
import { Expr, isLiteralValue, Variable } from "./expr";
/**
* Hpml evaluator
*/
export class Hpml {
private variables: Variable[];
private pageVars: PageVar[];
private envVars: Record<keyof typeof envVarsDef, any>;
public aiscript?: AiScript;
public pageVarUpdatedCallback?: values.VFn;
public canvases: Record<string, HTMLCanvasElement> = {};
public vars: Ref<Record<string, any>> = ref({});
public page: Record<string, any>;
private opts: {
randomSeed: string;
visitor?: any;
url?: string;
enableAiScript: boolean;
};
constructor(page: Hpml["page"], opts: Hpml["opts"]) {
this.page = page;
this.variables = this.page.variables;
this.pageVars = collectPageVars(this.page.content);
this.opts = opts;
if (this.opts.enableAiScript) {
this.aiscript = markRaw(
new AiScript(
{
...createAiScriptEnv({
storageKey: `pages:${this.page.id}`,
}),
...initAiLib(this),
},
{
in: (q) => {
return new Promise((ok) => {
os.inputText({
title: q,
}).then(({ canceled, result: a }) => {
ok(a);
});
});
},
out: (value) => {
console.log(value);
},
log: (type, params) => {},
}
)
);
this.aiscript.scope.opts.onUpdated = (name, value) => {
this.eval();
};
}
const date = new Date();
this.envVars = {
AI: "kawaii",
VERSION: version,
URL: this.page
? `${opts.url}/@${this.page.user.username}/pages/${this.page.name}`
: "",
LOGIN: opts.visitor != null,
NAME: opts.visitor
? opts.visitor.name || opts.visitor.username
: "",
USERNAME: opts.visitor ? opts.visitor.username : "",
USERID: opts.visitor ? opts.visitor.id : "",
NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0,
FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0,
FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0,
IS_CAT: opts.visitor ? opts.visitor.isCat : false,
SEED: opts.randomSeed ? opts.randomSeed : "",
YMD: `${date.getFullYear()}/${
date.getMonth() + 1
}/${date.getDate()}`,
AISCRIPT_DISABLED: !this.opts.enableAiScript,
NULL: null,
};
this.eval();
}
@autobind
public eval() {
try {
this.vars.value = this.evaluateVars();
} catch (err) {
//this.onError(e);
}
}
@autobind
public interpolate(str: string) {
if (str == null) return null;
return str.replace(/{(.+?)}/g, (match) => {
const v = unref(this.vars)[match.slice(1, -1).trim()];
return v == null ? "NULL" : v.toString();
});
}
@autobind
public callAiScript(fn: string) {
try {
if (this.aiscript)
this.aiscript.execFn(this.aiscript.scope.get(fn), []);
} catch (err) {}
}
@autobind
public registerCanvas(id: string, canvas: any) {
this.canvases[id] = canvas;
}
@autobind
public updatePageVar(name: string, value: any) {
const pageVar = this.pageVars.find((v) => v.name === name);
if (pageVar !== undefined) {
pageVar.value = value;
if (this.pageVarUpdatedCallback) {
if (this.aiscript)
this.aiscript.execFn(this.pageVarUpdatedCallback, [
values.STR(name),
utils.jsToVal(value),
]);
}
} else {
throw new HpmlError(`No such page var '${name}'`);
}
}
@autobind
public updateRandomSeed(seed: string) {
this.opts.randomSeed = seed;
this.envVars.SEED = seed;
}
@autobind
private _interpolateScope(str: string, scope: HpmlScope) {
return str.replace(/{(.+?)}/g, (match) => {
const v = scope.getState(match.slice(1, -1).trim());
return v == null ? "NULL" : v.toString();
});
}
@autobind
public evaluateVars(): Record<string, any> {
const values: Record<string, any> = {};
for (const [k, v] of Object.entries(this.envVars)) {
values[k] = v;
}
for (const v of this.pageVars) {
values[v.name] = v.value;
}
for (const v of this.variables) {
values[v.name] = this.evaluate(v, new HpmlScope([values]));
}
return values;
}
@autobind
private evaluate(expr: Expr, scope: HpmlScope): any {
if (isLiteralValue(expr)) {
if (expr.type === null) {
return null;
}
if (expr.type === "number") {
return parseInt(expr.value as any, 10);
}
if (expr.type === "text" || expr.type === "multiLineText") {
return this._interpolateScope(expr.value || "", scope);
}
if (expr.type === "textList") {
return this._interpolateScope(expr.value || "", scope)
.trim()
.split("\n");
}
if (expr.type === "ref") {
return scope.getState(expr.value);
}
if (expr.type === "aiScriptVar") {
if (this.aiscript) {
try {
return utils.valToJs(
this.aiscript.scope.get(expr.value)
);
} catch (err) {
return null;
}
} else {
return null;
}
}
// Define user function
if (expr.type === "fn") {
return {
slots: expr.value.slots.map((x) => x.name),
exec: (slotArg: Record<string, any>) => {
return this.evaluate(
expr.value.expression,
scope.createChildScope(slotArg, expr.id)
);
},
} as Fn;
}
return;
}
// Call user function
if (expr.type.startsWith("fn:")) {
const fnName = expr.type.split(":")[1];
const fn = scope.getState(fnName);
const args = {} as Record<string, any>;
for (let i = 0; i < fn.slots.length; i++) {
const name = fn.slots[i];
args[name] = this.evaluate(expr.args[i], scope);
}
return fn.exec(args);
}
if (expr.args === undefined) return null;
const funcs = initHpmlLib(
expr,
scope,
this.opts.randomSeed,
this.opts.visitor
);
// Call function
const fnName = expr.type;
const fn = (funcs as any)[fnName];
if (fn == null) {
throw new HpmlError(`No such function '${fnName}'`);
} else {
return fn(...expr.args.map((x) => this.evaluate(x, scope)));
}
}
}

View File

@ -0,0 +1,87 @@
import { literalDefs, Type } from ".";
export type ExprBase = {
id: string;
};
// value
export type EmptyValue = ExprBase & {
type: null;
value: null;
};
export type TextValue = ExprBase & {
type: "text";
value: string;
};
export type MultiLineTextValue = ExprBase & {
type: "multiLineText";
value: string;
};
export type TextListValue = ExprBase & {
type: "textList";
value: string;
};
export type NumberValue = ExprBase & {
type: "number";
value: number;
};
export type RefValue = ExprBase & {
type: "ref";
value: string; // value is variable name
};
export type AiScriptRefValue = ExprBase & {
type: "aiScriptVar";
value: string; // value is variable name
};
export type UserFnValue = ExprBase & {
type: "fn";
value: UserFnInnerValue;
};
type UserFnInnerValue = {
slots: {
name: string;
type: Type;
}[];
expression: Expr;
};
export type Value =
| EmptyValue
| TextValue
| MultiLineTextValue
| TextListValue
| NumberValue
| RefValue
| AiScriptRefValue
| UserFnValue;
export function isLiteralValue(expr: Expr): expr is Value {
if (expr.type == null) return true;
if (literalDefs[expr.type]) return true;
return false;
}
// call function
export type CallFn = ExprBase & {
// "fn:hoge" or string
type: string;
args: Expr[];
value: null;
};
// variable
export type Variable = (Value | CallFn) & {
name: string;
};
// expression
export type Expr = Variable | Value | CallFn;

View File

@ -0,0 +1,140 @@
/**
* Hpml
*/
import autobind from "autobind-decorator";
import { Hpml } from "./evaluator";
import { funcDefs } from "./lib";
export type Fn = {
slots: string[];
exec: (args: Record<string, any>) => ReturnType<Hpml["evaluate"]>;
};
export type Type = "string" | "number" | "boolean" | "stringArray" | null;
export const literalDefs: Record<
string,
{ out: any; category: string; icon: any }
> = {
text: { out: "string", category: "value", icon: "ph-quotes ph-bold ph-lg" },
multiLineText: {
out: "string",
category: "value",
icon: "ph-align-left ph-bold ph-lg",
},
textList: {
out: "stringArray",
category: "value",
icon: "ph-list ph-bold ph-lg",
},
number: {
out: "number",
category: "value",
icon: "ph-sort-descending-up ph-bold ph-lg",
},
ref: { out: null, category: "value", icon: "ph-magic-wand ph-bold ph-lg" },
aiScriptVar: {
out: null,
category: "value",
icon: "ph-magic-wand ph-bold ph-lg",
},
fn: {
out: "function",
category: "value",
icon: "ph-radical ph-bold ph-lg",
},
};
export const blockDefs = [
...Object.entries(literalDefs).map(([k, v]) => ({
type: k,
out: v.out,
category: v.category,
icon: v.icon,
})),
...Object.entries(funcDefs).map(([k, v]) => ({
type: k,
out: v.out,
category: v.category,
icon: v.icon,
})),
];
export type PageVar = { name: string; value: any; type: Type };
export const envVarsDef: Record<string, Type> = {
AI: "string",
URL: "string",
VERSION: "string",
LOGIN: "boolean",
NAME: "string",
USERNAME: "string",
USERID: "string",
NOTES_COUNT: "number",
FOLLOWERS_COUNT: "number",
FOLLOWING_COUNT: "number",
IS_CAT: "boolean",
SEED: null,
YMD: "string",
AISCRIPT_DISABLED: "boolean",
NULL: null,
};
export class HpmlScope {
private layerdStates: Record<string, any>[];
public name: string;
constructor(
layerdStates: HpmlScope["layerdStates"],
name?: HpmlScope["name"]
) {
this.layerdStates = layerdStates;
this.name = name || "anonymous";
}
@autobind
public createChildScope(
states: Record<string, any>,
name?: HpmlScope["name"]
): HpmlScope {
const layer = [states, ...this.layerdStates];
return new HpmlScope(layer, name);
}
/**
*
* @param name
*/
@autobind
public getState(name: string): any {
for (const later of this.layerdStates) {
const state = later[name];
if (state !== undefined) {
return state;
}
}
throw new HpmlError(
`No such variable '${name}' in scope '${this.name}'`,
{
scope: this.layerdStates,
}
);
}
}
export class HpmlError extends Error {
public info?: any;
constructor(message: string, info?: any) {
super(message);
this.info = info;
// Maintains proper stack trace for where our error was thrown (only available on V8)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, HpmlError);
}
}
}

View File

@ -0,0 +1,587 @@
import tinycolor from "tinycolor2";
import { Hpml } from "./evaluator";
import { values, utils } from "@syuilo/aiscript";
import { Fn, HpmlScope } from ".";
import { Expr } from "./expr";
import seedrandom from "seedrandom";
/* TODO: https://www.chartjs.org/docs/latest/configuration/canvas-background.html#color
// https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs
Chart.pluginService.register({
beforeDraw: (chart, easing) => {
if (chart.config.options.chartArea && chart.config.options.chartArea.backgroundColor) {
const ctx = chart.chart.ctx;
ctx.save();
ctx.fillStyle = chart.config.options.chartArea.backgroundColor;
ctx.fillRect(0, 0, chart.chart.width, chart.chart.height);
ctx.restore();
}
}
});
*/
export function initAiLib(hpml: Hpml) {
return {
"MkPages:updated": values.FN_NATIVE(([callback]) => {
hpml.pageVarUpdatedCallback = callback as values.VFn;
}),
"MkPages:get_canvas": values.FN_NATIVE(([id]) => {
utils.assertString(id);
const canvas = hpml.canvases[id.value];
const ctx = canvas.getContext("2d");
return values.OBJ(
new Map([
[
"clear_rect",
values.FN_NATIVE(([x, y, width, height]) => {
ctx.clearRect(
x.value,
y.value,
width.value,
height.value
);
}),
],
[
"fill_rect",
values.FN_NATIVE(([x, y, width, height]) => {
ctx.fillRect(
x.value,
y.value,
width.value,
height.value
);
}),
],
[
"stroke_rect",
values.FN_NATIVE(([x, y, width, height]) => {
ctx.strokeRect(
x.value,
y.value,
width.value,
height.value
);
}),
],
[
"fill_text",
values.FN_NATIVE(([text, x, y, width]) => {
ctx.fillText(
text.value,
x.value,
y.value,
width ? width.value : undefined
);
}),
],
[
"stroke_text",
values.FN_NATIVE(([text, x, y, width]) => {
ctx.strokeText(
text.value,
x.value,
y.value,
width ? width.value : undefined
);
}),
],
[
"set_line_width",
values.FN_NATIVE(([width]) => {
ctx.lineWidth = width.value;
}),
],
[
"set_font",
values.FN_NATIVE(([font]) => {
ctx.font = font.value;
}),
],
[
"set_fill_style",
values.FN_NATIVE(([style]) => {
ctx.fillStyle = style.value;
}),
],
[
"set_stroke_style",
values.FN_NATIVE(([style]) => {
ctx.strokeStyle = style.value;
}),
],
[
"begin_path",
values.FN_NATIVE(() => {
ctx.beginPath();
}),
],
[
"close_path",
values.FN_NATIVE(() => {
ctx.closePath();
}),
],
[
"move_to",
values.FN_NATIVE(([x, y]) => {
ctx.moveTo(x.value, y.value);
}),
],
[
"line_to",
values.FN_NATIVE(([x, y]) => {
ctx.lineTo(x.value, y.value);
}),
],
[
"arc",
values.FN_NATIVE(
([x, y, radius, startAngle, endAngle]) => {
ctx.arc(
x.value,
y.value,
radius.value,
startAngle.value,
endAngle.value
);
}
),
],
[
"rect",
values.FN_NATIVE(([x, y, width, height]) => {
ctx.rect(
x.value,
y.value,
width.value,
height.value
);
}),
],
[
"fill",
values.FN_NATIVE(() => {
ctx.fill();
}),
],
[
"stroke",
values.FN_NATIVE(() => {
ctx.stroke();
}),
],
])
);
}),
"MkPages:chart": values.FN_NATIVE(([id, opts]) => {
/* TODO
utils.assertString(id);
utils.assertObject(opts);
const canvas = hpml.canvases[id.value];
const color = getComputedStyle(document.documentElement).getPropertyValue('--accent');
Chart.defaults.color = '#555';
const chart = new Chart(canvas, {
type: opts.value.get('type').value,
data: {
labels: opts.value.get('labels').value.map(x => x.value),
datasets: opts.value.get('datasets').value.map(x => ({
label: x.value.has('label') ? x.value.get('label').value : '',
data: x.value.get('data').value.map(x => x.value),
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: x.value.has('color') ? x.value.get('color') : color,
backgroundColor: tinycolor(x.value.has('color') ? x.value.get('color') : color).setAlpha(0.1).toRgbString(),
}))
},
options: {
responsive: false,
devicePixelRatio: 1.5,
title: {
display: opts.value.has('title'),
text: opts.value.has('title') ? opts.value.get('title').value : '',
fontSize: 14,
},
layout: {
padding: {
left: 32,
right: 32,
top: opts.value.has('title') ? 16 : 32,
bottom: 16
}
},
legend: {
display: opts.value.get('datasets').value.filter(x => x.value.has('label') && x.value.get('label').value).length === 0 ? false : true,
position: 'bottom',
labels: {
boxWidth: 16,
}
},
tooltips: {
enabled: false,
},
chartArea: {
backgroundColor: '#fff'
},
...(opts.value.get('type').value === 'radar' ? {
scale: {
ticks: {
display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : false,
min: opts.value.has('min') ? opts.value.get('min').value : undefined,
max: opts.value.has('max') ? opts.value.get('max').value : undefined,
maxTicksLimit: 8,
},
pointLabels: {
fontSize: 12
}
}
} : {
scales: {
yAxes: [{
ticks: {
display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : true,
min: opts.value.has('min') ? opts.value.get('min').value : undefined,
max: opts.value.has('max') ? opts.value.get('max').value : undefined,
}
}]
}
})
}
});
*/
}),
};
}
export const funcDefs: Record<
string,
{ in: any[]; out: any; category: string; icon: any }
> = {
if: {
in: ["boolean", 0, 0],
out: 0,
category: "flow",
icon: "ph-share-network ph-bold ph-lg",
},
for: {
in: ["number", "function"],
out: null,
category: "flow",
icon: "ph-recycle ph-bold ph-lg",
},
not: {
in: ["boolean"],
out: "boolean",
category: "logical",
icon: "ph-flag ph-bold ph-lg",
},
or: {
in: ["boolean", "boolean"],
out: "boolean",
category: "logical",
icon: "ph-flag ph-bold ph-lg",
},
and: {
in: ["boolean", "boolean"],
out: "boolean",
category: "logical",
icon: "ph-flag ph-bold ph-lg",
},
add: {
in: ["number", "number"],
out: "number",
category: "operation",
icon: "ph-plus ph-bold ph-lg",
},
subtract: {
in: ["number", "number"],
out: "number",
category: "operation",
icon: "ph-minus ph-bold ph-lg",
},
multiply: {
in: ["number", "number"],
out: "number",
category: "operation",
icon: "ph-x ph-bold ph-lg",
},
divide: {
in: ["number", "number"],
out: "number",
category: "operation",
icon: "ph-divide ph-bold ph-lg",
},
mod: {
in: ["number", "number"],
out: "number",
category: "operation",
icon: "ph-divide ph-bold ph-lg",
},
round: {
in: ["number"],
out: "number",
category: "operation",
icon: "ph-calculator ph-bold ph-lg",
},
eq: {
in: [0, 0],
out: "boolean",
category: "comparison",
icon: "ph-equals ph-bold ph-lg",
},
notEq: {
in: [0, 0],
out: "boolean",
category: "comparison",
icon: "ph-prohibit-insert ph-bold ph-lg",
},
gt: {
in: ["number", "number"],
out: "boolean",
category: "comparison",
icon: "ph-caret-right ph-bold ph-lg",
},
lt: {
in: ["number", "number"],
out: "boolean",
category: "comparison",
icon: "ph-caret-left ph-bold ph-lg",
},
gtEq: {
in: ["number", "number"],
out: "boolean",
category: "comparison",
icon: "ph-caret-double-right ph-bold ph-lg",
},
ltEq: {
in: ["number", "number"],
out: "boolean",
category: "comparison",
icon: "ph-caret-double-right ph-bold ph-lg",
},
strLen: {
in: ["string"],
out: "number",
category: "text",
icon: "ph-quotes ph-bold ph-lg",
},
strPick: {
in: ["string", "number"],
out: "string",
category: "text",
icon: "ph-quotes ph-bold ph-lg",
},
strReplace: {
in: ["string", "string", "string"],
out: "string",
category: "text",
icon: "ph-quotes ph-bold ph-lg",
},
strReverse: {
in: ["string"],
out: "string",
category: "text",
icon: "ph-quotes ph-bold ph-lg",
},
join: {
in: ["stringArray", "string"],
out: "string",
category: "text",
icon: "ph-quotes ph-bold ph-lg",
},
stringToNumber: {
in: ["string"],
out: "number",
category: "convert",
icon: "ph-swap ph-bold ph-lg",
},
numberToString: {
in: ["number"],
out: "string",
category: "convert",
icon: "ph-swap ph-bold ph-lg",
},
splitStrByLine: {
in: ["string"],
out: "stringArray",
category: "convert",
icon: "ph-swap ph-bold ph-lg",
},
pick: {
in: [null, "number"],
out: null,
category: "list",
icon: "ph-text-indent ph-bold ph-lg",
},
listLen: {
in: [null],
out: "number",
category: "list",
icon: "ph-text-indent ph-bold ph-lg",
},
rannum: {
in: ["number", "number"],
out: "number",
category: "random",
icon: "ph-dice-five ph-bold ph-lg",
},
dailyRannum: {
in: ["number", "number"],
out: "number",
category: "random",
icon: "ph-dice-five ph-bold ph-lg",
},
seedRannum: {
in: [null, "number", "number"],
out: "number",
category: "random",
icon: "ph-dice-five ph-bold ph-lg",
},
random: {
in: ["number"],
out: "boolean",
category: "random",
icon: "ph-dice-five ph-bold ph-lg",
},
dailyRandom: {
in: ["number"],
out: "boolean",
category: "random",
icon: "ph-dice-five ph-bold ph-lg",
},
seedRandom: {
in: [null, "number"],
out: "boolean",
category: "random",
icon: "ph-dice-five ph-bold ph-lg",
},
randomPick: {
in: [0],
out: 0,
category: "random",
icon: "ph-dice-five ph-bold ph-lg",
},
dailyRandomPick: {
in: [0],
out: 0,
category: "random",
icon: "ph-dice-five ph-bold ph-lg",
},
seedRandomPick: {
in: [null, 0],
out: 0,
category: "random",
icon: "ph-dice-five ph-bold ph-lg",
},
DRPWPM: {
in: ["stringArray"],
out: "string",
category: "random",
icon: "ph-dice-five ph-bold ph-lg",
}, // dailyRandomPickWithProbabilityMapping
};
export function initHpmlLib(
expr: Expr,
scope: HpmlScope,
randomSeed: string,
visitor?: any
) {
const date = new Date();
const day = `${visitor ? visitor.id : ""} ${date.getFullYear()}/${
date.getMonth() + 1
}/${date.getDate()}`;
// SHOULD be fine to ignore since it's intended + function shape isn't defined
const funcs: Record<string, Function> = {
not: (a: boolean) => !a,
or: (a: boolean, b: boolean) => a || b,
and: (a: boolean, b: boolean) => a && b,
eq: (a: any, b: any) => a === b,
notEq: (a: any, b: any) => a !== b,
gt: (a: number, b: number) => a > b,
lt: (a: number, b: number) => a < b,
gtEq: (a: number, b: number) => a >= b,
ltEq: (a: number, b: number) => a <= b,
if: (bool: boolean, a: any, b: any) => (bool ? a : b),
for: (times: number, fn: Fn) => {
const result: any[] = [];
for (let i = 0; i < times; i++) {
result.push(
fn.exec({
[fn.slots[0]]: i + 1,
})
);
}
return result;
},
add: (a: number, b: number) => a + b,
subtract: (a: number, b: number) => a - b,
multiply: (a: number, b: number) => a * b,
divide: (a: number, b: number) => a / b,
mod: (a: number, b: number) => a % b,
round: (a: number) => Math.round(a),
strLen: (a: string) => a.length,
strPick: (a: string, b: number) => a[b - 1],
strReplace: (a: string, b: string, c: string) => a.split(b).join(c),
strReverse: (a: string) => a.split("").reverse().join(""),
join: (texts: string[], separator: string) =>
texts.join(separator || ""),
stringToNumber: (a: string) => parseInt(a),
numberToString: (a: number) => a.toString(),
splitStrByLine: (a: string) => a.split("\n"),
pick: (list: any[], i: number) => list[i - 1],
listLen: (list: any[]) => list.length,
random: (probability: number) =>
Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * 100) <
probability,
rannum: (min: number, max: number) =>
min +
Math.floor(
seedrandom(`${randomSeed}:${expr.id}`)() * (max - min + 1)
),
randomPick: (list: any[]) =>
list[
Math.floor(
seedrandom(`${randomSeed}:${expr.id}`)() * list.length
)
],
dailyRandom: (probability: number) =>
Math.floor(seedrandom(`${day}:${expr.id}`)() * 100) < probability,
dailyRannum: (min: number, max: number) =>
min +
Math.floor(seedrandom(`${day}:${expr.id}`)() * (max - min + 1)),
dailyRandomPick: (list: any[]) =>
list[Math.floor(seedrandom(`${day}:${expr.id}`)() * list.length)],
seedRandom: (seed: any, probability: number) =>
Math.floor(seedrandom(seed)() * 100) < probability,
seedRannum: (seed: any, min: number, max: number) =>
min + Math.floor(seedrandom(seed)() * (max - min + 1)),
seedRandomPick: (seed: any, list: any[]) =>
list[Math.floor(seedrandom(seed)() * list.length)],
DRPWPM: (list: string[]) => {
const xs: any[] = [];
let totalFactor = 0;
for (const x of list) {
const parts = x.split(" ");
const factor = parseInt(parts.pop()!, 10);
const text = parts.join(" ");
totalFactor += factor;
xs.push({ factor, text });
}
const r = seedrandom(`${day}:${expr.id}`)() * totalFactor;
let stackedFactor = 0;
for (const x of xs) {
if (r >= stackedFactor && r <= stackedFactor + x.factor) {
return x.text;
} else {
stackedFactor += x.factor;
}
}
return xs[0].text;
},
};
return funcs;
}

View File

@ -0,0 +1,196 @@
import autobind from "autobind-decorator";
import { Type, envVarsDef, PageVar } from ".";
import { Expr, isLiteralValue, Variable } from "./expr";
import { funcDefs } from "./lib";
type TypeError = {
arg: number;
expect: Type;
actual: Type;
};
/**
* Hpml type checker
*/
export class HpmlTypeChecker {
public variables: Variable[];
public pageVars: PageVar[];
constructor(
variables: HpmlTypeChecker["variables"] = [],
pageVars: HpmlTypeChecker["pageVars"] = []
) {
this.variables = variables;
this.pageVars = pageVars;
}
@autobind
public typeCheck(v: Expr): TypeError | null {
if (isLiteralValue(v)) return null;
const def = funcDefs[v.type || ""];
if (def == null) {
throw new Error(`Unknown type: ${v.type}`);
}
const generic: Type[] = [];
for (let i = 0; i < def.in.length; i++) {
const arg = def.in[i];
const type = this.infer(v.args[i]);
if (type === null) continue;
if (typeof arg === "number") {
if (generic[arg] === undefined) {
generic[arg] = type;
} else if (type !== generic[arg]) {
return {
arg: i,
expect: generic[arg],
actual: type,
};
}
} else if (type !== arg) {
return {
arg: i,
expect: arg,
actual: type,
};
}
}
return null;
}
@autobind
public getExpectedType(v: Expr, slot: number): Type {
const def = funcDefs[v.type || ""];
if (def == null) {
throw new Error(`Unknown type: ${v.type}`);
}
const generic: Type[] = [];
for (let i = 0; i < def.in.length; i++) {
const arg = def.in[i];
const type = this.infer(v.args[i]);
if (type === null) continue;
if (typeof arg === "number") {
if (generic[arg] === undefined) {
generic[arg] = type;
}
}
}
if (typeof def.in[slot] === "number") {
return generic[def.in[slot]] || null;
} else {
return def.in[slot];
}
}
@autobind
public infer(v: Expr): Type {
if (v.type === null) return null;
if (v.type === "text") return "string";
if (v.type === "multiLineText") return "string";
if (v.type === "textList") return "stringArray";
if (v.type === "number") return "number";
if (v.type === "ref") {
const variable = this.variables.find((va) => va.name === v.value);
if (variable) {
return this.infer(variable);
}
const pageVar = this.pageVars.find((va) => va.name === v.value);
if (pageVar) {
return pageVar.type;
}
const envVar = envVarsDef[v.value || ""];
if (envVar !== undefined) {
return envVar;
}
return null;
}
if (v.type === "aiScriptVar") return null;
if (v.type === "fn") return null; // todo
if (v.type.startsWith("fn:")) return null; // todo
const generic: Type[] = [];
const def = funcDefs[v.type];
for (let i = 0; i < def.in.length; i++) {
const arg = def.in[i];
if (typeof arg === "number") {
const type = this.infer(v.args[i]);
if (generic[arg] === undefined) {
generic[arg] = type;
} else {
if (type !== generic[arg]) {
generic[arg] = null;
}
}
}
}
if (typeof def.out === "number") {
return generic[def.out];
} else {
return def.out;
}
}
@autobind
public getVarByName(name: string): Variable {
const v = this.variables.find((x) => x.name === name);
if (v !== undefined) {
return v;
} else {
throw new Error(`No such variable '${name}'`);
}
}
@autobind
public getVarsByType(type: Type): Variable[] {
if (type == null) return this.variables;
return this.variables.filter(
(x) => this.infer(x) === null || this.infer(x) === type
);
}
@autobind
public getEnvVarsByType(type: Type): string[] {
if (type == null) return Object.keys(envVarsDef);
return Object.entries(envVarsDef)
.filter(([k, v]) => v === null || type === v)
.map(([k, v]) => k);
}
@autobind
public getPageVarsByType(type: Type): string[] {
if (type == null) return this.pageVars.map((v) => v.name);
return this.pageVars.filter((v) => type === v.type).map((v) => v.name);
}
@autobind
public isUsedName(name: string) {
if (this.variables.some((v) => v.name === name)) {
return true;
}
if (this.pageVars.some((v) => v.name === name)) {
return true;
}
if (envVarsDef[name]) {
return true;
}
return false;
}
}

View File

@ -6,6 +6,17 @@ import { Storage } from "./pizzax";
import lightTheme from "@/themes/l-rosepinedawn.json5";
import darkTheme from "@/themes/d-rosepine.json5";
export const postFormActions: {
title: any;
handler: (form: any, update: any) => void;
}[] = [];
export const userActions: { title: any; handler: (user: any) => void }[] = [];
export const noteActions: { title: any; handler: (note: any) => void }[] = [];
export const noteViewInterruptors: { handler: (note: any) => Promise<any> }[] =
[];
export const notePostInterruptors: { handler: (note: any) => Promise<any> }[] =
[];
const menuOptions = [
"notifications",
"followRequests",

View File

@ -735,6 +735,35 @@ hr {
}
}
._anime_bounce {
will-change: transform;
animation: bounce ease 0.7s;
animation-iteration-count: 1;
transform-origin: 50% 50%;
}
._anime_bounce_ready {
will-change: transform;
transform: scaleX(0.9) scaleY(0.9);
}
._anime_bounce_standBy {
transition: transform 0.1s ease;
}
@keyframes bounce {
0% {
transform: scaleX(0.9) scaleY(0.9);
}
19% {
transform: scaleX(1.1) scaleY(1.1);
}
48% {
transform: scaleX(0.95) scaleY(0.95);
}
100% {
transform: scaleX(1) scaleY(1);
}
}
.ph-xxs {
font-size: 0.5em;
}

View File

@ -8,6 +8,7 @@
:style="{ backgroundImage: `url(${$i.bannerUrl})` }"
></div>
<button
v-click-anime
v-tooltip.noDelay.right="
`${i18n.ts.account}: @${$i.username}`
"
@ -22,7 +23,13 @@
</button>
</div>
<div class="middle">
<MkA class="item index" active-class="active" to="/" exact>
<MkA
v-click-anime
class="item index"
active-class="active"
to="/"
exact
>
<i class="icon ph-house ph-bold ph-lg ph-fw ph-lg"></i
><span class="text">{{ i18n.ts.timeline }}</span>
</MkA>
@ -34,6 +41,7 @@
navbarItemDef[item] &&
navbarItemDef[item].show !== false
"
v-click-anime
class="item _button"
:class="[item, { active: navbarItemDef[item].active }]"
active-class="active"
@ -61,6 +69,7 @@
<div class="divider"></div>
<MkA
v-if="$i.isAdmin || $i.isModerator"
v-click-anime
class="item"
active-class="active"
to="/admin"
@ -68,7 +77,7 @@
<i class="icon ph-door ph-bold ph-lg ph-fw ph-lg"></i
><span class="text">{{ i18n.ts.controlPanel }}</span>
</MkA>
<button class="item _button" @click="more">
<button v-click-anime class="item _button" @click="more">
<i
class="icon ph-dots-three-outline ph-bold ph-lg ph-fw ph-lg"
></i
@ -77,7 +86,12 @@
><i class="icon ph-circle ph-fill"></i
></span>
</button>
<MkA class="item" active-class="active" to="/settings">
<MkA
v-click-anime
class="item"
active-class="active"
to="/settings"
>
<i class="icon ph-gear-six ph-bold ph-lg ph-fw ph-lg"></i
><span class="text">{{ i18n.ts.settings }}</span>
</MkA>

View File

@ -8,6 +8,7 @@
:style="{ backgroundImage: `url(${$i.bannerUrl})` }"
></div>
<button
v-click-anime
v-tooltip.noDelay.right="
`${i18n.ts.account}: @${$i.username}`
"
@ -23,6 +24,7 @@
</div>
<nav class="middle">
<MkA
v-click-anime
v-tooltip.noDelay.right="i18n.ts.timeline"
class="item index"
active-class="active"
@ -40,6 +42,7 @@
navbarItemDef[item] &&
navbarItemDef[item].show !== false
"
v-click-anime
v-tooltip.noDelay.right="
i18n.ts[navbarItemDef[item].title]
"
@ -70,6 +73,7 @@
<div class="divider"></div>
<MkA
v-if="$i.isAdmin || $i.isModerator"
v-click-anime
v-tooltip.noDelay.right="i18n.ts.controlPanel"
class="item _button"
active-class="active"
@ -88,6 +92,7 @@
><span class="text">{{ i18n.ts.controlPanel }}</span>
</MkA>
<button
v-click-anime
v-tooltip.noDelay.right="i18n.ts.more"
class="item _button"
@click="more"
@ -101,6 +106,7 @@
></span>
</button>
<MkA
v-click-anime
v-tooltip.noDelay.right="i18n.ts.settings"
class="item _button"
active-class="active"

View File

@ -18,9 +18,7 @@
>
<small
>Powered by
<a
href="https://git.astolfo.cool/natty/magnetar"
target="_blank"
<a href="https://git.astolfo.cool/natty/magnetar" target="_blank"
>Magnetar</a
></small
>
@ -47,6 +45,10 @@
><i class="ph-compass ph-bold ph-lg icon"></i
>{{ i18n.ts.explore }}</MkA
>
<MkA to="/pages" class="link" active-class="active"
><i class="ph-file-text ph-bold ph-lg icon"></i
>{{ i18n.ts.pages }}</MkA
>
<MkA to="/gallery" class="link" active-class="active"
><i class="ph-image-square ph-bold ph-lg icon"></i
>{{ i18n.ts.gallery }}</MkA
@ -72,11 +74,17 @@ import { host, instanceName } from "@/config";
import { search } from "@/scripts/search";
import * as os from "@/os";
import { instance } from "@/instance";
import MkPagination from "@/components/MkPagination.vue";
import XSigninDialog from "@/components/MkSigninDialog.vue";
import XSignupDialog from "@/components/MkSignupDialog.vue";
import MkButton from "@/components/MkButton.vue";
import { ColdDeviceStorage, defaultStore } from "@/store";
import { mainRouter } from "@/router";
import { PageMetadata, provideMetadataReceiver } from "@/scripts/page-metadata";
import {
PageMetadata,
provideMetadataReceiver,
setPageMetadata,
} from "@/scripts/page-metadata";
import { i18n } from "@/i18n";
const DESKTOP_THRESHOLD = 1000;

View File

@ -18,6 +18,10 @@
><i class="ph-compass ph-bold ph-lg icon"></i
>{{ i18n.ts.explore }}</MkA
>
<MkA to="/pages" class="link" active-class="active"
><i class="ph-file-text ph-bold ph-lg icon"></i
>{{ i18n.ts.pages }}</MkA
>
<MkA to="/gallery" class="link" active-class="active"
><i class="ph-image-square ph-bold ph-lg icon"></i
>{{ i18n.ts.gallery }}</MkA

View File

@ -0,0 +1,206 @@
<template>
<MkContainer :show-header="widgetProps.showHeader" class="mkw-aiscript">
<template #header
><i class="ph-terminal-window ph-bold ph-lg"></i
>{{ i18n.ts._widgets.aiscript }}</template
>
<div class="uylguesu _monospace">
<textarea
v-model="widgetProps.script"
placeholder="(1 + 1)"
></textarea>
<button class="_buttonPrimary" @click="run">RUN</button>
<div class="logs">
<div
v-for="log in logs"
:key="log.id"
class="log"
:class="{ print: log.print }"
>
{{ log.text }}
</div>
</div>
</div>
</MkContainer>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, watch } from "vue";
import { AiScript, parse, utils } from "@syuilo/aiscript";
import {
useWidgetPropsManager,
Widget,
WidgetComponentEmits,
WidgetComponentExpose,
WidgetComponentProps,
} from "./widget";
import { GetFormResultType } from "@/scripts/form";
import * as os from "@/os";
import MkContainer from "@/components/MkContainer.vue";
import { createAiScriptEnv } from "@/scripts/aiscript/api";
import { $i } from "@/account";
import { i18n } from "@/i18n";
const name = "aiscript";
const widgetPropsDef = {
showHeader: {
type: "boolean" as const,
default: true,
},
script: {
type: "string" as const,
multiline: true,
default: "(1 + 1)",
hidden: true,
},
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const props = defineProps<{ widget?: Widget<WidgetProps> }>();
const emit = defineEmits<{ (ev: "updateProps", props: WidgetProps) }>();
const { widgetProps, configure } = useWidgetPropsManager(
name,
widgetPropsDef,
props,
emit
);
const logs = ref<
{
id: string;
text: string;
print: boolean;
}[]
>([]);
const run = async () => {
logs.value = [];
const aiscript = new AiScript(
createAiScriptEnv({
storageKey: "widget",
token: $i?.token,
}),
{
in: (q) => {
return new Promise((ok) => {
os.inputText({
title: q,
}).then(({ canceled, result: a }) => {
ok(a);
});
});
},
out: (value) => {
logs.value.push({
id: Math.random().toString(),
text:
value.type === "str"
? value.value
: utils.valToString(value),
print: true,
});
},
log: (type, params) => {
switch (type) {
case "end":
logs.value.push({
id: Math.random().toString(),
text: utils.valToString(params.val, true),
print: false,
});
break;
default:
break;
}
},
}
);
let ast;
try {
ast = parse(widgetProps.script);
} catch (err) {
os.alert({
type: "error",
text: "Syntax error :(",
});
return;
}
try {
await aiscript.exec(ast);
} catch (err) {
os.alert({
type: "error",
text: err,
});
}
};
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>
<style lang="scss" scoped>
.uylguesu {
text-align: right;
> textarea {
display: block;
width: 100%;
max-width: 100%;
min-width: 100%;
padding: 16px;
color: var(--fg);
background: transparent;
border: none;
border-bottom: solid 0.5px var(--divider);
border-radius: 0;
box-sizing: border-box;
font: inherit;
&:focus-visible {
outline: none;
}
}
> button {
display: inline-block;
margin: 8px;
padding: 0 10px;
height: 28px;
outline: none;
border-radius: 4px;
&:disabled {
opacity: 0.7;
cursor: default;
}
}
> .logs {
border-top: solid 0.5px var(--divider);
text-align: left;
padding: 16px;
&:empty {
display: none;
}
> .log {
&:not(.print) {
opacity: 0.7;
}
}
}
}
</style>

View File

@ -0,0 +1,113 @@
<template>
<div class="mkw-button">
<MkButton :primary="widgetProps.colored" full @click="run">
{{ widgetProps.label }}
</MkButton>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, watch } from "vue";
import { AiScript, parse, utils } from "@syuilo/aiscript";
import {
useWidgetPropsManager,
Widget,
WidgetComponentEmits,
WidgetComponentExpose,
WidgetComponentProps,
} from "./widget";
import { GetFormResultType } from "@/scripts/form";
import * as os from "@/os";
import { createAiScriptEnv } from "@/scripts/aiscript/api";
import { $i } from "@/account";
import MkButton from "@/components/MkButton.vue";
const name = "button";
const widgetPropsDef = {
label: {
type: "string" as const,
default: "BUTTON",
},
colored: {
type: "boolean" as const,
default: true,
},
script: {
type: "string" as const,
multiline: true,
default: 'Mk:dialog("hello" "world")',
},
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const props = defineProps<{ widget?: Widget<WidgetProps> }>();
const emit = defineEmits<{ (ev: "updateProps", props: WidgetProps) }>();
const { widgetProps, configure } = useWidgetPropsManager(
name,
widgetPropsDef,
props,
emit
);
const run = async () => {
const aiscript = new AiScript(
createAiScriptEnv({
storageKey: "widget",
token: $i?.token,
}),
{
in: (q) => {
return new Promise((ok) => {
os.inputText({
title: q,
}).then(({ canceled, result: a }) => {
ok(a);
});
});
},
out: (value) => {
// nop
},
log: (type, params) => {
// nop
},
}
);
let ast;
try {
ast = parse(widgetProps.script);
} catch (err) {
os.alert({
type: "error",
text: "Syntax error :(",
});
return;
}
try {
await aiscript.exec(ast);
} catch (err) {
os.alert({
type: "error",
text: err,
});
}
};
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>
<style lang="scss" scoped>
.mkw-button {
}
</style>

View File

@ -61,6 +61,10 @@ export default function (app: App) {
"MkwSlideshow",
defineAsyncComponent(() => import("./slideshow.vue"))
);
app.component(
"MkwServerMetric",
defineAsyncComponent(() => import("./server-metric/index.vue"))
);
app.component(
"MkwOnlineUsers",
defineAsyncComponent(() => import("./online-users.vue"))
@ -69,6 +73,18 @@ export default function (app: App) {
"MkwJobQueue",
defineAsyncComponent(() => import("./job-queue.vue"))
);
app.component(
"MkwInstanceCloud",
defineAsyncComponent(() => import("./instance-cloud.vue"))
);
app.component(
"MkwButton",
defineAsyncComponent(() => import("./button.vue"))
);
app.component(
"MkwAiscript",
defineAsyncComponent(() => import("./aiscript.vue"))
);
app.component(
"MkwUserList",
defineAsyncComponent(() => import("./user-list.vue"))
@ -94,9 +110,13 @@ export const widgets = [
"digitalClock",
"unixClock",
"federation",
"instanceCloud",
"postForm",
"slideshow",
"serverMetric",
"serverInfo",
"onlineUsers",
"jobQueue",
"button",
"aiscript",
];

View File

@ -0,0 +1,100 @@
<template>
<MkContainer
:naked="widgetProps.transparent"
:show-header="false"
class="mkw-instance-cloud"
>
<div class="">
<MkTagCloud v-if="activeInstances">
<li v-for="instance in activeInstances" :key="instance.id">
<a @click.prevent="onInstanceClick(instance)">
<img
style="width: 32px"
:src="getInstanceIcon(instance)"
/>
</a>
</li>
</MkTagCloud>
</div>
</MkContainer>
</template>
<script lang="ts" setup>
import {} from "vue";
import {
useWidgetPropsManager,
WidgetComponentEmits,
WidgetComponentProps,
} from "./widget";
import type { Widget, WidgetComponentExpose } from "./widget";
import type { GetFormResultType } from "@/scripts/form";
import MkContainer from "@/components/MkContainer.vue";
import MkTagCloud from "@/components/MkTagCloud.vue";
import * as os from "@/os";
import { useInterval } from "@/scripts/use-interval";
import { getProxiedImageUrlNullable } from "@/scripts/media-proxy";
const name = "instanceCloud";
const widgetPropsDef = {
transparent: {
type: "boolean" as const,
default: false,
},
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const props = defineProps<{ widget?: Widget<WidgetProps> }>();
const emit = defineEmits<{ (ev: "updateProps", props: WidgetProps) }>();
const { widgetProps, configure } = useWidgetPropsManager(
name,
widgetPropsDef,
props,
emit
);
let cloud = $ref<InstanceType<typeof MkTagCloud> | null>();
let activeInstances = $shallowRef(null);
function onInstanceClick(i) {
os.pageWindow(`/instance-info/${i.host}`);
}
useInterval(
() => {
os.api("federation/instances", {
sort: "+lastCommunicatedAt",
limit: 25,
}).then((res) => {
activeInstances = res;
if (cloud) cloud.update();
});
},
1000 * 60 * 3,
{
immediate: true,
afterMounted: true,
}
);
function getInstanceIcon(instance): string {
return (
getProxiedImageUrlNullable(instance.iconUrl, "preview") ??
getProxiedImageUrlNullable(instance.faviconUrl, "preview") ??
"/client-assets/dummy.png"
);
}
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,193 @@
<template>
<div class="lcfyofjk">
<svg :viewBox="`0 0 ${viewBoxX} ${viewBoxY}`">
<defs>
<linearGradient :id="cpuGradientId" x1="0" x2="0" y1="1" y2="0">
<stop offset="0%" stop-color="hsl(189, 43%, 73%)"></stop>
<stop offset="100%" stop-color="hsl(343, 76%, 68%)"></stop>
</linearGradient>
<mask
:id="cpuMaskId"
x="0"
y="0"
:width="viewBoxX"
:height="viewBoxY"
>
<polygon
:points="cpuPolygonPoints"
fill="#fff"
fill-opacity="0.5"
/>
<polyline
:points="cpuPolylinePoints"
fill="none"
stroke="#fff"
stroke-width="1"
/>
<circle :cx="cpuHeadX" :cy="cpuHeadY" r="1.5" fill="#fff" />
</mask>
</defs>
<rect
x="-2"
y="-2"
:width="viewBoxX + 4"
:height="viewBoxY + 4"
:style="`stroke: none; fill: url(#${cpuGradientId}); mask: url(#${cpuMaskId})`"
/>
<text x="1" y="5">
CPU
<tspan>{{ cpuP }}%</tspan>
</text>
</svg>
<svg :viewBox="`0 0 ${viewBoxX} ${viewBoxY}`">
<defs>
<linearGradient :id="memGradientId" x1="0" x2="0" y1="1" y2="0">
<stop offset="0%" stop-color="hsl(189, 43%, 73%)"></stop>
<stop offset="100%" stop-color="hsl(343, 76%, 68%)"></stop>
</linearGradient>
<mask
:id="memMaskId"
x="0"
y="0"
:width="viewBoxX"
:height="viewBoxY"
>
<polygon
:points="memPolygonPoints"
fill="#fff"
fill-opacity="0.5"
/>
<polyline
:points="memPolylinePoints"
fill="none"
stroke="#fff"
stroke-width="1"
/>
<circle :cx="memHeadX" :cy="memHeadY" r="1.5" fill="#fff" />
</mask>
</defs>
<rect
x="-2"
y="-2"
:width="viewBoxX + 4"
:height="viewBoxY + 4"
:style="`stroke: none; fill: url(#${memGradientId}); mask: url(#${memMaskId})`"
/>
<text x="1" y="5">
MEM
<tspan>{{ memP }}%</tspan>
</text>
</svg>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount } from "vue";
import { v4 as uuid } from "uuid";
const props = defineProps<{
connection: any;
meta: any;
}>();
let viewBoxX: number = $ref(50);
let viewBoxY: number = $ref(30);
let stats: any[] = $ref([]);
const cpuGradientId = uuid();
const cpuMaskId = uuid();
const memGradientId = uuid();
const memMaskId = uuid();
let cpuPolylinePoints: string = $ref("");
let memPolylinePoints: string = $ref("");
let cpuPolygonPoints: string = $ref("");
let memPolygonPoints: string = $ref("");
let cpuHeadX: any = $ref(null);
let cpuHeadY: any = $ref(null);
let memHeadX: any = $ref(null);
let memHeadY: any = $ref(null);
let cpuP: string = $ref("");
let memP: string = $ref("");
onMounted(() => {
props.connection.on("stats", onStats);
props.connection.on("statsLog", onStatsLog);
props.connection.send("requestLog", {
id: Math.random().toString().substr(2, 8),
});
});
onBeforeUnmount(() => {
props.connection.off("stats", onStats);
props.connection.off("statsLog", onStatsLog);
});
function onStats(connStats) {
stats.push(connStats);
if (stats.length > 50) stats.shift();
let cpuPolylinePointsStats = stats.map((s, i) => [
viewBoxX - (stats.length - 1 - i),
(1 - s.cpu) * viewBoxY,
]);
let memPolylinePointsStats = stats.map((s, i) => [
viewBoxX - (stats.length - 1 - i),
(1 - s.mem.active / props.meta.mem.total) * viewBoxY,
]);
cpuPolylinePoints = cpuPolylinePointsStats
.map((xy) => `${xy[0]},${xy[1]}`)
.join(" ");
memPolylinePoints = memPolylinePointsStats
.map((xy) => `${xy[0]},${xy[1]}`)
.join(" ");
cpuPolygonPoints = `${
viewBoxX - (stats.length - 1)
},${viewBoxY} ${cpuPolylinePoints} ${viewBoxX},${viewBoxY}`;
memPolygonPoints = `${
viewBoxX - (stats.length - 1)
},${viewBoxY} ${memPolylinePoints} ${viewBoxX},${viewBoxY}`;
cpuHeadX = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][0];
cpuHeadY = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][1];
memHeadX = memPolylinePointsStats[memPolylinePointsStats.length - 1][0];
memHeadY = memPolylinePointsStats[memPolylinePointsStats.length - 1][1];
cpuP = (connStats.cpu * 100).toFixed(0);
memP = ((connStats.mem.active / props.meta.mem.total) * 100).toFixed(0);
}
function onStatsLog(statsLog) {
for (const revStats of [...statsLog].reverse()) {
onStats(revStats);
}
}
</script>
<style lang="scss" scoped>
.lcfyofjk {
display: flex;
> svg {
display: block;
padding: 10px;
width: 50%;
&:first-child {
padding-right: 5px;
}
&:last-child {
padding-left: 5px;
}
> text {
font-size: 5px;
fill: currentColor;
> tspan {
opacity: 0.5;
}
}
}
}
</style>

View File

@ -0,0 +1,65 @@
<template>
<div class="vrvdvrys">
<XPie class="pie" :value="usage" />
<div>
<p><i class="ph-cpu ph-bold ph-lg"></i>CPU</p>
<p>{{ meta.cpu.cores }} Logical cores</p>
<p>{{ meta.cpu.model }}</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount } from "vue";
import XPie from "./pie.vue";
const props = defineProps<{
connection: any;
meta: any;
}>();
let usage: number = $ref(0);
function onStats(stats) {
usage = stats.cpu;
}
onMounted(() => {
props.connection.on("stats", onStats);
});
onBeforeUnmount(() => {
props.connection.off("stats", onStats);
});
</script>
<style lang="scss" scoped>
.vrvdvrys {
display: flex;
padding: 16px;
> .pie {
height: 82px;
flex-shrink: 0;
margin-right: 16px;
}
> div {
flex: 1;
> p {
margin: 0;
font-size: 0.8em;
&:first-child {
font-weight: bold;
margin-bottom: 4px;
> i {
margin-right: 4px;
}
}
}
}
}
</style>

View File

@ -0,0 +1,57 @@
<template>
<div class="zbwaqsat">
<XPie class="pie" :value="usage" />
<div>
<p><i class="ph-hard-drives ph-bold ph-lg"></i>Disk</p>
<p>Total: {{ bytes(total, 1) }}</p>
<p>Free: {{ bytes(available, 1) }}</p>
<p>Used: {{ bytes(used, 1) }}</p>
</div>
</div>
</template>
<script lang="ts" setup>
import {} from "vue";
import XPie from "./pie.vue";
import bytes from "@/filters/bytes";
const props = defineProps<{
meta: any; // TODO
}>();
const usage = $computed(() => props.meta.fs.used / props.meta.fs.total);
const total = $computed(() => props.meta.fs.total);
const used = $computed(() => props.meta.fs.used);
const available = $computed(() => props.meta.fs.total - props.meta.fs.used);
</script>
<style lang="scss" scoped>
.zbwaqsat {
display: flex;
padding: 16px;
> .pie {
height: 82px;
flex-shrink: 0;
margin-right: 16px;
}
> div {
flex: 1;
> p {
margin: 0;
font-size: 0.8em;
&:first-child {
font-weight: bold;
margin-bottom: 4px;
> i {
margin-right: 4px;
}
}
}
}
}
</style>

View File

@ -0,0 +1,135 @@
<template>
<MkContainer
:show-header="widgetProps.showHeader"
:naked="widgetProps.transparent"
>
<template #header
><i class="ph-hard-drives ph-bold ph-lg"></i
>{{ i18n.ts._widgets.serverMetric }}</template
>
<template #func
><button class="_button" @click="toggleView()">
<i class="ph-sort-ascending ph-bold ph-lg"></i></button
></template>
<div v-if="meta" class="mkw-serverMetric">
<XCpuMemory
v-if="widgetProps.view === 0"
:connection="connection"
:meta="meta"
/>
<XNet
v-else-if="widgetProps.view === 1"
:connection="connection"
:meta="meta"
/>
<XCpu
v-else-if="widgetProps.view === 2"
:connection="connection"
:meta="meta"
/>
<XMemory
v-else-if="widgetProps.view === 3"
:connection="connection"
:meta="meta"
/>
<XDisk
v-else-if="widgetProps.view === 4"
:connection="connection"
:meta="meta"
/>
<XMeili
v-else-if="
instance.features.searchFilters && widgetProps.view === 5
"
:connection="connection"
:meta="meta"
/>
</div>
</MkContainer>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from "vue";
import {
useWidgetPropsManager,
Widget,
WidgetComponentEmits,
WidgetComponentExpose,
WidgetComponentProps,
} from "../widget";
import XCpuMemory from "./cpu-mem.vue";
import XNet from "./net.vue";
import XCpu from "./cpu.vue";
import XMemory from "./mem.vue";
import XDisk from "./disk.vue";
import XMeili from "./meilisearch.vue";
import MkContainer from "@/components/MkContainer.vue";
import { GetFormResultType } from "@/scripts/form";
import * as os from "@/os";
import { stream } from "@/stream";
import { i18n } from "@/i18n";
import { instance } from "@/instance";
const name = "serverMetric";
const widgetPropsDef = {
showHeader: {
type: "boolean" as const,
default: true,
},
transparent: {
type: "boolean" as const,
default: false,
},
view: {
type: "number" as const,
default: 0,
hidden: true,
},
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const props = defineProps<{ widget?: Widget<WidgetProps> }>();
const emit = defineEmits<{ (ev: "updateProps", props: WidgetProps) }>();
const { widgetProps, configure, save } = useWidgetPropsManager(
name,
widgetPropsDef,
props,
emit
);
const meta = ref(null);
os.api("server-info", {}).then((res) => {
meta.value = res;
});
const toggleView = () => {
if (
(widgetProps.view === 5 && instance.features.searchFilters) ||
(widgetProps.view === 4 && !instance.features.searchFilters)
) {
widgetProps.view = 0;
} else {
widgetProps.view++;
}
save();
};
const connection = stream.useChannel("serverStats");
onUnmounted(() => {
connection.dispose();
});
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View File

@ -0,0 +1,85 @@
<template>
<div class="verusivbr">
<XPie
v-tooltip="i18n.ts.meiliIndexCount"
class="pie"
:value="progress"
:reverse="true"
/>
<div>
<p><i class="ph-file-search ph-bold ph-lg"></i>MeiliSearch</p>
<p>{{ i18n.ts._widgets.meiliStatus }}: {{ available }}</p>
<p>{{ i18n.ts._widgets.meiliSize }}: {{ bytes(totalSize, 1) }}</p>
<p>{{ i18n.ts._widgets.meiliIndexCount }}: {{ indexCount }}</p>
</div>
</div>
<br />
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted } from "vue";
import bytes from "@/filters/bytes";
import XPie from "./pie.vue";
import { i18n } from "@/i18n";
import * as os from "@/os";
const props = defineProps<{
connection: any;
meta: any;
}>();
let progress: number = $ref(0);
let serverStats = $ref(null);
let totalSize: number = $ref(0);
let indexCount: number = $ref(0);
let available: string = $ref("unavailable");
function onStats(stats) {
totalSize = stats.meilisearch.size;
indexCount = stats.meilisearch.indexed_count;
available = stats.meilisearch.health;
progress = indexCount / serverStats.notesCount;
}
onMounted(() => {
os.api("stats", {}).then((res) => {
serverStats = res;
});
props.connection.on("stats", onStats);
});
onBeforeUnmount(() => {
props.connection.off("stats", onStats);
});
</script>
<style lang="scss" scoped>
.verusivbr {
display: flex;
padding: 16px;
> .pie {
height: 82px;
flex-shrink: 0;
margin-right: 16px;
}
> div {
flex: 1;
> p {
margin: 0;
font-size: 0.8em;
&:first-child {
font-weight: bold;
margin-bottom: 4px;
> i {
margin-right: 4px;
}
}
}
}
}
</style>

View File

@ -0,0 +1,73 @@
<template>
<div class="zlxnikvl">
<XPie class="pie" :value="usage" />
<div>
<p><i class="ph-microchip ph-bold ph-lg"></i>RAM</p>
<p>Total: {{ bytes(total, 1) }}</p>
<p>Used: {{ bytes(used, 1) }}</p>
<p>Free: {{ bytes(free, 1) }}</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount } from "vue";
import XPie from "./pie.vue";
import bytes from "@/filters/bytes";
const props = defineProps<{
connection: any;
meta: any;
}>();
let usage: number = $ref(0);
let total: number = $ref(0);
let used: number = $ref(0);
let free: number = $ref(0);
function onStats(stats) {
usage = stats.mem.active / props.meta.mem.total;
total = props.meta.mem.total;
used = stats.mem.active;
free = total - used;
}
onMounted(() => {
props.connection.on("stats", onStats);
});
onBeforeUnmount(() => {
props.connection.off("stats", onStats);
});
</script>
<style lang="scss" scoped>
.zlxnikvl {
display: flex;
padding: 16px;
> .pie {
height: 82px;
flex-shrink: 0;
margin-right: 16px;
}
> div {
flex: 1;
> p {
margin: 0;
font-size: 0.8em;
&:first-child {
font-weight: bold;
margin-bottom: 4px;
> i {
margin-right: 4px;
}
}
}
}
}
</style>

View File

@ -0,0 +1,153 @@
<template>
<div class="oxxrhrto">
<svg :viewBox="`0 0 ${viewBoxX} ${viewBoxY}`">
<polygon
:points="inPolygonPoints"
fill="#f6c177"
fill-opacity="0.5"
/>
<polyline
:points="inPolylinePoints"
fill="none"
stroke="#f6c177"
stroke-width="1"
/>
<circle :cx="inHeadX" :cy="inHeadY" r="1.5" fill="#f6c177" />
<text x="1" y="5">
NET rx
<tspan>{{ bytes(inRecent) }}</tspan>
</text>
</svg>
<svg :viewBox="`0 0 ${viewBoxX} ${viewBoxY}`">
<polygon
:points="outPolygonPoints"
fill="#31748f"
fill-opacity="0.5"
/>
<polyline
:points="outPolylinePoints"
fill="none"
stroke="#31748f"
stroke-width="1"
/>
<circle :cx="outHeadX" :cy="outHeadY" r="1.5" fill="#31748f" />
<text x="1" y="5">
NET tx
<tspan>{{ bytes(outRecent) }}</tspan>
</text>
</svg>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount } from "vue";
import bytes from "@/filters/bytes";
const props = defineProps<{
connection: any;
meta: any;
}>();
let viewBoxX: number = $ref(50);
let viewBoxY: number = $ref(30);
let stats: any[] = $ref([]);
let inPolylinePoints: string = $ref("");
let outPolylinePoints: string = $ref("");
let inPolygonPoints: string = $ref("");
let outPolygonPoints: string = $ref("");
let inHeadX: any = $ref(null);
let inHeadY: any = $ref(null);
let outHeadX: any = $ref(null);
let outHeadY: any = $ref(null);
let inRecent: number = $ref(0);
let outRecent: number = $ref(0);
onMounted(() => {
props.connection.on("stats", onStats);
props.connection.on("statsLog", onStatsLog);
props.connection.send("requestLog", {
id: Math.random().toString().substr(2, 8),
});
});
onBeforeUnmount(() => {
props.connection.off("stats", onStats);
props.connection.off("statsLog", onStatsLog);
});
function onStats(connStats) {
stats.push(connStats);
if (stats.length > 50) stats.shift();
const inPeak = Math.max(1024 * 64, Math.max(...stats.map((s) => s.net.rx)));
const outPeak = Math.max(
1024 * 64,
Math.max(...stats.map((s) => s.net.tx))
);
let inPolylinePointsStats = stats.map((s, i) => [
viewBoxX - (stats.length - 1 - i),
(1 - s.net.rx / inPeak) * viewBoxY,
]);
let outPolylinePointsStats = stats.map((s, i) => [
viewBoxX - (stats.length - 1 - i),
(1 - s.net.tx / outPeak) * viewBoxY,
]);
inPolylinePoints = inPolylinePointsStats
.map((xy) => `${xy[0]},${xy[1]}`)
.join(" ");
outPolylinePoints = outPolylinePointsStats
.map((xy) => `${xy[0]},${xy[1]}`)
.join(" ");
inPolygonPoints = `${
viewBoxX - (stats.length - 1)
},${viewBoxY} ${inPolylinePoints} ${viewBoxX},${viewBoxY}`;
outPolygonPoints = `${
viewBoxX - (stats.length - 1)
},${viewBoxY} ${outPolylinePoints} ${viewBoxX},${viewBoxY}`;
inHeadX = inPolylinePointsStats[inPolylinePointsStats.length - 1][0];
inHeadY = inPolylinePointsStats[inPolylinePointsStats.length - 1][1];
outHeadX = outPolylinePointsStats[outPolylinePointsStats.length - 1][0];
outHeadY = outPolylinePointsStats[outPolylinePointsStats.length - 1][1];
inRecent = connStats.net.rx;
outRecent = connStats.net.tx;
}
function onStatsLog(statsLog) {
for (const revStats of [...statsLog].reverse()) {
onStats(revStats);
}
}
</script>
<style lang="scss" scoped>
.oxxrhrto {
display: flex;
> svg {
display: block;
padding: 10px;
width: 50%;
&:first-child {
padding-right: 5px;
}
&:last-child {
padding-left: 5px;
}
> text {
font-size: 5px;
fill: currentColor;
> tspan {
opacity: 0.5;
}
}
}
}
</style>

View File

@ -0,0 +1,64 @@
<template>
<svg class="hsalcinq" viewBox="0 0 1 1" preserveAspectRatio="none">
<circle
:r="r"
cx="50%"
cy="50%"
fill="none"
stroke-width="0.1"
stroke="rgba(0, 0, 0, 0.05)"
/>
<circle
:r="r"
cx="50%"
cy="50%"
:stroke-dasharray="Math.PI * (r * 2)"
:stroke-dashoffset="strokeDashoffset"
fill="none"
stroke-width="0.1"
:stroke="color"
/>
<text x="50%" y="50%" dy="0.05" text-anchor="middle">
{{ (value * 100).toFixed(0) }}%
</text>
</svg>
</template>
<script lang="ts" setup>
import {} from "vue";
const props = defineProps<{
value: number;
reverse?: boolean;
}>();
const r = 0.45;
const color = $computed(
() =>
`hsl(${
props.reverse ? props.value * 180 : 180 - props.value * 180
}, 80%, 70%)`
);
const strokeDashoffset = $computed(
() => (1 - props.value) * (Math.PI * (r * 2))
);
</script>
<style lang="scss" scoped>
.hsalcinq {
display: block;
height: 100%;
> circle {
transform-origin: center;
transform: rotate(-90deg);
transition: stroke-dashoffset 0.5s ease;
}
> text {
font-size: 0.15px;
fill: currentColor;
}
}
</style>

View File

@ -122,6 +122,9 @@ importers:
'@rollup/pluginutils':
specifier: ^4.2.1
version: 4.2.1
'@syuilo/aiscript':
specifier: 0.11.1
version: 0.11.1
'@types/escape-regexp':
specifier: 0.0.1
version: 0.0.1
@ -350,6 +353,9 @@ importers:
vue-plyr:
specifier: ^7.0.0
version: 7.0.0
vue-prism-editor:
specifier: 2.0.0-alpha.2
version: 2.0.0-alpha.2(vue@3.3.4)
vue3-otp-input:
specifier: ^0.4.1
version: 0.4.1(vue@3.3.4)
@ -1004,7 +1010,7 @@ packages:
hasBin: true
peerDependencies:
'@swc/core': ^1.2.66
chokidar: ^3.5.1
chokidar: ^3.3.1
peerDependenciesMeta:
chokidar:
optional: true
@ -1141,6 +1147,16 @@ packages:
/@swc/wasm@1.2.130:
resolution: {integrity: sha512-rNcJsBxS70+pv8YUWwf5fRlWX6JoY/HJc25HD/F8m6Kv7XhJdqPPMhyX6TKkUBPAG7TWlZYoxa+rHAjPy4Cj3Q==}
/@syuilo/aiscript@0.11.1:
resolution: {integrity: sha512-chwOIA3yLUKvOB0G611hjLArKTeOWNmTm3lHERSaDW1d+dS6do56naX6Lkwy2UpnwWC0qzeNSgg35elk6t2gZg==}
dependencies:
autobind-decorator: 2.4.0
chalk: 4.0.0
seedrandom: 3.0.5
stringz: 2.1.0
uuid: 7.0.3
dev: true
/@szmarczak/http-timer@4.0.6:
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
@ -2312,6 +2328,14 @@ packages:
supports-color: 5.5.0
dev: true
/chalk@4.0.0:
resolution: {integrity: sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==}
engines: {node: '>=10'}
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
dev: true
/chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@ -7274,6 +7298,11 @@ packages:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true
/uuid@7.0.3:
resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==}
hasBin: true
dev: true
/uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
@ -7433,6 +7462,15 @@ packages:
vue: 2.7.14
dev: true
/vue-prism-editor@2.0.0-alpha.2(vue@3.3.4):
resolution: {integrity: sha512-Gu42ba9nosrE+gJpnAEuEkDMqG9zSUysIR8SdXUw8MQKDjBnnNR9lHC18uOr/ICz7yrA/5c7jHJr9lpElODC7w==}
engines: {node: '>=10'}
peerDependencies:
vue: ^3.0.0
dependencies:
vue: 3.3.4
dev: true
/vue3-otp-input@0.4.1(vue@3.3.4):
resolution: {integrity: sha512-wVl9i3DcWlO0C7fBI9V+RIP3crm/1tY72fuhvb3YM2JfbLoYofB96aPl5AgFhA0Cse5bQEMYtIvOeiqW3rfbAw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}

View File

@ -96,6 +96,7 @@ pub struct UserProfileExt {
#[ts(export)]
pub struct UserProfilePinsEx {
pub pinned_notes: Vec<PackNoteMaybeFull>,
// pub pinned_page: Option<Page>,
}
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]