summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-05-04 21:15:57 +0900
committerGitHub <noreply@github.com>2021-05-04 21:15:57 +0900
commit18e1efc7ecd3f5a6d774c16f17526d12ae46b2f5 (patch)
tree8f2cb50644bb3679eafd29fb9e7448ed5069321c /src
parentメールアドレスの設定を促すように (diff)
downloadsharkey-18e1efc7ecd3f5a6d774c16f17526d12ae46b2f5.tar.gz
sharkey-18e1efc7ecd3f5a6d774c16f17526d12ae46b2f5.tar.bz2
sharkey-18e1efc7ecd3f5a6d774c16f17526d12ae46b2f5.zip
Ad (#7495)
* wip * Update ad.vue * Update default.widgets.vue * wip * Create 1620019354680-ad.ts * wip * Update ads.vue * wip * Update ad.vue
Diffstat (limited to 'src')
-rw-r--r--src/client/components/date-separated-list.vue22
-rw-r--r--src/client/components/global/ad.vue142
-rw-r--r--src/client/components/index.ts4
-rw-r--r--src/client/components/notes.vue2
-rw-r--r--src/client/pages/gallery/post.vue1
-rw-r--r--src/client/pages/instance/ads.vue125
-rw-r--r--src/client/pages/instance/index.vue2
-rw-r--r--src/client/pages/page.vue1
-rw-r--r--src/client/scripts/paging.ts8
-rw-r--r--src/client/style.scss2
-rw-r--r--src/client/ui/chat/date-separated-list.vue6
-rw-r--r--src/client/ui/default.widgets.vue1
-rw-r--r--src/db/postgre.ts2
-rw-r--r--src/models/entities/ad.ts53
-rw-r--r--src/models/index.ts2
-rw-r--r--src/models/repositories/note.ts11
-rw-r--r--src/server/api/endpoints/admin/ad/create.ts45
-rw-r--r--src/server/api/endpoints/admin/ad/delete.ts34
-rw-r--r--src/server/api/endpoints/admin/ad/list.ts36
-rw-r--r--src/server/api/endpoints/admin/ad/update.ts59
-rw-r--r--src/server/api/endpoints/meta.ts39
21 files changed, 571 insertions, 26 deletions
diff --git a/src/client/components/date-separated-list.vue b/src/client/components/date-separated-list.vue
index 2a861adb09..d458a0eeb8 100644
--- a/src/client/components/date-separated-list.vue
+++ b/src/client/components/date-separated-list.vue
@@ -1,5 +1,6 @@
<script lang="ts">
import { defineComponent, h, TransitionGroup } from 'vue';
+import MkAd from '@client/components/global/ad.vue';
export default defineComponent({
props: {
@@ -22,6 +23,11 @@ export default defineComponent({
required: false,
default: false
},
+ ad: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
},
methods: {
@@ -58,11 +64,7 @@ export default defineComponent({
if (
i != this.items.length - 1 &&
- new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() &&
- !item._prId_ &&
- !this.items[i + 1]._prId_ &&
- !item._featuredId_ &&
- !this.items[i + 1]._featuredId_
+ new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate()
) {
const separator = h('div', {
class: 'separator',
@@ -86,7 +88,15 @@ export default defineComponent({
return [el, separator];
} else {
- return el;
+ if (this.ad && item._shouldInsertAd_) {
+ return [h(MkAd, {
+ class: 'ad',
+ key: item.id + ':ad',
+ prefer: 'horizontal',
+ }), el];
+ } else {
+ return el;
+ }
}
}));
},
diff --git a/src/client/components/global/ad.vue b/src/client/components/global/ad.vue
new file mode 100644
index 0000000000..00592e4ca2
--- /dev/null
+++ b/src/client/components/global/ad.vue
@@ -0,0 +1,142 @@
+<template>
+<div class="qiivuoyo" v-if="ad">
+ <div class="main" :class="ad.place" v-if="!showMenu">
+ <a :href="ad.url" target="_blank">
+ <img :src="ad.imageUrl">
+ <button class="_button menu" @click.prevent.stop="toggleMenu"><span class="fas fa-info-circle"></span></button>
+ </a>
+ </div>
+ <div class="menu" v-else>
+ <div class="body">
+ <div>Ads by {{ host }}</div>
+ <!--<MkButton>{{ $ts.stopThisAd }}</MkButton>-->
+ <button class="_textButton" @click="toggleMenu">{{ $ts.close }}</button>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref } from 'vue';
+import { instance } from '@client/instance';
+import { host } from '@client/config';
+import MkButton from '@client/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ props: {
+ prefer: {
+ type: String,
+ required: true
+ },
+ ad: {
+ type: Object,
+ required: false
+ },
+ },
+
+ setup(props) {
+ const showMenu = ref(false);
+ const toggleMenu = () => {
+ showMenu.value = !showMenu.value;
+ };
+
+ let ad = null;
+
+ if (props.ad) {
+ ad = props.ad;
+ } else {
+ let ads = instance.ads.filter(ad => ad.place === props.prefer);
+
+ if (ads.length === 0) {
+ ads = instance.ads.filter(ad => ad.place === 'square');
+ }
+
+ const high = ads.filter(ad => ad.priority === 'high');
+ const middle = ads.filter(ad => ad.priority === 'middle');
+ const low = ads.filter(ad => ad.priority === 'low');
+
+ if (high.length > 0) {
+ ad = high[Math.floor(Math.random() * high.length)];
+ } else if (middle.length > 0) {
+ ad = middle[Math.floor(Math.random() * middle.length)];
+ } else if (low.length > 0) {
+ ad = low[Math.floor(Math.random() * low.length)];
+ }
+ }
+
+ return {
+ ad,
+ showMenu,
+ toggleMenu,
+ host,
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qiivuoyo {
+ background-size: auto auto;
+ background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--ad) 8px, var(--ad) 14px );
+
+ > .main {
+ > a {
+ display: block;
+ position: relative;
+ margin: 0 auto;
+
+ > img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+
+ > .menu {
+ position: absolute;
+ top: 0;
+ right: 0;
+ background: var(--panel);
+ }
+ }
+
+ &.square {
+ > a {
+ max-width: min(300px, 100%);
+ max-height: min(300px, 100%);
+ }
+ }
+
+ &.horizontal {
+ padding: 8px;
+
+ > a {
+ max-width: min(600px, 100%);
+ max-height: min(100px, 100%);
+ }
+ }
+
+ &.vertical {
+ > a {
+ max-width: min(100px, 100%);
+ }
+ }
+ }
+
+ > .menu {
+ padding: 8px;
+ text-align: center;
+
+ > .body {
+ padding: 8px;
+ margin: 0 auto;
+ max-width: 400px;
+ border: solid 1px var(--divider);
+ }
+ }
+}
+</style>
diff --git a/src/client/components/index.ts b/src/client/components/index.ts
index 0630ed3d8c..8b914c5eec 100644
--- a/src/client/components/index.ts
+++ b/src/client/components/index.ts
@@ -12,8 +12,10 @@ import url from './global/url.vue';
import i18n from './global/i18n';
import loading from './global/loading.vue';
import error from './global/error.vue';
+import ad from './global/ad.vue';
export default function(app: App) {
+ app.component('I18n', i18n);
app.component('Mfm', mfm);
app.component('MkA', a);
app.component('MkAcct', acct);
@@ -25,5 +27,5 @@ export default function(app: App) {
app.component('MkUrl', url);
app.component('MkLoading', loading);
app.component('MkError', error);
- app.component('I18n', i18n);
+ app.component('MkAd', ad);
}
diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue
index 675748d540..e90102921a 100644
--- a/src/client/components/notes.vue
+++ b/src/client/components/notes.vue
@@ -17,7 +17,7 @@
</MkButton>
</div>
- <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap">
+ <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap" :ad="true">
<XNote :note="note" class="_block" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/>
</XList>
diff --git a/src/client/pages/gallery/post.vue b/src/client/pages/gallery/post.vue
index 703506a78d..50f81376ec 100644
--- a/src/client/pages/gallery/post.vue
+++ b/src/client/pages/gallery/post.vue
@@ -33,6 +33,7 @@
<MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
</div>
</div>
+ <MkAd prefer="horizontal"/>
<MkContainer :max-height="300" :foldable="true" class="other">
<template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
<MkPagination :pagination="otherPostsPagination" #default="{items}">
diff --git a/src/client/pages/instance/ads.vue b/src/client/pages/instance/ads.vue
new file mode 100644
index 0000000000..4297e56c37
--- /dev/null
+++ b/src/client/pages/instance/ads.vue
@@ -0,0 +1,125 @@
+<template>
+<div class="uqshojas">
+ <MkButton @click="add()" primary style="margin: 0 auto 16px auto;"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
+ <section class="_card _gap ads" v-for="ad in ads">
+ <div class="_content ad">
+ <MkAd v-if="ad.url" :ad="ad"/>
+ <MkInput v-model:value="ad.url" type="url">
+ <span>URL</span>
+ </MkInput>
+ <MkInput v-model:value="ad.imageUrl">
+ <span>{{ $ts.imageUrl }}</span>
+ </MkInput>
+ <div style="margin: 32px 0;">
+ <MkRadio v-model="ad.place" value="square">square</MkRadio>
+ <MkRadio v-model="ad.place" value="horizontal">horizontal</MkRadio>
+ </div>
+ <div style="margin: 32px 0;">
+ {{ $ts.priority }}
+ <MkRadio v-model="ad.priority" value="high">{{ $ts.high }}</MkRadio>
+ <MkRadio v-model="ad.priority" value="middle">{{ $ts.middle }}</MkRadio>
+ <MkRadio v-model="ad.priority" value="low">{{ $ts.low }}</MkRadio>
+ </div>
+ <MkInput v-model:value="ad.expiresAt" type="date">
+ <span>{{ $ts.expiration }}</span>
+ </MkInput>
+ <MkTextarea v-model:value="ad.memo">
+ <span>{{ $ts.memo }}</span>
+ </MkTextarea>
+ <div class="buttons">
+ <MkButton class="button" inline @click="save(ad)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton class="button" inline @click="remove(ad)" danger><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
+ </div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@client/components/ui/button.vue';
+import MkInput from '@client/components/ui/input.vue';
+import MkTextarea from '@client/components/ui/textarea.vue';
+import MkRadio from '@client/components/ui/radio.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ MkRadio,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.ads,
+ icon: 'fas fa-audio-description'
+ },
+ ads: [],
+ }
+ },
+
+ created() {
+ os.api('admin/ad/list').then(ads => {
+ this.ads = ads;
+ });
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ add() {
+ this.ads.unshift({
+ id: null,
+ memo: '',
+ place: 'square',
+ priority: 'middle',
+ url: '',
+ imageUrl: null,
+ expiresAt: null,
+ });
+ },
+
+ remove(ad) {
+ os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: ad.url }),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ this.ads = this.ads.filter(x => x != ad);
+ os.apiWithDialog('admin/ad/delete', {
+ id: ad.id
+ });
+ });
+ },
+
+ save(ad) {
+ if (ad.id == null) {
+ os.apiWithDialog('admin/ad/create', {
+ ...ad,
+ expiresAt: new Date(ad.expiresAt).getTime()
+ });
+ } else {
+ os.apiWithDialog('admin/ad/update', {
+ ...ad,
+ expiresAt: new Date(ad.expiresAt).getTime()
+ });
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.uqshojas {
+ margin: var(--margin);
+}
+</style>
diff --git a/src/client/pages/instance/index.vue b/src/client/pages/instance/index.vue
index 5972a02de0..974c4345bb 100644
--- a/src/client/pages/instance/index.vue
+++ b/src/client/pages/instance/index.vue
@@ -23,6 +23,7 @@
<FormLink :active="page === 'queue'" replace to="/instance/queue"><template #icon><i class="fas fa-clipboard-list"></i></template>{{ $ts.jobQueue }}</FormLink>
<FormLink :active="page === 'files'" replace to="/instance/files"><template #icon><i class="fas fa-cloud"></i></template>{{ $ts.files }}</FormLink>
<FormLink :active="page === 'announcements'" replace to="/instance/announcements"><template #icon><i class="fas fa-broadcast-tower"></i></template>{{ $ts.announcements }}</FormLink>
+ <FormLink :active="page === 'ads'" replace to="/instance/ads"><template #icon><i class="fas fa-audio-description"></i></template>{{ $ts.ads }}</FormLink>
<FormLink :active="page === 'abuses'" replace to="/instance/abuses"><template #icon><i class="fas fa-exclamation-circle"></i></template>{{ $ts.abuseReports }}</FormLink>
</FormGroup>
<FormGroup>
@@ -102,6 +103,7 @@ export default defineComponent({
case 'queue': return defineAsyncComponent(() => import('./queue.vue'));
case 'files': return defineAsyncComponent(() => import('./files.vue'));
case 'announcements': return defineAsyncComponent(() => import('./announcements.vue'));
+ case 'ads': return defineAsyncComponent(() => import('./ads.vue'));
case 'database': return defineAsyncComponent(() => import('./database.vue'));
case 'abuses': return defineAsyncComponent(() => import('./abuses.vue'));
case 'settings': return defineAsyncComponent(() => import('./settings.vue'));
diff --git a/src/client/pages/page.vue b/src/client/pages/page.vue
index 6ee3ee8d26..4e237c2186 100644
--- a/src/client/pages/page.vue
+++ b/src/client/pages/page.vue
@@ -45,6 +45,7 @@
<div><i class="far fa-clock"></i> {{ $ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
<div v-if="page.createdAt != page.updatedAt"><i class="far fa-clock"></i> {{ $ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
</div>
+ <MkAd prefer="horizontal"/>
<MkContainer :max-height="300" :foldable="true" class="other">
<template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
<MkPagination :pagination="otherPostsPagination" #default="{items}">
diff --git a/src/client/scripts/paging.ts b/src/client/scripts/paging.ts
index 2e49f1a64c..bcb0d7f2b0 100644
--- a/src/client/scripts/paging.ts
+++ b/src/client/scripts/paging.ts
@@ -91,8 +91,10 @@ export default (opts) => ({
...params,
limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1,
}).then(items => {
- for (const item of items) {
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
markRaw(item);
+ if (i === 3) item._shouldInsertAd_ = true;
}
if (!this.pagination.noPaging && (items.length > (this.pagination.limit || 10))) {
items.pop();
@@ -128,8 +130,10 @@ export default (opts) => ({
untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
}),
}).then(items => {
- for (const item of items) {
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
markRaw(item);
+ if (i === 10) item._shouldInsertAd_ = true;
}
if (items.length > SECOND_FETCH_LIMIT) {
items.pop();
diff --git a/src/client/style.scss b/src/client/style.scss
index 523ab13034..39bf6ef2d5 100644
--- a/src/client/style.scss
+++ b/src/client/style.scss
@@ -11,6 +11,8 @@
@media (max-width: 500px) {
--margin: var(--marginHalf);
}
+
+ //--ad: rgb(255 169 0 / 10%);
}
::selection {
diff --git a/src/client/ui/chat/date-separated-list.vue b/src/client/ui/chat/date-separated-list.vue
index b073a38eb1..bc7fc91d38 100644
--- a/src/client/ui/chat/date-separated-list.vue
+++ b/src/client/ui/chat/date-separated-list.vue
@@ -42,11 +42,7 @@ export default defineComponent({
if (
i != this.items.length - 1 &&
- new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() &&
- !item._prId_ &&
- !this.items[i + 1]._prId_ &&
- !item._featuredId_ &&
- !this.items[i + 1]._featuredId_
+ new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate()
) {
const separator = h('div', {
class: 'separator',
diff --git a/src/client/ui/default.widgets.vue b/src/client/ui/default.widgets.vue
index cabd83937e..0dd073409b 100644
--- a/src/client/ui/default.widgets.vue
+++ b/src/client/ui/default.widgets.vue
@@ -1,6 +1,7 @@
<template>
<div class="efzpzdvf">
<XWidgets class="widgets" :edit="editMode" :widgets="$store.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
+ <MkAd prefer="square"/>
<button v-if="editMode" @click="editMode = false" class="_textButton edit" style="font-size: 0.9em;"><i class="fas fa-check"></i> {{ $ts.editWidgetsExit }}</button>
<button v-else @click="editMode = true" class="_textButton edit" style="font-size: 0.9em;"><i class="fas fa-pencil-alt"></i> {{ $ts.editWidgets }}</button>
diff --git a/src/db/postgre.ts b/src/db/postgre.ts
index e2a779a52d..3ad81203f2 100644
--- a/src/db/postgre.ts
+++ b/src/db/postgre.ts
@@ -70,6 +70,7 @@ import { Channel } from '../models/entities/channel';
import { ChannelFollowing } from '../models/entities/channel-following';
import { ChannelNotePining } from '../models/entities/channel-note-pining';
import { RegistryItem } from '../models/entities/registry-item';
+import { Ad } from '../models/entities/ad';
import { PasswordResetRequest } from '@/models/entities/password-reset-request';
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@@ -170,6 +171,7 @@ export const entities = [
ChannelFollowing,
ChannelNotePining,
RegistryItem,
+ Ad,
PasswordResetRequest,
...charts as any
];
diff --git a/src/models/entities/ad.ts b/src/models/entities/ad.ts
new file mode 100644
index 0000000000..3279de29ea
--- /dev/null
+++ b/src/models/entities/ad.ts
@@ -0,0 +1,53 @@
+import { Entity, Index, Column, PrimaryColumn } from 'typeorm';
+import { id } from '../id';
+
+@Entity()
+export class Ad {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Index()
+ @Column('timestamp with time zone', {
+ comment: 'The created date of the Ad.'
+ })
+ public createdAt: Date;
+
+ @Index()
+ @Column('timestamp with time zone', {
+ comment: 'The expired date of the Ad.'
+ })
+ public expiresAt: Date;
+
+ @Column('varchar', {
+ length: 32, nullable: false
+ })
+ public place: string;
+
+ @Column('varchar', {
+ length: 32, nullable: false
+ })
+ public priority: string;
+
+ @Column('varchar', {
+ length: 1024, nullable: false
+ })
+ public url: string;
+
+ @Column('varchar', {
+ length: 1024, nullable: false
+ })
+ public imageUrl: string;
+
+ @Column('varchar', {
+ length: 8192, nullable: false
+ })
+ public memo: string;
+
+ constructor(data: Partial<Ad>) {
+ if (data == null) return;
+
+ for (const [k, v] of Object.entries(data)) {
+ (this as any)[k] = v;
+ }
+ }
+}
diff --git a/src/models/index.ts b/src/models/index.ts
index 6ce453ef33..9f8bd104e9 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -60,6 +60,7 @@ import { MutedNote } from './entities/muted-note';
import { ChannelFollowing } from './entities/channel-following';
import { ChannelNotePining } from './entities/channel-note-pining';
import { RegistryItem } from './entities/registry-item';
+import { Ad } from './entities/ad';
import { PasswordResetRequest } from './entities/password-reset-request';
export const Announcements = getRepository(Announcement);
@@ -123,4 +124,5 @@ export const Channels = getCustomRepository(ChannelRepository);
export const ChannelFollowings = getRepository(ChannelFollowing);
export const ChannelNotePinings = getRepository(ChannelNotePining);
export const RegistryItems = getRepository(RegistryItem);
+export const Ads = getRepository(Ad);
export const PasswordResetRequests = getRepository(PasswordResetRequest);
diff --git a/src/models/repositories/note.ts b/src/models/repositories/note.ts
index cdf4841918..7b1df73024 100644
--- a/src/models/repositories/note.ts
+++ b/src/models/repositories/note.ts
@@ -200,8 +200,6 @@ export class NoteRepository extends Repository<Note> {
mentions: note.mentions.length > 0 ? note.mentions : undefined,
uri: note.uri || undefined,
url: note.url || undefined,
- _featuredId_: (note as any)._featuredId_ || undefined,
- _prId_: (note as any)._prId_ || undefined,
...(opts.detail ? {
reply: note.replyId ? this.pack(note.reply || note.replyId, me, {
@@ -448,14 +446,7 @@ export const packedNoteSchema = {
optional: false as const, nullable: true as const,
description: 'The human readable url of a note. it will be null when the note is local.',
},
- _featuredId_: {
- type: 'string' as const,
- optional: false as const, nullable: true as const,
- },
- _prId_: {
- type: 'string' as const,
- optional: false as const, nullable: true as const,
- },
+
myReaction: {
type: 'object' as const,
optional: true as const, nullable: true as const,
diff --git a/src/server/api/endpoints/admin/ad/create.ts b/src/server/api/endpoints/admin/ad/create.ts
new file mode 100644
index 0000000000..7777e95e6e
--- /dev/null
+++ b/src/server/api/endpoints/admin/ad/create.ts
@@ -0,0 +1,45 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { Ads } from '../../../../../models';
+import { genId } from '@/misc/gen-id';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true as const,
+ requireModerator: true,
+
+ params: {
+ url: {
+ validator: $.str.min(1)
+ },
+ memo: {
+ validator: $.str
+ },
+ place: {
+ validator: $.str
+ },
+ priority: {
+ validator: $.str
+ },
+ expiresAt: {
+ validator: $.num.int()
+ },
+ imageUrl: {
+ validator: $.str.min(1)
+ }
+ },
+};
+
+export default define(meta, async (ps) => {
+ await Ads.insert({
+ id: genId(),
+ createdAt: new Date(),
+ expiresAt: new Date(ps.expiresAt),
+ url: ps.url,
+ imageUrl: ps.imageUrl,
+ priority: ps.priority,
+ place: ps.place,
+ memo: ps.memo,
+ });
+});
diff --git a/src/server/api/endpoints/admin/ad/delete.ts b/src/server/api/endpoints/admin/ad/delete.ts
new file mode 100644
index 0000000000..6a5f92193e
--- /dev/null
+++ b/src/server/api/endpoints/admin/ad/delete.ts
@@ -0,0 +1,34 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { ID } from '@/misc/cafy-id';
+import { Ads } from '../../../../../models';
+import { ApiError } from '../../../error';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true as const,
+ requireModerator: true,
+
+ params: {
+ id: {
+ validator: $.type(ID)
+ }
+ },
+
+ errors: {
+ noSuchAd: {
+ message: 'No such ad.',
+ code: 'NO_SUCH_AD',
+ id: 'ccac9863-3a03-416e-b899-8a64041118b1'
+ }
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ const ad = await Ads.findOne(ps.id);
+
+ if (ad == null) throw new ApiError(meta.errors.noSuchAd);
+
+ await Ads.delete(ad.id);
+});
diff --git a/src/server/api/endpoints/admin/ad/list.ts b/src/server/api/endpoints/admin/ad/list.ts
new file mode 100644
index 0000000000..a323f2a9ed
--- /dev/null
+++ b/src/server/api/endpoints/admin/ad/list.ts
@@ -0,0 +1,36 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { Ads } from '../../../../../models';
+import { makePaginationQuery } from '../../../common/make-pagination-query';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true as const,
+ requireModerator: true,
+
+ params: {
+ 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) => {
+ const query = makePaginationQuery(Ads.createQueryBuilder('ad'), ps.sinceId, ps.untilId)
+ .andWhere('ad.expiresAt > :now', { now: new Date() });
+
+ const ads = await query.take(ps.limit!).getMany();
+
+ return ads;
+});
diff --git a/src/server/api/endpoints/admin/ad/update.ts b/src/server/api/endpoints/admin/ad/update.ts
new file mode 100644
index 0000000000..694af98394
--- /dev/null
+++ b/src/server/api/endpoints/admin/ad/update.ts
@@ -0,0 +1,59 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { ID } from '@/misc/cafy-id';
+import { Ads } from '../../../../../models';
+import { ApiError } from '../../../error';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true as const,
+ requireModerator: true,
+
+ params: {
+ id: {
+ validator: $.type(ID)
+ },
+ memo: {
+ validator: $.str
+ },
+ url: {
+ validator: $.str.min(1)
+ },
+ imageUrl: {
+ validator: $.str.min(1)
+ },
+ place: {
+ validator: $.str
+ },
+ priority: {
+ validator: $.str
+ },
+ expiresAt: {
+ validator: $.num.int()
+ },
+ },
+
+ errors: {
+ noSuchAd: {
+ message: 'No such ad.',
+ code: 'NO_SUCH_AD',
+ id: 'b7aa1727-1354-47bc-a182-3a9c3973d300'
+ }
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ const ad = await Ads.findOne(ps.id);
+
+ if (ad == null) throw new ApiError(meta.errors.noSuchAd);
+
+ await Ads.update(ad.id, {
+ url: ps.url,
+ place: ps.place,
+ priority: ps.priority,
+ memo: ps.memo,
+ imageUrl: ps.imageUrl,
+ expiresAt: new Date(ps.expiresAt),
+ });
+});
diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts
index 3760c8b37b..5b7292ef16 100644
--- a/src/server/api/endpoints/meta.ts
+++ b/src/server/api/endpoints/meta.ts
@@ -2,8 +2,9 @@ import $ from 'cafy';
import config from '@/config';
import define from '../define';
import { fetchMeta } from '@/misc/fetch-meta';
-import { Emojis, Users } from '../../../models';
+import { Ads, Emojis, Users } from '../../../models';
import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits';
+import { MoreThan } from 'typeorm';
export const meta = {
desc: {
@@ -193,6 +194,30 @@ export const meta = {
}
}
},
+ ads: {
+ 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,
+ properties: {
+ place: {
+ type: 'string' as const,
+ optional: false as const, nullable: false as const
+ },
+ url: {
+ type: 'string' as const,
+ optional: false as const, nullable: false as const,
+ format: 'url'
+ },
+ imageUrl: {
+ type: 'string' as const,
+ optional: false as const, nullable: false as const,
+ format: 'url'
+ },
+ }
+ }
+ },
requireSetup: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
@@ -443,6 +468,12 @@ export default define(meta, async (ps, me) => {
}
});
+ const ads = await Ads.find({
+ where: {
+ expiresAt: MoreThan(new Date())
+ },
+ });
+
const response: any = {
maintainerName: instance.maintainerName,
maintainerEmail: instance.maintainerEmail,
@@ -477,6 +508,12 @@ export default define(meta, async (ps, me) => {
logoImageUrl: instance.logoImageUrl,
maxNoteTextLength: Math.min(instance.maxNoteTextLength, DB_MAX_NOTE_TEXT_LENGTH),
emojis: await Emojis.packMany(emojis),
+ ads: ads.map(ad => ({
+ url: ad.url,
+ place: ad.place,
+ priority: ad.priority,
+ imageUrl: ad.imageUrl,
+ })),
enableEmail: instance.enableEmail,
enableTwitterIntegration: instance.enableTwitterIntegration,