AdminInstances.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898
  1. <template>
  2. <div>
  3. <div class="header bg-primary pb-3 mt-n4">
  4. <div class="container-fluid">
  5. <div class="header-body">
  6. <div class="row align-items-center py-4">
  7. <div class="col-lg-6 col-7">
  8. <p class="display-1 text-white d-inline-block mb-0">Instances</p>
  9. </div>
  10. </div>
  11. <div class="row">
  12. <div class="col-xl-2 col-md-6">
  13. <div class="mb-3">
  14. <h5 class="text-light text-uppercase mb-0">Total Instances</h5>
  15. <span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.total_count) }}</span>
  16. </div>
  17. </div>
  18. <div class="col-xl-2 col-md-6">
  19. <div class="mb-3">
  20. <h5 class="text-light text-uppercase mb-0">New (past 14 days)</h5>
  21. <span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.new_count) }}</span>
  22. </div>
  23. </div>
  24. <div class="col-xl-2 col-md-6">
  25. <div class="mb-3">
  26. <h5 class="text-light text-uppercase mb-0">Banned Instances</h5>
  27. <span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.banned_count) }}</span>
  28. </div>
  29. </div>
  30. <div class="col-xl-2 col-md-6">
  31. <div class="mb-3">
  32. <h5 class="text-light text-uppercase mb-0">NSFW Instances</h5>
  33. <span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.nsfw_count) }}</span>
  34. </div>
  35. </div>
  36. <div class="col-xl-2 col-md-6">
  37. <div class="mb-3">
  38. <button class="btn btn-outline-white btn-block btn-sm mt-1" @click.prevent="showAddModal = true">Create New Instance</button>
  39. <div v-if="showImportForm">
  40. <div class="form-group mt-3">
  41. <div class="custom-file">
  42. <input ref="importInput" type="file" class="custom-file-input" id="customFile" v-on:change="onImportUpload">
  43. <label class="custom-file-label" for="customFile">Choose file</label>
  44. </div>
  45. </div>
  46. <p class="mb-0 mt-n3">
  47. <a href="#" class="text-white font-weight-bold small" @click.prevent="showImportForm = false">Cancel</a>
  48. </p>
  49. </div>
  50. <div v-else class="d-flex mt-1">
  51. <button class="btn btn-outline-white btn-sm mt-1" @click="openImportForm">Import</button>
  52. <button class="btn btn-outline-white btn-block btn-sm mt-1" @click="downloadBackup()">Download Backup</button>
  53. </div>
  54. </div>
  55. </div>
  56. </div>
  57. </div>
  58. </div>
  59. </div>
  60. <div v-if="!loaded" class="my-5 text-center">
  61. <b-spinner />
  62. </div>
  63. <div v-else class="m-n2 m-lg-4">
  64. <div class="container-fluid mt-4">
  65. <div class="row mb-3 justify-content-between">
  66. <div class="col-12 col-md-8">
  67. <ul class="nav nav-pills">
  68. <li class="nav-item">
  69. <button :class="['nav-link', { active: tabIndex == 0}]" @click="toggleTab(0)">All</button>
  70. </li>
  71. <li class="nav-item">
  72. <button :class="['nav-link', { active: tabIndex == 1}]" @click="toggleTab(1)">New</button>
  73. </li>
  74. <li class="nav-item">
  75. <button :class="['nav-link', { active: tabIndex == 2}]" @click="toggleTab(2)">Banned</button>
  76. </li>
  77. <li class="nav-item">
  78. <button :class="['nav-link', { active: tabIndex == 3}]" @click="toggleTab(3)">NSFW</button>
  79. </li>
  80. <li class="nav-item">
  81. <button :class="['nav-link', { active: tabIndex == 4}]" @click="toggleTab(4)">Unlisted</button>
  82. </li>
  83. <li class="nav-item">
  84. <button :class="['nav-link', { active: tabIndex == 5}]" @click="toggleTab(5)">Most Users</button>
  85. </li>
  86. <li class="nav-item">
  87. <button :class="['nav-link', { active: tabIndex == 6}]" @click="toggleTab(6)">Most Statuses</button>
  88. </li>
  89. </ul>
  90. </div>
  91. <div class="col-12 col-md-4">
  92. <autocomplete
  93. :search="composeSearch"
  94. :disabled="searchLoading"
  95. :defaultValue="searchQuery"
  96. placeholder="Search instances by domain"
  97. aria-label="Search instances by domain"
  98. :get-result-value="getTagResultValue"
  99. @submit="onSearchResultClick"
  100. ref="autocomplete"
  101. >
  102. <template #result="{ result, props }">
  103. <li
  104. v-bind="props"
  105. class="autocomplete-result d-flex justify-content-between align-items-center"
  106. >
  107. <div class="font-weight-bold" :class="{ 'text-danger': result.banned }">
  108. {{ result.domain }}
  109. </div>
  110. <div class="small text-muted">
  111. {{ prettyCount(result.user_count) }} users
  112. </div>
  113. </li>
  114. </template>
  115. </autocomplete>
  116. </div>
  117. </div>
  118. <div class="table-responsive">
  119. <table class="table table-dark">
  120. <thead class="thead-dark">
  121. <tr>
  122. <th scope="col" class="cursor-pointer" v-html="buildColumn('ID', 'id')" @click="toggleCol('id')"></th>
  123. <th scope="col" class="cursor-pointer" v-html="buildColumn('Domain', 'domain')" @click="toggleCol('domain')"></th>
  124. <th scope="col" class="cursor-pointer" v-html="buildColumn('Software', 'software')" @click="toggleCol('software')"></th>
  125. <th scope="col" class="cursor-pointer" v-html="buildColumn('User Count', 'user_count')" @click="toggleCol('user_count')"></th>
  126. <th scope="col" class="cursor-pointer" v-html="buildColumn('Status Count', 'status_count')" @click="toggleCol('status_count')"></th>
  127. <th scope="col" class="cursor-pointer" v-html="buildColumn('Banned', 'banned')" @click="toggleCol('banned')"></th>
  128. <th scope="col" class="cursor-pointer" v-html="buildColumn('NSFW', 'auto_cw')" @click="toggleCol('auto_cw')"></th>
  129. <th scope="col" class="cursor-pointer" v-html="buildColumn('Unlisted', 'unlisted')" @click="toggleCol('unlisted')"></th>
  130. <th scope="col">Created</th>
  131. </tr>
  132. </thead>
  133. <tbody>
  134. <tr v-for="(instance, idx) in instances">
  135. <td class="font-weight-bold text-monospace text-muted">
  136. <a href="#" @click.prevent="openInstanceModal(instance.id)">
  137. {{ instance.id }}
  138. </a>
  139. </td>
  140. <td class="font-weight-bold">{{ instance.domain }}</td>
  141. <td class="font-weight-bold">{{ instance.software }}</td>
  142. <td class="font-weight-bold">{{ prettyCount(instance.user_count) }}</td>
  143. <td class="font-weight-bold">{{ prettyCount(instance.status_count) }}</td>
  144. <td class="font-weight-bold" v-html="boolIcon(instance.banned, 'text-danger')"></td>
  145. <td class="font-weight-bold" v-html="boolIcon(instance.auto_cw, 'text-danger')"></td>
  146. <td class="font-weight-bold" v-html="boolIcon(instance.unlisted, 'text-danger')"></td>
  147. <td class="font-weight-bold">{{ timeAgo(instance.created_at) }}</td>
  148. </tr>
  149. </tbody>
  150. </table>
  151. </div>
  152. <div class="d-flex align-items-center justify-content-center">
  153. <button
  154. class="btn btn-primary rounded-pill"
  155. :disabled="!pagination.prev"
  156. @click="paginate('prev')">
  157. Prev
  158. </button>
  159. <button
  160. class="btn btn-primary rounded-pill"
  161. :disabled="!pagination.next"
  162. @click="paginate('next')">
  163. Next
  164. </button>
  165. </div>
  166. </div>
  167. </div>
  168. <b-modal
  169. v-model="showInstanceModal"
  170. title="View Instance"
  171. header-class="d-flex align-items-center justify-content-center mb-0 pb-0"
  172. ok-title="Save"
  173. :ok-disabled="!editingInstanceChanges"
  174. @ok="saveInstanceModalChanges">
  175. <div v-if="editingInstance && canEditInstance" class="list-group">
  176. <div class="list-group-item d-flex align-items-center justify-content-between">
  177. <div class="text-muted small">Domain</div>
  178. <div class="font-weight-bold">{{ editingInstance.domain }}</div>
  179. </div>
  180. <div class="list-group-item d-flex align-items-center justify-content-between">
  181. <div v-if="editingInstance.software">
  182. <div class="text-muted small">Software</div>
  183. <div class="font-weight-bold">{{ editingInstance.software ?? 'Unknown' }}</div>
  184. </div>
  185. <div>
  186. <div class="text-muted small">Total Users</div>
  187. <div class="font-weight-bold">{{ formatCount(editingInstance.user_count ?? 0) }}</div>
  188. </div>
  189. <div>
  190. <div class="text-muted small">Total Statuses</div>
  191. <div class="font-weight-bold">{{ formatCount(editingInstance.status_count ?? 0) }}</div>
  192. </div>
  193. </div>
  194. <div class="list-group-item d-flex align-items-center justify-content-between">
  195. <div class="text-muted small">Banned</div>
  196. <div class="mr-n2 mb-1">
  197. <b-form-checkbox v-model="editingInstance.banned" switch size="lg"></b-form-checkbox>
  198. </div>
  199. </div>
  200. <div class="list-group-item d-flex align-items-center justify-content-between">
  201. <div class="text-muted small">Apply CW to Media</div>
  202. <div class="mr-n2 mb-1">
  203. <b-form-checkbox v-model="editingInstance.auto_cw" switch size="lg"></b-form-checkbox>
  204. </div>
  205. </div>
  206. <div class="list-group-item d-flex align-items-center justify-content-between">
  207. <div class="text-muted small">Unlisted</div>
  208. <div class="mr-n2 mb-1">
  209. <b-form-checkbox v-model="editingInstance.unlisted" switch size="lg"></b-form-checkbox>
  210. </div>
  211. </div>
  212. <div class="list-group-item d-flex justify-content-between" :class="[ instanceModalNotes ? 'flex-column gap-2' : 'align-items-center']">
  213. <div class="text-muted small">Notes</div>
  214. <transition name="fade">
  215. <div v-if="instanceModalNotes" class="w-100">
  216. <b-form-textarea v-model="editingInstance.notes" rows="3" max-rows="5" maxlength="500"></b-form-textarea>
  217. <p class="small text-muted">{{editingInstance.notes ? editingInstance.notes.length : 0}}/500</p>
  218. </div>
  219. <div v-else class="mb-1">
  220. <a href="#" class="font-weight-bold small" @click.prevent="showModalNotes()">{{editingInstance.notes ? 'View' : 'Add'}}</a>
  221. </div>
  222. </transition>
  223. </div>
  224. </div>
  225. <template #modal-footer>
  226. <div class="w-100 d-flex justify-content-between align-items-center">
  227. <div>
  228. <b-button
  229. variant="outline-danger"
  230. size="sm"
  231. @click="deleteInstanceModal"
  232. >
  233. Delete
  234. </b-button>
  235. <b-button
  236. v-if="!refreshedModalStats"
  237. variant="outline-primary"
  238. size="sm"
  239. @click="refreshModalStats"
  240. >
  241. Refresh Stats
  242. </b-button>
  243. </div>
  244. <div>
  245. <b-button
  246. variant="secondary"
  247. @click="showInstanceModal = false"
  248. >
  249. Close
  250. </b-button>
  251. <b-button
  252. variant="primary"
  253. @click="saveInstanceModalChanges"
  254. >
  255. Save
  256. </b-button>
  257. </div>
  258. </div>
  259. </template>
  260. </b-modal>
  261. <b-modal
  262. v-model="showAddModal"
  263. title="Add Instance"
  264. ok-title="Save"
  265. :ok-disabled="addNewInstance.domain.length < 2"
  266. @ok="saveNewInstance">
  267. <div class="list-group">
  268. <div class="list-group-item d-flex align-items-center justify-content-between">
  269. <div class="text-muted small">Domain</div>
  270. <div>
  271. <b-form-input v-model="addNewInstance.domain" placeholder="Add domain here" />
  272. <p class="small text-light mb-0">Enter a valid domain without https://</p>
  273. </div>
  274. </div>
  275. <div class="list-group-item d-flex align-items-center justify-content-between">
  276. <div class="text-muted small">Banned</div>
  277. <div class="mr-n2 mb-1">
  278. <b-form-checkbox v-model="addNewInstance.banned" switch size="lg"></b-form-checkbox>
  279. </div>
  280. </div>
  281. <div class="list-group-item d-flex align-items-center justify-content-between">
  282. <div class="text-muted small">Apply CW to Media</div>
  283. <div class="mr-n2 mb-1">
  284. <b-form-checkbox v-model="addNewInstance.auto_cw" switch size="lg"></b-form-checkbox>
  285. </div>
  286. </div>
  287. <div class="list-group-item d-flex align-items-center justify-content-between">
  288. <div class="text-muted small">Unlisted</div>
  289. <div class="mr-n2 mb-1">
  290. <b-form-checkbox v-model="addNewInstance.unlisted" switch size="lg"></b-form-checkbox>
  291. </div>
  292. </div>
  293. <div class="list-group-item d-flex flex-column gap-2 justify-content-between">
  294. <div class="text-muted small">Notes</div>
  295. <div class="w-100">
  296. <b-form-textarea v-model="addNewInstance.notes" rows="3" max-rows="5" maxlength="500" placeholder="Add optional notes here"></b-form-textarea>
  297. <p class="small text-muted">{{addNewInstance.notes ? addNewInstance.notes.length : 0}}/500</p>
  298. </div>
  299. </div>
  300. </div>
  301. </b-modal>
  302. <b-modal
  303. v-model="showImportModal"
  304. title="Import Instance Backup"
  305. ok-title="Import"
  306. scrollable
  307. :ok-disabled="!importData || (!importData.banned.length && !importData.unlisted.length && !importData.auto_cw.length)"
  308. @ok="completeImport"
  309. @cancel="cancelImport">
  310. <div v-if="showImportModal && importData">
  311. <div v-if="importData.auto_cw && importData.auto_cw.length" class="mb-5">
  312. <p class="font-weight-bold text-center my-0">NSFW Instances ({{importData.auto_cw.length}})</p>
  313. <p class="small text-center text-muted mb-1">Tap on an instance to remove it.</p>
  314. <div class="list-group">
  315. <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)">
  316. {{ instance }}
  317. <span class="badge badge-warning">Auto CW</span>
  318. </a>
  319. </div>
  320. </div>
  321. <div v-if="importData.unlisted && importData.unlisted.length" class="mb-5">
  322. <p class="font-weight-bold text-center my-0">Unlisted Instances ({{importData.unlisted.length}})</p>
  323. <p class="small text-center text-muted mb-1">Tap on an instance to remove it.</p>
  324. <div class="list-group">
  325. <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)">
  326. {{ instance }}
  327. <span class="badge badge-primary">Unlisted</span>
  328. </a>
  329. </div>
  330. </div>
  331. <div v-if="importData.banned && importData.banned.length" class="mb-5">
  332. <p class="font-weight-bold text-center my-0">Banned Instances ({{importData.banned.length}})</p>
  333. <p class="small text-center text-muted mb-1">Review instances, tap on an instance to remove it.</p>
  334. <div class="list-group">
  335. <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)">
  336. {{ instance }}
  337. <span class="badge badge-danger">Banned</span>
  338. </a>
  339. </div>
  340. </div>
  341. <div v-if="!importData.banned.length && !importData.unlisted.length && !importData.auto_cw.length">
  342. <div class="text-center">
  343. <p>
  344. <i class="far fa-check-circle fa-4x text-success"></i>
  345. </p>
  346. <p class="lead">Nothing to import!</p>
  347. </div>
  348. </div>
  349. </div>
  350. </b-modal>
  351. </div>
  352. </template>
  353. <script type="text/javascript">
  354. import Autocomplete from '@trevoreyre/autocomplete-vue'
  355. import '@trevoreyre/autocomplete-vue/dist/style.css'
  356. export default {
  357. components: {
  358. Autocomplete,
  359. },
  360. data() {
  361. return {
  362. loaded: false,
  363. tabIndex: 0,
  364. stats: {
  365. total_count: 0,
  366. new_count: 0,
  367. banned_count: 0,
  368. nsfw_count: 0
  369. },
  370. instances: [],
  371. pagination: [],
  372. sortCol: undefined,
  373. sortDir: undefined,
  374. searchQuery: undefined,
  375. filterMap: [
  376. 'all',
  377. 'new',
  378. 'banned',
  379. 'cw',
  380. 'unlisted',
  381. 'popular_users',
  382. 'popular_statuses'
  383. ],
  384. searchLoading: false,
  385. showInstanceModal: false,
  386. instanceModal: {},
  387. editingInstanceChanges: false,
  388. canEditInstance: false,
  389. editingInstance: {},
  390. editingInstanceIndex: 0,
  391. instanceModalNotes: false,
  392. showAddModal: false,
  393. refreshedModalStats: false,
  394. addNewInstance: {
  395. domain: "",
  396. banned: false,
  397. auto_cw: false,
  398. unlisted: false,
  399. notes: undefined
  400. },
  401. showImportForm: false,
  402. showImportModal: false,
  403. importData: undefined,
  404. }
  405. },
  406. mounted() {
  407. this.fetchStats();
  408. let u = new URLSearchParams(window.location.search);
  409. if(u.has('filter') && !u.has('q') && !u.has('sort')) {
  410. const url = new URL(window.location.origin + '/i/admin/api/instances/get');
  411. if(u.has('filter')) {
  412. this.tabIndex = this.filterMap.indexOf(u.get('filter'));
  413. url.searchParams.set('filter', u.get('filter'));
  414. }
  415. if(u.has('cursor')) {
  416. url.searchParams.set('cursor', u.get('cursor'));
  417. }
  418. this.fetchInstances(url.toString());
  419. } else if(u.has('sort') && !u.has('q')) {
  420. const url = new URL(window.location.origin + '/i/admin/api/instances/get');
  421. url.searchParams.set('sort', u.get('sort'));
  422. if(u.has('dir')) {
  423. url.searchParams.set('dir', u.get('dir'));
  424. }
  425. if(u.has('filter')) {
  426. url.searchParams.set('filter', u.get('filter'));
  427. }
  428. if(u.has('cursor')) {
  429. url.searchParams.set('cursor', u.get('cursor'));
  430. }
  431. this.fetchInstances(url.toString());
  432. } else if(u.has('q')) {
  433. this.tabIndex = -1;
  434. this.searchQuery = u.get('q');
  435. const url = new URL(window.location.origin + '/i/admin/api/instances/query');
  436. url.searchParams.set('q', u.get('q'));
  437. if(u.has('cursor')) {
  438. url.searchParams.set('cursor', u.get('cursor'));
  439. }
  440. this.fetchInstances(url.toString());
  441. } else {
  442. this.fetchInstances();
  443. }
  444. },
  445. watch: {
  446. editingInstance: {
  447. deep: true,
  448. immediate: true,
  449. handler: function(updated, old) {
  450. if(!this.canEditInstance) {
  451. return;
  452. }
  453. if(
  454. JSON.stringify(old) === JSON.stringify(this.instances.filter(i => i.id === updated.id)[0]) &&
  455. JSON.stringify(updated) === JSON.stringify(this.instanceModal)
  456. ) {
  457. this.editingInstanceChanges = true;
  458. } else {
  459. this.editingInstanceChanges = false;
  460. }
  461. }
  462. }
  463. },
  464. methods: {
  465. fetchStats() {
  466. axios.get('/i/admin/api/instances/stats')
  467. .then(res => {
  468. this.stats = res.data;
  469. })
  470. },
  471. fetchInstances(url = '/i/admin/api/instances/get') {
  472. axios.get(url)
  473. .then(res => {
  474. this.instances = res.data.data;
  475. this.pagination = {...res.data.links, ...res.data.meta};
  476. })
  477. .then(() => {
  478. this.$nextTick(() => {
  479. this.loaded = true;
  480. })
  481. })
  482. },
  483. toggleTab(idx) {
  484. this.loaded = false;
  485. this.tabIndex = idx;
  486. this.searchQuery = undefined;
  487. let url = '/i/admin/api/instances/get?filter=' + this.filterMap[idx];
  488. history.pushState(null, '', '/i/admin/instances?filter=' + this.filterMap[idx]);
  489. this.fetchInstances(url);
  490. },
  491. prettyCount(str) {
  492. if(str) {
  493. return str.toLocaleString('en-CA', { compactDisplay: "short", notation: "compact"});
  494. } else {
  495. return 0;
  496. }
  497. return str;
  498. },
  499. formatCount(str) {
  500. if(str) {
  501. return str.toLocaleString('en-CA');
  502. } else {
  503. return 0;
  504. }
  505. return str;
  506. },
  507. timeAgo(str) {
  508. if(!str) {
  509. return str;
  510. }
  511. return App.util.format.timeAgo(str);
  512. },
  513. boolIcon(val, success = 'text-success', danger = 'text-muted') {
  514. if(val) {
  515. return `<i class="far fa-check-circle fa-lg ${success}"></i>`;
  516. }
  517. return `<i class="far fa-times-circle fa-lg ${danger}"></i>`;
  518. },
  519. toggleCol(col) {
  520. if(this.filterMap[this.tabIndex] == col || this.searchQuery) {
  521. return;
  522. }
  523. this.sortCol = col;
  524. if(!this.sortDir) {
  525. this.sortDir = 'desc';
  526. } else {
  527. this.sortDir = this.sortDir == 'asc' ? 'desc' : 'asc';
  528. }
  529. const url = new URL(window.location.origin + '/i/admin/instances');
  530. url.searchParams.set('sort', col);
  531. url.searchParams.set('dir', this.sortDir);
  532. if(this.tabIndex != 0) {
  533. url.searchParams.set('filter', this.filterMap[this.tabIndex]);
  534. }
  535. history.pushState(null, '', url);
  536. const apiUrl = new URL(window.location.origin + '/i/admin/api/instances/get');
  537. apiUrl.searchParams.set('sort', col);
  538. apiUrl.searchParams.set('dir', this.sortDir);
  539. if(this.tabIndex != 0) {
  540. apiUrl.searchParams.set('filter', this.filterMap[this.tabIndex]);
  541. }
  542. this.fetchInstances(apiUrl.toString());
  543. },
  544. buildColumn(name, col) {
  545. if([1, 5, 6].indexOf(this.tabIndex) != -1 || (this.searchQuery && this.searchQuery.length)) {
  546. return name;
  547. }
  548. if(this.tabIndex === 2 && col === 'banned') {
  549. return name;
  550. }
  551. if(this.tabIndex === 3 && col === 'auto_cw') {
  552. return name;
  553. }
  554. if(this.tabIndex === 4 && col === 'unlisted') {
  555. return name;
  556. }
  557. let icon = `<i class="far fa-sort"></i>`;
  558. if(col == this.sortCol) {
  559. icon = this.sortDir == 'desc' ?
  560. `<i class="far fa-sort-up"></i>` :
  561. `<i class="far fa-sort-down"></i>`
  562. }
  563. return `${name} ${icon}`;
  564. },
  565. paginate(dir) {
  566. event.currentTarget.blur();
  567. let apiUrl = dir == 'next' ? this.pagination.next : this.pagination.prev;
  568. let cursor = dir == 'next' ? this.pagination.next_cursor : this.pagination.prev_cursor;
  569. const url = new URL(window.location.origin + '/i/admin/instances');
  570. if(cursor) {
  571. url.searchParams.set('cursor', cursor);
  572. }
  573. if(this.searchQuery) {
  574. url.searchParams.set('q', this.searchQuery);
  575. }
  576. if(this.sortCol) {
  577. url.searchParams.set('sort', this.sortCol);
  578. }
  579. if(this.sortDir) {
  580. url.searchParams.set('dir', this.sortDir);
  581. }
  582. history.pushState(null, '', url.toString());
  583. this.fetchInstances(apiUrl);
  584. },
  585. composeSearch(input) {
  586. if (input.length < 1) { return []; };
  587. this.searchQuery = input;
  588. history.pushState(null, '', '/i/admin/instances?q=' + input);
  589. return axios.get('/i/admin/api/instances/query', {
  590. params: {
  591. q: input,
  592. }
  593. }).then(res => {
  594. if(!res || !res.data) {
  595. this.fetchInstances();
  596. } else {
  597. this.tabIndex = -1;
  598. this.instances = res.data.data;
  599. this.pagination = {...res.data.links, ...res.data.meta};
  600. }
  601. return res.data.data;
  602. });
  603. },
  604. getTagResultValue(result) {
  605. return result.name;
  606. },
  607. onSearchResultClick(result) {
  608. this.openInstanceModal(result.id);
  609. return;
  610. },
  611. openInstanceModal(id) {
  612. const cached = this.instances.filter(i => i.id === id)[0];
  613. this.refreshedModalStats = false;
  614. this.editingInstanceChanges = false;
  615. this.instanceModalNotes = false;
  616. this.canEditInstance = false;
  617. this.instanceModal = cached;
  618. this.$nextTick(() => {
  619. this.editingInstance = cached;
  620. this.showInstanceModal = true;
  621. this.canEditInstance = true;
  622. })
  623. },
  624. showModalNotes() {
  625. this.instanceModalNotes = true;
  626. },
  627. saveInstanceModalChanges() {
  628. axios.post('/i/admin/api/instances/update', this.editingInstance)
  629. .then(res => {
  630. this.showInstanceModal = false;
  631. this.$bvToast.toast(`Successfully updated ${res.data.data.domain}`, {
  632. title: 'Instance Updated',
  633. autoHideDelay: 5000,
  634. appendToast: true,
  635. variant: 'success'
  636. })
  637. })
  638. },
  639. saveNewInstance() {
  640. axios.post('/i/admin/api/instances/create', this.addNewInstance)
  641. .then(res => {
  642. this.showInstanceModal = false;
  643. this.instances.unshift(res.data.data);
  644. })
  645. .catch(err => {
  646. swal('Oops!', 'An error occured, please try again later.', 'error');
  647. this.addNewInstance = {
  648. domain: "",
  649. banned: false,
  650. auto_cw: false,
  651. unlisted: false,
  652. notes: undefined
  653. }
  654. })
  655. },
  656. refreshModalStats() {
  657. axios.post('/i/admin/api/instances/refresh-stats', {
  658. id: this.instanceModal.id
  659. })
  660. .then(res => {
  661. this.refreshedModalStats = true;
  662. this.instanceModal = res.data.data;
  663. this.editingInstance = res.data.data;
  664. this.instances = this.instances.map(i => {
  665. if(i.id === res.data.data.id) {
  666. return res.data.data;
  667. }
  668. return i;
  669. })
  670. })
  671. },
  672. deleteInstanceModal() {
  673. if(!window.confirm('Are you sure you want to delete this instance? This will not delete posts or profiles from this instance.')) {
  674. return;
  675. }
  676. axios.post('/i/admin/api/instances/delete', {
  677. id: this.instanceModal.id
  678. })
  679. .then(res => {
  680. this.showInstanceModal = false;
  681. this.instances = this.instances.filter(i => i.id != this.instanceModal.id);
  682. })
  683. .then(() => {
  684. setTimeout(() => this.fetchStats(), 1000);
  685. })
  686. },
  687. openImportForm() {
  688. let el = document.createElement('p');
  689. el.classList.add('text-left');
  690. el.classList.add('mb-0');
  691. 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>';
  692. let wrapper = document.createElement('div');
  693. wrapper.appendChild(el);
  694. swal({
  695. title: 'Import Backup',
  696. content: wrapper,
  697. icon: 'info'
  698. })
  699. this.showImportForm = true;
  700. },
  701. downloadBackup($event) {
  702. axios.get('/i/admin/api/instances/download-backup', {
  703. responseType: "blob"
  704. })
  705. .then(res => {
  706. let el = document.createElement('a');
  707. el.setAttribute('download', 'pixelfed-instances-mod.json')
  708. const href = URL.createObjectURL(res.data);
  709. el.href = href;
  710. el.setAttribute('target', '_blank');
  711. el.click();
  712. swal(
  713. 'Instance Backup Downloading',
  714. 'Your instance moderation backup is downloading. Use this to import auto_cw, banned and unlisted instances to supported Pixelfed instances.',
  715. 'success'
  716. )
  717. })
  718. },
  719. async onImportUpload(ev) {
  720. let res = await this.getParsedImport(ev.target.files[0]);
  721. if(!res.hasOwnProperty('version') || res.version !== 1) {
  722. swal('Invalid Backup', 'We cannot validate this backup. Please try again later.', 'error');
  723. this.showImportForm = false;
  724. this.$refs.importInput.reset();
  725. return;
  726. }
  727. this.importData = res;
  728. this.showImportModal = true;
  729. },
  730. async getParsedImport(ev) {
  731. try {
  732. return await this.parseJsonFile(ev);
  733. } catch(err) {
  734. let el = document.createElement('p');
  735. el.classList.add('text-left');
  736. el.classList.add('mb-0');
  737. 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>';
  738. let wrapper = document.createElement('div');
  739. wrapper.appendChild(el);
  740. swal({
  741. title: 'Import Error',
  742. content: wrapper,
  743. icon: 'error'
  744. })
  745. return;
  746. }
  747. },
  748. async promisedParseJSON(json) {
  749. return new Promise((resolve, reject) => {
  750. try {
  751. resolve(JSON.parse(json))
  752. } catch (e) {
  753. reject(e)
  754. }
  755. })
  756. },
  757. async parseJsonFile(file) {
  758. return new Promise((resolve, reject) => {
  759. const fileReader = new FileReader()
  760. fileReader.onload = event => resolve(this.promisedParseJSON(event.target.result))
  761. fileReader.onerror = error => reject(error)
  762. fileReader.readAsText(file)
  763. })
  764. },
  765. filterImportData(type, index) {
  766. switch(type) {
  767. case 'auto_cw':
  768. this.importData.auto_cw.splice(index, 1);
  769. break;
  770. case 'unlisted':
  771. this.importData.unlisted.splice(index, 1);
  772. break;
  773. case 'banned':
  774. this.importData.banned.splice(index, 1);
  775. break;
  776. }
  777. },
  778. completeImport() {
  779. this.showImportForm = false;
  780. axios.post('/i/admin/api/instances/import-data', {
  781. 'banned': this.importData.banned,
  782. 'auto_cw': this.importData.auto_cw,
  783. 'unlisted': this.importData.unlisted,
  784. })
  785. .then(res => {
  786. swal('Import Uploaded', 'Import successfully uploaded, please allow a few minutes to process.', 'success');
  787. })
  788. .then(() => {
  789. setTimeout(() => this.fetchStats(), 1000);
  790. })
  791. },
  792. cancelImport(bvModalEvent) {
  793. if(this.importData.banned.length || this.importData.auto_cw.length || this.importData.unlisted.length) {
  794. if(!window.confirm('Are you sure you want to cancel importing?')) {
  795. bvModalEvent.preventDefault();
  796. return;
  797. } else {
  798. this.showImportForm = false;
  799. this.$refs.importInput.value = '';
  800. this.importData = {
  801. banned: [],
  802. auto_cw: [],
  803. unlisted: []
  804. };
  805. }
  806. }
  807. }
  808. }
  809. }
  810. </script>
  811. <style lang="scss" scoped>
  812. .gap-2 {
  813. gap: 1rem;
  814. }
  815. </style>