diff --git a/src/client/pages/gallery/edit.vue b/src/client/pages/gallery/edit.vue new file mode 100644 index 0000000000..cd6a0defdd --- /dev/null +++ b/src/client/pages/gallery/edit.vue @@ -0,0 +1,168 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormInput v-model:value="title"> + <span>{{ $ts.title }}</span> + </FormInput> + + <FormTextarea v-model:value="description" :max="500"> + <span>{{ $ts.description }}</span> + </FormTextarea> + + <FormGroup> + <div v-for="file in files" :key="file.id" class="_formItem _formPanel wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }"> + <div class="name">{{ file.name }}</div> + <button class="remove _button" @click="remove(file)" v-tooltip="$ts.remove"><i class="fas fa-times"></i></button> + </div> + <FormButton @click="selectFile" primary><i class="fas fa-plus"></i> {{ $ts.attachFile }}</FormButton> + </FormGroup> + + <FormSwitch v-model:value="isSensitive">{{ $ts.markAsSensitive }}</FormSwitch> + + <FormButton v-if="postId" @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + <FormButton v-else @click="save" primary><i class="fas fa-save"></i> {{ $ts.publish }}</FormButton> + + <FormButton v-if="postId" @click="del" danger><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import FormButton from '@client/components/form/button.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormTextarea from '@client/components/form/textarea.vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormTuple from '@client/components/form/tuple.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import { selectFile } from '@client/scripts/select-file'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; + +export default defineComponent({ + components: { + FormButton, + FormInput, + FormTextarea, + FormSwitch, + FormBase, + FormGroup, + FormSuspense, + }, + + props: { + postId: { + type: String, + required: false, + default: null, + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.postId ? { + title: this.$ts.edit, + icon: 'fas fa-pencil-alt' + } : { + title: this.$ts.postToGallery, + icon: 'fas fa-pencil-alt' + }), + init: null, + files: [], + description: null, + title: null, + isSensitive: false, + } + }, + + watch: { + postId: { + handler() { + this.init = () => this.postId ? os.api('gallery/posts/show', { + postId: this.postId + }).then(post => { + this.files = post.files; + this.title = post.title; + this.description = post.description; + this.isSensitive = post.isSensitive; + }) : Promise.resolve(null); + }, + immediate: true, + } + }, + + methods: { + selectFile(e) { + selectFile(e.currentTarget || e.target, null, true).then(files => { + this.files = this.files.concat(files); + }); + }, + + remove(file) { + this.files = this.files.filter(f => f.id !== file.id); + }, + + async save() { + if (this.postId) { + await os.apiWithDialog('gallery/posts/update', { + postId: this.postId, + title: this.title, + description: this.description, + fileIds: this.files.map(file => file.id), + isSensitive: this.isSensitive, + }); + this.$router.push(`/gallery/${this.postId}`); + } else { + const post = await os.apiWithDialog('gallery/posts/create', { + title: this.title, + description: this.description, + fileIds: this.files.map(file => file.id), + isSensitive: this.isSensitive, + }); + this.$router.push(`/gallery/${post.id}`); + } + }, + + async del() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$ts.deleteConfirm, + showCancelButton: true + }); + if (canceled) return; + await os.apiWithDialog('gallery/posts/delete', { + postId: this.postId, + }); + this.$router.push(`/gallery`); + } + } +}); +</script> + +<style lang="scss" scoped> +.wqugxsfx { + height: 200px; + background-size: contain; + background-position: center; + background-repeat: no-repeat; + position: relative; + + > .name { + position: absolute; + top: 8px; + left: 9px; + padding: 8px; + background: var(--panel); + } + + > .remove { + position: absolute; + top: 8px; + right: 9px; + padding: 8px; + background: var(--panel); + } +} +</style> diff --git a/src/client/pages/gallery/new.vue b/src/client/pages/gallery/new.vue deleted file mode 100644 index 3f9756df8e..0000000000 --- a/src/client/pages/gallery/new.vue +++ /dev/null @@ -1,110 +0,0 @@ -<template> -<FormBase> - <FormInput v-model:value="title"> - <span>{{ $ts.title }}</span> - </FormInput> - - <FormTextarea v-model:value="description" :max="500"> - <span>{{ $ts.description }}</span> - </FormTextarea> - - <FormGroup> - <div v-for="file in files" :key="file.id" class="_formItem _formPanel wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }"> - <div class="name">{{ file.name }}</div> - <button class="remove _button" @click="remove(file)" v-tooltip="$ts.remove"><i class="fas fa-times"></i></button> - </div> - <FormButton @click="selectFile" primary><i class="fas fa-plus"></i> {{ $ts.attachFile }}</FormButton> - </FormGroup> - - <FormSwitch v-model:value="isSensitive">{{ $ts.markAsSensitive }}</FormSwitch> - - <FormButton @click="publish" primary><i class="fas fa-save"></i> {{ $ts.publish }}</FormButton> -</FormBase> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import FormButton from '@client/components/form/button.vue'; -import FormInput from '@client/components/form/input.vue'; -import FormTextarea from '@client/components/form/textarea.vue'; -import FormSwitch from '@client/components/form/switch.vue'; -import FormTuple from '@client/components/form/tuple.vue'; -import FormBase from '@client/components/form/base.vue'; -import FormGroup from '@client/components/form/group.vue'; -import { selectFile } from '@client/scripts/select-file'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - components: { - FormButton, - FormInput, - FormTextarea, - FormSwitch, - FormBase, - FormGroup, - }, - - data() { - return { - [symbols.PAGE_INFO]: { - title: this.$ts.postToGallery, - icon: 'fas fa-pencil-alt' - }, - files: [], - description: null, - title: null, - isSensitive: false, - } - }, - - methods: { - selectFile(e) { - selectFile(e.currentTarget || e.target, null, true).then(files => { - this.files = this.files.concat(files); - }); - }, - - remove(file) { - this.files = this.files.filter(f => f.id !== file.id); - }, - - async publish() { - const post = await os.apiWithDialog('gallery/posts/create', { - title: this.title, - description: this.description, - fileIds: this.files.map(file => file.id), - isSensitive: this.isSensitive, - }); - - this.$router.push(`/gallery/${post.id}`); - } - } -}); -</script> - -<style lang="scss" scoped> -.wqugxsfx { - height: 200px; - background-size: contain; - background-position: center; - background-repeat: no-repeat; - position: relative; - - > .name { - position: absolute; - top: 8px; - left: 9px; - padding: 8px; - background: var(--panel); - } - - > .remove { - position: absolute; - top: 8px; - right: 9px; - padding: 8px; - background: var(--panel); - } -} -</style> diff --git a/src/client/pages/gallery/post.vue b/src/client/pages/gallery/post.vue index 9bd102cee2..703506a78d 100644 --- a/src/client/pages/gallery/post.vue +++ b/src/client/pages/gallery/post.vue @@ -19,6 +19,7 @@ <MkButton class="button" @click="like()" v-else v-tooltip="$ts._gallery.like"><i class="far fa-heart"></i><span class="count" v-if="post.likedCount > 0">{{ post.likedCount }}</span></MkButton> </div> <div class="other"> + <button v-if="$i && $i.id === post.user.id" class="_button" @click="edit" v-tooltip="$ts.edit" v-click-anime><i class="fas fa-pencil-alt fa-fw"></i></button> <button class="_button" @click="shareWithNote" v-tooltip="$ts.shareWithNote" v-click-anime><i class="fas fa-retweet fa-fw"></i></button> <button class="_button" @click="share" v-tooltip="$ts.share" v-click-anime><i class="fas fa-share-alt fa-fw"></i></button> </div> @@ -84,6 +85,11 @@ export default defineComponent({ title: this.post.title, text: this.post.description, }, + actions: [{ + icon: 'fas fa-pencil-alt', + text: this.$ts.edit, + handler: this.edit + }] } : null), otherPostsPagination: { endpoint: 'users/gallery/posts', @@ -154,6 +160,10 @@ export default defineComponent({ this.post.likedCount--; }); }, + + edit() { + this.$router.push(`/gallery/${this.post.id}/edit`); + } } }); </script> diff --git a/src/client/router.ts b/src/client/router.ts index 5371bf17d9..8dcc1d1eb4 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -38,7 +38,8 @@ export const router = createRouter({ { path: '/pages/new', component: page('page-editor/page-editor') }, { path: '/pages/edit/:pageId', component: page('page-editor/page-editor'), props: route => ({ initPageId: route.params.pageId }) }, { path: '/gallery', component: page('gallery/index') }, - { path: '/gallery/new', component: page('gallery/new') }, + { path: '/gallery/new', component: page('gallery/edit') }, + { path: '/gallery/:postId/edit', component: page('gallery/edit'), props: route => ({ postId: route.params.postId }) }, { path: '/gallery/:postId', component: page('gallery/post'), props: route => ({ postId: route.params.postId }) }, { path: '/channels', component: page('channels') }, { path: '/channels/new', component: page('channel-editor') }, diff --git a/src/server/api/endpoints/gallery/posts/delete.ts b/src/server/api/endpoints/gallery/posts/delete.ts new file mode 100644 index 0000000000..8b54828b20 --- /dev/null +++ b/src/server/api/endpoints/gallery/posts/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { GalleryPosts } from '../../../../../models'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery', + + params: { + postId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchPost: { + message: 'No such post.', + code: 'NO_SUCH_POST', + id: 'ae52f367-4bd7-4ecd-afc6-5672fff427f5' + }, + } +}; + +export default define(meta, async (ps, user) => { + const post = await GalleryPosts.findOne({ + id: ps.postId, + userId: user.id, + }); + + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + await GalleryPosts.delete(post.id); +}); diff --git a/src/server/api/endpoints/gallery/posts/update.ts b/src/server/api/endpoints/gallery/posts/update.ts new file mode 100644 index 0000000000..c8bb8d48c9 --- /dev/null +++ b/src/server/api/endpoints/gallery/posts/update.ts @@ -0,0 +1,81 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../../define'; +import { ID } from '../../../../../misc/cafy-id'; +import { DriveFiles, GalleryPosts } from '../../../../../models'; +import { GalleryPost } from '../../../../../models/entities/gallery-post'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + postId: { + validator: $.type(ID), + }, + + title: { + validator: $.str.min(1), + }, + + description: { + validator: $.optional.nullable.str, + }, + + fileIds: { + validator: $.arr($.type(ID)).unique().range(1, 32), + }, + + isSensitive: { + validator: $.optional.bool, + default: false, + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost', + }, + + errors: { + + } +}; + +export default define(meta, async (ps, user) => { + const files = (await Promise.all(ps.fileIds.map(fileId => + DriveFiles.findOne({ + id: fileId, + userId: user.id + }) + ))).filter(file => file != null); + + if (files.length === 0) { + throw new Error(); + } + + await GalleryPosts.update({ + id: ps.postId, + userId: user.id, + }, { + updatedAt: new Date(), + title: ps.title, + description: ps.description, + isSensitive: ps.isSensitive, + fileIds: files.map(file => file.id) + }); + + const post = await GalleryPosts.findOneOrFail(ps.postId); + + return await GalleryPosts.pack(post, user); +});