mirror of
https://github.com/misskey-dev/misskey
synced 2024-11-24 23:26:35 +09:00
Gallery (#7194)
* wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip
This commit is contained in:
parent
ccf063709e
commit
fec3c70886
@ -704,6 +704,7 @@ editCode: "コードを編集"
|
||||
apply: "適用"
|
||||
receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る"
|
||||
emailNotification: "メール通知"
|
||||
publish: "公開"
|
||||
inChannelSearch: "チャンネル内検索"
|
||||
useReactionPickerForContextMenu: "右クリックでリアクションピッカーを開く"
|
||||
typingUsers: "{users}が入力中"
|
||||
@ -741,6 +742,17 @@ switch: "切り替え"
|
||||
noMaintainerInformationWarning: "管理者情報が設定されていません。"
|
||||
noBotProtectionWarning: "Bot防御が設定されていません。"
|
||||
configure: "設定する"
|
||||
postToGallery: "ギャラリーへ投稿"
|
||||
gallery: "ギャラリー"
|
||||
recentPosts: "最近の投稿"
|
||||
popularPosts: "人気の投稿"
|
||||
shareWithNote: "ノートで共有"
|
||||
|
||||
_gallery:
|
||||
my: "自分の投稿"
|
||||
liked: "いいねした投稿"
|
||||
like: "いいね!"
|
||||
unlike: "いいね解除"
|
||||
|
||||
_email:
|
||||
_follow:
|
||||
|
40
migration/1611397665007-gallery.ts
Normal file
40
migration/1611397665007-gallery.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import {MigrationInterface, QueryRunner} from "typeorm";
|
||||
|
||||
export class gallery1611397665007 implements MigrationInterface {
|
||||
name = 'gallery1611397665007'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE "gallery_post" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "title" character varying(256) NOT NULL, "description" character varying(2048), "userId" character varying(32) NOT NULL, "fileIds" character varying(32) array NOT NULL DEFAULT '{}'::varchar[], "isSensitive" boolean NOT NULL DEFAULT false, "likedCount" integer NOT NULL DEFAULT '0', "tags" character varying(128) array NOT NULL DEFAULT '{}'::varchar[], CONSTRAINT "PK_8e90d7b6015f2c4518881b14753" PRIMARY KEY ("id")); COMMENT ON COLUMN "gallery_post"."createdAt" IS 'The created date of the GalleryPost.'; COMMENT ON COLUMN "gallery_post"."updatedAt" IS 'The updated date of the GalleryPost.'; COMMENT ON COLUMN "gallery_post"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "gallery_post"."isSensitive" IS 'Whether the post is sensitive.'`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_8f1a239bd077c8864a20c62c2c" ON "gallery_post" ("createdAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_f631d37835adb04792e361807c" ON "gallery_post" ("updatedAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_985b836dddd8615e432d7043dd" ON "gallery_post" ("userId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_3ca50563facd913c425e7a89ee" ON "gallery_post" ("fileIds") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_f2d744d9a14d0dfb8b96cb7fc5" ON "gallery_post" ("isSensitive") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_1a165c68a49d08f11caffbd206" ON "gallery_post" ("likedCount") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_05cca34b985d1b8edc1d1e28df" ON "gallery_post" ("tags") `);
|
||||
await queryRunner.query(`CREATE TABLE "gallery_like" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "postId" character varying(32) NOT NULL, CONSTRAINT "PK_853ab02be39b8de45cd720cc15f" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_8fd5215095473061855ceb948c" ON "gallery_like" ("userId") `);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_df1b5f4099e99fb0bc5eae53b6" ON "gallery_like" ("userId", "postId") `);
|
||||
await queryRunner.query(`ALTER TABLE "gallery_post" ADD CONSTRAINT "FK_985b836dddd8615e432d7043ddb" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "gallery_like" ADD CONSTRAINT "FK_8fd5215095473061855ceb948cf" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "gallery_like" ADD CONSTRAINT "FK_b1cb568bfe569e47b7051699fc8" FOREIGN KEY ("postId") REFERENCES "gallery_post"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "gallery_like" DROP CONSTRAINT "FK_b1cb568bfe569e47b7051699fc8"`);
|
||||
await queryRunner.query(`ALTER TABLE "gallery_like" DROP CONSTRAINT "FK_8fd5215095473061855ceb948cf"`);
|
||||
await queryRunner.query(`ALTER TABLE "gallery_post" DROP CONSTRAINT "FK_985b836dddd8615e432d7043ddb"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_df1b5f4099e99fb0bc5eae53b6"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_8fd5215095473061855ceb948c"`);
|
||||
await queryRunner.query(`DROP TABLE "gallery_like"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_05cca34b985d1b8edc1d1e28df"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_1a165c68a49d08f11caffbd206"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_f2d744d9a14d0dfb8b96cb7fc5"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_3ca50563facd913c425e7a89ee"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_985b836dddd8615e432d7043dd"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_f631d37835adb04792e361807c"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_8f1a239bd077c8864a20c62c2c"`);
|
||||
await queryRunner.query(`DROP TABLE "gallery_post"`);
|
||||
}
|
||||
|
||||
}
|
126
src/client/components/gallery-post-preview.vue
Normal file
126
src/client/components/gallery-post-preview.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1">
|
||||
<div class="thumbnail">
|
||||
<ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/>
|
||||
</div>
|
||||
<article>
|
||||
<header>
|
||||
<MkAvatar :user="post.user" class="avatar"/>
|
||||
</header>
|
||||
<footer>
|
||||
<span class="title">{{ post.title }}</span>
|
||||
</footer>
|
||||
</article>
|
||||
</MkA>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { userName } from '@client/filters/user';
|
||||
import ImgWithBlurhash from '@client/components/img-with-blurhash.vue';
|
||||
import * as os from '@client/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
ImgWithBlurhash
|
||||
},
|
||||
props: {
|
||||
post: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
userName
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ttasepnz {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 200px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
|
||||
> .thumbnail {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
> article {
|
||||
> footer {
|
||||
&:before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
transition: all 0.5s ease;
|
||||
|
||||
> .img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
> article {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
|
||||
> .avatar {
|
||||
margin-left: auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 8px #000;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(rgba(0, 0, 0, 0.4), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
> .title {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -139,7 +139,8 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
color: #fff !important;
|
||||
background: var(--accent);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
@ -200,10 +201,6 @@ export default defineComponent({
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> .ripples {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
|
@ -199,6 +199,7 @@ export default defineComponent({
|
||||
> .fade {
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
|
@ -10,8 +10,8 @@
|
||||
|
||||
<div v-else class="cxiknjgy">
|
||||
<slot :items="items"></slot>
|
||||
<div class="more" v-show="more" key="_more_">
|
||||
<MkButton class="button" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary>
|
||||
<div class="more _gap" v-show="more" key="_more_">
|
||||
<MkButton class="button" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary>
|
||||
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
|
||||
<template v-if="moreFetching"><MkLoading inline/></template>
|
||||
</MkButton>
|
||||
@ -38,6 +38,12 @@ export default defineComponent({
|
||||
pagination: {
|
||||
required: true
|
||||
},
|
||||
|
||||
disableAutoLoad: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
152
src/client/pages/gallery/index.vue
Normal file
152
src/client/pages/gallery/index.vue
Normal file
@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="xprsixdl _root">
|
||||
<MkTab v-model:value="tab" v-if="$i">
|
||||
<option value="explore"><i class="fas fa-icons"></i> {{ $ts.gallery }}</option>
|
||||
<option value="liked"><i class="fas fa-heart"></i> {{ $ts._gallery.liked }}</option>
|
||||
<option value="my"><i class="fas fa-edit"></i> {{ $ts._gallery.my }}</option>
|
||||
</MkTab>
|
||||
|
||||
<div v-if="tab === 'explore'">
|
||||
<MkFolder class="_gap">
|
||||
<template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template>
|
||||
<MkPagination :pagination="recentPostsPagination" #default="{items}" :disable-auto-load="true">
|
||||
<div class="vfpdbgtk">
|
||||
<MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</MkFolder>
|
||||
<MkFolder class="_gap">
|
||||
<template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template>
|
||||
<MkPagination :pagination="popularPostsPagination" #default="{items}" :disable-auto-load="true">
|
||||
<div class="vfpdbgtk">
|
||||
<MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</MkFolder>
|
||||
</div>
|
||||
<div v-else-if="tab === 'liked'">
|
||||
<MkPagination :pagination="likedPostsPagination" #default="{items}">
|
||||
<div class="vfpdbgtk">
|
||||
<MkGalleryPostPreview v-for="like in items" :post="like.post" :key="like.id" class="post"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</div>
|
||||
<div v-else-if="tab === 'my'">
|
||||
<MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA>
|
||||
<MkPagination :pagination="myPostsPagination" #default="{items}">
|
||||
<div class="vfpdbgtk">
|
||||
<MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from 'vue';
|
||||
import XUserList from '@client/components/user-list.vue';
|
||||
import MkFolder from '@client/components/ui/folder.vue';
|
||||
import MkInput from '@client/components/ui/input.vue';
|
||||
import MkButton from '@client/components/ui/button.vue';
|
||||
import MkTab from '@client/components/tab.vue';
|
||||
import MkPagination from '@client/components/ui/pagination.vue';
|
||||
import MkGalleryPostPreview from '@client/components/gallery-post-preview.vue';
|
||||
import number from '@client/filters/number';
|
||||
import * as os from '@client/os';
|
||||
import * as symbols from '@client/symbols';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
XUserList,
|
||||
MkFolder,
|
||||
MkInput,
|
||||
MkButton,
|
||||
MkTab,
|
||||
MkPagination,
|
||||
MkGalleryPostPreview,
|
||||
},
|
||||
|
||||
props: {
|
||||
tag: {
|
||||
type: String,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
[symbols.PAGE_INFO]: {
|
||||
title: this.$ts.gallery,
|
||||
icon: 'fas fa-icons'
|
||||
},
|
||||
tab: 'explore',
|
||||
recentPostsPagination: {
|
||||
endpoint: 'gallery/posts',
|
||||
limit: 6,
|
||||
},
|
||||
popularPostsPagination: {
|
||||
endpoint: 'gallery/featured',
|
||||
limit: 5,
|
||||
},
|
||||
myPostsPagination: {
|
||||
endpoint: 'i/gallery/posts',
|
||||
limit: 5,
|
||||
},
|
||||
likedPostsPagination: {
|
||||
endpoint: 'i/gallery/likes',
|
||||
limit: 5,
|
||||
},
|
||||
tags: [],
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
meta() {
|
||||
return this.$instance;
|
||||
},
|
||||
tagUsers(): any {
|
||||
return {
|
||||
endpoint: 'hashtags/users',
|
||||
limit: 30,
|
||||
params: {
|
||||
tag: this.tag,
|
||||
origin: 'combined',
|
||||
sort: '+follower',
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
tag() {
|
||||
if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null);
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.xprsixdl {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.vfpdbgtk {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
grid-gap: 12px;
|
||||
margin: 0 var(--margin);
|
||||
|
||||
> .post {
|
||||
|
||||
}
|
||||
}
|
||||
</style>
|
110
src/client/pages/gallery/new.vue
Normal file
110
src/client/pages/gallery/new.vue
Normal file
@ -0,0 +1,110 @@
|
||||
<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>
|
271
src/client/pages/gallery/post.vue
Normal file
271
src/client/pages/gallery/post.vue
Normal file
@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="_root">
|
||||
<transition name="fade" mode="out-in">
|
||||
<div v-if="post" class="rkxwuolj">
|
||||
<div class="files">
|
||||
<div class="file" v-for="file in post.files" :key="file.id">
|
||||
<img :src="file.url"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body _block">
|
||||
<div class="title">{{ post.title }}</div>
|
||||
<div class="description"><Mfm :text="post.description"/></div>
|
||||
<div class="info">
|
||||
<i class="fas fa-clock"></i> <MkTime :time="post.createdAt" mode="detail"/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="like">
|
||||
<MkButton class="button" @click="unlike()" v-if="post.isLiked" v-tooltip="$ts._gallery.unlike" primary><i class="fas fa-heart"></i><span class="count" v-if="post.likedCount > 0">{{ post.likedCount }}</span></MkButton>
|
||||
<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 class="_button" @click="createNote" 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>
|
||||
</div>
|
||||
<div class="user">
|
||||
<MkAvatar :user="post.user" class="avatar"/>
|
||||
<div class="name">
|
||||
<MkUserName :user="post.user" style="display: block;"/>
|
||||
<MkAcct :user="post.user"/>
|
||||
</div>
|
||||
<MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
|
||||
</div>
|
||||
</div>
|
||||
<MkContainer :max-height="300" :foldable="true" class="other">
|
||||
<template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
|
||||
<MkPagination :pagination="otherPostsPagination" #default="{items}">
|
||||
<div class="sdrarzaf">
|
||||
<MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</MkContainer>
|
||||
</div>
|
||||
<MkError v-else-if="error" @retry="fetch()"/>
|
||||
<MkLoading v-else/>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from 'vue';
|
||||
import MkButton from '@client/components/ui/button.vue';
|
||||
import * as os from '@client/os';
|
||||
import * as symbols from '@client/symbols';
|
||||
import MkContainer from '@client/components/ui/container.vue';
|
||||
import ImgWithBlurhash from '@client/components/img-with-blurhash.vue';
|
||||
import MkPagination from '@client/components/ui/pagination.vue';
|
||||
import MkGalleryPostPreview from '@client/components/gallery-post-preview.vue';
|
||||
import MkFollowButton from '@client/components/follow-button.vue';
|
||||
import { url } from '@client/config';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkContainer,
|
||||
ImgWithBlurhash,
|
||||
MkPagination,
|
||||
MkGalleryPostPreview,
|
||||
MkButton,
|
||||
MkFollowButton,
|
||||
},
|
||||
props: {
|
||||
postId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
[symbols.PAGE_INFO]: computed(() => this.post ? {
|
||||
title: this.post.title,
|
||||
avatar: this.post.user,
|
||||
path: `/gallery/${this.post.id}`,
|
||||
share: {
|
||||
title: this.post.title,
|
||||
text: this.post.description,
|
||||
},
|
||||
} : null),
|
||||
otherPostsPagination: {
|
||||
endpoint: 'users/gallery/posts',
|
||||
limit: 6,
|
||||
params: () => ({
|
||||
userId: this.post.user.id
|
||||
})
|
||||
},
|
||||
post: null,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
postId: 'fetch'
|
||||
},
|
||||
|
||||
created() {
|
||||
this.fetch();
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetch() {
|
||||
this.post = null;
|
||||
os.api('gallery/posts/show', {
|
||||
postId: this.postId
|
||||
}).then(post => {
|
||||
this.post = post;
|
||||
}).catch(e => {
|
||||
this.error = e;
|
||||
});
|
||||
},
|
||||
|
||||
share() {
|
||||
navigator.share({
|
||||
title: this.post.title,
|
||||
text: this.post.description,
|
||||
url: `${url}/gallery/${this.post.id}`
|
||||
});
|
||||
},
|
||||
|
||||
like() {
|
||||
os.apiWithDialog('gallery/posts/like', {
|
||||
postId: this.postId,
|
||||
}).then(() => {
|
||||
this.post.isLiked = true;
|
||||
this.post.likedCount++;
|
||||
});
|
||||
},
|
||||
|
||||
async unlike() {
|
||||
const confirm = await os.dialog({
|
||||
type: 'warning',
|
||||
showCancelButton: true,
|
||||
text: this.$ts.unlikeConfirm,
|
||||
});
|
||||
if (confirm.canceled) return;
|
||||
os.apiWithDialog('gallery/posts/unlike', {
|
||||
postId: this.postId,
|
||||
}).then(() => {
|
||||
this.post.isLiked = false;
|
||||
this.post.likedCount--;
|
||||
});
|
||||
},
|
||||
|
||||
createNote() {
|
||||
os.post({
|
||||
initialText: `${this.post.title} ${url}/gallery/${this.post.id}`
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.125s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.rkxwuolj {
|
||||
> .files {
|
||||
> .file {
|
||||
> img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
& + .file {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .body {
|
||||
padding: 32px;
|
||||
|
||||
> .title {
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
> .info {
|
||||
margin-top: 16px;
|
||||
font-size: 90%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> .actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
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;
|
||||
|
||||
> button {
|
||||
padding: 8px;
|
||||
margin: 0 8px;
|
||||
|
||||
&:hover {
|
||||
color: var(--fgHighlighted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .user {
|
||||
margin-top: 16px;
|
||||
padding: 16px 0 0 0;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> .avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
> .name {
|
||||
margin: 0 0 0 12px;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
> .koudoku {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sdrarzaf {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
grid-gap: 12px;
|
||||
margin: var(--margin);
|
||||
|
||||
> .post {
|
||||
|
||||
}
|
||||
}
|
||||
</style>
|
@ -166,10 +166,11 @@ export default defineComponent({
|
||||
border-top: solid 0.5px var(--divider);
|
||||
|
||||
> .button {
|
||||
--accent: rgb(216 71 106);
|
||||
--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;
|
||||
|
63
src/client/pages/user/gallery.vue
Normal file
63
src/client/pages/user/gallery.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div>
|
||||
<MkPagination :pagination="pagination" #default="{items}">
|
||||
<div class="jrnovfpt">
|
||||
<MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import MkGalleryPostPreview from '@client/components/gallery-post-preview.vue';
|
||||
import MkPagination from '@client/components/ui/pagination.vue';
|
||||
import { userPage, acct } from '../../filters/user';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkPagination,
|
||||
MkGalleryPostPreview,
|
||||
},
|
||||
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination: {
|
||||
endpoint: 'users/gallery/posts',
|
||||
limit: 6,
|
||||
params: () => ({
|
||||
userId: this.user.id
|
||||
})
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
user() {
|
||||
this.$refs.list.reload();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
userPage,
|
||||
|
||||
acct
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.jrnovfpt {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
grid-gap: 12px;
|
||||
margin: var(--margin);
|
||||
}
|
||||
</style>
|
@ -191,6 +191,10 @@
|
||||
<i class="fas fa-file-alt icon"></i>
|
||||
<span>{{ $ts.pages }}</span>
|
||||
</MkA>
|
||||
<MkA :to="userPage(user, 'gallery')" :class="{ active: page === 'gallery' }" class="link">
|
||||
<i class="fas fa-icons icon"></i>
|
||||
<span>{{ $ts.gallery }}</span>
|
||||
</MkA>
|
||||
</div>
|
||||
|
||||
<template v-if="page === 'index'">
|
||||
@ -210,6 +214,7 @@
|
||||
<XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/>
|
||||
<XClips v-else-if="page === 'clips'" :user="user" class="_gap"/>
|
||||
<XPages v-else-if="page === 'pages'" :user="user" class="_gap"/>
|
||||
<XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/>
|
||||
</div>
|
||||
</div>
|
||||
<MkError v-else-if="error" @retry="fetch()"/>
|
||||
@ -250,6 +255,7 @@ export default defineComponent({
|
||||
XFollowList: defineAsyncComponent(() => import('./follow-list.vue')),
|
||||
XClips: defineAsyncComponent(() => import('./clips.vue')),
|
||||
XPages: defineAsyncComponent(() => import('./pages.vue')),
|
||||
XGallery: defineAsyncComponent(() => import('./gallery.vue')),
|
||||
XPhotos: defineAsyncComponent(() => import('./index.photos.vue')),
|
||||
XActivity: defineAsyncComponent(() => import('./index.activity.vue')),
|
||||
},
|
||||
|
@ -37,6 +37,9 @@ export const router = createRouter({
|
||||
{ path: '/pages', name: 'pages', component: page('pages') },
|
||||
{ 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/:postId', component: page('gallery/post'), props: route => ({ postId: route.params.postId }) },
|
||||
{ path: '/channels', component: page('channels') },
|
||||
{ path: '/channels/new', component: page('channel-editor') },
|
||||
{ path: '/channels/:channelId/edit', component: page('channel-editor'), props: true },
|
||||
|
@ -97,6 +97,11 @@ export const sidebarDef = {
|
||||
icon: 'fas fa-file-alt',
|
||||
to: '/pages',
|
||||
},
|
||||
gallery: {
|
||||
title: 'gallery',
|
||||
icon: 'fas fa-icons',
|
||||
to: '/gallery',
|
||||
},
|
||||
clips: {
|
||||
title: 'clip',
|
||||
icon: 'fas fa-paperclip',
|
||||
|
@ -51,6 +51,8 @@ import { UserSecurityKey } from '../models/entities/user-security-key';
|
||||
import { AttestationChallenge } from '../models/entities/attestation-challenge';
|
||||
import { Page } from '../models/entities/page';
|
||||
import { PageLike } from '../models/entities/page-like';
|
||||
import { GalleryPost } from '../models/entities/gallery-post';
|
||||
import { GalleryLike } from '../models/entities/gallery-like';
|
||||
import { ModerationLog } from '../models/entities/moderation-log';
|
||||
import { UsedUsername } from '../models/entities/used-username';
|
||||
import { Announcement } from '../models/entities/announcement';
|
||||
@ -137,6 +139,8 @@ export const entities = [
|
||||
NoteUnread,
|
||||
Page,
|
||||
PageLike,
|
||||
GalleryPost,
|
||||
GalleryLike,
|
||||
Log,
|
||||
DriveFile,
|
||||
DriveFolder,
|
||||
|
33
src/models/entities/gallery-like.ts
Normal file
33
src/models/entities/gallery-like.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
import { GalleryPost } from './gallery-post';
|
||||
|
||||
@Entity()
|
||||
@Index(['userId', 'postId'], { unique: true })
|
||||
export class GalleryLike {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone')
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Column(id())
|
||||
public postId: GalleryPost['id'];
|
||||
|
||||
@ManyToOne(type => GalleryPost, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public post: GalleryPost | null;
|
||||
}
|
79
src/models/entities/gallery-post.ts
Normal file
79
src/models/entities/gallery-post.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
import { DriveFile } from './drive-file';
|
||||
|
||||
@Entity()
|
||||
export class GalleryPost {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the GalleryPost.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The updated date of the GalleryPost.'
|
||||
})
|
||||
public updatedAt: Date;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 256,
|
||||
})
|
||||
public title: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 2048, nullable: true
|
||||
})
|
||||
public description: string | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The ID of author.'
|
||||
})
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
array: true, default: '{}'
|
||||
})
|
||||
public fileIds: DriveFile['id'][];
|
||||
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
comment: 'Whether the post is sensitive.'
|
||||
})
|
||||
public isSensitive: boolean;
|
||||
|
||||
@Index()
|
||||
@Column('integer', {
|
||||
default: 0
|
||||
})
|
||||
public likedCount: number;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128, array: true, default: '{}'
|
||||
})
|
||||
public tags: string[];
|
||||
|
||||
constructor(data: Partial<GalleryPost>) {
|
||||
if (data == null) return;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as any)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
@ -43,6 +43,8 @@ import { UserSecurityKey } from './entities/user-security-key';
|
||||
import { HashtagRepository } from './repositories/hashtag';
|
||||
import { PageRepository } from './repositories/page';
|
||||
import { PageLikeRepository } from './repositories/page-like';
|
||||
import { GalleryPostRepository } from './repositories/gallery-post';
|
||||
import { GalleryLikeRepository } from './repositories/gallery-like';
|
||||
import { ModerationLogRepository } from './repositories/moderation-logs';
|
||||
import { UsedUsername } from './entities/used-username';
|
||||
import { ClipRepository } from './repositories/clip';
|
||||
@ -105,6 +107,8 @@ export const ReversiMatchings = getCustomRepository(ReversiMatchingRepository);
|
||||
export const Logs = getRepository(Log);
|
||||
export const Pages = getCustomRepository(PageRepository);
|
||||
export const PageLikes = getCustomRepository(PageLikeRepository);
|
||||
export const GalleryPosts = getCustomRepository(GalleryPostRepository);
|
||||
export const GalleryLikes = getCustomRepository(GalleryLikeRepository);
|
||||
export const ModerationLogs = getCustomRepository(ModerationLogRepository);
|
||||
export const Clips = getCustomRepository(ClipRepository);
|
||||
export const ClipNotes = getRepository(ClipNote);
|
||||
|
25
src/models/repositories/gallery-like.ts
Normal file
25
src/models/repositories/gallery-like.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { EntityRepository, Repository } from 'typeorm';
|
||||
import { GalleryLike } from '../entities/gallery-like';
|
||||
import { GalleryPosts } from '..';
|
||||
|
||||
@EntityRepository(GalleryLike)
|
||||
export class GalleryLikeRepository extends Repository<GalleryLike> {
|
||||
public async pack(
|
||||
src: GalleryLike['id'] | GalleryLike,
|
||||
me?: any
|
||||
) {
|
||||
const like = typeof src === 'object' ? src : await this.findOneOrFail(src);
|
||||
|
||||
return {
|
||||
id: like.id,
|
||||
post: await GalleryPosts.pack(like.post || like.postId, me),
|
||||
};
|
||||
}
|
||||
|
||||
public packMany(
|
||||
likes: any[],
|
||||
me: any
|
||||
) {
|
||||
return Promise.all(likes.map(x => this.pack(x, me)));
|
||||
}
|
||||
}
|
113
src/models/repositories/gallery-post.ts
Normal file
113
src/models/repositories/gallery-post.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { EntityRepository, Repository } from 'typeorm';
|
||||
import { GalleryPost } from '../entities/gallery-post';
|
||||
import { SchemaType } from '../../misc/schema';
|
||||
import { Users, DriveFiles, GalleryLikes } from '..';
|
||||
import { awaitAll } from '../../prelude/await-all';
|
||||
import { User } from '../entities/user';
|
||||
|
||||
export type PackedGalleryPost = SchemaType<typeof packedGalleryPostSchema>;
|
||||
|
||||
@EntityRepository(GalleryPost)
|
||||
export class GalleryPostRepository extends Repository<GalleryPost> {
|
||||
public async pack(
|
||||
src: GalleryPost['id'] | GalleryPost,
|
||||
me?: { id: User['id'] } | null | undefined,
|
||||
): Promise<PackedGalleryPost> {
|
||||
const meId = me ? me.id : null;
|
||||
const post = typeof src === 'object' ? src : await this.findOneOrFail(src);
|
||||
|
||||
return await awaitAll({
|
||||
id: post.id,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
updatedAt: post.updatedAt.toISOString(),
|
||||
userId: post.userId,
|
||||
user: Users.pack(post.user || post.userId, me),
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
fileIds: post.fileIds,
|
||||
files: DriveFiles.packMany(post.fileIds),
|
||||
tags: post.tags.length > 0 ? post.tags : undefined,
|
||||
isSensitive: post.isSensitive,
|
||||
likedCount: post.likedCount,
|
||||
isLiked: meId ? await GalleryLikes.findOne({ postId: post.id, userId: meId }).then(x => x != null) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
public packMany(
|
||||
posts: GalleryPost[],
|
||||
me?: { id: User['id'] } | null | undefined,
|
||||
) {
|
||||
return Promise.all(posts.map(x => this.pack(x, me)));
|
||||
}
|
||||
}
|
||||
|
||||
export const packedGalleryPostSchema = {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
format: 'date-time',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
format: 'date-time',
|
||||
},
|
||||
title: {
|
||||
type: 'string' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
},
|
||||
description: {
|
||||
type: 'string' as const,
|
||||
optional: false as const, nullable: true as const,
|
||||
},
|
||||
userId: {
|
||||
type: 'string' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
format: 'id',
|
||||
},
|
||||
user: {
|
||||
type: 'object' as const,
|
||||
ref: 'User',
|
||||
optional: false as const, nullable: false as const,
|
||||
},
|
||||
fileIds: {
|
||||
type: 'array' as const,
|
||||
optional: true as const, nullable: false as const,
|
||||
items: {
|
||||
type: 'string' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
format: 'id'
|
||||
}
|
||||
},
|
||||
files: {
|
||||
type: 'array' as const,
|
||||
optional: true as const, nullable: false as const,
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'DriveFile'
|
||||
}
|
||||
},
|
||||
tags: {
|
||||
type: 'array' as const,
|
||||
optional: true as const, nullable: false as const,
|
||||
items: {
|
||||
type: 'string' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
}
|
||||
},
|
||||
isSensitive: {
|
||||
type: 'boolean' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
},
|
||||
}
|
||||
};
|
29
src/server/api/endpoints/gallery/featured.ts
Normal file
29
src/server/api/endpoints/gallery/featured.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import define from '../../define';
|
||||
import { GalleryPosts } from '../../../../models';
|
||||
|
||||
export const meta = {
|
||||
tags: ['gallery'],
|
||||
|
||||
requireCredential: false as const,
|
||||
|
||||
res: {
|
||||
type: 'array' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'GalleryPost',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, me) => {
|
||||
const query = GalleryPosts.createQueryBuilder('post')
|
||||
.andWhere('post.createdAt > :date', { date: new Date(Date.now() - (1000 * 60 * 60 * 24 * 3)) })
|
||||
.andWhere('post.likedCount > 0')
|
||||
.orderBy('post.likedCount', 'DESC');
|
||||
|
||||
const posts = await query.take(10).getMany();
|
||||
|
||||
return await GalleryPosts.packMany(posts, me);
|
||||
});
|
28
src/server/api/endpoints/gallery/popular.ts
Normal file
28
src/server/api/endpoints/gallery/popular.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import define from '../../define';
|
||||
import { GalleryPosts } from '../../../../models';
|
||||
|
||||
export const meta = {
|
||||
tags: ['gallery'],
|
||||
|
||||
requireCredential: false as const,
|
||||
|
||||
res: {
|
||||
type: 'array' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'GalleryPost',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, me) => {
|
||||
const query = GalleryPosts.createQueryBuilder('post')
|
||||
.andWhere('post.likedCount > 0')
|
||||
.orderBy('post.likedCount', 'DESC');
|
||||
|
||||
const posts = await query.take(10).getMany();
|
||||
|
||||
return await GalleryPosts.packMany(posts, me);
|
||||
});
|
43
src/server/api/endpoints/gallery/posts.ts
Normal file
43
src/server/api/endpoints/gallery/posts.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import $ from 'cafy';
|
||||
import { ID } from '@/misc/cafy-id';
|
||||
import define from '../../define';
|
||||
import { makePaginationQuery } from '../../common/make-pagination-query';
|
||||
import { GalleryPosts } from '../../../../models';
|
||||
|
||||
export const meta = {
|
||||
tags: ['gallery'],
|
||||
|
||||
params: {
|
||||
limit: {
|
||||
validator: $.optional.num.range(1, 100),
|
||||
default: 10
|
||||
},
|
||||
|
||||
sinceId: {
|
||||
validator: $.optional.type(ID),
|
||||
},
|
||||
|
||||
untilId: {
|
||||
validator: $.optional.type(ID),
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'array' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'GalleryPost',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, me) => {
|
||||
const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId)
|
||||
.innerJoinAndSelect('post.user', 'user');
|
||||
|
||||
const posts = await query.take(ps.limit!).getMany();
|
||||
|
||||
return await GalleryPosts.packMany(posts, me);
|
||||
});
|
76
src/server/api/endpoints/gallery/posts/create.ts
Normal file
76
src/server/api/endpoints/gallery/posts/create.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import $ from 'cafy';
|
||||
import * as ms from 'ms';
|
||||
import define from '../../../define';
|
||||
import { ID } from '../../../../../misc/cafy-id';
|
||||
import { DriveFiles, GalleryPosts } from '../../../../../models';
|
||||
import { genId } from '../../../../../misc/gen-id';
|
||||
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: {
|
||||
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();
|
||||
}
|
||||
|
||||
const post = await GalleryPosts.insert(new GalleryPost({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
title: ps.title,
|
||||
description: ps.description,
|
||||
userId: user.id,
|
||||
isSensitive: ps.isSensitive,
|
||||
fileIds: files.map(file => file.id)
|
||||
})).then(x => GalleryPosts.findOneOrFail(x.identifiers[0]));
|
||||
|
||||
return await GalleryPosts.pack(post, user);
|
||||
});
|
71
src/server/api/endpoints/gallery/posts/like.ts
Normal file
71
src/server/api/endpoints/gallery/posts/like.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import $ from 'cafy';
|
||||
import { ID } from '@/misc/cafy-id';
|
||||
import define from '../../../define';
|
||||
import { ApiError } from '../../../error';
|
||||
import { GalleryPosts, GalleryLikes } from '../../../../../models';
|
||||
import { genId } from '@/misc/gen-id';
|
||||
|
||||
export const meta = {
|
||||
tags: ['gallery'],
|
||||
|
||||
requireCredential: true as const,
|
||||
|
||||
kind: 'write:gallery-likes',
|
||||
|
||||
params: {
|
||||
postId: {
|
||||
validator: $.type(ID),
|
||||
}
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchPost: {
|
||||
message: 'No such post.',
|
||||
code: 'NO_SUCH_POST',
|
||||
id: '56c06af3-1287-442f-9701-c93f7c4a62ff'
|
||||
},
|
||||
|
||||
yourPost: {
|
||||
message: 'You cannot like your post.',
|
||||
code: 'YOUR_POST',
|
||||
id: 'f78f1511-5ebc-4478-a888-1198d752da68'
|
||||
},
|
||||
|
||||
alreadyLiked: {
|
||||
message: 'The post has already been liked.',
|
||||
code: 'ALREADY_LIKED',
|
||||
id: '40e9ed56-a59c-473a-bf3f-f289c54fb5a7'
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
const post = await GalleryPosts.findOne(ps.postId);
|
||||
if (post == null) {
|
||||
throw new ApiError(meta.errors.noSuchPost);
|
||||
}
|
||||
|
||||
if (post.userId === user.id) {
|
||||
throw new ApiError(meta.errors.yourPost);
|
||||
}
|
||||
|
||||
// if already liked
|
||||
const exist = await GalleryLikes.findOne({
|
||||
postId: post.id,
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
if (exist != null) {
|
||||
throw new ApiError(meta.errors.alreadyLiked);
|
||||
}
|
||||
|
||||
// Create like
|
||||
await GalleryLikes.insert({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
postId: post.id,
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
GalleryPosts.increment({ id: post.id }, 'likedCount', 1);
|
||||
});
|
43
src/server/api/endpoints/gallery/posts/show.ts
Normal file
43
src/server/api/endpoints/gallery/posts/show.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import $ from 'cafy';
|
||||
import { ID } from '@/misc/cafy-id';
|
||||
import define from '../../../define';
|
||||
import { ApiError } from '../../../error';
|
||||
import { GalleryPosts } from '@/models';
|
||||
|
||||
export const meta = {
|
||||
tags: ['gallery'],
|
||||
|
||||
requireCredential: false as const,
|
||||
|
||||
params: {
|
||||
postId: {
|
||||
validator: $.type(ID),
|
||||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchPost: {
|
||||
message: 'No such post.',
|
||||
code: 'NO_SUCH_POST',
|
||||
id: '1137bf14-c5b0-4604-85bb-5b5371b1cd45'
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'GalleryPost'
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, me) => {
|
||||
const post = await GalleryPosts.findOne({
|
||||
id: ps.postId,
|
||||
});
|
||||
|
||||
if (post == null) {
|
||||
throw new ApiError(meta.errors.noSuchPost);
|
||||
}
|
||||
|
||||
return await GalleryPosts.pack(post, me);
|
||||
});
|
54
src/server/api/endpoints/gallery/posts/unlike.ts
Normal file
54
src/server/api/endpoints/gallery/posts/unlike.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import $ from 'cafy';
|
||||
import { ID } from '@/misc/cafy-id';
|
||||
import define from '../../../define';
|
||||
import { ApiError } from '../../../error';
|
||||
import { GalleryPosts, GalleryLikes } from '../../../../../models';
|
||||
|
||||
export const meta = {
|
||||
tags: ['gallery'],
|
||||
|
||||
requireCredential: true as const,
|
||||
|
||||
kind: 'write:gallery-likes',
|
||||
|
||||
params: {
|
||||
postId: {
|
||||
validator: $.type(ID),
|
||||
}
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchPost: {
|
||||
message: 'No such post.',
|
||||
code: 'NO_SUCH_POST',
|
||||
id: 'c32e6dd0-b555-4413-925e-b3757d19ed84'
|
||||
},
|
||||
|
||||
notLiked: {
|
||||
message: 'You have not liked that post.',
|
||||
code: 'NOT_LIKED',
|
||||
id: 'e3e8e06e-be37-41f7-a5b4-87a8250288f0'
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
const post = await GalleryPosts.findOne(ps.postId);
|
||||
if (post == null) {
|
||||
throw new ApiError(meta.errors.noSuchPost);
|
||||
}
|
||||
|
||||
const exist = await GalleryLikes.findOne({
|
||||
postId: post.id,
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
if (exist == null) {
|
||||
throw new ApiError(meta.errors.notLiked);
|
||||
}
|
||||
|
||||
// Delete like
|
||||
await GalleryLikes.delete(exist.id);
|
||||
|
||||
GalleryPosts.decrement({ id: post.id }, 'likedCount', 1);
|
||||
});
|
57
src/server/api/endpoints/i/gallery/likes.ts
Normal file
57
src/server/api/endpoints/i/gallery/likes.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import $ from 'cafy';
|
||||
import { ID } from '@/misc/cafy-id';
|
||||
import define from '../../../define';
|
||||
import { GalleryLikes } from '../../../../../models';
|
||||
import { makePaginationQuery } from '../../../common/make-pagination-query';
|
||||
|
||||
export const meta = {
|
||||
tags: ['account', 'gallery'],
|
||||
|
||||
requireCredential: true as const,
|
||||
|
||||
kind: 'read:gallery-likes',
|
||||
|
||||
params: {
|
||||
limit: {
|
||||
validator: $.optional.num.range(1, 100),
|
||||
default: 10
|
||||
},
|
||||
|
||||
sinceId: {
|
||||
validator: $.optional.type(ID),
|
||||
},
|
||||
|
||||
untilId: {
|
||||
validator: $.optional.type(ID),
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
format: 'id'
|
||||
},
|
||||
page: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'GalleryPost'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
const query = makePaginationQuery(GalleryLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId)
|
||||
.andWhere(`like.userId = :meId`, { meId: user.id })
|
||||
.leftJoinAndSelect('like.post', 'post');
|
||||
|
||||
const likes = await query
|
||||
.take(ps.limit!)
|
||||
.getMany();
|
||||
|
||||
return await GalleryLikes.packMany(likes, user);
|
||||
});
|
49
src/server/api/endpoints/i/gallery/posts.ts
Normal file
49
src/server/api/endpoints/i/gallery/posts.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import $ from 'cafy';
|
||||
import { ID } from '@/misc/cafy-id';
|
||||
import define from '../../../define';
|
||||
import { GalleryPosts } from '../../../../../models';
|
||||
import { makePaginationQuery } from '../../../common/make-pagination-query';
|
||||
|
||||
export const meta = {
|
||||
tags: ['account', 'gallery'],
|
||||
|
||||
requireCredential: true as const,
|
||||
|
||||
kind: 'read:gallery',
|
||||
|
||||
params: {
|
||||
limit: {
|
||||
validator: $.optional.num.range(1, 100),
|
||||
default: 10
|
||||
},
|
||||
|
||||
sinceId: {
|
||||
validator: $.optional.type(ID),
|
||||
},
|
||||
|
||||
untilId: {
|
||||
validator: $.optional.type(ID),
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'array' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
optional: false as const, nullable: false as const,
|
||||
ref: 'GalleryPost'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId)
|
||||
.andWhere(`post.userId = :meId`, { meId: user.id });
|
||||
|
||||
const posts = await query
|
||||
.take(ps.limit!)
|
||||
.getMany();
|
||||
|
||||
return await GalleryPosts.packMany(posts, user);
|
||||
});
|
39
src/server/api/endpoints/users/gallery/posts.ts
Normal file
39
src/server/api/endpoints/users/gallery/posts.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import $ from 'cafy';
|
||||
import { ID } from '@/misc/cafy-id';
|
||||
import define from '../../../define';
|
||||
import { GalleryPosts } from '../../../../../models';
|
||||
import { makePaginationQuery } from '../../../common/make-pagination-query';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users', 'gallery'],
|
||||
|
||||
params: {
|
||||
userId: {
|
||||
validator: $.type(ID),
|
||||
},
|
||||
|
||||
limit: {
|
||||
validator: $.optional.num.range(1, 100),
|
||||
default: 10
|
||||
},
|
||||
|
||||
sinceId: {
|
||||
validator: $.optional.type(ID),
|
||||
},
|
||||
|
||||
untilId: {
|
||||
validator: $.optional.type(ID),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId)
|
||||
.andWhere(`post.userId = :userId`, { userId: ps.userId });
|
||||
|
||||
const posts = await query
|
||||
.take(ps.limit!)
|
||||
.getMany();
|
||||
|
||||
return await GalleryPosts.packMany(posts, user);
|
||||
});
|
@ -17,7 +17,7 @@ import packFeed from './feed';
|
||||
import { fetchMeta } from '@/misc/fetch-meta';
|
||||
import { genOpenapiSpec } from '../api/openapi/gen-spec';
|
||||
import config from '@/config';
|
||||
import { Users, Notes, Emojis, UserProfiles, Pages, Channels, Clips } from '../../models';
|
||||
import { Users, Notes, Emojis, UserProfiles, Pages, Channels, Clips, GalleryPosts } from '../../models';
|
||||
import parseAcct from '@/misc/acct/parse';
|
||||
import { getNoteSummary } from '@/misc/get-note-summary';
|
||||
import { getConnection } from 'typeorm';
|
||||
@ -342,6 +342,29 @@ router.get('/clips/:clip', async ctx => {
|
||||
ctx.status = 404;
|
||||
});
|
||||
|
||||
// Gallery post
|
||||
router.get('/gallery/:post', async ctx => {
|
||||
const post = await GalleryPosts.findOne(ctx.params.post);
|
||||
|
||||
if (post) {
|
||||
const _post = await GalleryPosts.pack(post);
|
||||
const profile = await UserProfiles.findOneOrFail(post.userId);
|
||||
const meta = await fetchMeta();
|
||||
await ctx.render('gallery-post', {
|
||||
post: _post,
|
||||
profile,
|
||||
instanceName: meta.name || 'Misskey',
|
||||
icon: meta.iconUrl
|
||||
});
|
||||
|
||||
ctx.set('Cache-Control', 'public, max-age=180');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.status = 404;
|
||||
});
|
||||
|
||||
// Channel
|
||||
router.get('/channels/:channel', async ctx => {
|
||||
const channel = await Channels.findOne({
|
||||
|
35
src/server/web/views/gallery-post.pug
Normal file
35
src/server/web/views/gallery-post.pug
Normal file
@ -0,0 +1,35 @@
|
||||
extends ./base
|
||||
|
||||
block vars
|
||||
- const user = post.user;
|
||||
- const title = post.title;
|
||||
- const url = `${config.url}/gallery/${post.id}`;
|
||||
|
||||
block title
|
||||
= `${title} | ${instanceName}`
|
||||
|
||||
block desc
|
||||
meta(name='description' content= post.description)
|
||||
|
||||
block og
|
||||
meta(property='og:type' content='article')
|
||||
meta(property='og:title' content= title)
|
||||
meta(property='og:description' content= post.description)
|
||||
meta(property='og:url' content= url)
|
||||
meta(property='og:image' content= post.files[0].thumbnailUrl)
|
||||
|
||||
block meta
|
||||
if user.host || profile.noCrawle
|
||||
meta(name='robots' content='noindex')
|
||||
|
||||
meta(name='misskey:user-username' content=user.username)
|
||||
meta(name='misskey:user-id' content=user.id)
|
||||
|
||||
meta(name='twitter:card' content='summary')
|
||||
|
||||
// todo
|
||||
if user.twitter
|
||||
meta(name='twitter:creator' content=`@${user.twitter.screenName}`)
|
||||
|
||||
if !user.host
|
||||
link(rel='alternate' href=url type='application/activity+json')
|
Loading…
Reference in New Issue
Block a user