Merge pull request #4246 from pixelfed/staging

Staging
This commit is contained in:
daniel 2023-03-25 01:32:29 -06:00 committed by GitHub
commit a781c73e33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 799 additions and 41 deletions

View file

@ -138,6 +138,7 @@
- Update admin instances dashboard ([ecfc0766](https://github.com/pixelfed/pixelfed/commit/ecfc0766)) - Update admin instances dashboard ([ecfc0766](https://github.com/pixelfed/pixelfed/commit/ecfc0766))
- Update ap helpers, fix album order bug by setting media order ([871f798c](https://github.com/pixelfed/pixelfed/commit/871f798c)) - Update ap helpers, fix album order bug by setting media order ([871f798c](https://github.com/pixelfed/pixelfed/commit/871f798c))
- Update image pipeline, dispatch jobs to mmo queue and add "replace_id" param to v2/media endpoint to dispatch delayed MediaDeletePipeline job for original media id to improve media gc on supported clients ([5a67e9f9](https://github.com/pixelfed/pixelfed/commit/5a67e9f9)) - Update image pipeline, dispatch jobs to mmo queue and add "replace_id" param to v2/media endpoint to dispatch delayed MediaDeletePipeline job for original media id to improve media gc on supported clients ([5a67e9f9](https://github.com/pixelfed/pixelfed/commit/5a67e9f9))
- Update admin instance management, improve filtering/sorting and add import/export support ([d5d9500d](https://github.com/pixelfed/pixelfed/commit/d5d9500d))
- ([](https://github.com/pixelfed/pixelfed/commit/)) - ([](https://github.com/pixelfed/pixelfed/commit/))
## [v0.11.4 (2022-10-04)](https://github.com/pixelfed/pixelfed/compare/v0.11.3...v0.11.4) ## [v0.11.4 (2022-10-04)](https://github.com/pixelfed/pixelfed/compare/v0.11.3...v0.11.4)

View file

@ -97,7 +97,7 @@ trait AdminInstanceController
return AdminInstance::collection( return AdminInstance::collection(
Instance::where('domain', 'like', '%' . $q . '%') Instance::where('domain', 'like', '%' . $q . '%')
->orderByDesc('user_count') ->orderByDesc('user_count')
->cursorPaginate(20) ->cursorPaginate(10)
->withQueryString() ->withQueryString()
); );
} }
@ -120,23 +120,47 @@ trait AdminInstanceController
'all' 'all'
]) ])
], ],
'sort' => [
'sometimes',
'string',
Rule::in([
'id',
'domain',
'software',
'user_count',
'status_count',
'banned',
'auto_cw',
'unlisted'
])
],
'dir' => 'sometimes|in:desc,asc'
]); ]);
$filter = $request->input('filter'); $filter = $request->input('filter');
$query = $request->input('q'); $query = $request->input('q');
$sortCol = $request->input('sort');
$sortDir = $request->input('dir');
return AdminInstance::collection(Instance::when($query, function($q, $qq) use($query) { return AdminInstance::collection(Instance::when($query, function($q, $qq) use($query) {
return $q->where('domain', 'like', '%' . $query . '%'); return $q->where('domain', 'like', '%' . $query . '%');
}) })
->when($filter, function($q, $f) use($filter) { ->when($filter, function($q, $f) use($filter) {
if($filter == 'cw') { return $q->whereAutoCw(true)->orderByDesc('id'); } if($filter == 'cw') { return $q->whereAutoCw(true); }
if($filter == 'unlisted') { return $q->whereUnlisted(true)->orderByDesc('id'); } if($filter == 'unlisted') { return $q->whereUnlisted(true); }
if($filter == 'banned') { return $q->whereBanned(true)->orderByDesc('id'); } if($filter == 'banned') { return $q->whereBanned(true); }
if($filter == 'new') { return $q->orderByDesc('id'); } if($filter == 'new') { return $q->orderByDesc('id'); }
if($filter == 'popular_users') { return $q->orderByDesc('user_count'); } if($filter == 'popular_users') { return $q->orderByDesc('user_count'); }
if($filter == 'popular_statuses') { return $q->orderByDesc('status_count'); } if($filter == 'popular_statuses') { return $q->orderByDesc('status_count'); }
return $q->orderByDesc('id'); return $q->orderByDesc('id');
}, function($q) { })
->when($sortCol, function($q, $s) use($sortCol, $sortDir, $filter) {
if(!in_array($filter, ['popular_users', 'popular_statuses'])) {
return $q->whereNotNull($sortCol)->orderBy($sortCol, $sortDir);
}
}, function($q) use($filter) {
if(!$filter || !in_array($filter, ['popular_users', 'popular_statuses'])) {
return $q->orderByDesc('id'); return $q->orderByDesc('id');
}
}) })
->cursorPaginate(10) ->cursorPaginate(10)
->withQueryString()); ->withQueryString());
@ -222,4 +246,57 @@ trait AdminInstanceController
return 200; return 200;
} }
public function downloadBackup(Request $request)
{
return response()->streamDownload(function () {
$json = [
'version' => 1,
'auto_cw' => Instance::whereAutoCw(true)->pluck('domain')->toArray(),
'unlisted' => Instance::whereUnlisted(true)->pluck('domain')->toArray(),
'banned' => Instance::whereBanned(true)->pluck('domain')->toArray(),
'created_at' => now()->format('c'),
];
$chk = hash('sha256', json_encode($json, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));
$json['_sha256'] = $chk;
echo json_encode($json, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}, 'pixelfed-instances-mod.json');
}
public function importBackup(Request $request)
{
$this->validate($request, [
'banned' => 'sometimes|array',
'auto_cw' => 'sometimes|array',
'unlisted' => 'sometimes|array',
]);
$banned = $request->input('banned');
$auto_cw = $request->input('auto_cw');
$unlisted = $request->input('unlisted');
foreach($banned as $i) {
Instance::updateOrCreate(
['domain' => $i],
['banned' => true]
);
}
foreach($auto_cw as $i) {
Instance::updateOrCreate(
['domain' => $i],
['auto_cw' => true]
);
}
foreach($unlisted as $i) {
Instance::updateOrCreate(
['domain' => $i],
['unlisted' => true]
);
}
InstanceService::refresh();
return [200];
}
} }

View file

@ -150,6 +150,6 @@ class DeleteRemoteProfilePipeline implements ShouldQueue
// Delete profile // Delete profile
Profile::findOrFail($profile->id)->delete(); Profile::findOrFail($profile->id)->delete();
return; return 1;
} }
} }

View file

@ -61,7 +61,7 @@ class DeleteRemoteStatusPipeline implements ShouldQueue
} }
NetworkTimelineService::del($status->id); NetworkTimelineService::del($status->id);
Cache::forget(StatusService::key($status->id)); StatusService::del($status->id, true);
Bookmark::whereStatusId($status->id)->delete(); Bookmark::whereStatusId($status->id)->delete();
Notification::whereItemType('App\Status') Notification::whereItemType('App\Status')
->whereItemId($status->id) ->whereItemId($status->id)

BIN
public/js/admin.js vendored

Binary file not shown.

View file

@ -17,3 +17,5 @@
*/ */
/*! @source http://purl.eligrey.com/github/canvas-toBlob.js/blob/master/canvas-toBlob.js */ /*! @source http://purl.eligrey.com/github/canvas-toBlob.js/blob/master/canvas-toBlob.js */
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */

BIN
public/js/manifest.js vendored

Binary file not shown.

BIN
public/js/post.chunk.52e6d50f600ac40a.js vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,406 @@
<template>
<div class="post-timeline-component web-wrapper">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-md-4 col-lg-3 d-md-block">
<sidebar :user="user" />
</div>
<div class="col-md-8 col-lg-6">
<div v-if="isReply" class="p-3 rounded-top mb-n3" style="background-color: var(--card-header-accent)">
<p>
<i class="fal fa-reply mr-1"></i> In reply to
<a
:href="'/i/web/profile/' + reply.account.id"
class="font-weight-bold primary"
@click.prevent="goToProfile(reply.account)">
&commat;{{ reply.account.acct }}
</a>
<button
@click.prevent="goToPost(reply)"
class="btn btn-primary font-weight-bold btn-sm px-3 float-right rounded-pill">
View Post
</button>
</p>
</div>
<status
:key="post.id"
:status="post"
:profile="user"
v-on:menu="openContextMenu()"
v-on:like="likeStatus()"
v-on:unlike="unlikeStatus()"
v-on:likes-modal="openLikesModal()"
v-on:shares-modal="openSharesModal()"
v-on:bookmark="handleBookmark()"
v-on:share="shareStatus()"
v-on:unshare="unshareStatus()"
v-on:counter-change="counterChange"
/>
</div>
<div class="d-none d-lg-block col-lg-3">
<rightbar />
</div>
</div>
</div>
<div v-if="postStateError" class="container-fluid mt-3">
<div class="row">
<div class="col-md-4 col-lg-3 d-md-block">
<sidebar :user="user" />
</div>
<div class="col-md-8 col-lg-6">
<div class="card card-body shadow-none border">
<div class="d-flex align-self-center flex-column" style="max-width: 500px;">
<p class="text-center">
<i class="far fa-exclamation-triangle fa-3x text-lighter"></i>
</p>
<p class="text-center lead font-weight-bold">Error displaying post</p>
<p class="mb-0">This can happen for a few reasons:</p>
<ul class="text-lighter">
<li>The url is invalid or has a typo</li>
<li>The page has been flagged for review by our automated abuse detection systems</li>
<li>The content may have been deleted</li>
<li>You do not have permission to view this content</li>
</ul>
</div>
</div>
</div>
<div class="d-none d-lg-block col-lg-3">
<rightbar />
</div>
</div>
</div>
<context-menu
v-if="isLoaded"
ref="contextMenu"
:status="post"
:profile="user"
@report-modal="handleReport()"
@delete="deletePost()"
/>
<likes-modal
v-if="showLikesModal"
ref="likesModal"
:status="post"
:profile="user"
/>
<shares-modal
v-if="showSharesModal"
ref="sharesModal"
:status="post"
:profile="profile"
/>
<report-modal
v-if="post"
ref="reportModal"
:status="post"
/>
<drawer />
</div>
</template>
<script type="text/javascript">
import Drawer from './partials/drawer.vue';
import Rightbar from './partials/rightbar.vue';
import Sidebar from './partials/sidebar.vue';
import Status from './partials/TimelineStatus.vue';
import ContextMenu from './partials/post/ContextMenu.vue';
import MediaContainer from './partials/post/MediaContainer.vue';
import LikesModal from './partials/post/LikeModal.vue';
import SharesModal from './partials/post/ShareModal.vue';
import ReportModal from './partials/modal/ReportPost.vue';
export default {
props: {
cachedStatus: {
type: Object
},
cachedProfile: {
type: Object
}
},
components: {
"drawer": Drawer,
"sidebar": Sidebar,
"status": Status,
"context-menu": ContextMenu,
"media-container": MediaContainer,
"likes-modal": LikesModal,
"shares-modal": SharesModal,
"rightbar": Rightbar,
"report-modal": ReportModal
},
data() {
return {
isLoaded: false,
user: undefined,
profile: undefined,
post: undefined,
relationship: {},
media: undefined,
mediaIndex: 0,
showLikesModal: false,
isReply: false,
reply: {},
showSharesModal: false,
postStateError: false
}
},
beforeMount() {
this.init();
},
watch: {
'$route': 'init'
},
methods: {
init() {
if(this.cachedStatus && this.cachedProfile) {
this.post = this.cachedStatus;
this.media = this.post.media_attachments;
this.profile = this.post.account;
this.user = this.cachedProfile;
if(this.post.in_reply_to_id) {
this.fetchReply();
} else {
this.isReply = false;
this.fetchRelationship();
}
} else {
this.fetchSelf();
}
},
fetchSelf() {
this.user = window._sharedData.user;
this.fetchPost();
},
fetchPost() {
axios.get('/api/pixelfed/v1/statuses/'+this.$route.params.id)
.then(res => {
if(!res.data || !res.data.hasOwnProperty('id')) {
this.$router.push('/i/web/404');
}
this.post = res.data;
this.media = this.post.media_attachments;
this.profile = this.post.account;
if(this.post.in_reply_to_id) {
this.fetchReply();
} else {
this.fetchRelationship();
}
}).catch(err => {
switch(err.response.status) {
case 403:
case 404:
this.$router.push('/i/web/404');
break;
}
})
},
fetchReply() {
axios.get('/api/pixelfed/v1/statuses/' + this.post.in_reply_to_id)
.then(res => {
this.reply = res.data;
this.isReply = true;
this.fetchRelationship();
})
.catch(err => {
this.fetchRelationship();
})
},
fetchRelationship() {
if(this.profile.id == this.user.id) {
this.relationship = {};
this.fetchState();
return;
}
axios.get('/api/pixelfed/v1/accounts/relationships', {
params: {
'id[]': this.profile.id
}
}).then(res => {
this.relationship = res.data[0];
this.fetchState();
});
},
fetchState() {
axios.get('/api/v2/statuses/'+this.post.id+'/state')
.then(res => {
this.post.favourited = res.data.liked;
this.post.reblogged = res.data.shared;
this.post.bookmarked = res.data.bookmarked;
if(!this.post.favourites_count && this.post.favourited) {
this.post.favourites_count = 1;
}
this.isLoaded = true;
}).catch(err => {
this.isLoaded = false;
this.postStateError = true;
})
},
goBack() {
this.$router.push('/i/web');
},
likeStatus() {
let count = this.post.favourites_count;
this.post.favourites_count = count + 1;
this.post.favourited = !this.post.favourited;
axios.post('/api/v1/statuses/' + this.post.id + '/favourite')
.then(res => {
//
}).catch(err => {
this.post.favourites_count = count;
this.post.favourited = false;
})
},
unlikeStatus() {
let count = this.post.favourites_count;
this.post.favourites_count = count - 1;
this.post.favourited = !this.post.favourited;
axios.post('/api/v1/statuses/' + this.post.id + '/unfavourite')
.then(res => {
//
}).catch(err => {
this.post.favourites_count = count;
this.post.favourited = false;
})
},
shareStatus() {
let count = this.post.reblogs_count;
this.post.reblogs_count = count + 1;
this.post.reblogged = !this.post.reblogged;
axios.post('/api/v1/statuses/' + this.post.id + '/reblog')
.then(res => {
//
}).catch(err => {
this.post.reblogs_count = count;
this.post.reblogged = false;
})
},
unshareStatus() {
let count = this.post.reblogs_count;
this.post.reblogs_count = count - 1;
this.post.reblogged = !this.post.reblogged;
axios.post('/api/v1/statuses/' + this.post.id + '/unreblog')
.then(res => {
//
}).catch(err => {
this.post.reblogs_count = count;
this.post.reblogged = false;
})
},
openContextMenu() {
this.$nextTick(() => {
this.$refs.contextMenu.open();
});
},
openLikesModal() {
this.showLikesModal = true;
this.$nextTick(() => {
this.$refs.likesModal.open();
});
},
openSharesModal() {
this.showSharesModal = true;
this.$nextTick(() => {
this.$refs.sharesModal.open();
});
},
deletePost() {
this.$router.push('/i/web');
},
goToPost(post) {
this.$router.push({
name: 'post',
path: `/i/web/post/${post.id}`,
params: {
id: post.id,
cachedStatus: post,
cachedProfile: this.user
}
})
},
goToProfile(account) {
this.$router.push({
name: 'profile',
path: `/i/web/profile/${account.id}`,
params: {
id: account.id,
cachedProfile: account,
cachedUser: this.user
}
})
},
handleBookmark() {
axios.post('/i/bookmark', {
item: this.post.id
})
.then(res => {
this.post.bookmarked = !this.post.bookmarked;
})
.catch(err => {
this.$bvToast.toast('Cannot bookmark post at this time.', {
title: 'Bookmark Error',
variant: 'danger',
autoHideDelay: 5000
});
});
},
handleReport() {
this.$nextTick(() => {
this.$refs.reportModal.open();
});
},
counterChange(type) {
switch(type) {
case 'comment-increment':
this.post.reply_count = this.post.reply_count + 1;
break;
case 'comment-decrement':
this.post.reply_count = this.post.reply_count - 1;
break;
}
},
}
}
</script>

View file

@ -39,6 +39,21 @@
<div class="col-xl-2 col-md-6"> <div class="col-xl-2 col-md-6">
<div class="mb-3"> <div class="mb-3">
<button class="btn btn-outline-white btn-block btn-sm mt-1" @click.prevent="showAddModal = true">Create New Instance</button> <button class="btn btn-outline-white btn-block btn-sm mt-1" @click.prevent="showAddModal = true">Create New Instance</button>
<div v-if="showImportForm">
<div class="form-group mt-3">
<div class="custom-file">
<input ref="importInput" type="file" class="custom-file-input" id="customFile" v-on:change="onImportUpload">
<label class="custom-file-label" for="customFile">Choose file</label>
</div>
</div>
<p class="mb-0 mt-n3">
<a href="#" class="text-white font-weight-bold small" @click.prevent="showImportForm = false">Cancel</a>
</p>
</div>
<div v-else class="d-flex mt-1">
<button class="btn btn-outline-white btn-sm mt-1" @click="openImportForm">Import</button>
<button class="btn btn-outline-white btn-block btn-sm mt-1" @click="downloadBackup()">Download Backup</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -111,7 +126,7 @@
<thead class="thead-dark"> <thead class="thead-dark">
<tr> <tr>
<th scope="col" class="cursor-pointer" v-html="buildColumn('ID', 'id')" @click="toggleCol('id')"></th> <th scope="col" class="cursor-pointer" v-html="buildColumn('ID', 'id')" @click="toggleCol('id')"></th>
<th scope="col" class="cursor-pointer" v-html="buildColumn('Domain', 'name')" @click="toggleCol('name')"></th> <th scope="col" class="cursor-pointer" v-html="buildColumn('Domain', 'domain')" @click="toggleCol('domain')"></th>
<th scope="col" class="cursor-pointer" v-html="buildColumn('Software', 'software')" @click="toggleCol('software')"></th> <th scope="col" class="cursor-pointer" v-html="buildColumn('Software', 'software')" @click="toggleCol('software')"></th>
<th scope="col" class="cursor-pointer" v-html="buildColumn('User Count', 'user_count')" @click="toggleCol('user_count')"></th> <th scope="col" class="cursor-pointer" v-html="buildColumn('User Count', 'user_count')" @click="toggleCol('user_count')"></th>
<th scope="col" class="cursor-pointer" v-html="buildColumn('Status Count', 'status_count')" @click="toggleCol('status_count')"></th> <th scope="col" class="cursor-pointer" v-html="buildColumn('Status Count', 'status_count')" @click="toggleCol('status_count')"></th>
@ -293,7 +308,62 @@
</div> </div>
</div> </div>
</div> </div>
</b-modal>
<b-modal
v-model="showImportModal"
title="Import Instance Backup"
ok-title="Import"
scrollable
:ok-disabled="!importData || (!importData.banned.length && !importData.unlisted.length && !importData.auto_cw.length)"
@ok="completeImport"
@cancel="cancelImport">
<div v-if="showImportModal && importData">
<div v-if="importData.auto_cw && importData.auto_cw.length" class="mb-5">
<p class="font-weight-bold text-center my-0">NSFW Instances ({{importData.auto_cw.length}})</p>
<p class="small text-center text-muted mb-1">Tap on an instance to remove it.</p>
<div class="list-group">
<a v-for="(instance, idx) in importData.auto_cw" class="list-group-item d-flex align-items-center justify-content-between" href="#" @click.prevent="filterImportData('auto_cw', idx)">
{{ instance }}
<span class="badge badge-warning">Auto CW</span>
</a>
</div>
</div>
<div v-if="importData.unlisted && importData.unlisted.length" class="mb-5">
<p class="font-weight-bold text-center my-0">Unlisted Instances ({{importData.unlisted.length}})</p>
<p class="small text-center text-muted mb-1">Tap on an instance to remove it.</p>
<div class="list-group">
<a v-for="(instance, idx) in importData.unlisted" class="list-group-item d-flex align-items-center justify-content-between" href="#" @click.prevent="filterImportData('unlisted', idx)">
{{ instance }}
<span class="badge badge-primary">Unlisted</span>
</a>
</div>
</div>
<div v-if="importData.banned && importData.banned.length" class="mb-5">
<p class="font-weight-bold text-center my-0">Banned Instances ({{importData.banned.length}})</p>
<p class="small text-center text-muted mb-1">Review instances, tap on an instance to remove it.</p>
<div class="list-group">
<a v-for="(instance, idx) in importData.banned" class="list-group-item d-flex align-items-center justify-content-between" href="#" @click.prevent="filterImportData('banned', idx)">
{{ instance }}
<span class="badge badge-danger">Banned</span>
</a>
</div>
</div>
<div v-if="!importData.banned.length && !importData.unlisted.length && !importData.auto_cw.length">
<div class="text-center">
<p>
<i class="far fa-check-circle fa-4x text-success"></i>
</p>
<p class="lead">Nothing to import!</p>
</div>
</div>
</div>
</b-modal> </b-modal>
</div> </div>
</template> </template>
@ -347,7 +417,10 @@
auto_cw: false, auto_cw: false,
unlisted: false, unlisted: false,
notes: undefined notes: undefined
} },
showImportForm: false,
showImportModal: false,
importData: undefined,
} }
}, },
@ -355,31 +428,47 @@
this.fetchStats(); this.fetchStats();
let u = new URLSearchParams(window.location.search); let u = new URLSearchParams(window.location.search);
if(u.has('filter') || u.has('cursor') && !u.has('q')) { if(u.has('filter') && !u.has('q') && !u.has('sort')) {
let url = '/i/admin/api/instances/get?'; const url = new URL(window.location.origin + '/i/admin/api/instances/get');
let filter = u.get('filter'); if(u.has('filter')) {
if(filter) { this.tabIndex = this.filterMap.indexOf(u.get('filter'));
this.tabIndex = this.filterMap.indexOf(filter); url.searchParams.set('filter', u.get('filter'));
url = url + 'filter=' + filter + '&'; }
if(u.has('cursor')) {
url.searchParams.set('cursor', u.get('cursor'));
} }
let cursor = u.get('cursor'); this.fetchInstances(url.toString());
if(cursor) { } else if(u.has('sort') && !u.has('q')) {
url = url + 'cursor=' + cursor; const url = new URL(window.location.origin + '/i/admin/api/instances/get');
url.searchParams.set('sort', u.get('sort'));
if(u.has('dir')) {
url.searchParams.set('dir', u.get('dir'));
} }
this.fetchInstances(url); if(u.has('filter')) {
url.searchParams.set('filter', u.get('filter'));
}
if(u.has('cursor')) {
url.searchParams.set('cursor', u.get('cursor'));
}
this.fetchInstances(url.toString());
} else if(u.has('q')) { } else if(u.has('q')) {
this.tabIndex = -1; this.tabIndex = -1;
this.searchQuery = u.get('q'); this.searchQuery = u.get('q');
let cursor = u.has('cursor');
let q = u.get('q'); const url = new URL(window.location.origin + '/i/admin/api/instances/query');
let url = '/i/admin/api/instances/query?q=' + q; url.searchParams.set('q', u.get('q'));
if(cursor) {
url = url + '&cursor=' + u.get('cursor'); if(u.has('cursor')) {
url.searchParams.set('cursor', u.get('cursor'));
} }
this.fetchInstances(url);
this.fetchInstances(url.toString());
} else { } else {
this.fetchInstances(); this.fetchInstances();
} }
@ -470,19 +559,52 @@
}, },
toggleCol(col) { toggleCol(col) {
// this.sortCol = col; if(this.filterMap[this.tabIndex] == col || this.searchQuery) {
return;
}
this.sortCol = col;
// if(!this.sortDir) { if(!this.sortDir) {
// this.sortDir = 'desc'; this.sortDir = 'desc';
// } else { } else {
// this.sortDir = this.sortDir == 'asc' ? 'desc' : 'asc'; this.sortDir = this.sortDir == 'asc' ? 'desc' : 'asc';
// } }
// let url = '/i/admin/api/hashtags/query?sort=' + col + '&dir=' + this.sortDir; const url = new URL(window.location.origin + '/i/admin/instances');
// this.fetchHashtags(url); url.searchParams.set('sort', col);
url.searchParams.set('dir', this.sortDir);
if(this.tabIndex != 0) {
url.searchParams.set('filter', this.filterMap[this.tabIndex]);
}
history.pushState(null, '', url);
const apiUrl = new URL(window.location.origin + '/i/admin/api/instances/get');
apiUrl.searchParams.set('sort', col);
apiUrl.searchParams.set('dir', this.sortDir);
if(this.tabIndex != 0) {
apiUrl.searchParams.set('filter', this.filterMap[this.tabIndex]);
}
this.fetchInstances(apiUrl.toString());
}, },
buildColumn(name, col) { buildColumn(name, col) {
if([1, 5, 6].indexOf(this.tabIndex) != -1 || (this.searchQuery && this.searchQuery.length)) {
return name;
}
if(this.tabIndex === 2 && col === 'banned') {
return name;
}
if(this.tabIndex === 3 && col === 'auto_cw') {
return name;
}
if(this.tabIndex === 4 && col === 'unlisted') {
return name;
}
let icon = `<i class="far fa-sort"></i>`; let icon = `<i class="far fa-sort"></i>`;
if(col == this.sortCol) { if(col == this.sortCol) {
icon = this.sortDir == 'desc' ? icon = this.sortDir == 'desc' ?
@ -496,17 +618,26 @@
event.currentTarget.blur(); event.currentTarget.blur();
let apiUrl = dir == 'next' ? this.pagination.next : this.pagination.prev; let apiUrl = dir == 'next' ? this.pagination.next : this.pagination.prev;
let cursor = dir == 'next' ? this.pagination.next_cursor : this.pagination.prev_cursor; let cursor = dir == 'next' ? this.pagination.next_cursor : this.pagination.prev_cursor;
let url = '/i/admin/instances?';
if(this.tabIndex && !this.searchQuery) { const url = new URL(window.location.origin + '/i/admin/instances');
url = url + 'filter=' + this.filterMap[this.tabIndex] + '&';
}
if(cursor) { if(cursor) {
url = url + 'cursor=' + cursor; url.searchParams.set('cursor', cursor);
} }
if(this.searchQuery) { if(this.searchQuery) {
url = url + '&q=' + this.searchQuery; url.searchParams.set('q', this.searchQuery);
} }
history.pushState(null, '', url);
if(this.sortCol) {
url.searchParams.set('sort', this.sortCol);
}
if(this.sortDir) {
url.searchParams.set('dir', this.sortDir);
}
history.pushState(null, '', url.toString());
this.fetchInstances(apiUrl); this.fetchInstances(apiUrl);
}, },
@ -616,7 +747,146 @@
this.showInstanceModal = false; this.showInstanceModal = false;
this.instances = this.instances.filter(i => i.id != this.instanceModal.id); this.instances = this.instances.filter(i => i.id != this.instanceModal.id);
}) })
.then(() => {
setTimeout(() => this.fetchStats(), 1000);
})
},
openImportForm() {
let el = document.createElement('p');
el.classList.add('text-left');
el.classList.add('mb-0');
el.innerHTML = '<p class="lead mb-0">Import your instance moderation backup.</span></p><br /><p>Import Instructions:</p><ol><li>Press OK</li><li>Press "Choose File" on Import form input</li><li>Select your <kbd>pixelfed-instances-mod.json</kbd> file</li><li>Review instance moderation actions. Tap on an instance to remove it</li><li>Press "Import" button to finish importing</li></ol>';
let wrapper = document.createElement('div');
wrapper.appendChild(el);
swal({
title: 'Import Backup',
content: wrapper,
icon: 'info'
})
this.showImportForm = true;
},
downloadBackup($event) {
axios.get('/i/admin/api/instances/download-backup', {
responseType: "blob"
})
.then(res => {
let el = document.createElement('a');
el.setAttribute('download', 'pixelfed-instances-mod.json')
const href = URL.createObjectURL(res.data);
el.href = href;
el.setAttribute('target', '_blank');
el.click();
swal(
'Instance Backup Downloading',
'Your instance moderation backup is downloading. Use this to import auto_cw, banned and unlisted instances to supported Pixelfed instances.',
'success'
)
})
},
async onImportUpload(ev) {
let res = await this.getParsedImport(ev.target.files[0]);
if(!res.hasOwnProperty('version') || res.version !== 1) {
swal('Invalid Backup', 'We cannot validate this backup. Please try again later.', 'error');
this.showImportForm = false;
this.$refs.importInput.reset();
return;
} }
this.importData = res;
this.showImportModal = true;
},
async getParsedImport(ev) {
try {
return await this.parseJsonFile(ev);
} catch(err) {
let el = document.createElement('p');
el.classList.add('text-left');
el.classList.add('mb-0');
el.innerHTML = '<p class="lead">An error occured when attempting to parse the import file. <span class="font-weight-bold">Please try again later.</span></p><br /><p class="small text-danger mb-0">Error message:</p><div class="card card-body"><code>' + err.message + '</code></div>';
let wrapper = document.createElement('div');
wrapper.appendChild(el);
swal({
title: 'Import Error',
content: wrapper,
icon: 'error'
})
return;
}
},
async promisedParseJSON(json) {
return new Promise((resolve, reject) => {
try {
resolve(JSON.parse(json))
} catch (e) {
reject(e)
}
})
},
async parseJsonFile(file) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader()
fileReader.onload = event => resolve(this.promisedParseJSON(event.target.result))
fileReader.onerror = error => reject(error)
fileReader.readAsText(file)
})
},
filterImportData(type, index) {
switch(type) {
case 'auto_cw':
this.importData.auto_cw.splice(index, 1);
break;
case 'unlisted':
this.importData.unlisted.splice(index, 1);
break;
case 'banned':
this.importData.banned.splice(index, 1);
break;
}
},
completeImport() {
this.showImportForm = false;
axios.post('/i/admin/api/instances/import-data', {
'banned': this.importData.banned,
'auto_cw': this.importData.auto_cw,
'unlisted': this.importData.unlisted,
})
.then(res => {
swal('Import Uploaded', 'Import successfully uploaded, please allow a few minutes to process.', 'success');
})
.then(() => {
setTimeout(() => this.fetchStats(), 1000);
})
},
cancelImport(bvModalEvent) {
if(this.importData.banned.length || this.importData.auto_cw.length || this.importData.unlisted.length) {
if(!window.confirm('Are you sure you want to cancel importing?')) {
bvModalEvent.preventDefault();
return;
} else {
this.showImportForm = false;
this.$refs.importInput.value = '';
this.importData = {
banned: [],
auto_cw: [],
unlisted: []
};
}
}
}
} }
} }
</script> </script>

View file

@ -120,6 +120,8 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio
Route::post('instances/create', 'AdminController@postInstanceCreateNewApi'); Route::post('instances/create', 'AdminController@postInstanceCreateNewApi');
Route::post('instances/delete', 'AdminController@postInstanceDeleteApi'); Route::post('instances/delete', 'AdminController@postInstanceDeleteApi');
Route::post('instances/refresh-stats', 'AdminController@postInstanceRefreshStatsApi'); Route::post('instances/refresh-stats', 'AdminController@postInstanceRefreshStatsApi');
Route::get('instances/download-backup', 'AdminController@downloadBackup');
Route::post('instances/import-data', 'AdminController@importBackup');
}); });
}); });