Compare commits

...

6 Commits
stage ... ad

Author SHA1 Message Date
syuilo a55211744b Create 1620019354680-ad.ts 2021-05-03 14:24:17 +09:00
syuilo 346282ba9d wip 2021-05-02 14:08:43 +09:00
syuilo 7696792386 Merge branch 'develop' into ad 2021-05-02 11:35:21 +09:00
syuilo 66d6196df1 Update default.widgets.vue 2021-04-30 12:52:45 +09:00
syuilo 4939a3d4a3 Update ad.vue 2021-04-30 12:42:57 +09:00
syuilo 3cb5d6d88e wip 2021-04-29 23:12:11 +09:00
17 changed files with 370 additions and 24 deletions

View File

@ -747,6 +747,7 @@ gallery: "ギャラリー"
recentPosts: "最近の投稿"
popularPosts: "人気の投稿"
shareWithNote: "ノートで共有"
ads: "広告"
_gallery:
my: "自分の投稿"

View File

@ -0,0 +1,18 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class ad1620019354680 implements MigrationInterface {
name = 'ad1620019354680'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "ad" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "place" character varying(32) NOT NULL, "url" character varying(1024) NOT NULL, "imageUrl" character varying(1024) NOT NULL, "memo" character varying(8192) NOT NULL, CONSTRAINT "PK_0193d5ef09746e88e9ea92c634d" PRIMARY KEY ("id")); COMMENT ON COLUMN "ad"."createdAt" IS 'The created date of the Ad.'; COMMENT ON COLUMN "ad"."expiresAt" IS 'The expired date of the Ad.'`);
await queryRunner.query(`CREATE INDEX "IDX_1129c2ef687fc272df040bafaa" ON "ad" ("createdAt") `);
await queryRunner.query(`CREATE INDEX "IDX_2da24ce20ad209f1d9dc032457" ON "ad" ("expiresAt") `);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_2da24ce20ad209f1d9dc032457"`);
await queryRunner.query(`DROP INDEX "IDX_1129c2ef687fc272df040bafaa"`);
await queryRunner.query(`DROP TABLE "ad"`);
}
}

View File

@ -32,7 +32,6 @@
"resolutions": {
"chokidar": "^3.3.1",
"constantinople": "^4.0.1",
"gulp/gulp-cli/yargs/yargs-parser": "5.0.0-security.0",
"jsonld/rdf-canonize/node-forge": "0.10.0",
"lodash": "^4.17.20"
},

View File

@ -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: {
@ -58,11 +59,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 +83,14 @@ export default defineComponent({
return [el, separator];
} else {
return el;
if (i === 3) {
return [h(MkAd, {
class: 'ad',
key: i + ':ad',
}), el];
} else {
return el;
}
}
}));
},

View File

@ -0,0 +1,96 @@
<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>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
export default defineComponent({
props: {
prefer: {
type: String,
required: true
},
},
setup(props) {
const showMenu = ref(false);
const toggleMenu = () => {
showMenu.value = !showMenu.value;
};
let ads = this.$instance.ads.find(ad => ad.place === props.prefer);
if (ads.length === 0) {
ads = this.$instance.ads.find(ad => ad.place === 'square');
}
const ad = ads.length === 0 ? null : ads[Math.floor(Math.random() * ads.length)];
return {
ad,
showMenu,
toggleMenu,
};
}
});
</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 {
> a {
max-width: 100%;
max-height: min(100px, 100%);
}
}
&.vertical {
> a {
max-width: min(100px, 100%);
}
}
}
}
</style>

View File

@ -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);
}

View File

@ -0,0 +1,72 @@
<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 announcements" v-for="announcement in announcements">
<div class="_content announcement">
<MkInput v-model:value="announcement.title">
<span>{{ $ts.title }}</span>
</MkInput>
<MkTextarea v-model:value="announcement.text">
<span>{{ $ts.text }}</span>
</MkTextarea>
<MkInput v-model:value="announcement.imageUrl">
<span>{{ $ts.imageUrl }}</span>
</MkInput>
<p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p>
<div class="buttons">
<MkButton class="button" inline @click="save(announcement)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
<MkButton class="button" inline @click="remove(announcement)"><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 * as os from '@client/os';
import * as symbols from '@client/symbols';
export default defineComponent({
components: {
MkButton,
MkInput,
MkTextarea,
},
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: {
}
});
</script>
<style lang="scss" scoped>
.uqshojas {
margin: var(--margin);
}
</style>

View File

@ -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'));

View File

@ -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',

View File

@ -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/>
<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>

View File

@ -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';
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@ -169,6 +170,7 @@ export const entities = [
ChannelFollowing,
ChannelNotePining,
RegistryItem,
Ad,
...charts as any
];

48
src/models/entities/ad.ts Normal file
View File

@ -0,0 +1,48 @@
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: 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;
}
}
}

View File

@ -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';
export const Announcements = getRepository(Announcement);
export const AnnouncementReads = getRepository(AnnouncementRead);
@ -122,3 +123,4 @@ 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);

View File

@ -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,

View File

@ -0,0 +1,41 @@
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
},
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,
place: ps.place,
memo: ps.memo,
});
});

View File

@ -0,0 +1,35 @@
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);
const ads = await query.take(ps.limit!).getMany();
return ads;
});

View File

@ -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,11 @@ 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,
imageUrl: ad.imageUrl,
})),
enableEmail: instance.enableEmail,
enableTwitterIntegration: instance.enableTwitterIntegration,