MisskeyPlay (#9467)

* wip

* wip

* wip

* wip

* wip

* Update ui.ts

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update CHANGELOG.md

* wip

* wip

* wip

* wip

* 🎨

* wip

* ✌️
This commit is contained in:
syuilo
2023-01-05 13:59:48 +09:00
committed by GitHub
parent 5d904b05dd
commit ebe340d510
45 changed files with 2465 additions and 93 deletions

View File

@@ -0,0 +1,107 @@
<template>
<div>
<div v-if="c.type === 'root'" :class="$style.root">
<template v-for="child in c.children" :key="child">
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
</template>
</div>
<span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }">{{ c.text }}</span>
<Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, color: c.color ?? null }" :text="c.text"/>
<MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" @click="c.onClick">{{ c.text }}</MkButton>
<div v-else-if="c.type === 'buttons'" style="display: flex; gap: 8px; flex-wrap: wrap;">
<MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton>
</div>
<MkSwitch v-else-if="c.type === 'switch'" :model-value="valueForSwitch" @update:model-value="onSwitchUpdate">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkSwitch>
<MkTextarea v-else-if="c.type === 'textarea'" :model-value="c.default" @update:model-value="c.onInput">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkTextarea>
<MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onInput">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkInput>
<MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :model-value="c.default" type="number" @update:model-value="c.onInput">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkInput>
<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onChange">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
<option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option>
</MkSelect>
<MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" @click="openPostForm">{{ c.text }}</MkButton>
<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace', [$style.containerCenter]: c.align === 'center' }]" :style="{ backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }">
<template v-for="child in c.children" :key="child">
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted, onUnmounted, Ref } from 'vue';
import * as os from '@/os';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/form/input.vue';
import MkSwitch from '@/components/form/switch.vue';
import MkTextarea from '@/components/form/textarea.vue';
import MkSelect from '@/components/form/select.vue';
import { AsUiComponent } from '@/scripts/aiscript/ui';
const props = withDefaults(defineProps<{
component: AsUiComponent;
components: Ref<AsUiComponent>[];
size: 'small' | 'medium' | 'large';
}>(), {
size: 'medium',
});
const c = props.component;
function g(id) {
return props.components.find(x => x.value.id === id).value;
}
let valueForSwitch = $ref(c.default ?? false);
function onSwitchUpdate(v) {
valueForSwitch = v;
if (c.onChange) c.onChange(v);
}
function openPostForm() {
os.post({
initialText: c.form.text,
instant: true,
});
}
</script>
<style lang="scss" module>
.root {
display: flex;
flex-direction: column;
gap: 12px;
}
.container {
display: flex;
flex-direction: column;
gap: 12px;
}
.containerCenter {
text-align: center;
}
.fontSerif {
font-family: serif;
}
.fontMonospace {
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
}
</style>

View File

@@ -2,7 +2,7 @@
<button
v-if="!link"
ref="el" class="bghgjjyj _button"
:class="{ inline, primary, gradate, danger, rounded, full, small }"
:class="{ inline, primary, gradate, danger, rounded, full, small, large, asLike }"
:type="type"
@click="emit('click', $event)"
@mousedown="onMousedown"
@@ -41,6 +41,8 @@ const props = defineProps<{
danger?: boolean;
full?: boolean;
small?: boolean;
large?: boolean;
asLike?: boolean;
}>();
const emit = defineEmits<{
@@ -131,6 +133,11 @@ function onMousedown(evt: MouseEvent): void {
padding: 6px 12px;
}
&.large {
font-size: 100%;
padding: 8px 16px;
}
&.full {
width: 100%;
}
@@ -153,6 +160,37 @@ function onMousedown(evt: MouseEvent): void {
}
}
&.asLike {
background: rgba(255, 86, 125, 0.07);
color: #ff002f;
&:not(:disabled):hover {
background: rgba(255, 74, 116, 0.11);
}
&:not(:disabled):active {
background: rgba(224, 57, 96, 0.125);
}
> .ripples {
::v-deep(div) {
background: rgba(255, 60, 106, 0.15);
}
}
&.primary {
background: rgb(241 97 132);
&:not(:disabled):hover {
background: rgb(241 92 128);
}
&:not(:disabled):active {
background: rgb(241 92 128);
}
}
}
&.gradate {
font-weight: bold;
color: var(--fgOnAccent) !important;

View File

@@ -59,7 +59,7 @@ defineExpose({
&.disabled {
text-decoration: line-through;
opacity: 0.6;
opacity: 0.5;
}
> .box {

View File

@@ -0,0 +1,112 @@
<template>
<MkA :to="`/play/${flash.id}`" class="vhpxefrk _block" tabindex="-1">
<article>
<header>
<h1 :title="flash.title">{{ flash.title }}</h1>
</header>
<p v-if="flash.summary" :title="flash.summary">{{ flash.summary.length > 85 ? flash.summary.slice(0, 85) + '…' : flash.summary }}</p>
<footer>
<img class="icon" :src="flash.user.avatarUrl"/>
<p>{{ userName(flash.user) }}</p>
</footer>
</article>
</MkA>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as misskey from 'misskey-js';
import { userName } from '@/filters/user';
import * as os from '@/os';
const props = defineProps<{
//flash: misskey.entities.Flash;
flash: any;
}>();
</script>
<style lang="scss" scoped>
.vhpxefrk {
display: block;
&:hover {
text-decoration: none;
color: var(--accent);
}
> 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) {
}
@media (max-width: 550px) {
font-size: 12px;
> article {
padding: 12px;
}
}
@media (max-width: 500px) {
font-size: 10px;
> article {
padding: 8px;
> header {
margin-bottom: 4px;
}
> footer {
margin-top: 4px;
> img {
width: 12px;
height: 12px;
}
}
}
}
}
</style>

View File

@@ -50,7 +50,7 @@ const menu = defaultStore.state.menu;
const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
type: def.to ? 'link' : 'button',
text: i18n.ts[def.title],
text: def.title,
icon: def.icon,
to: def.to,
action: def.action,

View File

@@ -14,22 +14,15 @@
</MkA>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import * as misskey from 'misskey-js';
import { userName } from '@/filters/user';
import * as os from '@/os';
export default defineComponent({
props: {
page: {
type: Object,
required: true,
},
},
methods: {
userName,
},
});
const props = defineProps<{
page: misskey.entities.Page;
}>();
</script>
<style lang="scss" scoped>

View File

@@ -126,7 +126,7 @@ const onClick = (ev: MouseEvent) => {
const pushOption = (option: VNode) => {
menu.push({
text: option.children,
active: v.value === option.props.value,
active: computed(() => v.value === option.props.value),
action: () => {
v.value = option.props.value;
},

View File

@@ -8,97 +8,102 @@ import { unisonReload } from '@/scripts/unison-reload';
export const navbarItemDef = reactive({
notifications: {
title: 'notifications',
title: i18n.ts.notifications,
icon: 'ti ti-bell',
show: computed(() => $i != null),
indicated: computed(() => $i != null && $i.hasUnreadNotification),
to: '/my/notifications',
},
messaging: {
title: 'messaging',
title: i18n.ts.messaging,
icon: 'ti ti-messages',
show: computed(() => $i != null),
indicated: computed(() => $i != null && $i.hasUnreadMessagingMessage),
to: '/my/messaging',
},
drive: {
title: 'drive',
title: i18n.ts.drive,
icon: 'ti ti-cloud',
show: computed(() => $i != null),
to: '/my/drive',
},
followRequests: {
title: 'followRequests',
title: i18n.ts.followRequests,
icon: 'ti ti-user-plus',
show: computed(() => $i != null && $i.isLocked),
indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest),
to: '/my/follow-requests',
},
explore: {
title: 'explore',
title: i18n.ts.explore,
icon: 'ti ti-hash',
to: '/explore',
},
announcements: {
title: 'announcements',
title: i18n.ts.announcements,
icon: 'ti ti-speakerphone',
indicated: computed(() => $i != null && $i.hasUnreadAnnouncement),
to: '/announcements',
},
search: {
title: 'search',
title: i18n.ts.search,
icon: 'ti ti-search',
action: () => search(),
},
lists: {
title: 'lists',
title: i18n.ts.lists,
icon: 'ti ti-list',
show: computed(() => $i != null),
to: '/my/lists',
},
/*
groups: {
title: 'groups',
title: i18n.ts.groups,
icon: 'ti ti-users',
show: computed(() => $i != null),
to: '/my/groups',
},
*/
antennas: {
title: 'antennas',
title: i18n.ts.antennas,
icon: 'ti ti-antenna',
show: computed(() => $i != null),
to: '/my/antennas',
},
favorites: {
title: 'favorites',
title: i18n.ts.favorites,
icon: 'ti ti-star',
show: computed(() => $i != null),
to: '/my/favorites',
},
pages: {
title: 'pages',
title: i18n.ts.pages,
icon: 'ti ti-news',
to: '/pages',
},
play: {
title: 'Play',
icon: 'ti ti-player-play',
to: '/play',
},
gallery: {
title: 'gallery',
title: i18n.ts.gallery,
icon: 'ti ti-icons',
to: '/gallery',
},
clips: {
title: 'clip',
title: i18n.ts.clip,
icon: 'ti ti-paperclip',
show: computed(() => $i != null),
to: '/my/clips',
},
channels: {
title: 'channel',
title: i18n.ts.channel,
icon: 'ti ti-device-tv',
to: '/channels',
},
ui: {
title: 'switchUi',
title: i18n.ts.switchUi,
icon: 'ti ti-devices',
action: (ev) => {
os.popupMenu([{
@@ -126,7 +131,7 @@ export const navbarItemDef = reactive({
},
},
reload: {
title: 'reload',
title: i18n.ts.reload,
icon: 'ti ti-refresh',
action: (ev) => {
location.reload();

View File

@@ -0,0 +1,111 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="700">
<MkInput v-model="title" class="_formBlock">
<template #label>{{ i18n.ts._play.title }}</template>
</MkInput>
<MkTextarea v-model="summary" class="_formBlock">
<template #label>{{ i18n.ts._play.summary }}</template>
</MkTextarea>
<MkTextarea v-model="script" class="_formBlock _monospace" tall spellcheck="false">
<template #label>{{ i18n.ts._play.script }}</template>
</MkTextarea>
<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
<MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, onDeactivated, onUnmounted, Ref, ref, watch } from 'vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os';
import { url } from '@/config';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import MkTextarea from '@/components/form/textarea.vue';
import MkInput from '@/components/form/input.vue';
import { useRouter } from '@/router';
const router = useRouter();
const props = defineProps<{
id?: string;
}>();
let flash = $ref(null);
if (props.id) {
flash = await os.api('flash/show', {
flashId: props.id,
});
}
let title = $ref(flash?.title ?? 'New Play');
let summary = $ref(flash?.summary ?? '');
let permissions = $ref(flash?.permissions ?? []);
let script = $ref(flash?.script ?? `/// @ 0.12.0
var name = ""
Ui:render([
Ui:C:textInput({
label: "Your name"
onInput: @(v) { name = v }
})
Ui:C:button({
text: "Hello"
onClick: @() {
Mk:dialog(null \`Hello, {name}!\`)
}
})
])
`);
async function save() {
if (flash) {
os.apiWithDialog('flash/update', {
flashId: props.id,
title,
summary,
permissions,
script,
});
} else {
const created = await os.apiWithDialog('flash/create', {
title,
summary,
permissions,
script,
});
router.push('/play/' + created.id + '/edit');
}
}
function show() {
if (flash == null) {
os.alert({
text: 'Please save',
});
} else {
os.pageWindow(`/play/${flash.id}`);
}
}
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata(computed(() => flash ? {
title: i18n.ts._play.edit + ': ' + flash.title,
} : {
title: i18n.ts._play.new,
}));
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,99 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="700">
<div v-if="tab === 'featured'" class="">
<MkPagination v-slot="{items}" :pagination="featuredFlashsPagination">
<MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/>
</MkPagination>
</div>
<div v-else-if="tab === 'my'" class="my">
<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
<MkPagination v-slot="{items}" :pagination="myFlashsPagination">
<MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/>
</MkPagination>
</div>
<div v-else-if="tab === 'liked'" class="">
<MkPagination v-slot="{items}" :pagination="likedFlashsPagination">
<MkFlashPreview v-for="like in items" :key="like.flash.id" class="" :flash="like.flash"/>
</MkPagination>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue';
import MkFlashPreview from '@/components/MkFlashPreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
import { useRouter } from '@/router';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
const router = useRouter();
let tab = $ref('featured');
const featuredFlashsPagination = {
endpoint: 'flash/featured' as const,
noPaging: true,
};
const myFlashsPagination = {
endpoint: 'flash/my' as const,
limit: 5,
};
const likedFlashsPagination = {
endpoint: 'flash/my-likes' as const,
limit: 5,
};
function create() {
router.push('/play/new');
}
const headerActions = $computed(() => [{
icon: 'ti ti-plus',
text: i18n.ts.create,
handler: create,
}]);
const headerTabs = $computed(() => [{
key: 'featured',
title: i18n.ts._play.featured,
icon: 'fas fa-fire-alt',
}, {
key: 'my',
title: i18n.ts._play.my,
icon: 'ti ti-edit',
}, {
key: 'liked',
title: i18n.ts._play.liked,
icon: 'ti ti-heart',
}]);
definePageMetadata(computed(() => ({
title: 'Play',
icon: 'ti ti-player-play',
})));
</script>
<style lang="scss" scoped>
.rknalgpo {
&.my .ckltabjg:first-child {
margin-top: 16px;
}
.ckltabjg:not(:last-child) {
margin-bottom: 8px;
}
@media (min-width: 500px) {
.ckltabjg:not(:last-child) {
margin-bottom: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,291 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="700">
<Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
<div v-if="flash" :key="flash.id">
<Transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
<div v-if="started" :class="$style.started">
<div class="main _panel">
<MkAsUi v-if="root" :component="root" :components="components"/>
</div>
<div class="actions _panel">
<MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" as-like class="button" rounded primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="flash.likedCount > 0" class="count">{{ flash.likedCount }}</span></MkButton>
<MkButton v-else v-tooltip="i18n.ts.like" as-like class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" class="count">{{ flash.likedCount }}</span></MkButton>
<MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></MkButton>
<MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
</div>
</div>
<div v-else :class="$style.ready">
<div class="_panel main">
<div class="title">{{ flash.title }}</div>
<div class="summary">{{ flash.summary }}</div>
<MkButton class="start" gradate rounded large @click="start">Play</MkButton>
<div class="info">
<span v-tooltip="i18n.ts.numberOfLikes"><i class="ti ti-heart"></i> {{ flash.likedCount }}</span>
</div>
</div>
</div>
</Transition>
<FormFolder class="_formBlock">
<template #icon><i class="ti ti-code"></i></template>
<template #label>{{ i18n.ts._play.viewSource }}</template>
<MkTextarea :model-value="flash.script" readonly tall class="_monospace" spellcheck="false"></MkTextarea>
</FormFolder>
<div :class="$style.footer">
<Mfm :text="`By @${flash.user.username}`"/>
<div class="date">
<div v-if="flash.createdAt != flash.updatedAt"><i class="ti ti-clock"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="flash.updatedAt" mode="detail"/></div>
<div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="flash.createdAt" mode="detail"/></div>
</div>
</div>
<MkA v-if="$i && $i.id === flash.userId" :to="`/play/${flash.id}/edit`" style="color: var(--accent);">{{ i18n.ts._play.editThisPage }}</MkA>
<MkAd :prefer="['horizontal', 'horizontal-big']"/>
</div>
<MkError v-else-if="error" @retry="fetchPage()"/>
<MkLoading v-else/>
</Transition>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, onDeactivated, onUnmounted, Ref, ref, watch } from 'vue';
import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os';
import { url } from '@/config';
import MkFollowButton from '@/components/MkFollowButton.vue';
import MkContainer from '@/components/MkContainer.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkPagePreview from '@/components/MkPagePreview.vue';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import MkAsUi from '@/components/MkAsUi.vue';
import { AsUiComponent, AsUiRoot, patch, registerAsUiLib, render } from '@/scripts/aiscript/ui';
import { createAiScriptEnv } from '@/scripts/aiscript/api';
import FormFolder from '@/components/form/folder.vue';
import MkTextarea from '@/components/form/textarea.vue';
const props = defineProps<{
id: string;
}>();
let flash = $ref(null);
let error = $ref(null);
function fetchFlash() {
flash = null;
os.api('flash/show', {
flashId: props.id,
}).then(_flash => {
flash = _flash;
}).catch(err => {
error = err;
});
}
function share() {
navigator.share({
title: flash.title,
text: flash.summary,
url: `${url}/play/${flash.id}`,
});
}
function shareWithNote() {
os.post({
initialText: `${flash.title} ${url}/play/${flash.id}`,
});
}
function like() {
os.apiWithDialog('flash/like', {
flashId: flash.id,
}).then(() => {
flash.isLiked = true;
flash.likedCount++;
});
}
async function unlike() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unlikeConfirm,
});
if (confirm.canceled) return;
os.apiWithDialog('flash/unlike', {
flashId: flash.id,
}).then(() => {
flash.isLiked = false;
flash.likedCount--;
});
}
watch(() => props.id, fetchFlash, { immediate: true });
const parser = new Parser();
let started = $ref(false);
let aiscript = $shallowRef<Interpreter | null>(null);
const root = ref<AsUiRoot>();
const components: Ref<AsUiComponent>[] = [];
function start() {
started = true;
run();
}
async function run() {
if (aiscript) aiscript.abort();
aiscript = new Interpreter({
...createAiScriptEnv({
storageKey: 'flash:' + flash.id,
}),
...registerAsUiLib(components, (_root) => {
root.value = _root.value;
}),
}, {
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 = parser.parse(flash.script);
} catch (err) {
os.alert({
type: 'error',
text: 'Syntax error :(',
});
return;
}
try {
await aiscript.exec(ast);
} catch (err) {
os.alert({
type: 'error',
title: 'AiScript Error',
text: err.message,
});
}
}
onDeactivated(() => {
if (aiscript) aiscript.abort();
});
onUnmounted(() => {
if (aiscript) aiscript.abort();
});
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata(computed(() => flash ? {
title: flash.title,
avatar: flash.user,
path: `/play/${flash.id}`,
share: {
title: flash.title,
text: flash.summary,
},
} : null));
</script>
<style lang="scss" module>
.ready {
&:global {
> .main {
padding: 32px;
> .title {
font-size: 1.4em;
font-weight: bold;
margin-bottom: 1rem;
text-align: center;
}
> .summary {
font-size: 1.1em;
text-align: center;
}
> .start {
margin: 1em auto 1em auto;
}
> .info {
text-align: center;
}
}
}
}
.footer {
margin-top: 16px;
&:global {
> .date {
margin: 8px 0;
opacity: 0.6;
}
}
}
.started {
&:global {
> .main {
padding: 32px;
}
> .actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 16px;
padding: 16px;
}
}
}
</style>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.125s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.zoom-enter-active,
.zoom-leave-active {
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
}
.zoom-enter-from {
opacity: 0;
transform: scale(0.7);
}
.zoom-leave-to {
opacity: 0;
transform: scale(1.3);
}
</style>

View File

@@ -18,8 +18,8 @@
</div>
<div class="actions">
<div class="like">
<MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
<MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
<MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" as-like primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
<MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" as-like @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
</div>
<div class="other">
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
@@ -207,20 +207,6 @@ definePageMetadata(computed(() => page ? {
padding: 16px 0 0 0;
border-top: solid 0.5px var(--divider);
> .like {
> .button {
--accent: rgb(241 97 132);
--X8: rgb(241 92 128);
--buttonBg: rgb(216 71 106 / 5%);
--buttonHoverBg: rgb(216 71 106 / 10%);
color: #ff002f;
::v-deep(.count) {
margin-left: 0.5em;
}
}
}
> .other {
margin-left: auto;

View File

@@ -1,25 +1,34 @@
<template>
<div class="iltifgqe">
<div class="editor _panel _gap">
<PrismEditor v-model="code" class="_code code" :highlight="highlighter" :line-numbers="false"/>
<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
</div>
<MkContainer :foldable="true" class="_gap">
<template #header>{{ i18n.ts.output }}</template>
<div class="bepmlvbi">
<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
<MkSpacer :content-max="800">
<div :class="$style.root">
<div :class="$style.editor" class="_panel">
<PrismEditor v-model="code" class="_code code" :highlight="highlighter" :line-numbers="false"/>
<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
</div>
</MkContainer>
<div class="_gap">
{{ i18n.ts.scratchpadDescription }}
<MkContainer v-if="root && components.length > 0" :key="uiKey" :foldable="true">
<template #header>UI</template>
<div :class="$style.ui">
<MkAsUi :component="root" :components="components" size="small"/>
</div>
</MkContainer>
<MkContainer :foldable="true" class="">
<template #header>{{ i18n.ts.output }}</template>
<div :class="$style.logs">
<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
</div>
</MkContainer>
<div class="">
{{ i18n.ts.scratchpadDescription }}
</div>
</div>
</div>
</MkSpacer>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { onDeactivated, onUnmounted, Ref, ref, watch } from 'vue';
import 'prismjs';
import { highlight, languages } from 'prismjs/components/prism-core';
import 'prismjs/components/prism-clike';
@@ -35,11 +44,16 @@ import * as os from '@/os';
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { AsUiComponent, AsUiRoot, patch, registerAsUiLib, render } from '@/scripts/aiscript/ui';
import MkAsUi from '@/components/MkAsUi.vue';
const parser = new Parser();
let aiscript: Interpreter;
const code = ref('');
const logs = ref<any[]>([]);
const root = ref<AsUiRoot>();
let components: Ref<AsUiComponent>[] = [];
let uiKey = $ref(0);
const saved = localStorage.getItem('scratchpad');
if (saved) {
@@ -51,10 +65,19 @@ watch(code, () => {
});
async function run() {
if (aiscript) aiscript.abort();
root.value = undefined;
components = [];
uiKey++;
logs.value = [];
const aiscript = new Interpreter(createAiScriptEnv({
storageKey: 'scratchpad',
token: $i?.token,
aiscript = new Interpreter(({
...createAiScriptEnv({
storageKey: 'widget',
token: $i?.token,
}),
...registerAsUiLib(components, (_root) => {
root.value = _root.value;
}),
}), {
in: (q) => {
return new Promise(ok => {
@@ -96,10 +119,11 @@ async function run() {
}
try {
await aiscript.exec(ast);
} catch (error: any) {
} catch (err: any) {
os.alert({
type: 'error',
text: error.message,
title: 'AiScript Error',
text: err.message,
});
}
}
@@ -108,6 +132,14 @@ function highlighter(code) {
return highlight(code, languages.js, 'javascript');
}
onDeactivated(() => {
if (aiscript) aiscript.abort();
});
onUnmounted(() => {
if (aiscript) aiscript.abort();
});
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
@@ -118,21 +150,29 @@ definePageMetadata({
});
</script>
<style lang="scss" scoped>
.iltifgqe {
padding: 16px;
> .editor {
position: relative;
}
<style lang="scss" module>
.root {
display: flex;
flex-direction: column;
gap: var(--margin);
}
.bepmlvbi {
.editor {
position: relative;
}
.ui {
padding: 32px;
}
.logs {
padding: 16px;
> .log {
&:not(.print) {
opacity: 0.7;
&:global {
> .log {
&:not(.print) {
opacity: 0.7;
}
}
}
}

View File

@@ -49,7 +49,7 @@ async function addItem() {
const { canceled, result: item } = await os.select({
title: i18n.ts.addItem,
items: [...menu.map(k => ({
value: k, text: i18n.ts[navbarItemDef[k].title],
value: k, text: navbarItemDef[k].title,
})), {
value: '-', text: i18n.ts.divider,
}],

View File

@@ -262,6 +262,20 @@ export const routes = [{
}, {
path: '/pages',
component: page(() => import('./pages/pages.vue')),
}, {
path: '/play/:id/edit',
component: page(() => import('./pages/flash/flash-edit.vue')),
loginRequired: true,
}, {
path: '/play/new',
component: page(() => import('./pages/flash/flash-edit.vue')),
loginRequired: true,
}, {
path: '/play/:id',
component: page(() => import('./pages/flash/flash.vue')),
}, {
path: '/play',
component: page(() => import('./pages/flash/flash-index.vue')),
}, {
path: '/gallery/:postId/edit',
component: page(() => import('./pages/gallery/edit.vue')),

View File

@@ -0,0 +1,526 @@
import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
import { v4 as uuid } from 'uuid';
import { ref, Ref } from 'vue';
export type AsUiComponentBase = {
id: string;
hidden?: boolean;
};
export type AsUiRoot = AsUiComponentBase & {
type: 'root';
children: AsUiComponent['id'][];
};
export type AsUiContainer = AsUiComponentBase & {
type: 'container';
children?: AsUiComponent['id'][];
align?: 'left' | 'center' | 'right';
bgColor?: string;
fgColor?: string;
font?: 'serif' | 'sans-serif' | 'monospace';
borderWidth?: number;
borderColor?: string;
padding?: number;
rounded?: boolean;
hidden?: boolean;
};
export type AsUiText = AsUiComponentBase & {
type: 'text';
text?: string;
size?: number;
bold?: boolean;
color?: string;
font?: 'serif' | 'sans-serif' | 'monospace';
};
export type AsUiMfm = AsUiComponentBase & {
type: 'mfm';
text?: string;
size?: number;
color?: string;
font?: 'serif' | 'sans-serif' | 'monospace';
};
export type AsUiButton = AsUiComponentBase & {
type: 'button';
text?: string;
onClick?: () => void;
primary?: boolean;
rounded?: boolean;
};
export type AsUiButtons = AsUiComponentBase & {
type: 'buttons';
buttons?: AsUiButton[];
};
export type AsUiSwitch = AsUiComponentBase & {
type: 'switch';
onChange?: (v: boolean) => void;
default?: boolean;
label?: string;
caption?: string;
};
export type AsUiTextarea = AsUiComponentBase & {
type: 'textarea';
onInput?: (v: string) => void;
default?: string;
label?: string;
caption?: string;
};
export type AsUiTextInput = AsUiComponentBase & {
type: 'textInput';
onInput?: (v: string) => void;
default?: string;
label?: string;
caption?: string;
};
export type AsUiNumberInput = AsUiComponentBase & {
type: 'numberInput';
onInput?: (v: number) => void;
default?: number;
label?: string;
caption?: string;
};
export type AsUiSelect = AsUiComponentBase & {
type: 'select';
items?: {
text: string;
value: string;
}[];
onChange?: (v: string) => void;
default?: string;
label?: string;
caption?: string;
};
export type AsUiPostFormButton = AsUiComponentBase & {
type: 'postFormButton';
text?: string;
primary?: boolean;
rounded?: boolean;
form?: {
text: string;
};
};
export type AsUiComponent = AsUiRoot | AsUiContainer | AsUiText | AsUiMfm | AsUiButton | AsUiButtons | AsUiSwitch | AsUiTextarea | AsUiTextInput | AsUiNumberInput | AsUiSelect | AsUiPostFormButton;
export function patch(id: string, def: values.Value, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
// TODO
}
function getRootOptions(def: values.Value | undefined): Omit<AsUiRoot, 'id' | 'type'> {
utils.assertObject(def);
const children = def.value.get('children');
utils.assertArray(children);
return {
children: children.value.map(v => {
utils.assertObject(v);
return v.value.get('id').value;
}),
};
}
function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer, 'id' | 'type'> {
utils.assertObject(def);
const children = def.value.get('children');
if (children) utils.assertArray(children);
const align = def.value.get('align');
if (align) utils.assertString(align);
const bgColor = def.value.get('bgColor');
if (bgColor) utils.assertString(bgColor);
const fgColor = def.value.get('fgColor');
if (fgColor) utils.assertString(fgColor);
const font = def.value.get('font');
if (font) utils.assertString(font);
const borderWidth = def.value.get('borderWidth');
if (borderWidth) utils.assertNumber(borderWidth);
const borderColor = def.value.get('borderColor');
if (borderColor) utils.assertString(borderColor);
const padding = def.value.get('padding');
if (padding) utils.assertNumber(padding);
const rounded = def.value.get('rounded');
if (rounded) utils.assertBoolean(rounded);
const hidden = def.value.get('hidden');
if (hidden) utils.assertBoolean(hidden);
return {
children: children ? children.value.map(v => {
utils.assertObject(v);
return v.value.get('id').value;
}) : [],
align: align?.value,
fgColor: fgColor?.value,
bgColor: bgColor?.value,
font: font?.value,
borderWidth: borderWidth?.value,
borderColor: borderColor?.value,
padding: padding?.value,
rounded: rounded?.value,
hidden: hidden?.value,
};
}
function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 'type'> {
utils.assertObject(def);
const text = def.value.get('text');
if (text) utils.assertString(text);
const size = def.value.get('size');
if (size) utils.assertNumber(size);
const bold = def.value.get('bold');
if (bold) utils.assertBoolean(bold);
const color = def.value.get('color');
if (color) utils.assertString(color);
const font = def.value.get('font');
if (font) utils.assertString(font);
return {
text: text?.value,
size: size?.value,
bold: bold?.value,
color: color?.value,
font: font?.value,
};
}
function getMfmOptions(def: values.Value | undefined): Omit<AsUiMfm, 'id' | 'type'> {
utils.assertObject(def);
const text = def.value.get('text');
if (text) utils.assertString(text);
const size = def.value.get('size');
if (size) utils.assertNumber(size);
const color = def.value.get('color');
if (color) utils.assertString(color);
const font = def.value.get('font');
if (font) utils.assertString(font);
return {
text: text?.value,
size: size?.value,
color: color?.value,
font: font?.value,
};
}
function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiTextInput, 'id' | 'type'> {
utils.assertObject(def);
const onInput = def.value.get('onInput');
if (onInput) utils.assertFunction(onInput);
const defaultValue = def.value.get('default');
if (defaultValue) utils.assertString(defaultValue);
const label = def.value.get('label');
if (label) utils.assertString(label);
const caption = def.value.get('caption');
if (caption) utils.assertString(caption);
return {
onInput: (v) => {
if (onInput) call(onInput, [utils.jsToVal(v)]);
},
default: defaultValue?.value,
label: label?.value,
caption: caption?.value,
};
}
function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiTextarea, 'id' | 'type'> {
utils.assertObject(def);
const onInput = def.value.get('onInput');
if (onInput) utils.assertFunction(onInput);
const defaultValue = def.value.get('default');
if (defaultValue) utils.assertString(defaultValue);
const label = def.value.get('label');
if (label) utils.assertString(label);
const caption = def.value.get('caption');
if (caption) utils.assertString(caption);
return {
onInput: (v) => {
if (onInput) call(onInput, [utils.jsToVal(v)]);
},
default: defaultValue?.value,
label: label?.value,
caption: caption?.value,
};
}
function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiNumberInput, 'id' | 'type'> {
utils.assertObject(def);
const onInput = def.value.get('onInput');
if (onInput) utils.assertFunction(onInput);
const defaultValue = def.value.get('default');
if (defaultValue) utils.assertNumber(defaultValue);
const label = def.value.get('label');
if (label) utils.assertString(label);
const caption = def.value.get('caption');
if (caption) utils.assertString(caption);
return {
onInput: (v) => {
if (onInput) call(onInput, [utils.jsToVal(v)]);
},
default: defaultValue?.value,
label: label?.value,
caption: caption?.value,
};
}
function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButton, 'id' | 'type'> {
utils.assertObject(def);
const text = def.value.get('text');
if (text) utils.assertString(text);
const onClick = def.value.get('onClick');
if (onClick) utils.assertFunction(onClick);
const primary = def.value.get('primary');
if (primary) utils.assertBoolean(primary);
const rounded = def.value.get('rounded');
if (rounded) utils.assertBoolean(rounded);
return {
text: text?.value,
onClick: () => {
if (onClick) call(onClick, []);
},
primary: primary?.value,
rounded: rounded?.value,
};
}
function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButtons, 'id' | 'type'> {
utils.assertObject(def);
const buttons = def.value.get('buttons');
if (buttons) utils.assertArray(buttons);
return {
buttons: buttons ? buttons.value.map(button => {
utils.assertObject(button);
const text = button.value.get('text');
utils.assertString(text);
const onClick = button.value.get('onClick');
utils.assertFunction(onClick);
const primary = button.value.get('primary');
if (primary) utils.assertBoolean(primary);
const rounded = button.value.get('rounded');
if (rounded) utils.assertBoolean(rounded);
return {
text: text.value,
onClick: () => {
call(onClick, []);
},
primary: primary?.value,
rounded: rounded?.value,
};
}) : [],
};
}
function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSwitch, 'id' | 'type'> {
utils.assertObject(def);
const onChange = def.value.get('onChange');
if (onChange) utils.assertFunction(onChange);
const defaultValue = def.value.get('default');
if (defaultValue) utils.assertBoolean(defaultValue);
const label = def.value.get('label');
if (label) utils.assertString(label);
const caption = def.value.get('caption');
if (caption) utils.assertString(caption);
return {
onChange: (v) => {
if (onChange) call(onChange, [utils.jsToVal(v)]);
},
default: defaultValue?.value,
label: label?.value,
caption: caption?.value,
};
}
function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSelect, 'id' | 'type'> {
utils.assertObject(def);
const items = def.value.get('items');
if (items) utils.assertArray(items);
const onChange = def.value.get('onChange');
if (onChange) utils.assertFunction(onChange);
const defaultValue = def.value.get('default');
if (defaultValue) utils.assertString(defaultValue);
const label = def.value.get('label');
if (label) utils.assertString(label);
const caption = def.value.get('caption');
if (caption) utils.assertString(caption);
return {
items: items ? items.value.map(item => {
utils.assertObject(item);
const text = item.value.get('text');
utils.assertString(text);
const value = item.value.get('value');
if (value) utils.assertString(value);
return {
text: text.value,
value: value ? value.value : text.value,
};
}) : [],
onChange: (v) => {
if (onChange) call(onChange, [utils.jsToVal(v)]);
},
default: defaultValue?.value,
label: label?.value,
caption: caption?.value,
};
}
function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiPostFormButton, 'id' | 'type'> {
utils.assertObject(def);
const text = def.value.get('text');
if (text) utils.assertString(text);
const primary = def.value.get('primary');
if (primary) utils.assertBoolean(primary);
const rounded = def.value.get('rounded');
if (rounded) utils.assertBoolean(rounded);
const form = def.value.get('form');
if (form) utils.assertObject(form);
const getForm = () => {
const text = form!.value.get('text');
utils.assertString(text);
return {
text: text.value,
};
};
return {
text: text?.value,
primary: primary?.value,
rounded: rounded?.value,
form: form ? getForm() : {
text: '',
},
};
}
export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: Ref<AsUiRoot>) => void) {
const instances = {};
function createComponentInstance(type: AsUiComponent['type'], def: values.Value | undefined, id: values.Value | undefined, getOptions: (def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) => any, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
if (id) utils.assertString(id);
const _id = id?.value ?? uuid();
const component = ref({
...getOptions(def, call),
type,
id: _id,
});
components.push(component);
const instance = values.OBJ(new Map([
['id', values.STR(_id)],
['update', values.FN_NATIVE(async ([def], opts) => {
utils.assertObject(def);
const updates = getOptions(def, call);
for (const update of def.value.keys()) {
if (!Object.hasOwn(updates, update)) continue;
component.value[update] = updates[update];
}
})],
]));
instances[_id] = instance;
return instance;
}
const rootInstance = createComponentInstance('root', utils.jsToVal({ children: [] }), utils.jsToVal('___root___'), getRootOptions, () => {});
const rootComponent = components[0] as Ref<AsUiRoot>;
done(rootComponent);
return {
'Ui:root': rootInstance,
'Ui:patch': values.FN_NATIVE(async ([id, val], opts) => {
utils.assertString(id);
utils.assertArray(val);
patch(id.value, val.value, opts.call);
}),
'Ui:get': values.FN_NATIVE(async ([id], opts) => {
utils.assertString(id);
const instance = instances[id.value];
if (instance) {
return instance;
} else {
return values.NULL;
}
}),
// Ui:root.update({ children: [...] }) の糖衣構文
'Ui:render': values.FN_NATIVE(async ([children], opts) => {
utils.assertArray(children);
rootComponent.value.children = children.value.map(v => {
utils.assertObject(v);
return v.value.get('id').value;
});
}),
'Ui:C:container': values.FN_NATIVE(async ([def, id], opts) => {
return createComponentInstance('container', def, id, getContainerOptions, opts.call);
}),
'Ui:C:text': values.FN_NATIVE(async ([def, id], opts) => {
return createComponentInstance('text', def, id, getTextOptions, opts.call);
}),
'Ui:C:mfm': values.FN_NATIVE(async ([def, id], opts) => {
return createComponentInstance('mfm', def, id, getMfmOptions, opts.call);
}),
'Ui:C:textarea': values.FN_NATIVE(async ([def, id], opts) => {
return createComponentInstance('textarea', def, id, getTextareaOptions, opts.call);
}),
'Ui:C:textInput': values.FN_NATIVE(async ([def, id], opts) => {
return createComponentInstance('textInput', def, id, getTextInputOptions, opts.call);
}),
'Ui:C:numberInput': values.FN_NATIVE(async ([def, id], opts) => {
return createComponentInstance('numberInput', def, id, getNumberInputOptions, opts.call);
}),
'Ui:C:button': values.FN_NATIVE(async ([def, id], opts) => {
return createComponentInstance('button', def, id, getButtonOptions, opts.call);
}),
'Ui:C:buttons': values.FN_NATIVE(async ([def, id], opts) => {
return createComponentInstance('buttons', def, id, getButtonsOptions, opts.call);
}),
'Ui:C:switch': values.FN_NATIVE(async ([def, id], opts) => {
return createComponentInstance('switch', def, id, getSwitchOptions, opts.call);
}),
'Ui:C:select': values.FN_NATIVE(async ([def, id], opts) => {
return createComponentInstance('select', def, id, getSelectOptions, opts.call);
}),
'Ui:C:postFormButton': values.FN_NATIVE(async ([def, id], opts) => {
return createComponentInstance('postFormButton', def, id, getPostFormButtonOptions, opts.call);
}),
};
}

View File

@@ -14,7 +14,7 @@
<template v-for="item in menu">
<div v-if="item === '-'" class="divider"></div>
<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: navbarItemDef[item].active }]" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span>
<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
</component>
</template>

View File

@@ -17,14 +17,14 @@
:is="navbarItemDef[item].to ? 'MkA' : 'button'"
v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)"
v-click-anime
v-tooltip.noDelay.right="i18n.ts[navbarItemDef[item].title]"
v-tooltip.noDelay.right="navbarItemDef[item].title"
class="item _button"
:class="[item, { active: navbarItemDef[item].active }]"
active-class="active"
:to="navbarItemDef[item].to"
v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"
>
<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span>
<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
</component>
</template>

View File

@@ -10,7 +10,7 @@
</MkA>
<template v-for="item in menu">
<div v-if="item === '-'" class="divider"></div>
<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="$ts[navbarItemDef[item].title]" class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="navbarItemDef[item].title" class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
<i class="ti-fw" :class="navbarItemDef[item].icon"></i>
<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span>
</component>

View File

@@ -15,7 +15,7 @@
<template v-for="item in menu">
<div v-if="item === '-'" class="divider"></div>
<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
<i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ $ts[navbarItemDef[item].title] }}</span>
<i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span>
</component>
</template>

View File

@@ -0,0 +1,122 @@
<template>
<MkContainer :show-header="widgetProps.showHeader" class="mkw-aiscriptApp">
<template #header>App</template>
<div :class="$style.root">
<MkAsUi v-if="root" :component="root" :components="components" size="small"/>
</div>
</MkContainer>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, Ref, ref, watch } from 'vue';
import { Interpreter, Parser, utils, values } 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 MkAsUi from '@/components/MkAsUi.vue';
import MkContainer from '@/components/MkContainer.vue';
import { AsUiComponent, AsUiRoot, patch, registerAsUiLib, render } from '@/scripts/aiscript/ui';
const name = 'aiscriptApp';
const widgetPropsDef = {
script: {
type: 'string' as const,
multiline: true,
default: '',
},
showHeader: {
type: 'boolean' as const,
default: true,
},
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
props,
emit,
);
const parser = new Parser();
const root = ref<AsUiRoot>();
const components: Ref<AsUiComponent>[] = [];
async function run() {
const aiscript = new Interpreter({
...createAiScriptEnv({
storageKey: 'widget',
token: $i?.token,
}),
...registerAsUiLib(components, (_root) => {
root.value = _root.value;
}),
}, {
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 = parser.parse(widgetProps.script);
} catch (err) {
os.alert({
type: 'error',
text: 'Syntax error :(',
});
return;
}
try {
await aiscript.exec(ast);
} catch (err) {
os.alert({
type: 'error',
title: 'AiScript Error',
text: err.message,
});
}
}
watch(() => widgetProps.script, () => {
run();
});
onMounted(() => {
run();
});
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>
<style lang="scss" module>
.root {
padding: 16px;
}
</style>

View File

@@ -22,6 +22,7 @@ export default function(app: App) {
app.component('MkwInstanceCloud', defineAsyncComponent(() => import('./instance-cloud.vue')));
app.component('MkwButton', defineAsyncComponent(() => import('./button.vue')));
app.component('MkwAiscript', defineAsyncComponent(() => import('./aiscript.vue')));
app.component('MkwAiscriptApp', defineAsyncComponent(() => import('./aiscript-app.vue')));
app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue')));
app.component('MkwUserList', defineAsyncComponent(() => import('./user-list.vue')));
}
@@ -48,6 +49,7 @@ export const widgets = [
'jobQueue',
'button',
'aiscript',
'aiscriptApp',
'aichan',
'userList',
];