1
1
mirror of https://github.com/kokonect-link/cherrypick synced 2024-11-27 14:28:53 +09:00
* wip

* Update ad.vue

* Update default.widgets.vue

* wip

* Create 1620019354680-ad.ts

* wip

* Update ads.vue

* wip

* Update ad.vue
This commit is contained in:
syuilo 2021-05-04 21:15:57 +09:00 committed by GitHub
parent 71ebb068f7
commit 18e1efc7ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 596 additions and 27 deletions

View File

@ -748,6 +748,13 @@ gallery: "ギャラリー"
recentPosts: "最近の投稿"
popularPosts: "人気の投稿"
shareWithNote: "ノートで共有"
ads: "広告"
expiration: "期限"
memo: "メモ"
priority: "優先度"
high: "高"
middle: "中"
low: "低"
emailNotConfiguredWarning: "メールアドレスの設定がされていません。"
_forgotPassword:

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, "priority" 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: {
@ -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;
}
}
}));
},

View File

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

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

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

View File

@ -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}">

View File

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

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

@ -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}">

View File

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

View File

@ -11,6 +11,8 @@
@media (max-width: 500px) {
--margin: var(--marginHalf);
}
//--ad: rgb(255 169 0 / 10%);
}
::selection {

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

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';
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
];

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

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

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

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

View File

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

View File

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

View File

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

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