mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-07-04 12:57:38 +02:00
Merge branch 'develop' into activitypub
This commit is contained in:
@@ -7,6 +7,7 @@ const groups = require('../../groups');
|
||||
const privileges = require('../../privileges');
|
||||
const plugins = require('../../plugins');
|
||||
const file = require('../../file');
|
||||
const accountHelpers = require('./helpers');
|
||||
|
||||
const editController = module.exports;
|
||||
|
||||
@@ -25,11 +26,13 @@ editController.get = async function (req, res, next) {
|
||||
allowMultipleBadges,
|
||||
} = userData;
|
||||
|
||||
const [canUseSignature, canManageUsers] = await Promise.all([
|
||||
const [canUseSignature, canManageUsers, customUserFields] = await Promise.all([
|
||||
privileges.global.can('signature', req.uid),
|
||||
privileges.admin.can('admin:users', req.uid),
|
||||
accountHelpers.getCustomUserFields(userData),
|
||||
]);
|
||||
|
||||
userData.customUserFields = customUserFields;
|
||||
userData.maximumSignatureLength = meta.config.maximumSignatureLength;
|
||||
userData.maximumAboutMeLength = meta.config.maximumAboutMeLength;
|
||||
userData.maximumProfileImageSize = meta.config.maximumProfileImageSize;
|
||||
|
||||
@@ -143,6 +143,29 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {})
|
||||
return hookData.userData;
|
||||
};
|
||||
|
||||
helpers.getCustomUserFields = async function (userData) {
|
||||
const keys = await db.getSortedSetRange('user-custom-fields', 0, -1);
|
||||
const allFields = (await db.getObjects(keys.map(k => `user-custom-field:${k}`))).filter(Boolean);
|
||||
|
||||
const fields = allFields.filter((field) => {
|
||||
const minRep = field['min:rep'] || 0;
|
||||
return userData.reputation >= minRep || meta.config['reputation:disabled'];
|
||||
});
|
||||
|
||||
fields.forEach((f) => {
|
||||
f['select-options'] = f['select-options'].split('\n').filter(Boolean).map(
|
||||
opt => ({
|
||||
value: opt,
|
||||
selected: opt === userData[f.key],
|
||||
})
|
||||
);
|
||||
if (userData[f.key]) {
|
||||
f.value = validator.escape(String(userData[f.key]));
|
||||
}
|
||||
});
|
||||
return fields;
|
||||
};
|
||||
|
||||
function escape(value) {
|
||||
return translator.escape(validator.escape(String(value || '')));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const categories = require('../../categories');
|
||||
const plugins = require('../../plugins');
|
||||
const privileges = require('../../privileges');
|
||||
const helpers = require('../helpers');
|
||||
const accountHelpers = require('./helpers');
|
||||
const utils = require('../../utils');
|
||||
|
||||
const profileController = module.exports;
|
||||
@@ -23,12 +24,13 @@ profileController.get = async function (req, res, next) {
|
||||
|
||||
await incrementProfileViews(req, userData);
|
||||
|
||||
const [latestPosts, bestPosts] = await Promise.all([
|
||||
const [latestPosts, bestPosts, customUserFields] = await Promise.all([
|
||||
getLatestPosts(req.uid, userData),
|
||||
getBestPosts(req.uid, userData),
|
||||
accountHelpers.getCustomUserFields(userData),
|
||||
posts.parseSignature(userData, req.uid),
|
||||
]);
|
||||
|
||||
userData.customUserFields = customUserFields;
|
||||
userData.posts = latestPosts; // for backwards compat.
|
||||
userData.latestPosts = latestPosts;
|
||||
userData.bestPosts = bestPosts;
|
||||
|
||||
@@ -294,3 +294,15 @@ usersController.getCSV = async function (req, res, next) {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
usersController.customFields = async function (req, res) {
|
||||
const keys = await db.getSortedSetRange('user-custom-fields', 0, -1);
|
||||
const fields = (await db.getObjects(keys.map(k => `user-custom-field:${k}`))).filter(Boolean);
|
||||
fields.forEach((field) => {
|
||||
if (field['select-options']) {
|
||||
field.selectOptionsFormatted = field['select-options'].trim().split('\n').join(', ');
|
||||
}
|
||||
field['min:rep'] = field['min:rep'] || 0;
|
||||
});
|
||||
res.render('admin/manage/users/custom-fields', { fields: fields });
|
||||
};
|
||||
|
||||
@@ -78,6 +78,7 @@ apiController.loadConfig = async function (req) {
|
||||
enablePostHistory: meta.config.enablePostHistory === 1,
|
||||
timeagoCutoff: meta.config.timeagoCutoff !== '' ? Math.max(0, parseInt(meta.config.timeagoCutoff, 10)) : meta.config.timeagoCutoff,
|
||||
timeagoCodes: languages.timeagoCodes,
|
||||
resizeImageWidth: meta.config.resizeImageWidth,
|
||||
cookies: {
|
||||
enabled: meta.config.cookieConsentEnabled === 1,
|
||||
message: translator.escape(validator.escape(meta.config.cookieConsentMessage || '[[global:cookies.message]]')).replace(/\\/g, '\\\\'),
|
||||
|
||||
@@ -85,6 +85,7 @@ module.exports = function (Posts) {
|
||||
db.sortedSetsRemove(['posts:pid', 'posts:votes', 'posts:flagged'], pids),
|
||||
Posts.attachments.empty(pids),
|
||||
activitypub.notes.delete(pids),
|
||||
db.deleteAll(pids.map(pid => `pid:${pid}:editors`)),
|
||||
]);
|
||||
|
||||
await resolveFlags(postData, uid);
|
||||
|
||||
@@ -96,6 +96,9 @@ privsAdmin.socketMap = {
|
||||
'admin.user.removeAdmins': 'admin:admins-mods',
|
||||
|
||||
'admin.user.loadGroups': 'admin:users',
|
||||
'admin.user.addCustomField': 'admin:users',
|
||||
'admin.user.editCustomField': 'admin:users',
|
||||
'admin.user.deleteCustomField': 'admin:users',
|
||||
'admin.groups.join': 'admin:users',
|
||||
'admin.groups.leave': 'admin:users',
|
||||
'admin.user.resetLockouts': 'admin:users',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
const _ = require('lodash');
|
||||
|
||||
const db = require('../database');
|
||||
const meta = require('../meta');
|
||||
const posts = require('../posts');
|
||||
const topics = require('../topics');
|
||||
@@ -120,7 +121,8 @@ privsPosts.canEdit = async function (pid, uid) {
|
||||
const results = await utils.promiseParallel({
|
||||
isAdmin: user.isAdministrator(uid),
|
||||
isMod: posts.isModerator([pid], uid),
|
||||
owner: posts.isOwner(pid, uid),
|
||||
isOwner: posts.isOwner(pid, uid),
|
||||
isEditor: db.isSetMember(`pid:${pid}:editors`, uid),
|
||||
edit: privsPosts.can('posts:edit', pid, uid),
|
||||
postData: posts.getPostFields(pid, ['tid', 'timestamp', 'deleted', 'deleterUid']),
|
||||
userData: user.getUserFields(uid, ['reputation']),
|
||||
@@ -160,7 +162,10 @@ privsPosts.canEdit = async function (pid, uid) {
|
||||
results.uid = uid;
|
||||
|
||||
const result = await plugins.hooks.fire('filter:privileges.posts.edit', results);
|
||||
return { flag: result.edit && (result.owner || result.isMod), message: '[[error:no-privileges]]' };
|
||||
return {
|
||||
flag: result.edit && (result.isOwner || result.isEditor || result.isMod),
|
||||
message: '[[error:no-privileges]]',
|
||||
};
|
||||
};
|
||||
|
||||
privsPosts.canDelete = async function (pid, uid) {
|
||||
|
||||
@@ -22,6 +22,7 @@ module.exports = function (app, name, middleware, controllers) {
|
||||
helpers.setupAdminPageRoute(app, `/${name}/manage/tags`, middlewares, controllers.admin.tags.get);
|
||||
|
||||
helpers.setupAdminPageRoute(app, `/${name}/manage/users`, middlewares, controllers.admin.users.index);
|
||||
helpers.setupAdminPageRoute(app, `/${name}/manage/users/custom-fields`, middlewares, controllers.admin.users.customFields);
|
||||
helpers.setupAdminPageRoute(app, `/${name}/manage/registration`, middlewares, controllers.admin.users.registrationQueue);
|
||||
|
||||
helpers.setupAdminPageRoute(app, `/${name}/manage/admins-mods`, middlewares, controllers.admin.adminsMods.get);
|
||||
|
||||
@@ -187,3 +187,20 @@ User.exportUsersCSV = async function (socket, data) {
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
User.saveCustomFields = async function (socket, fields) {
|
||||
const keys = await db.getSortedSetRange('user-custom-fields', 0, -1);
|
||||
await db.delete('user-custom-fields');
|
||||
await db.deleteAll(keys.map(k => `user-custom-field:${k}`));
|
||||
|
||||
await db.sortedSetAdd(
|
||||
`user-custom-fields`,
|
||||
fields.map((f, i) => i),
|
||||
fields.map(f => f.key)
|
||||
);
|
||||
await db.setObjectBulk(
|
||||
fields.map(field => [`user-custom-field:${field.key}`, field])
|
||||
);
|
||||
await user.reloadCustomFieldWhitelist();
|
||||
};
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ module.exports = function (SocketPosts) {
|
||||
postData.display_moderator_tools = postData.display_edit_tools || postData.display_delete_tools;
|
||||
postData.display_move_tools = results.isAdmin || results.isModerator;
|
||||
postData.display_change_owner_tools = results.isAdmin || results.isModerator;
|
||||
postData.display_manage_editors_tools = results.isAdmin || results.isModerator || postData.selfPost;
|
||||
postData.display_ip_ban = (results.isAdmin || results.isGlobalMod) && !postData.selfPost;
|
||||
postData.display_history = results.history && results.canViewHistory;
|
||||
postData.display_original_url = !utils.isNumber(data.pid);
|
||||
@@ -94,4 +95,35 @@ module.exports = function (SocketPosts) {
|
||||
|
||||
await Promise.all(logs);
|
||||
};
|
||||
|
||||
SocketPosts.getEditors = async function (socket, data) {
|
||||
if (!data || !data.pid) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
await checkEditorPrivilege(socket.uid, data.pid);
|
||||
const editorUids = await db.getSetMembers(`pid:${data.pid}:editors`);
|
||||
const userData = await user.getUsersFields(editorUids, ['username', 'userslug', 'picture']);
|
||||
return userData;
|
||||
};
|
||||
|
||||
SocketPosts.saveEditors = async function (socket, data) {
|
||||
if (!data || !data.pid || !Array.isArray(data.uids)) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
await checkEditorPrivilege(socket.uid, data.pid);
|
||||
await db.delete(`pid:${data.pid}:editors`);
|
||||
await db.setAdd(`pid:${data.pid}:editors`, data.uids);
|
||||
};
|
||||
|
||||
async function checkEditorPrivilege(uid, pid) {
|
||||
const cid = await posts.getCidByPid(pid);
|
||||
const [isAdminOrMod, owner] = await Promise.all([
|
||||
privileges.categories.isAdminOrMod(cid, uid),
|
||||
posts.getPostField(pid, 'uid'),
|
||||
]);
|
||||
const isSelfPost = String(uid) === String(owner);
|
||||
if (!isAdminOrMod && !isSelfPost) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,7 +18,13 @@ const Scheduled = module.exports;
|
||||
|
||||
Scheduled.startJobs = function () {
|
||||
winston.verbose('[scheduled topics] Starting jobs.');
|
||||
new CronJob('*/1 * * * *', Scheduled.handleExpired, null, true);
|
||||
new CronJob('*/1 * * * *', async () => {
|
||||
try {
|
||||
await Scheduled.handleExpired();
|
||||
} catch (err) {
|
||||
winston.error(err.stack);
|
||||
}
|
||||
}, null, true);
|
||||
};
|
||||
|
||||
Scheduled.handleExpired = async function () {
|
||||
@@ -49,7 +55,7 @@ async function postTids(tids) {
|
||||
await Promise.all([].concat(
|
||||
sendNotifications(uids, topicsData),
|
||||
updateUserLastposttimes(uids, topicsData),
|
||||
updateGroupPosts(uids, topicsData),
|
||||
updateGroupPosts(topicsData),
|
||||
federatePosts(uids, topicsData),
|
||||
...topicsData.map(topicData => unpin(topicData.tid, topicData)),
|
||||
));
|
||||
@@ -113,7 +119,9 @@ async function sendNotifications(uids, topicsData) {
|
||||
const userData = await user.getUsersData(uids);
|
||||
const uidToUserData = Object.fromEntries(uids.map((uid, idx) => [uid, userData[idx]]));
|
||||
|
||||
const postsData = await posts.getPostsData(topicsData.map(t => t && t.mainPid));
|
||||
let postsData = await posts.getPostsData(topicsData.map(t => t && t.mainPid));
|
||||
topicsData = topicsData.filter((t, i) => t && postsData[i]);
|
||||
postsData = postsData.filter(Boolean);
|
||||
postsData.forEach((postData, idx) => {
|
||||
if (postData) {
|
||||
postData.user = uidToUserData[topicsData[idx].uid];
|
||||
@@ -153,10 +161,10 @@ async function updateUserLastposttimes(uids, topicsData) {
|
||||
return Promise.all(uidsToUpdate.map(uid => user.setUserField(uid, 'lastposttime', tstampByUid[uid])));
|
||||
}
|
||||
|
||||
async function updateGroupPosts(uids, topicsData) {
|
||||
async function updateGroupPosts(topicsData) {
|
||||
const postsData = await posts.getPostsData(topicsData.map(t => t && t.mainPid));
|
||||
await Promise.all(postsData.map(async (post, i) => {
|
||||
if (topicsData[i]) {
|
||||
if (post && topicsData[i]) {
|
||||
post.cid = topicsData[i].cid;
|
||||
await groups.onNewPostMade(post);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ module.exports = function (User) {
|
||||
'cover:position', 'groupTitle', 'mutedUntil', 'mutedReason',
|
||||
];
|
||||
|
||||
let customFieldWhiteList = null;
|
||||
|
||||
User.guestData = {
|
||||
uid: 0,
|
||||
username: '[[global:guest]]',
|
||||
@@ -47,6 +49,10 @@ module.exports = function (User) {
|
||||
|
||||
let iconBackgrounds;
|
||||
|
||||
User.reloadCustomFieldWhitelist = async () => {
|
||||
customFieldWhiteList = await db.getSortedSetRange('user-custom-fields', 0, -1);
|
||||
};
|
||||
|
||||
User.getUsersFields = async function (uids, fields) {
|
||||
if (!Array.isArray(uids) || !uids.length) {
|
||||
return [];
|
||||
@@ -68,10 +74,13 @@ module.exports = function (User) {
|
||||
|
||||
const uniqueUids = _.uniq(uids).filter(uid => isFinite(uid) && uid > 0);
|
||||
const remoteIds = _.uniq(uids).filter(uid => !isFinite(uid));
|
||||
if (!customFieldWhiteList) {
|
||||
await User.reloadCustomFieldWhitelist();
|
||||
}
|
||||
|
||||
const results = await plugins.hooks.fire('filter:user.whitelistFields', {
|
||||
uids: uids,
|
||||
whitelist: fieldWhitelist.slice(),
|
||||
whitelist: _.uniq(fieldWhitelist.concat(customFieldWhiteList)),
|
||||
});
|
||||
if (!fields.length) {
|
||||
fields = results.whitelist;
|
||||
|
||||
@@ -12,12 +12,14 @@ const db = require('../database');
|
||||
const groups = require('../groups');
|
||||
const plugins = require('../plugins');
|
||||
const api = require('../api');
|
||||
const tx = require('../translator');
|
||||
|
||||
module.exports = function (User) {
|
||||
User.updateProfile = async function (uid, data, extraFields) {
|
||||
let fields = [
|
||||
'username', 'email', 'fullname', 'website', 'location',
|
||||
'groupTitle', 'birthday', 'signature', 'aboutme',
|
||||
...await db.getSortedSetRange('user-custom-fields', 0, -1),
|
||||
];
|
||||
if (Array.isArray(extraFields)) {
|
||||
fields = _.uniq(fields.concat(extraFields));
|
||||
@@ -84,6 +86,49 @@ module.exports = function (User) {
|
||||
isLocationValid(data);
|
||||
isBirthdayValid(data);
|
||||
isGroupTitleValid(data);
|
||||
await validateCustomFields(data);
|
||||
}
|
||||
|
||||
async function validateCustomFields(data) {
|
||||
const keys = await db.getSortedSetRange('user-custom-fields', 0, -1);
|
||||
const fields = (await db.getObjects(keys.map(k => `user-custom-field:${k}`))).filter(Boolean);
|
||||
const reputation = await User.getUserField(data.uid, 'reputation');
|
||||
|
||||
fields.forEach((field) => {
|
||||
const { key, type } = field;
|
||||
if (data.hasOwnProperty(key)) {
|
||||
const value = data[key];
|
||||
const minRep = field['min:rep'] || 0;
|
||||
if (reputation < minRep && !meta.config['reputation:disabled']) {
|
||||
throw new Error(tx.compile(
|
||||
'error:not-enough-reputation-custom-field', minRep, field.name
|
||||
));
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.length > 255) {
|
||||
throw new Error(tx.compile(
|
||||
'error:custom-user-field-value-too-long', field.name
|
||||
));
|
||||
}
|
||||
|
||||
if (type === 'input-number' && !utils.isNumber(value)) {
|
||||
throw new Error(tx.compile(
|
||||
'error:custom-user-field-invalid-number', field.name
|
||||
));
|
||||
} else if (value && field.type === 'input-link' && !validator.isURL(String(value))) {
|
||||
throw new Error(tx.compile(
|
||||
'error:custom-user-field-invalid-link', field.name
|
||||
));
|
||||
} else if (field.type === 'select') {
|
||||
const opts = field['select-options'].split('\n').filter(Boolean);
|
||||
if (!opts.includes(value)) {
|
||||
throw new Error(tx.compile(
|
||||
'error:custom-user-field-select-value-invalid', field.name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function isEmailValid(data) {
|
||||
|
||||
@@ -53,9 +53,7 @@
|
||||
<div class="col-lg-9">
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade" id="trending">
|
||||
{{{ if !trending.length }}}
|
||||
<!-- IMPORT admin/partials/plugins/no-plugins.tpl -->
|
||||
{{{ end }}}
|
||||
<div class="alert alert-info no-plugins {{{ if !trending.length }}}hide{{{ end }}}">[[admin/extend/plugins:none-found]]</div>
|
||||
<ul class="trending list-unstyled">
|
||||
{{{ each trending }}}
|
||||
<!-- IMPORT admin/partials/installed_plugin_item.tpl -->
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="create" role="menuitem">[[admin/manage/users:create]]</a></li>
|
||||
{{{ if showInviteButton }}}<li><a class="dropdown-item rounded-1" href="#" component="user/invite" role="menuitem">[[admin/manage/users:invite]]</a></li>{{{ end }}}
|
||||
<li><a target="_blank" href="#" class="dropdown-item rounded-1 export-csv" role="menuitem">[[admin/manage/users:download-csv]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="{relative_path}/admin/manage/users/custom-fields">[[admin/manage/users:custom-user-fields]]</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
60
src/views/admin/manage/users/custom-fields.tpl
Normal file
60
src/views/admin/manage/users/custom-fields.tpl
Normal file
@@ -0,0 +1,60 @@
|
||||
<div class="manage-users d-flex flex-column gap-2 px-lg-4 h-100">
|
||||
<div class="d-flex border-bottom py-2 m-0 sticky-top acp-page-main-header align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div class="">
|
||||
<h4 class="fw-bold tracking-tight mb-0">[[admin/manage/user-custom-fields:title]]</h4>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<button id="new" class="btn btn-light btn-sm text-nowrap" type="button">
|
||||
<i class="fa fa-fw fa-plus"></i> [[admin/manage/user-custom-fields:create-field]]
|
||||
</button>
|
||||
<button id="save" class="btn btn-primary btn-sm fw-semibold ff-secondary w-100 text-center text-nowrap">[[admin/admin:save-changes]]</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row flex-grow-1">
|
||||
<div class="col-lg-12 d-flex flex-column gap-2">
|
||||
<div class="table-responsive flex-grow-1">
|
||||
<table class="table text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="text-muted">[[admin/manage/user-custom-fields:key]]</th>
|
||||
<th class="text-muted">[[admin/manage/user-custom-fields:name]]</th>
|
||||
<th class="text-muted">[[admin/manage/user-custom-fields:type]]</th>
|
||||
<th class="text-muted text-end">[[admin/manage/user-custom-fields:min-rep]]</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{{ each fields }}}
|
||||
<tr data-key="{./key}" data-name="{./name}" data-icon="{./icon}" data-type="{./type}" data-min-rep="{./min:rep}" data-select-options="{./select-options}" class="align-middle">
|
||||
<td style="width: 32px;">
|
||||
<a href="#" component="sort/handle" class="btn btn-light btn-sm d-none d-md-block ui-sortable-handle" style="cursor:grab;"><i class="fa fa-arrows-up-down text-muted"></i></a>
|
||||
</td>
|
||||
<td class="text-nowrap">{./key}</td>
|
||||
<td class="text-nowrap">{{{ if ./icon }}}<i class="text-muted {./icon}"></i> {{{ end }}}{./name}</td>
|
||||
<td>
|
||||
{./type}
|
||||
{{{ if (./type == "select") }}}
|
||||
<div class="text-muted">
|
||||
({./selectOptionsFormatted})
|
||||
</div>
|
||||
{{{ end }}}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{./min:rep}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex justify-content-end gap-1">
|
||||
<button data-action="edit" data-key="{./key}" class="btn btn-light btn-sm">[[admin/admin:edit]]</button>
|
||||
<button data-action="delete" data-key="{./key}" class="btn btn-light btn-sm"><i class="fa fa-trash text-danger"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{{ end }}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
41
src/views/admin/partials/manage-custom-user-fields-modal.tpl
Normal file
41
src/views/admin/partials/manage-custom-user-fields-modal.tpl
Normal file
@@ -0,0 +1,41 @@
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">[[admin/manage/user-custom-fields:type-of-input]]</label>
|
||||
<select class="form-select" id="type-select" name="type">
|
||||
<option value="input-text" {{{ if (type == "input-text") }}}selected{{{ end }}}>[[admin/manage/user-custom-fields:input-type-text]]</option>
|
||||
<option value="input-link" {{{ if (type == "input-link") }}}selected{{{ end }}}>[[admin/manage/user-custom-fields:input-type-link]]</option>
|
||||
<option value="input-number" {{{ if (type == "input-number") }}}selected{{{ end }}}>[[admin/manage/user-custom-fields:input-type-number]]</option>
|
||||
<option value="select" {{{ if (type == "select") }}}selected{{{ end }}}>[[admin/manage/user-custom-fields:input-type-select]]</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">[[admin/manage/user-custom-fields:key]]</label>
|
||||
<input class="form-control" type="text" name="key" value="{./key}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">[[admin/manage/user-custom-fields:name]]</label>
|
||||
<input class="form-control" type="text" name="name" value="{./name}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">[[admin/manage/user-custom-fields:icon]]</label>
|
||||
<div class=" d-flex gap-1">
|
||||
<input class="form-control" type="text" name="icon" value="{./icon}">
|
||||
<button id="icon-select" class="btn btn-light"><i class="fa fa-search text-primary"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">[[admin/manage/user-custom-fields:minimum-reputation]]</label>
|
||||
<input class="form-control" type="number" name="min:rep" value="{./min:rep}" placeholder="0">
|
||||
<p class="form-text">[[admin/manage/user-custom-fields:minimum-reputation-help]]</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 {{{ if (type != "select") }}}hidden{{{ end }}}" data-input-type="select">
|
||||
<label class="form-label">[[admin/manage/user-custom-fields:select-options]]</label>
|
||||
<textarea class="form-control" name="select-options" rows="6">{./select-options}</textarea>
|
||||
<p class="form-text">[[admin/manage/user-custom-fields:select-options-help]]</p>
|
||||
</div>
|
||||
</form>
|
||||
179
src/views/flags/detail.tpl
Normal file
179
src/views/flags/detail.tpl
Normal file
@@ -0,0 +1,179 @@
|
||||
<!-- IMPORT partials/breadcrumbs.tpl -->
|
||||
|
||||
<div class="d-flex flex-column flex-md-row">
|
||||
<div class="flex-shrink-0 d-flex flex-column gap-3 border-end-md text-sm mb-3 pe-4" style="flex-basis: 240px !important;">
|
||||
<div class="d-grid gap-1">
|
||||
<a class="btn btn-ghost btn-sm ff-secondary border d-flex gap-2 align-items-center" href="{config.relative_path}/{type_path}/{targetId}">
|
||||
<i class="fa fa-fw fa-external-link text-primary"></i>
|
||||
[[flags:go-to-target]]
|
||||
</a>
|
||||
|
||||
{{{ if target.uid }}}
|
||||
<div class="btn-group dropend" data-uid="{target.uid}">
|
||||
<button type="button" class="btn btn-ghost btn-sm ff-secondary border d-flex gap-2 align-items-center dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa fa-fw fa-street-view text-primary"></i>
|
||||
[[flags:flagged-user]]
|
||||
<i class="fa fa-chevron-right ms-auto text-secondary"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu p-1 text-sm" role="menu">
|
||||
<li><a class="dropdown-item rounded-1" href="{config.relative_path}/uid/{target.uid}" role="menuitem">[[flags:view-profile]]</a></li>
|
||||
{{{ if !config.disableChat }}}
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="chat" role="menuitem">[[flags:start-new-chat]]</a></li>
|
||||
{{{ end }}}
|
||||
<li class="dropdown-divider"></li>
|
||||
{{{ if privileges.ban }}}
|
||||
<li class="{{{ if target.user.banned }}}hidden{{{ end }}}"><a class="dropdown-item rounded-1" href="#" data-action="ban" role="menuitem">[[user:ban-account]]</a></li>
|
||||
<li class="{{{ if !target.user.banned }}}hidden{{{ end }}}"><a class="dropdown-item rounded-1" href="#" data-action="unban" role="menuitem">[[user:unban-account]]</a></li>
|
||||
{{{ end }}}
|
||||
{{{ if privileges.mute}}}
|
||||
<li class="{{{ if target.user.muted }}}hidden{{{ end }}}"><a class="dropdown-item rounded-1" href="#" data-action="mute" role="menuitem">[[user:mute-account]]</a></li>
|
||||
<li class="{{{ if !target.user.muted }}}hidden{{{ end }}}"><a class="dropdown-item rounded-1" href="#" data-action="unmute" role="menuitem">[[user:unmute-account]]</a></li>
|
||||
{{{ end }}}
|
||||
{{{ if privileges.admin:users }}}
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="delete-account" role="menuitem">[[user:delete-account-as-admin]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="delete-content" role="menuitem">[[user:delete-content]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="delete-all" role="menuitem">[[user:delete-all]]</a></li>
|
||||
{{{ end }}}
|
||||
</ul>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
|
||||
<a class="btn btn-ghost btn-sm ff-secondary border d-flex gap-2 align-items-center" href="#" data-action="assign">
|
||||
<i class="fa fa-fw fa-id-card-o text-primary"></i>
|
||||
[[flags:assign-to-me]]
|
||||
</a>
|
||||
|
||||
{{{ if type_bool.post }}}
|
||||
{{{ if !target.deleted}}}
|
||||
<a class="d-flex gap-2 align-items-center btn btn-sm btn-outline-danger border border-secondary-subtle text-start" href="#" data-action="delete-post"><i class="fa fa-fw fa-trash"></i> [[flags:delete-post]]</a>
|
||||
{{{ else }}}
|
||||
<a class="d-flex gap-2 align-items-center btn btn-sm btn-danger border border-secondary-subtle text-start" href="#" data-action="purge-post"><i class="fa fa-fw fa-trash"></i> [[flags:purge-post]]</a>
|
||||
<a class="d-flex gap-2 align-items-center btn btn-sm btn-outline-success border border-secondary-subtle text-start" href="#" data-action="restore-post"><i class="fa fa-fw fa-reply"></i><i class="fa fa-trash"></i> [[flags:restore-post]]</a>
|
||||
{{{ end }}}
|
||||
{{{ end }}}
|
||||
</div>
|
||||
|
||||
<form class="d-flex flex-column gap-3" id="attributes">
|
||||
<div>
|
||||
<label class="text-muted fw-semibold" for="state">[[flags:state]]</label>
|
||||
<select class="form-select form-select-sm" id="state" name="state" disabled>
|
||||
<option value="open">[[flags:state-open]]</option>
|
||||
<option value="wip">[[flags:state-wip]]</option>
|
||||
<option value="resolved">[[flags:state-resolved]]</option>
|
||||
<option value="rejected">[[flags:state-rejected]]</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted fw-semibold" for="assignee">[[flags:assignee]]</label>
|
||||
<select class="form-control form-control-sm" id="assignee" name="assignee" disabled>
|
||||
<option value="">[[flags:no-assignee]]</option>
|
||||
{{{each assignees}}}
|
||||
<option value="{../uid}">{../username}</option>
|
||||
{{{end}}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="button" class="btn btn-primary" data-action="update">[[flags:update]]</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-auto" component="flag/history" style="max-height: 30rem;">
|
||||
<h2 class="h6 fw-bold">[[flags:history]]</h2>
|
||||
{{{ if !history.length }}}
|
||||
<div class="alert alert-success text-center">[[flags:no-history]]</div>
|
||||
{{{ end }}}
|
||||
{{{ each history }}}
|
||||
<div class="d-flex flex-column gap-1">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<a class="d-flex text-decoration-none" href="{config.relative_path}/user/{./user.userslug}">{buildAvatar(./user, "16px", true)}</a>
|
||||
<a href="{config.relative_path}/user/{./user.userslug}">{./user.username}</a>
|
||||
<span class="timeago text-muted text-nowrap" title="{./datetimeISO}"></span>
|
||||
</div>
|
||||
<div>
|
||||
<ul class="list-unstyled">
|
||||
{{{ each ./fields }}}
|
||||
<li>
|
||||
[[flags:{@key}]]{{{ if @value }}} → <span class="fw-semibold">{@value}</span>{{{ end }}}
|
||||
</li>
|
||||
{{{ end }}}
|
||||
{{{ each ./meta }}}
|
||||
<li>
|
||||
{{./key}}{{{ if ./value }}} → <span class="fw-semibold">{./value}</span>{{{ end }}}
|
||||
</li>
|
||||
{{{ end }}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ps-md-2 ps-lg-5" style="min-width:0;">
|
||||
<div class="d-flex flex-column gap-4">
|
||||
<h2 class="h6 fw-bold">
|
||||
{target_readable}
|
||||
</h2>
|
||||
<div component="flag/content" class="d-flex flex-column gap-1 pb-3 border-bottom">
|
||||
{{{ if type_bool.post }}}
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<a class="d-flex text-decoration-none" href="{config.relative_path}/user/{target.user.userslug}">{buildAvatar(target.user, "16px", true)}</a>
|
||||
<a href="{config.relative_path}/user/{target.user.userslug}">{target.user.username}</a>
|
||||
<span class="timeago text-muted" title="{target.timestampISO}"></span>
|
||||
</div>
|
||||
<blockquote>{target.content}</blockquote>
|
||||
{{{ end }}}
|
||||
|
||||
{{{ if type_bool.user }}}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{config.relative_path}/user/{./target.userslug}">{buildAvatar(target, "16px", true)}</a>
|
||||
<a href="{config.relative_path}/user/{./target.userslug}">{target.username}</a>
|
||||
</div>
|
||||
<blockquote>{{{ if target.aboutme }}}{target.aboutme}{{{ else }}}<em>[[flags:target-aboutme-empty]]</em>{{{ end }}}</blockquote>
|
||||
{{{ end }}}
|
||||
|
||||
{{{ if type_bool.empty }}}
|
||||
<div class="alert alert-warning" role="alert">[[flags:target-purged]]</div>
|
||||
{{{ end }}}
|
||||
</div>
|
||||
<div class="flag/reports" class="pb-4 border-bottom">
|
||||
<h2 class="h6 fw-bold">[[flags:reports]]</h2>
|
||||
<ul class="list-unstyled mt-4">
|
||||
{{{ each reports }}}
|
||||
<li class="d-flex flex-column gap-1" component="flag/report" data-timestamp="{./timestamp}">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<a class="d-flex text-decoration-none" href="{config.relative_path}/user/{./reporter.userslug}">{buildAvatar(./reporter, "16px", true)}</a>
|
||||
<a href="{config.relative_path}/user/{./reporter.userslug}">{./reporter.username}</a>
|
||||
<span class="timeago text-muted" title="{./timestampISO}"></span>
|
||||
</div>
|
||||
<p>{./value}</p>
|
||||
</li>
|
||||
{{{ end }}}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pb-4 border-bottom">
|
||||
<div class="d-flex align-items-center">
|
||||
<h2 class="h6 fw-bold me-auto mb-0">[[flags:notes]]</h2>
|
||||
<button class="btn btn-ghost ff-secondary border" data-action="addEditNote">[[flags:add-note]]</button>
|
||||
</div>
|
||||
<ul component="flag/notes" class="list-unstyled mt-4">
|
||||
{{{ if !notes.length }}}
|
||||
<em>[[flags:no-notes]]</em>
|
||||
{{{ end }}}
|
||||
{{{ each notes }}}
|
||||
<li class="d-flex flex-column gap-1" component="flag/note" data-datetime="{./datetime}" data-index="{@index}">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<a href="{config.relative_path}/user/{./user.userslug}">{buildAvatar(./user, "16px", true)}</a>
|
||||
<a href="{config.relative_path}/user/{./user.userslug}">{./user.username}</a>
|
||||
<span class="timeago text-muted" title="{./datetimeISO}"></span>
|
||||
<div class=" ms-auto flex-shrink-0">
|
||||
<a href="#" class="btn btn-sm btn-link" data-action="addEditNote"><i class="fa fa-pencil"></i></a>
|
||||
<a href="#" class="btn btn-sm btn-link" data-action="delete-note"><i class="fa fa-trash text-danger"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<p>{./content}</p>
|
||||
</li>
|
||||
{{{ end }}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
6
src/views/flags/list.tpl
Normal file
6
src/views/flags/list.tpl
Normal file
@@ -0,0 +1,6 @@
|
||||
<!-- IMPORT partials/breadcrumbs.tpl -->
|
||||
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<!-- IMPORT partials/flags/filters.tpl -->
|
||||
<!-- IMPORT partials/flags/results.tpl -->
|
||||
</div>
|
||||
32
src/views/modals/manage-editors.tpl
Normal file
32
src/views/modals/manage-editors.tpl
Normal file
@@ -0,0 +1,32 @@
|
||||
<div class="card tool-modal shadow">
|
||||
<h5 class="card-header">[[topic:thread-tools.manage-editors]]</h5>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
[[topic:manage-editors-instruction]]
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username"><strong>[[user:username]]</strong></label>
|
||||
<div class="input-group">
|
||||
<input id="username" type="text" class="form-control" name="username">
|
||||
<span class="input-group-text" type="button">
|
||||
<i class="fa fa-search"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap" component="topic/editors">
|
||||
{{{ each editors }}}
|
||||
<div class="badge text-bg-light m-1 p-1 border d-inline-flex gap-1 align-items-center" data-uid="{./uid}">
|
||||
{buildAvatar(@value, "24px", true)}
|
||||
<a href="{config.relative_path}/user/{./userslug}">{./username}</a>
|
||||
<button class="btn btn-ghost btn-sm p-0 remove-user-icon">
|
||||
<i class="fa fa-fw fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<button class="btn btn-link btn-sm" id="manage_editors_cancel">[[global:buttons.close]]</button>
|
||||
<button class="btn btn-primary btn-sm" id="manage_editors_commit">[[global:save]]</button>
|
||||
</div>
|
||||
</div>
|
||||
9
src/views/partials/flags/bulk-actions.tpl
Normal file
9
src/views/partials/flags/bulk-actions.tpl
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="dropdown" component="flags/bulk-actions">
|
||||
<button class="filter-btn btn btn-light btn-sm border" data-bs-toggle="dropdown" autocomplete="off" aria-haspopup="true" aria-expanded="false" disabled="disabled">
|
||||
<span class="filter-label">[[flags:bulk-actions]]</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end p-1 text-sm" role="menu">
|
||||
<li><a href="#" class="dropdown-item rounded-1" data-action="bulk-assign" role="menuitem">[[flags:assign-to-me]]</a></li>
|
||||
<li><a href="#" class="dropdown-item rounded-1" data-action="bulk-mark-resolved" role="menuitem">[[flags:bulk-resolve]]</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
188
src/views/partials/flags/filters.tpl
Normal file
188
src/views/partials/flags/filters.tpl
Normal file
@@ -0,0 +1,188 @@
|
||||
<div component="flags/filters" class="d-flex flex-wrap gap-2 pb-3 border-bottom">
|
||||
<div class="btn-group bottom-sheet">
|
||||
<a class="filter-btn btn btn-light btn-sm border {{{ if filters.quick }}}active-filter{{{ end }}} dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="filter-label">{{{ if filters.quick }}}[[flags:filter-quick-{./filters.quick}]]{{{ else }}}[[flags:quick-filters]]{{{ end }}}</span>
|
||||
<span class="caret text-primary opacity-75"></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu p-1 text-sm" role="menu">
|
||||
<li>
|
||||
<a class="dropdown-item rounded-1" href="{config.relative_path}/flags?quick=mine" role="menuitem">[[flags:filter-quick-mine]]</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div component="category/dropdown" class="btn-group category-dropdown-container bottom-sheet">
|
||||
<button type="button" class="filter-btn btn btn-light btn-sm border dropdown-toggle {{{ if filters.cid }}}active-filter{{{ end }}}" data-bs-toggle="dropdown">
|
||||
{{{ if selectedCategory }}}
|
||||
<span class="category-item d-inline-flex align-items-center gap-1">
|
||||
{buildCategoryIcon(selectedCategory, "18px", "rounded-circle")}
|
||||
<span class="visible-md-inline visible-lg-inline">{selectedCategory.name}</span>
|
||||
</span>
|
||||
{{{ else }}}
|
||||
<span class="visible-md-inline visible-lg-inline">[[unread:all-categories]]</span>
|
||||
{{{ end }}}
|
||||
<span class="caret text-primary opacity-75"></span>
|
||||
</button>
|
||||
<div component="category-selector-search" class="hidden position-absolute">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="dropdown-menu p-1">
|
||||
<ul component="category/list" class="list-unstyled mb-0 text-sm category-dropdown-menu ghost-scrollbar" role="menu">
|
||||
{{{each categoryItems}}}
|
||||
<li role="presentation" class="category {{{ if ../disabledClass }}}disabled{{{ end }}}" data-cid="{../cid}" data-parent-cid="{../parentCid}" data-name="{../name}">
|
||||
<a class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem" href="#">
|
||||
{../level}
|
||||
<span component="category-markup" class="flex-grow-1" style="{{{ if ../match }}}font-weight: bold;{{{end}}}">
|
||||
<div class="category-item d-inline-flex align-items-center gap-1">
|
||||
{{{ if ./icon }}}
|
||||
{buildCategoryIcon(@value, "24px", "rounded-circle")}
|
||||
{{{ end }}}
|
||||
{./name}
|
||||
</div>
|
||||
</span>
|
||||
<i component="category/select/icon" class="flex-shrink-0 fa fa-fw fa-check {{{ if !../selected }}}invisible{{{ end }}}"></i>
|
||||
</a>
|
||||
</li>
|
||||
{{{end}}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group bottom-sheet">
|
||||
<a class="filter-btn btn btn-light btn-sm border {{{ if (sort != "newest") }}}active-filter{{{ end }}} dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="filter-label">{{{ if (sort != "newest") }}}[[flags:sort-{./sort}]]{{{ else }}}[[flags:sort]]{{{ end }}}</span>
|
||||
<span class="caret text-primary opacity-75"></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu p-1 text-sm" role="menu">
|
||||
<li><h6 class="dropdown-header">[[flags:sort-all]]</h6></li>
|
||||
<li class="dropdown-item rounded-1" data-name="sort" data-value="newest" role="menuitem">[[flags:sort-newest]]</li>
|
||||
<li class="dropdown-item rounded-1" data-name="sort" data-value="oldest" role="menuitem">[[flags:sort-oldest]]</li>
|
||||
<li class="dropdown-item rounded-1" data-name="sort" data-value="reports" role="menuitem">[[flags:sort-reports]]</li>
|
||||
<li><h6 class="dropdown-header">[[flags:sort-posts-only]]</h6></li>
|
||||
<li class="dropdown-item rounded-1" data-name="sort" data-value="downvotes" role="menuitem">[[flags:sort-downvotes]]</li>
|
||||
<li class="dropdown-item rounded-1" data-name="sort" data-value="upvotes" role="menuitem">[[flags:sort-upvotes]]</li>
|
||||
<li class="dropdown-item rounded-1" data-name="sort" data-value="replies" role="menuitem">[[flags:sort-replies]]</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="btn-group bottom-sheet">
|
||||
<a class="filter-btn btn btn-light btn-sm border {{{ if filters.state }}}active-filter{{{ end }}} dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="filter-label">{{{ if filters.state }}}[[flags:state-{./filters.state}]]{{{ else }}}[[flags:filter-state]]{{{ end }}}</span>
|
||||
<span class="caret text-primary opacity-75"></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu p-1 text-sm" role="menu">
|
||||
<li class="dropdown-item rounded-1" data-name="state" data-value="open" role="menuitem">[[flags:state-open]]</li>
|
||||
<li class="dropdown-item rounded-1" data-name="state" data-value="wip" role="menuitem">[[flags:state-wip]]</li>
|
||||
<li class="dropdown-item rounded-1" data-name="state" data-value="resolved" role="menuitem">[[flags:state-resolved]]</li>
|
||||
<li class="dropdown-item rounded-1" data-name="state" data-value="rejected" role="menuitem">[[flags:state-rejected]]</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="btn-group bottom-sheet">
|
||||
<a class="filter-btn btn btn-light btn-sm border {{{ if filters.type }}}active-filter{{{ end }}} dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="filter-label">{{{ if filters.type }}}[[flags:filter-type-{./filters.type}]]{{{ else }}}[[flags:filter-type]]{{{ end }}}</span>
|
||||
<span class="caret text-primary opacity-75"></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu p-1 text-sm" role="menu">
|
||||
<li class="dropdown-item rounded-1" data-name="type" data-value="all" role="menuitem">[[flags:filter-type-all]]</li>
|
||||
<li class="dropdown-item rounded-1" data-name="type" data-value="post" role="menuitem">[[flags:filter-type-post]]</li>
|
||||
<li class="dropdown-item rounded-1" data-name="type" data-value="user" role="menuitem">[[flags:filter-type-user]]</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div component="flags/filter/assignee" class="dropdown bottom-sheet" data-filter-name="assignee">
|
||||
<a component="user/filter/button" class="filter-btn btn btn-light btn-sm border {{{ if filters.assignee }}}active-filter{{{ end }}} dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="filter-label">[[flags:filter-assignee]]</span>
|
||||
<span class="caret text-primary opacity-75"></span>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu p-1 text-sm" style="min-width: 350px;" role="menu">
|
||||
<li class="px-3 py-1 d-flex flex-column gap-2">
|
||||
<input type="text" class="form-control" component="user/filter/search" placeholder="[[search:type-a-username]]">
|
||||
<div component="user/filter/selected" class="d-flex flex-wrap gap-2">
|
||||
{{{ each selected.assignee }}}
|
||||
<div class="d-flex px-2 py-1 rounded-1 text-bg-primary gap-2 align-items-center text-sm">
|
||||
{buildAvatar(@value, "16px", true)} {./username}
|
||||
<button component="user/filter/delete" data-uid="{./uid}" class="btn btn-primary btn-sm py-0"><i class="fa fa-times fa-xs"></i></button>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
</div>
|
||||
<hr/>
|
||||
<div component="user/filter/results" class="d-flex flex-wrap gap-2">
|
||||
{{{ each userFilterResults }}}
|
||||
<button class="btn btn-light btn-sm border" data-uid="{./uid}" data-username="{./username}">{buildAvatar(@value, "16px", true)} {./username}</button>
|
||||
{{{ end }}}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div component="flags/filter/reporterId" class="dropdown bottom-sheet" data-filter-name="reporterId">
|
||||
<a component="user/filter/button" class="filter-btn btn btn-light btn-sm border {{{ if filters.reporterId }}}active-filter{{{ end }}} dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="filter-label">[[flags:filter-reporterId]]</span>
|
||||
<span class="caret text-primary opacity-75"></span>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu p-1 text-sm" style="min-width: 350px;" role="menu">
|
||||
<li class="px-3 py-1 d-flex flex-column gap-2">
|
||||
<input type="text" class="form-control" component="user/filter/search" placeholder="[[search:type-a-username]]">
|
||||
<div component="user/filter/selected" class="d-flex flex-wrap gap-2">
|
||||
{{{ each selected.reporterId }}}
|
||||
<div class="d-flex px-2 py-1 rounded-1 text-bg-primary gap-2 align-items-center text-sm">
|
||||
{buildAvatar(@value, "16px", true)} {./username}
|
||||
<button component="user/filter/delete" data-uid="{./uid}" class="btn btn-primary btn-sm py-0"><i class="fa fa-times fa-xs"></i></button>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
</div>
|
||||
<hr/>
|
||||
<div component="user/filter/results" class="d-flex flex-wrap gap-2">
|
||||
{{{ each userFilterResults }}}
|
||||
<button class="btn btn-light btn-sm border" data-uid="{./uid}" data-username="{./username}">{buildAvatar(@value, "16px", true)} {./username}</button>
|
||||
{{{ end }}}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div component="flags/filter/targetUid" class="dropdown bottom-sheet" data-filter-name="targetUid">
|
||||
<a component="user/filter/button" class="filter-btn btn btn-light btn-sm border {{{ if filters.targetUid }}}active-filter{{{ end }}} dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="filter-label">[[flags:filter-targetUid]]</span>
|
||||
<span class="caret text-primary opacity-75"></span>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu p-1 text-sm" style="min-width: 350px;" role="menu">
|
||||
<li class="px-3 py-1 d-flex flex-column">
|
||||
<input type="text" class="form-control" component="user/filter/search" placeholder="[[search:type-a-username]]">
|
||||
<div component="user/filter/selected" class="d-flex flex-wrap gap-2">
|
||||
{{{ each selected.targetUid }}}
|
||||
<div class="d-flex px-2 py-1 rounded-1 text-bg-primary gap-2 align-items-center text-sm">
|
||||
{buildAvatar(@value, "16px", true)} {./username}
|
||||
<button component="user/filter/delete" data-uid="{./uid}" class="btn btn-primary btn-sm py-0"><i class="fa fa-times fa-xs"></i></button>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
</div>
|
||||
<hr/>
|
||||
<div component="user/filter/results" class="d-flex flex-wrap gap-2">
|
||||
{{{ each userFilterResults }}}
|
||||
<button class="btn btn-light btn-sm border" data-uid="{./uid}" data-username="{./username}">{buildAvatar(@value, "16px", true)} {./username}</button>
|
||||
{{{ end }}}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div component="flags/filters/reset" class="ms-auto">
|
||||
<a class="filter-btn btn btn-warning btn-sm border {{{ if !hasFilter }}}btn-light disabled{{{ end }}}" href="{config.relative_path}/flags" role="button">
|
||||
<span class="filter-label">[[flags:filter-reset]]</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- IMPORT partials/flags/bulk-actions.tpl -->
|
||||
|
||||
<form role="form">
|
||||
<input type="hidden" name="sort" value="{./sort}" />
|
||||
<input type="hidden" name="state" value="{./filters.state}" />
|
||||
<input type="hidden" name="type" value="{./filters.type}" />
|
||||
</form>
|
||||
</div>
|
||||
38
src/views/partials/flags/results.tpl
Normal file
38
src/views/partials/flags/results.tpl
Normal file
@@ -0,0 +1,38 @@
|
||||
<div class="card card-header text-xs px-2 py-1 fw-semibold border-0 align-self-start">
|
||||
[[flags:x-flags-found, {count}]]
|
||||
</div>
|
||||
|
||||
<table class="table table-hover" component="flags/list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="text-xs text-muted">[[flags:reports]]</th>
|
||||
<th class="text-xs text-muted">[[flags:first-reported]]</th>
|
||||
<th class="text-xs text-muted">[[flags:state]]</th>
|
||||
<th>
|
||||
<input type="checkbox" data-action="toggle-all" autocomplete="off" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{{ each flags }}}
|
||||
<tr data-flag-id="{./flagId}">
|
||||
<td>
|
||||
<a class="text-reset text-decoration-underline" href="{config.relative_path}/flags/{./flagId}">
|
||||
{./target_readable}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{./heat}
|
||||
</td>
|
||||
<td><span class="timeago" title="{./datetimeISO}"></span></td>
|
||||
<td><span class="badge bg-{./labelClass}">[[flags:state-{./state}]]</span></td>
|
||||
<td>
|
||||
<input type="checkbox" autocomplete="off" />
|
||||
</td>
|
||||
</tr>
|
||||
{{{end}}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- IMPORT partials/paginator.tpl -->
|
||||
@@ -10,7 +10,7 @@
|
||||
{{{ end }}}
|
||||
</button>
|
||||
|
||||
<div component="tag/filter/search" class="hidden position-absolute top-0">
|
||||
<div component="tag/filter/search" class="hidden position-absolute" style="min-width: 120px;">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div component="topic/deleted/message" class="alert alert-warning mt-3{{{ if !deleted }}} hidden{{{ end }}} d-flex justify-content-between flex-wrap">
|
||||
<div component="topic/deleted/message" class="alert alert-warning d-flex justify-content-between flex-wrap{{{ if !deleted }}} hidden{{{ end }}}">
|
||||
<span>[[topic:deleted-message]]</span>
|
||||
<span>
|
||||
{{{ if deleter }}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div component="topic/forked/message" class="alert alert-info mt-3 d-flex justify-content-between flex-wrap">
|
||||
<div component="topic/forked/message" class="alert alert-info d-flex justify-content-between flex-wrap">
|
||||
<span>[[topic:forked-message, {config.relative_path}/topic/{forkedFromTid}, {forker.forkedFromTitle}]]</span>
|
||||
<span>
|
||||
<a class="fw-bold" href="{config.relative_path}/user/{forker.userslug}">{forker.username}</strong></a> <small class="timeago" title="{forkTimestampISO}"></small>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div component="topic/merged/message" class="alert alert-info mt-3 d-flex justify-content-between flex-wrap">
|
||||
<div component="topic/merged/message" class="alert alert-info d-flex justify-content-between flex-wrap">
|
||||
<span>[[topic:merged-message, {config.relative_path}/topic/{mergeIntoTid}, {merger.mergedIntoTitle}]]</span>
|
||||
<span>
|
||||
<a class="fw-bold" href="{config.relative_path}/user/{merger.userslug}">{merger.username}</strong></a> <small class="timeago" title="{mergedTimestampISO}"></small>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{{ if isAdmin }}}
|
||||
{{{ if !enabled }}}
|
||||
<div class="alert alert-info">
|
||||
<p>[[post-queue:enabling-help, {config.relative_path}/admin/settings/post#post-queue]]</p>
|
||||
[[post-queue:enabling-help, {config.relative_path}/admin/settings/post#post-queue]]
|
||||
</div>
|
||||
{{{ end }}}
|
||||
{{{ else }}}
|
||||
@@ -18,19 +18,19 @@
|
||||
<!-- IMPORT partials/category/filter-dropdown-right.tpl -->
|
||||
</div>
|
||||
<div class="btn-group bottom-sheet" component="post-queue/bulk-actions">
|
||||
<button type="button" class="btn btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" autocomplete="off" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa fa-clone"></i> [[post-queue:bulk-actions]] <span class="caret"></span>
|
||||
<button type="button" class="btn btn-ghost btn-sm dropdown-toggle" data-bs-toggle="dropdown" autocomplete="off" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa fa-clone"></i> [[post-queue:bulk-actions]]
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" role="menu">
|
||||
<ul class="dropdown-menu dropdown-menu-end p-1" role="menu">
|
||||
{{{ if canAccept }}}
|
||||
<li><a class="dropdown-item" href="#" data-action="accept-all" role="menuitem">[[post-queue:accept-all]]</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-action="accept-selected" role="menuitem">[[post-queue:accept-selected]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="accept-all" role="menuitem">[[post-queue:accept-all]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="accept-selected" role="menuitem">[[post-queue:accept-selected]]</a></li>
|
||||
<li class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="#" data-action="reject-all" role="menuitem">[[post-queue:reject-all]]</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-action="reject-selected" role="menuitem">[[post-queue:reject-selected]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-all" role="menuitem">[[post-queue:reject-all]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-selected" role="menuitem">[[post-queue:reject-selected]]</a></li>
|
||||
{{{ else }}}
|
||||
<li><a class="dropdown-item" href="#" data-action="reject-all">[[post-queue:remove-all]]</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-action="reject-selected" role="menuitem">[[post-queue:remove-selected]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-all">[[post-queue:remove-all]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-selected" role="menuitem">[[post-queue:remove-selected]]</a></li>
|
||||
{{{ end }}}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user