diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-01-05 13:59:48 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-01-05 13:59:48 +0900 |
| commit | ebe340d5105595abe2406e8f386c3ab69703b73b (patch) | |
| tree | 36eb93333667353fb71a430b7d5e1a700d0e904e /packages/frontend/src | |
| parent | Update CHANGELOG.md (diff) | |
| download | misskey-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')
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', ]; |