mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-26 16:46:12 +01:00
refactor: stub routes for category synchronization, refactor remote follow logic to allow categories to conduct follows as well
This commit is contained in:
@@ -39,6 +39,7 @@
|
|||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
|
"federation": "Federation",
|
||||||
|
|
||||||
"view-category": "View category",
|
"view-category": "View category",
|
||||||
"set-order": "Set order",
|
"set-order": "Set order",
|
||||||
@@ -78,6 +79,18 @@
|
|||||||
"analytics.topics-daily": "<strong>Figure 3</strong> – Daily topics created in this category</small>",
|
"analytics.topics-daily": "<strong>Figure 3</strong> – Daily topics created in this category</small>",
|
||||||
"analytics.posts-daily": "<strong>Figure 4</strong> – Daily posts made in this category</small>",
|
"analytics.posts-daily": "<strong>Figure 4</strong> – Daily posts made in this category</small>",
|
||||||
|
|
||||||
|
"federation.title": "Federation settings for \"%1\" category",
|
||||||
|
"federation.disabled": "Federation is disabled site-wide, so category federation settings are currently unavailable.",
|
||||||
|
"federation.disabled-cta": "Federation Settings →",
|
||||||
|
"federation.syncing-header": "Synchronization",
|
||||||
|
"federation.syncing-intro": "A category can follow a \"Group Actor\" via the ActivityPub protocol. If content is received from one of the actors listed below, it will be automatically added to this category.",
|
||||||
|
"federation.syncing-caveat": "N.B. Setting up syncing here establishes a one-way synchronization. NodeBB attempts to subscribe/follow the actor, but the reverse cannot be assumed.",
|
||||||
|
"federation.syncing-none": "This category is not currently following anybody.",
|
||||||
|
"federation.syncing-add": "Synchronize with...",
|
||||||
|
"federation.syncing-actorUri": "Actor",
|
||||||
|
"federation.syncing-follow": "Follow",
|
||||||
|
"federation.syncing-unfollow": "Unfollow",
|
||||||
|
|
||||||
"alert.created": "Created",
|
"alert.created": "Created",
|
||||||
"alert.create-success": "Category successfully created!",
|
"alert.create-success": "Category successfully created!",
|
||||||
"alert.none-active": "You have no active categories.",
|
"alert.none-active": "You have no active categories.",
|
||||||
|
|||||||
48
public/src/admin/manage/category-federation.js
Normal file
48
public/src/admin/manage/category-federation.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { put, del } from '../../modules/api';
|
||||||
|
import { error } from '../../modules/alerts';
|
||||||
|
|
||||||
|
import * as categorySelector from '../../modules/categorySelector';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export function init() {
|
||||||
|
categorySelector.init($('[component="category-selector"]'), {
|
||||||
|
onSelect: function (selectedCategory) {
|
||||||
|
ajaxify.go('admin/manage/categories/' + selectedCategory.cid + '/federation');
|
||||||
|
},
|
||||||
|
showLinks: true,
|
||||||
|
template: 'admin/partials/category/selector-dropdown-right',
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('site-settings').addEventListener('click', async (e) => {
|
||||||
|
const subselector = e.target.closest('[data-action]');
|
||||||
|
if (!subselector) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = subselector.getAttribute('data-action');
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'follow': {
|
||||||
|
const inputEl = document.getElementById('syncing.add');
|
||||||
|
const actor = inputEl.value;
|
||||||
|
|
||||||
|
put(`/categories/${ajaxify.data.cid}/follow`, { actor })
|
||||||
|
.then(ajaxify.refresh)
|
||||||
|
.catch(error);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'unfollow': {
|
||||||
|
const actor = subselector.getAttribute('data-actor');
|
||||||
|
|
||||||
|
del(`/categories/${ajaxify.data.cid}/follow`, { actor })
|
||||||
|
.then(ajaxify.refresh)
|
||||||
|
.catch(error);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -112,6 +112,7 @@ Helpers.resolveLocalId = async (input) => {
|
|||||||
activityData = { activity, data };
|
activityData = { activity, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://bb.devnull.land/cid/2#activity/follow/activitypub@community.nodebb.org│
|
||||||
switch (prefix) {
|
switch (prefix) {
|
||||||
case 'uid':
|
case 'uid':
|
||||||
return { type: 'user', id: value, ...activityData };
|
return { type: 'user', id: value, ...activityData };
|
||||||
@@ -119,6 +120,7 @@ Helpers.resolveLocalId = async (input) => {
|
|||||||
case 'post':
|
case 'post':
|
||||||
return { type: 'post', id: value, ...activityData };
|
return { type: 'post', id: value, ...activityData };
|
||||||
|
|
||||||
|
case 'cid':
|
||||||
case 'category':
|
case 'category':
|
||||||
return { type: 'category', id: value, ...activityData };
|
return { type: 'category', id: value, ...activityData };
|
||||||
|
|
||||||
|
|||||||
@@ -250,9 +250,9 @@ inbox.accept = async (req) => {
|
|||||||
const { actor, object } = req.body;
|
const { actor, object } = req.body;
|
||||||
const { type } = object;
|
const { type } = object;
|
||||||
|
|
||||||
const { type: localType, id: uid } = await helpers.resolveLocalId(object.actor);
|
const { type: localType, id } = await helpers.resolveLocalId(object.actor);
|
||||||
if (localType !== 'user' || !uid) {
|
if (!['user', 'category'].includes(localType)) {
|
||||||
throw new Error('[[error:invalid-uid]]');
|
throw new Error('[[error:invalid-data]]');
|
||||||
}
|
}
|
||||||
|
|
||||||
const assertion = await activitypub.actors.assert(actor);
|
const assertion = await activitypub.actors.assert(actor);
|
||||||
@@ -261,18 +261,30 @@ inbox.accept = async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'Follow') {
|
if (type === 'Follow') {
|
||||||
if (!await db.isSortedSetMember(`followRequests:${uid}`, actor)) {
|
if (localType === 'user') {
|
||||||
if (await db.isSortedSetMember(`followingRemote:${uid}`, actor)) return; // already following
|
if (!await db.isSortedSetMember(`followRequests:uid.${id}`, actor)) {
|
||||||
return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries
|
if (await db.isSortedSetMember(`followingRemote:${id}`, actor)) return; // already following
|
||||||
|
return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
await Promise.all([
|
||||||
|
db.sortedSetRemove(`followRequests:uid.${id}`, actor),
|
||||||
|
db.sortedSetAdd(`followingRemote:${id}`, now, actor),
|
||||||
|
db.sortedSetAdd(`followersRemote:${actor}`, now, id), // for followers backreference and notes assertion checking
|
||||||
|
]);
|
||||||
|
const followingRemoteCount = await db.sortedSetCard(`followingRemote:${id}`);
|
||||||
|
await user.setUserField(id, 'followingRemoteCount', followingRemoteCount);
|
||||||
|
} else if (localType === 'category') {
|
||||||
|
if (!await db.isSortedSetMember(`followRequests:cid.${id}`, actor)) {
|
||||||
|
if (await db.isSortedSetMember(`cid:${id}:following`, actor)) return; // already following
|
||||||
|
return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
await Promise.all([
|
||||||
|
db.sortedSetRemove(`followRequests:cid.${id}`, actor),
|
||||||
|
db.sortedSetAdd(`cid:${id}:following`, now, actor),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
|
||||||
await Promise.all([
|
|
||||||
db.sortedSetRemove(`followRequests:${uid}`, actor),
|
|
||||||
db.sortedSetAdd(`followingRemote:${uid}`, now, actor),
|
|
||||||
db.sortedSetAdd(`followersRemote:${actor}`, now, uid), // for followers backreference and notes assertion checking
|
|
||||||
]);
|
|
||||||
const followingRemoteCount = await db.sortedSetCard(`followingRemote:${uid}`);
|
|
||||||
await user.setUserField(uid, 'followingRemoteCount', followingRemoteCount);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const nconf = require('nconf');
|
|||||||
const winston = require('winston');
|
const winston = require('winston');
|
||||||
|
|
||||||
const db = require('../database');
|
const db = require('../database');
|
||||||
|
const user = require('../user');
|
||||||
const meta = require('../meta');
|
const meta = require('../meta');
|
||||||
const privileges = require('../privileges');
|
const privileges = require('../privileges');
|
||||||
const activitypub = require('../activitypub');
|
const activitypub = require('../activitypub');
|
||||||
@@ -31,43 +32,63 @@ function enabledCheck(next) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
activitypubApi.follow = enabledCheck(async (caller, { uid } = {}) => {
|
activitypubApi.follow = enabledCheck(async (caller, { type, id, actor } = {}) => {
|
||||||
const result = await activitypub.helpers.query(uid);
|
// Privilege checks should be done upstream
|
||||||
if (!result) {
|
const assertion = await activitypub.actors.assert(actor);
|
||||||
|
if (!assertion) {
|
||||||
throw new Error('[[error:activitypub.invalid-id]]');
|
throw new Error('[[error:activitypub.invalid-id]]');
|
||||||
}
|
}
|
||||||
|
|
||||||
await activitypub.send('uid', caller.uid, [result.actorUri], {
|
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
|
||||||
id: `${nconf.get('url')}/uid/${caller.uid}#activity/follow/${result.username}@${result.hostname}`,
|
const handle = await user.getUserField(actor, 'username');
|
||||||
|
|
||||||
|
await activitypub.send(type, id, [actor], {
|
||||||
|
id: `${nconf.get('url')}/${type}/${id}#activity/follow/${handle}`,
|
||||||
type: 'Follow',
|
type: 'Follow',
|
||||||
object: result.actorUri,
|
object: actor,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.sortedSetAdd(`followRequests:${caller.uid}`, Date.now(), result.actorUri);
|
await db.sortedSetAdd(`followRequests:${type}.${id}`, Date.now(), actor);
|
||||||
});
|
});
|
||||||
|
|
||||||
// should be .undo.follow
|
// should be .undo.follow
|
||||||
activitypubApi.unfollow = enabledCheck(async (caller, { uid }) => {
|
activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => {
|
||||||
const result = await activitypub.helpers.query(uid);
|
const assertion = await activitypub.actors.assert(actor);
|
||||||
if (!result) {
|
if (!assertion) {
|
||||||
throw new Error('[[error:activitypub.invalid-id]]');
|
throw new Error('[[error:activitypub.invalid-id]]');
|
||||||
}
|
}
|
||||||
|
|
||||||
await activitypub.send('uid', caller.uid, [result.actorUri], {
|
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
|
||||||
id: `${nconf.get('url')}/uid/${caller.uid}#activity/undo:follow/${result.username}@${result.hostname}`,
|
const handle = await user.getUserField(actor, 'username');
|
||||||
|
|
||||||
|
const object = {
|
||||||
|
id: `${nconf.get('url')}/${type}/${id}#activity/follow/${handle}`,
|
||||||
|
type: 'Follow',
|
||||||
|
object: actor,
|
||||||
|
};
|
||||||
|
if (type === 'uid') {
|
||||||
|
object.actor = `${nconf.get('url')}/uid/${id}`;
|
||||||
|
} else if (type === 'cid') {
|
||||||
|
object.actor = `${nconf.get('url')}/category/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await activitypub.send(type, id, [actor], {
|
||||||
|
id: `${nconf.get('url')}/${type}/${id}#activity/undo:follow/${handle}`,
|
||||||
type: 'Undo',
|
type: 'Undo',
|
||||||
object: {
|
object,
|
||||||
id: `${nconf.get('url')}/uid/${caller.uid}#activity/follow/${result.username}@${result.hostname}`,
|
|
||||||
type: 'Follow',
|
|
||||||
actor: `${nconf.get('url')}/uid/${caller.uid}`,
|
|
||||||
object: result.actorUri,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all([
|
if (type === 'uid') {
|
||||||
db.sortedSetRemove(`followingRemote:${caller.uid}`, result.actorUri),
|
await Promise.all([
|
||||||
db.decrObjectField(`user:${caller.uid}`, 'followingRemoteCount'),
|
db.sortedSetRemove(`followingRemote:${id}`, actor),
|
||||||
]);
|
db.decrObjectField(`user:${id}`, 'followingRemoteCount'),
|
||||||
|
]);
|
||||||
|
} else if (type === 'cid') {
|
||||||
|
await Promise.all([
|
||||||
|
db.sortedSetRemove(`cid:${id}:following`, actor),
|
||||||
|
db.sortedSetRemove(`followRequests:cid.${id}`, actor),
|
||||||
|
]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
activitypubApi.create = {};
|
activitypubApi.create = {};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
const nconf = require('nconf');
|
const nconf = require('nconf');
|
||||||
|
const db = require('../../database');
|
||||||
const categories = require('../../categories');
|
const categories = require('../../categories');
|
||||||
const analytics = require('../../analytics');
|
const analytics = require('../../analytics');
|
||||||
const plugins = require('../../plugins');
|
const plugins = require('../../plugins');
|
||||||
@@ -145,3 +146,26 @@ categoriesController.getAnalytics = async function (req, res) {
|
|||||||
selectedCategory: selectedData.selectedCategory,
|
selectedCategory: selectedData.selectedCategory,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
categoriesController.getFederation = async function (req, res) {
|
||||||
|
const cid = req.params.category_id;
|
||||||
|
const [_following, pending, name, { selectedCategory }] = await Promise.all([
|
||||||
|
db.getSortedSetMembers(`cid:${cid}:following`),
|
||||||
|
db.getSortedSetMembers(`followRequests:cid.${cid}`),
|
||||||
|
categories.getCategoryField(cid, 'name'),
|
||||||
|
helpers.getSelectedCategory(cid),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const following = [..._following, ...pending].map(entry => ({
|
||||||
|
id: entry,
|
||||||
|
approved: !pending.includes(entry),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.render('admin/manage/category-federation', {
|
||||||
|
cid: cid,
|
||||||
|
enabled: meta.config.activitypubEnabled,
|
||||||
|
name,
|
||||||
|
selectedCategory,
|
||||||
|
following,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -105,3 +105,29 @@ Categories.setModerator = async (req, res) => {
|
|||||||
const privilegeSet = await api.categories.getPrivileges(req, { cid: req.params.cid });
|
const privilegeSet = await api.categories.getPrivileges(req, { cid: req.params.cid });
|
||||||
helpers.formatApiResponse(200, res, privilegeSet);
|
helpers.formatApiResponse(200, res, privilegeSet);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Categories.follow = async (req, res) => {
|
||||||
|
const { actor } = req.body;
|
||||||
|
const id = req.params.cid;
|
||||||
|
|
||||||
|
await api.activitypub.follow(req, {
|
||||||
|
type: 'cid',
|
||||||
|
id,
|
||||||
|
actor,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.sendStatus(200);
|
||||||
|
};
|
||||||
|
|
||||||
|
Categories.unfollow = async (req, res) => {
|
||||||
|
const { actor } = req.body;
|
||||||
|
const id = req.params.cid;
|
||||||
|
|
||||||
|
await api.activitypub.unfollow(req, {
|
||||||
|
type: 'cid',
|
||||||
|
id,
|
||||||
|
actor,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.sendStatus(200);
|
||||||
|
};
|
||||||
|
|||||||
@@ -93,15 +93,30 @@ Users.changePassword = async (req, res) => {
|
|||||||
|
|
||||||
Users.follow = async (req, res) => {
|
Users.follow = async (req, res) => {
|
||||||
const remote = String(req.params.uid).includes('@');
|
const remote = String(req.params.uid).includes('@');
|
||||||
const controller = remote ? api.activitypub.follow : api.users.follow;
|
if (remote) {
|
||||||
await controller(req, req.params);
|
await api.activitypub.follow(req, {
|
||||||
|
type: 'uid',
|
||||||
|
id: req.uid,
|
||||||
|
actor: req.params.uid,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await api.users.follow(req, req.params);
|
||||||
|
}
|
||||||
|
|
||||||
helpers.formatApiResponse(200, res);
|
helpers.formatApiResponse(200, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
Users.unfollow = async (req, res) => {
|
Users.unfollow = async (req, res) => {
|
||||||
const remote = String(req.params.uid).includes('@');
|
const remote = String(req.params.uid).includes('@');
|
||||||
const controller = remote ? api.activitypub.unfollow : api.users.unfollow;
|
if (remote) {
|
||||||
await controller(req, req.params);
|
await api.activitypub.unfollow(req, {
|
||||||
|
type: 'uid',
|
||||||
|
id: req.uid,
|
||||||
|
actor: req.params.uid,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await api.users.unfollow(req, req.params);
|
||||||
|
}
|
||||||
helpers.formatApiResponse(200, res);
|
helpers.formatApiResponse(200, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ module.exports = function (app, name, middleware, controllers) {
|
|||||||
helpers.setupAdminPageRoute(app, `/${name}/manage/categories`, middlewares, controllers.admin.categories.getAll);
|
helpers.setupAdminPageRoute(app, `/${name}/manage/categories`, middlewares, controllers.admin.categories.getAll);
|
||||||
helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id`, middlewares, controllers.admin.categories.get);
|
helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id`, middlewares, controllers.admin.categories.get);
|
||||||
helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id/analytics`, middlewares, controllers.admin.categories.getAnalytics);
|
helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id/analytics`, middlewares, controllers.admin.categories.getAnalytics);
|
||||||
|
helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id/federation`, middlewares, controllers.admin.categories.getFederation);
|
||||||
|
|
||||||
helpers.setupAdminPageRoute(app, `/${name}/manage/privileges/:cid?`, middlewares, controllers.admin.privileges.get);
|
helpers.setupAdminPageRoute(app, `/${name}/manage/privileges/:cid?`, middlewares, controllers.admin.privileges.get);
|
||||||
helpers.setupAdminPageRoute(app, `/${name}/manage/tags`, middlewares, controllers.admin.tags.get);
|
helpers.setupAdminPageRoute(app, `/${name}/manage/tags`, middlewares, controllers.admin.tags.get);
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ module.exports = function () {
|
|||||||
setupApiRoute(router, 'put', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege);
|
setupApiRoute(router, 'put', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege);
|
||||||
setupApiRoute(router, 'delete', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege);
|
setupApiRoute(router, 'delete', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege);
|
||||||
|
|
||||||
setupApiRoute(router, 'put', '/:cid/moderator/:uid', [...middlewares], controllers.write.categories.setModerator);
|
setupApiRoute(router, 'put', '/:cid/moderator/:uid', [...middlewares, middleware.assert.category], controllers.write.categories.setModerator);
|
||||||
setupApiRoute(router, 'delete', '/:cid/moderator/:uid', [...middlewares], controllers.write.categories.setModerator);
|
setupApiRoute(router, 'delete', '/:cid/moderator/:uid', [...middlewares, middleware.assert.category], controllers.write.categories.setModerator);
|
||||||
|
|
||||||
|
setupApiRoute(router, 'put', '/:cid/follow', [...middlewares, middleware.admin.checkPrivileges, middleware.assert.category], controllers.write.categories.follow);
|
||||||
|
setupApiRoute(router, 'delete', '/:cid/follow', [...middlewares, middleware.admin.checkPrivileges, middleware.assert.category], controllers.write.categories.unfollow);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
67
src/views/admin/manage/category-federation.tpl
Normal file
67
src/views/admin/manage/category-federation.tpl
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
|
||||||
|
<div class="px-lg-4">
|
||||||
|
|
||||||
|
<div class="row border-bottom py-2 m-0 mb-3 sticky-top acp-page-main-header align-items-center">
|
||||||
|
<div class="col-12 px-0 mb-1 mb-md-0 d-flex justify-content-between align-items-center">
|
||||||
|
<h4 class="fw-bold tracking-tight mb-0">[[admin/manage/categories:federation.title, {name}]]</h4>
|
||||||
|
<!-- IMPORT admin/partials/category/selector-dropdown-right.tpl -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{{ if !enabled }}}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<p>[[admin/manage/categories:federation.disabled]]</p>
|
||||||
|
<a class="btn btn-primary" href="{config.relative_path}/admin/settings/activitypub">[[admin/manage/categories:federation.disabled-cta]]</a>
|
||||||
|
</div>
|
||||||
|
{{{ else }}}
|
||||||
|
<div class="acp-page-container">
|
||||||
|
<div class="row settings m-0">
|
||||||
|
<div class="col-12 col-md-8 px-0 mb-4" tabindex="0">
|
||||||
|
<div id="site-settings" class="mb-4">
|
||||||
|
<form role="form">
|
||||||
|
<h5 class="fw-bold settings-header">[[admin/manage/categories:federation.syncing-header]]</h5>
|
||||||
|
<p>[[admin/manage/categories:federation.syncing-intro]]</p>
|
||||||
|
<p class="form-text">[[admin/manage/categories:federation.syncing-caveat]]</p>
|
||||||
|
|
||||||
|
{{{ if !following.length }}}
|
||||||
|
<div class="alert alert-info">[[admin/manage/categories:federation.syncing-none]]</div>
|
||||||
|
{{{ else }}}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>[[admin/manage/categories:federation.syncing-actorUri]]</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{{ each following }}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<pre class="mb-0 mt-1">{./id}</pre>
|
||||||
|
{{{ if !./approved }}}
|
||||||
|
<span class="form-text text-warning">Pending</span>
|
||||||
|
{{{ end }}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" data-action="unfollow" data-actor="{./id}" class="btn btn-sm btn-danger">[[admin/manage/categories:federation.syncing-unfollow]]</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{{ end }}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{{ end }}}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="syncing.add">[[admin/manage/categories:federation.syncing-add]]</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input id="syncing.add" type="url" class="form-control" />
|
||||||
|
<button data-action="follow" type="button" class="btn btn-primary">[[admin/manage/categories:federation.syncing-follow]]</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{{ end }}}
|
||||||
|
</div>
|
||||||
@@ -190,6 +190,8 @@
|
|||||||
<i class="fa fa-fw fa-lock text-primary"></i> [[admin/manage/categories:privileges]]
|
<i class="fa fa-fw fa-lock text-primary"></i> [[admin/manage/categories:privileges]]
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a class="btn-ghost-sm justify-content-start" href="{config.relative_path}/admin/manage/categories/{category.cid}/federation"><i class="fa fa-fw fa-globe text-primary"></i> [[admin/manage/categories:federation]]</a>
|
||||||
|
|
||||||
<a href="{config.relative_path}/category/{category.cid}" class="btn-ghost-sm justify-content-start">
|
<a href="{config.relative_path}/category/{category.cid}" class="btn-ghost-sm justify-content-start">
|
||||||
<i class="fa fa-fw fa-eye text-primary"></i> [[admin/manage/categories:view-category]]
|
<i class="fa fa-fw fa-eye text-primary"></i> [[admin/manage/categories:view-category]]
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
|
|
||||||
<li><a class="dropdown-item rounded-1" href="./categories/{categories.cid}/analytics" role="menuitem">[[admin/manage/categories:analytics]]</a></li>
|
<li><a class="dropdown-item rounded-1" href="./categories/{categories.cid}/analytics" role="menuitem">[[admin/manage/categories:analytics]]</a></li>
|
||||||
<li><a class="dropdown-item rounded-1" href="{config.relative_path}/admin/manage/privileges/{categories.cid}" role="menuitem">[[admin/manage/categories:privileges]]</a></li>
|
<li><a class="dropdown-item rounded-1" href="{config.relative_path}/admin/manage/privileges/{categories.cid}" role="menuitem">[[admin/manage/categories:privileges]]</a></li>
|
||||||
|
<li><a class="dropdown-item rounded-1" href="./categories/{categories.cid}/federation" role="menuitem">[[admin/manage/categories:federation]]</a></li>
|
||||||
<li><a href="#" class="set-order dropdown-item rounded-1" data-cid="{categories.cid}" data-order="{categories.order}" role="menuitem">[[admin/manage/categories:set-order]]</a></li>
|
<li><a href="#" class="set-order dropdown-item rounded-1" data-cid="{categories.cid}" data-order="{categories.order}" role="menuitem">[[admin/manage/categories:set-order]]</a></li>
|
||||||
<li class="dropdown-divider"></li>
|
<li class="dropdown-divider"></li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
Reference in New Issue
Block a user