summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-01-05 13:59:48 +0900
committerGitHub <noreply@github.com>2023-01-05 13:59:48 +0900
commitebe340d5105595abe2406e8f386c3ab69703b73b (patch)
tree36eb93333667353fb71a430b7d5e1a700d0e904e /packages/frontend/src
parentUpdate CHANGELOG.md (diff)
downloadmisskey-ebe340d5105595abe2406e8f386c3ab69703b73b.tar.gz
misskey-ebe340d5105595abe2406e8f386c3ab69703b73b.tar.bz2
misskey-ebe340d5105595abe2406e8f386c3ab69703b73b.zip
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 * :art: * wip * :v:
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/components/MkAsUi.vue107
-rw-r--r--packages/frontend/src/components/MkButton.vue40
-rw-r--r--packages/frontend/src/components/MkChartLegend.vue2
-rw-r--r--packages/frontend/src/components/MkFlashPreview.vue112
-rw-r--r--packages/frontend/src/components/MkLaunchPad.vue2
-rw-r--r--packages/frontend/src/components/MkPagePreview.vue19
-rw-r--r--packages/frontend/src/components/form/select.vue2
-rw-r--r--packages/frontend/src/navbar.ts39
-rw-r--r--packages/frontend/src/pages/flash/flash-edit.vue111
-rw-r--r--packages/frontend/src/pages/flash/flash-index.vue99
-rw-r--r--packages/frontend/src/pages/flash/flash.vue291
-rw-r--r--packages/frontend/src/pages/page.vue18
-rw-r--r--packages/frontend/src/pages/scratchpad.vue102
-rw-r--r--packages/frontend/src/pages/settings/navbar.vue2
-rw-r--r--packages/frontend/src/router.ts14
-rw-r--r--packages/frontend/src/scripts/aiscript/ui.ts526
-rw-r--r--packages/frontend/src/ui/_common_/navbar-for-mobile.vue2
-rw-r--r--packages/frontend/src/ui/_common_/navbar.vue4
-rw-r--r--packages/frontend/src/ui/classic.header.vue2
-rw-r--r--packages/frontend/src/ui/classic.sidebar.vue2
-rw-r--r--packages/frontend/src/widgets/aiscript-app.vue122
-rw-r--r--packages/frontend/src/widgets/index.ts2
22 files changed, 1533 insertions, 87 deletions
diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue
new file mode 100644
index 0000000000..bc1e25957b
--- /dev/null
+++ b/packages/frontend/src/components/MkAsUi.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index daf47e12d4..f9602de787 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -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;
diff --git a/packages/frontend/src/components/MkChartLegend.vue b/packages/frontend/src/components/MkChartLegend.vue
index f33f753723..8d2a2be8e8 100644
--- a/packages/frontend/src/components/MkChartLegend.vue
+++ b/packages/frontend/src/components/MkChartLegend.vue
@@ -59,7 +59,7 @@ defineExpose({
&.disabled {
text-decoration: line-through;
- opacity: 0.6;
+ opacity: 0.5;
}
> .box {
diff --git a/packages/frontend/src/components/MkFlashPreview.vue b/packages/frontend/src/components/MkFlashPreview.vue
new file mode 100644
index 0000000000..1a82ffe5ab
--- /dev/null
+++ b/packages/frontend/src/components/MkFlashPreview.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue
index 3ea90712a0..aab7631e36 100644
--- a/packages/frontend/src/components/MkLaunchPad.vue
+++ b/packages/frontend/src/components/MkLaunchPad.vue
@@ -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,
diff --git a/packages/frontend/src/components/MkPagePreview.vue b/packages/frontend/src/components/MkPagePreview.vue
index 009582e540..1eb61d4344 100644
--- a/packages/frontend/src/components/MkPagePreview.vue
+++ b/packages/frontend/src/components/MkPagePreview.vue
@@ -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>
diff --git a/packages/frontend/src/components/form/select.vue b/packages/frontend/src/components/form/select.vue
index c8cdd9e508..068ca2ebc6 100644
--- a/packages/frontend/src/components/form/select.vue
+++ b/packages/frontend/src/components/form/select.vue
@@ -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;
},
diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts
index 31e6cd64a4..efc0abfc6e 100644
--- a/packages/frontend/src/navbar.ts
+++ b/packages/frontend/src/navbar.ts
@@ -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();
diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue
new file mode 100644
index 0000000000..561331e002
--- /dev/null
+++ b/packages/frontend/src/pages/flash/flash-edit.vue
@@ -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>
diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue
new file mode 100644
index 0000000000..bc4828f416
--- /dev/null
+++ b/packages/frontend/src/pages/flash/flash-index.vue
@@ -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>
diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue
new file mode 100644
index 0000000000..9495206c54
--- /dev/null
+++ b/packages/frontend/src/pages/flash/flash.vue
@@ -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>
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index e01dae2cd9..11ed8f9f44 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -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;
diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue
index 9db17efc03..7d097fbaaa 100644
--- a/packages/frontend/src/pages/scratchpad.vue
+++ b/packages/frontend/src/pages/scratchpad.vue
@@ -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;
+<style lang="scss" module>
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: var(--margin);
+}
- > .editor {
- position: relative;
- }
+.editor {
+ position: relative;
+}
+
+.ui {
+ padding: 32px;
}
-.bepmlvbi {
+.logs {
padding: 16px;
- > .log {
- &:not(.print) {
- opacity: 0.7;
+ &:global {
+ > .log {
+ &:not(.print) {
+ opacity: 0.7;
+ }
}
}
}
diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue
index 0b2776ec90..9ab8700b01 100644
--- a/packages/frontend/src/pages/settings/navbar.vue
+++ b/packages/frontend/src/pages/settings/navbar.vue
@@ -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,
}],
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index 9001f0f37f..63c753de22 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -263,6 +263,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')),
loginRequired: true,
diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts
new file mode 100644
index 0000000000..f4c89b8276
--- /dev/null
+++ b/packages/frontend/src/scripts/aiscript/ui.ts
@@ -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);
+ }),
+ };
+}
diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
index ac109d9def..989d861d27 100644
--- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
+++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
@@ -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>
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index 7c859bf3aa..e90098397a 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -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>
diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue
index 77a64aac37..34ddfa1d32 100644
--- a/packages/frontend/src/ui/classic.header.vue
+++ b/packages/frontend/src/ui/classic.header.vue
@@ -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>
diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue
index ec379fbaa7..a11c2ba10e 100644
--- a/packages/frontend/src/ui/classic.sidebar.vue
+++ b/packages/frontend/src/ui/classic.sidebar.vue
@@ -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>
diff --git a/packages/frontend/src/widgets/aiscript-app.vue b/packages/frontend/src/widgets/aiscript-app.vue
new file mode 100644
index 0000000000..1445e5b1ad
--- /dev/null
+++ b/packages/frontend/src/widgets/aiscript-app.vue
@@ -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>
diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts
index 39826f13c8..3966649da4 100644
--- a/packages/frontend/src/widgets/index.ts
+++ b/packages/frontend/src/widgets/index.ts
@@ -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',
];