{useSmallCards ? SmallUserCard.component(attributes) : UserDirectoryUserCard.component(attributes)}
;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/js/src/forum/components/UserDirectoryPage.js:
--------------------------------------------------------------------------------
1 | import IndexSidebar from 'flarum/forum/components/IndexSidebar';
2 | import PageStructure from 'flarum/forum/components/PageStructure';
3 | import app from 'flarum/forum/app';
4 | import Page from 'flarum/common/components/Page';
5 | import ItemList from 'flarum/common/utils/ItemList';
6 | import listItems from 'flarum/common/helpers/listItems';
7 | import Select from 'flarum/common/components/Select';
8 | import Button from 'flarum/common/components/Button';
9 | import Dropdown from 'flarum/common/components/Dropdown';
10 | import extractText from 'flarum/common/utils/extractText';
11 | import UserDirectoryList from './UserDirectoryList';
12 | import UserDirectoryState from '../states/UserDirectoryState';
13 | import CheckableButton from './CheckableButton';
14 | import SearchField from './SearchField';
15 | import Separator from 'flarum/common/components/Separator';
16 | import UserDirectoryHero from './UserDirectoryHero';
17 |
18 | /**
19 | * This page re-uses Flarum's IndexPage CSS classes
20 | */
21 | export default class UserDirectoryPage extends Page {
22 | oninit(vnode) {
23 | super.oninit(vnode);
24 |
25 | this.state = new UserDirectoryState({});
26 |
27 | // Initialize the group filters before refreshing params
28 | this.enabledGroupFilters = [];
29 | this.enabledSpecialGroupFilters = {};
30 |
31 | // Extract group IDs from the query parameter
32 | // First check if we have preloaded data from the server
33 | const preloadedApiDocument = app.preloadedApiDocument();
34 | const preloadedData = preloadedApiDocument && preloadedApiDocument.payload && preloadedApiDocument.payload.fofUserDirectory;
35 |
36 | // Get query from preloaded data or URL parameter
37 | const q = preloadedData ? preloadedData.q : m.route.param('q') || '';
38 |
39 | if (q) {
40 | // Extract group filters
41 | const groupMatches = q.match(/\bgroup:(\d+)\b/g);
42 | if (groupMatches) {
43 | this.enabledGroupFilters = groupMatches.map((match) => match.replace('group:', ''));
44 | }
45 |
46 | // Extract special group filters
47 | if (app.initializers.has('flarum-suspend') && app.forum.attribute('hasSuspendPermission')) {
48 | if (q.includes('is:suspended')) {
49 | this.enabledSpecialGroupFilters['flarum-suspend'] = 'is:suspended';
50 | }
51 | }
52 | }
53 |
54 | // Now refresh params with the current URL parameters or preloaded data
55 | const params = {
56 | q: q,
57 | sort: preloadedData ? preloadedData.sort : m.route.param('sort'),
58 | };
59 |
60 | this.state.refreshParams(params);
61 |
62 | this.bodyClass = 'User--directory';
63 |
64 | app.history.push('users', app.translator.trans('fof-user-directory.forum.header.back_to_user_directory_tooltip'));
65 | }
66 |
67 | oncreate(vnode) {
68 | super.oncreate(vnode);
69 |
70 | app.setTitle(extractText(app.translator.trans('fof-user-directory.forum.page.nav')));
71 | }
72 |
73 | view() {
74 | return (
75 | }
12 | */
13 | infoItems() {
14 | const items = super.infoItems();
15 | const user = this.attrs.user;
16 |
17 | if (items.has('lastSeen')) items.setPriority('lastSeen', 100);
18 | if (items.has('joined')) items.setPriority('joined', 95);
19 | if (items.has('points')) items.setPriority('points', 60);
20 | if (items.has('best-answer-count')) items.setPriority('best-answer-count', 68);
21 | if (items.has('masquerade-bio')) items.setPriority('masquerade-bio', 50);
22 |
23 | items.add(
24 | 'discussion-count',
25 |
26 |
27 | {app.translator.trans('fof-user-directory.forum.page.usercard.discussion-count', {
28 | count: user.discussionCount(),
29 | })}
30 |
,
31 | 70
32 | );
33 |
34 | items.add(
35 | 'comment-count',
36 |
37 |
38 | {app.translator.trans('fof-user-directory.forum.page.usercard.post-count', {
39 | count: user.commentCount(),
40 | })}
41 |
,
42 | 69
43 | );
44 |
45 | return items;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/js/src/forum/extend.ts:
--------------------------------------------------------------------------------
1 | import Extend from 'flarum/common/extenders';
2 | import UserDirectoryPage from './components/UserDirectoryPage';
3 | import Text from './models/Text';
4 |
5 | export default [
6 | new Extend.Routes() //
7 | .add('fof_user_directory', '/users', UserDirectoryPage),
8 |
9 | new Extend.Store() //
10 | .add('fof-user-directory-text', Text),
11 | ];
12 |
--------------------------------------------------------------------------------
/js/src/forum/extenders/extendCommentPost.tsx:
--------------------------------------------------------------------------------
1 | import app from 'flarum/forum/app';
2 | import { extend } from 'flarum/common/extend';
3 | import CommentPost from 'flarum/forum/components/CommentPost';
4 | import Group from 'flarum/common/models/Group';
5 |
6 | export const linkGroupMentions = function () {
7 | if (app.forum.attribute('canSeeUserDirectoryLink') && app.forum.attribute('userDirectoryLinkGroupMentions')) {
8 | // @ts-ignore
9 | this.$('.GroupMention').each(function () {
10 | // @ts-ignore
11 | if ($(this).hasClass('GroupMention--linked')) return;
12 |
13 | // @ts-ignore
14 | const name = $(this).find('.GroupMention-name').text();
15 | const group = app.store.getBy('groups', 'namePlural', name.slice(1));
16 |
17 | if (group) {
18 | const link = $(``);
19 |
20 | link.on('click', function (e) {
21 | // @ts-ignore
22 | m.route.set(this.getAttribute('href'));
23 | e.preventDefault();
24 | });
25 |
26 | // @ts-ignore
27 | $(this).addClass('GroupMention--linked').wrap(link);
28 | }
29 | });
30 | }
31 | };
32 |
33 | export default function extendCommentPost() {
34 | extend(CommentPost.prototype, 'oncreate', linkGroupMentions);
35 | extend(CommentPost.prototype, 'onupdate', linkGroupMentions);
36 | }
37 |
--------------------------------------------------------------------------------
/js/src/forum/extenders/extendIndexPage.tsx:
--------------------------------------------------------------------------------
1 | import IndexSidebar from 'flarum/forum/components/IndexSidebar';
2 | import app from 'flarum/forum/app';
3 | import { extend } from 'flarum/common/extend';
4 | import LinkButton from 'flarum/common/components/LinkButton';
5 |
6 | export default function extendIndexPage() {
7 | extend(IndexSidebar.prototype, 'navItems', (items) => {
8 | if (app.forum.attribute('canSeeUserDirectoryLink') && app.forum.attribute('canSearchUsers')) {
9 | items.add(
10 | 'fof-user-directory',
11 |
12 | {app.translator.trans('fof-user-directory.forum.page.nav')}
13 | ,
14 | 85
15 | );
16 | }
17 | });
18 | }
19 |
--------------------------------------------------------------------------------
/js/src/forum/extenders/extendUsersSearchSource.tsx:
--------------------------------------------------------------------------------
1 | import app from 'flarum/forum/app';
2 | import { extend } from 'flarum/common/extend';
3 | import UsersSearchSource from 'flarum/forum/components/UsersSearchSource';
4 | import LinkButton from 'flarum/common/components/LinkButton';
5 |
6 | export default function extendUsersSearchSource() {
7 | extend(UsersSearchSource.prototype, 'view', function (view, query: string) {
8 | if (!view || !app.forum.attribute('canSeeUserDirectoryLink') || app.forum.attribute('userDirectoryDisableGlobalSearchSource')) {
9 | return;
10 | }
11 |
12 | query = query.toLowerCase();
13 |
14 | view.splice(
15 | 1,
16 | 0,
17 |
18 |
19 | {app.translator.trans('fof-user-directory.forum.search.users_heading', { query })}
20 |
21 |
22 | );
23 | });
24 | }
25 |
--------------------------------------------------------------------------------
/js/src/forum/index.ts:
--------------------------------------------------------------------------------
1 | import app from 'flarum/forum/app';
2 | import extendCommentPost from './extenders/extendCommentPost';
3 | import extendUsersSearchSource from './extenders/extendUsersSearchSource';
4 | import extendIndexPage from './extenders/extendIndexPage';
5 |
6 | export { default as extend } from './extend';
7 |
8 | app.initializers.add('fof-user-directory', function () {
9 | extendCommentPost();
10 | extendUsersSearchSource();
11 | extendIndexPage();
12 | });
13 |
--------------------------------------------------------------------------------
/js/src/forum/models/Text.ts:
--------------------------------------------------------------------------------
1 | import Model from 'flarum/common/Model';
2 |
3 | /**
4 | * Special model used only client-side to hold a free text search value in the search field
5 | */
6 | export default class Text extends Model {
7 | text = Model.attribute('text');
8 | }
9 |
--------------------------------------------------------------------------------
/js/src/forum/searchTypes/AbstractType.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @abstract
3 | */
4 | export default class AbstractType {
5 | constructor() {
6 | this.suggestions = [];
7 | this.loading = false;
8 | }
9 |
10 | /**
11 | * The `type` property of the Models used in suggestions and applied filters for this type
12 | * @return {String}
13 | */
14 | resourceType() {
15 | //
16 | }
17 |
18 | /**
19 | * Executed when the search query changes
20 | * The method should update this.suggestions with the new results
21 | * If asynchronous loading is used, this.loading should be set to true during the process
22 | * @param {String} query
23 | */
24 | search(query) {
25 | //
26 | }
27 |
28 | /**
29 | * Renders the "kind" label next to the value indicating what kind of information that result is
30 | * Should probably just be a translated text
31 | * @param {Model} resource
32 | * @return {vnode}
33 | */
34 | renderKind(resource) {
35 | //
36 | }
37 |
38 | /**
39 | * Renders the Label containing the suggestion's value
40 | * Should be a vdom template using the .UserDirectorySearchLabel class or similar
41 | * @param {Model} resource
42 | * @return {vnode}
43 | */
44 | renderLabel(resource) {
45 | //
46 | }
47 |
48 | /**
49 | * Applies a filter on a params object to use in the page request
50 | * @param {Object} params Object. Might or might not contain a `q` property or `sort` property. In the future, `filters` object might be supported
51 | * @param {Model} resource
52 | */
53 | applyFilter(params, resource) {
54 | //
55 | }
56 |
57 | /**
58 | * Used to populate the search field on page load with values from the querystring
59 | * A promise must be returned, and the UI will auto-update once the promise returns
60 | * @param {Object} params Object with a `q` and `sort` property. `filters` might be supported in the future
61 | * @return {Promise}
62 | */
63 | initializeFromParams(params) {
64 | //
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/js/src/forum/searchTypes/GroupFilter.js:
--------------------------------------------------------------------------------
1 | import app from 'flarum/forum/app';
2 | import Group from 'flarum/common/models/Group';
3 | import Icon from 'flarum/common/components/Icon';
4 | import AbstractType from './AbstractType';
5 |
6 | /* global m */
7 |
8 | export default class GroupFilter extends AbstractType {
9 | resourceType() {
10 | return 'groups';
11 | }
12 |
13 | search(query) {
14 | this.suggestions = [];
15 |
16 | if (!query) {
17 | return;
18 | }
19 |
20 | query = query.toLowerCase();
21 |
22 | app.store.all('groups').forEach((group) => {
23 | // Do not allow Guest group as it wouldn't do anything
24 | if (group.id() === Group.GUEST_ID) {
25 | return;
26 | }
27 |
28 | if (group.nameSingular().toLowerCase().indexOf(query) !== -1 || group.namePlural().toLowerCase().indexOf(query) !== -1) {
29 | this.suggestions.push(group);
30 | }
31 | });
32 | }
33 |
34 | renderKind() {
35 | return app.translator.trans('fof-user-directory.forum.search.kinds.group');
36 | }
37 |
38 | renderLabel(group) {
39 | return m(
40 | '.UserDirectorySearchLabel',
41 | group.color()
42 | ? {
43 | className: 'colored',
44 | style: {
45 | backgroundColor: group.color(),
46 | },
47 | }
48 | : {},
49 | [
50 | group.icon()
51 | ? [
52 | Icon.component({
53 | name: group.icon(),
54 | }),
55 | ' ',
56 | ]
57 | : null,
58 | group.namePlural(),
59 | ]
60 | );
61 | }
62 |
63 | applyFilter(params, group) {
64 | params.q = params.q ? params.q + ' ' : '';
65 | params.q += 'group:' + group.id();
66 | }
67 |
68 | initializeFromParams(params) {
69 | if (!params.q) {
70 | return Promise.resolve([]);
71 | }
72 |
73 | const groups = [];
74 |
75 | // Extract all group: parameters from the query string
76 | const groupMatches = params.q.match(/\bgroup:(\d+)\b/g);
77 |
78 | if (!groupMatches || !groupMatches.length) {
79 | return Promise.resolve([]);
80 | }
81 |
82 | // Get all unique group IDs from all group: parameters
83 | const allGroupIds = [];
84 | groupMatches.forEach((match) => {
85 | const id = match.replace('group:', '');
86 | allGroupIds.push(id);
87 | });
88 |
89 | // Deduplicate group IDs
90 | const uniqueGroupIds = [...new Set(allGroupIds)];
91 |
92 | // Load all group models
93 | const promises = uniqueGroupIds.map((id) => {
94 | return app.store
95 | .find('groups', id)
96 | .then((group) => {
97 | if (group) groups.push(group);
98 | return group;
99 | })
100 | .catch((error) => {
101 | console.error('Error loading group:', id, error);
102 | return null;
103 | });
104 | });
105 |
106 | return Promise.all(promises).then(() => groups);
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/js/src/forum/searchTypes/TextFilter.js:
--------------------------------------------------------------------------------
1 | import app from 'flarum/forum/app';
2 | import AbstractType from './AbstractType';
3 |
4 | /* global m */
5 |
6 | export default class TextFilter extends AbstractType {
7 | resourceType() {
8 | return 'fof-user-directory-text';
9 | }
10 |
11 | search(query) {
12 | if (!query) {
13 | this.suggestions = [];
14 | return;
15 | }
16 |
17 | this.suggestions = [
18 | app.store.createRecord('fof-user-directory-text', {
19 | attributes: {
20 | text: query,
21 | },
22 | }),
23 | ];
24 | }
25 |
26 | renderKind() {
27 | return app.translator.trans('fof-user-directory.forum.search.kinds.text');
28 | }
29 |
30 | renderLabel(resource) {
31 | return m('.UserDirectorySearchLabel', resource.text());
32 | }
33 |
34 | applyFilter(params, resource) {
35 | params.q = params.q ? params.q + ' ' : '';
36 | params.q += resource.text();
37 | }
38 |
39 | initializeFromParams(params) {
40 | if (!params.q) {
41 | return Promise.resolve([]);
42 | }
43 |
44 | return Promise.resolve(
45 | params.q
46 | .split(' ')
47 | // Words with : are gambits and we will ignore them
48 | .filter((word) => word.indexOf(':') === -1)
49 | .map((word) =>
50 | app.store.createRecord('fof-user-directory-text', {
51 | attributes: {
52 | text: word,
53 | },
54 | })
55 | )
56 | );
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/js/src/forum/states/UserDirectoryState.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Based on Flarum's DiscussionListState
3 | */
4 | import SortMap from '../../common/utils/SortMap';
5 |
6 | export default class UserDirectoryState {
7 | constructor(params = {}, app = window.app) {
8 | this.params = params;
9 |
10 | this.app = app;
11 |
12 | this.users = [];
13 |
14 | this.moreResults = false;
15 |
16 | this.loading = false;
17 |
18 | this.qBuilder = {};
19 | }
20 |
21 | requestParams() {
22 | const params = { include: ['groups'], filter: {} };
23 |
24 | const sortKey = this.params.sort || app.forum.attribute('userDirectoryDefaultSort');
25 |
26 | // sort might be set to null if no sort params has been passed
27 | params.sort = this.sortMap()[sortKey];
28 |
29 | if (this.params.q) {
30 | params.filter.q = this.params.q;
31 | }
32 |
33 | return params;
34 | }
35 |
36 | sortMap() {
37 | return {
38 | default: '',
39 | ...new SortMap().sortMap(),
40 | };
41 | }
42 |
43 | getParams() {
44 | return this.params;
45 | }
46 |
47 | clear() {
48 | this.users = [];
49 | m.redraw();
50 | }
51 |
52 | refreshParams(newParams) {
53 | if (!this.hasUsers() || Object.keys(newParams).some((key) => this.getParams()[key] !== newParams[key])) {
54 | this.params = newParams;
55 |
56 | // If we have a qBuilder, use it to build the query
57 | if (newParams.qBuilder) {
58 | Object.assign(this.qBuilder, newParams.qBuilder || {});
59 | this.params.q = Object.values(this.qBuilder).join(' ').trim();
60 | }
61 |
62 | // Make sure we have a query parameter
63 | if (!this.params.q) {
64 | this.params.q = '';
65 | } else if (typeof this.params.q !== 'string') {
66 | // Ensure q is a string
67 | this.params.q = String(this.params.q);
68 | }
69 |
70 | this.refresh();
71 | }
72 | }
73 |
74 | refresh() {
75 | this.loading = true;
76 |
77 | this.clear();
78 |
79 | return this.loadResults().then(
80 | (results) => {
81 | this.users = [];
82 | this.parseResults(results);
83 | },
84 | () => {
85 | this.loading = false;
86 | m.redraw();
87 | }
88 | );
89 | }
90 |
91 | loadResults(offset) {
92 | const preloadedUsers = this.app.preloadedApiDocument();
93 |
94 | if (preloadedUsers) {
95 | return Promise.resolve(preloadedUsers);
96 | }
97 |
98 | const params = this.requestParams();
99 | params.page = { offset };
100 | params.include = params.include.join(',');
101 |
102 | return this.app.store.find('users', params);
103 | }
104 |
105 | loadMore() {
106 | this.loading = true;
107 |
108 | this.loadResults(this.users.length).then(this.parseResults.bind(this));
109 | }
110 |
111 | parseResults(results) {
112 | this.users.push(...results);
113 |
114 | this.loading = false;
115 | this.moreResults = !!results.payload.links && !!results.payload.links.next;
116 |
117 | m.redraw();
118 |
119 | return results;
120 | }
121 |
122 | hasUsers() {
123 | return this.users.length > 0;
124 | }
125 |
126 | isLoading() {
127 | return this.loading;
128 | }
129 |
130 | isSearchResults() {
131 | return !!this.params.q;
132 | }
133 |
134 | empty() {
135 | return !this.hasUsers() && !this.isLoading();
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/js/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use Flarum's tsconfig as a starting point
3 | "extends": "flarum-tsconfig",
4 | // This will match all .ts, .tsx, .d.ts, .js, .jsx files
5 | "include": ["src/**/*"],
6 | "compilerOptions": {
7 | // This will output typings to `dist-typings`
8 | "declarationDir": "./dist-typings",
9 | "baseUrl": ".",
10 | "paths": {
11 | "flarum/*": ["../vendor/flarum/core/js/dist-typings/*"]
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/js/webpack.config.js:
--------------------------------------------------------------------------------
1 | module.exports = require('flarum-webpack-config')();
2 |
--------------------------------------------------------------------------------
/migrations/2019_06_10_000000_rename_permissions.php:
--------------------------------------------------------------------------------
1 | function (Builder $schema) {
17 | Permission::query()
18 | ->where('permission', 'flagrow.user-directory.view')
19 | ->update(['permission' => 'fof.user-directory.view']);
20 | },
21 | ];
22 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | includes:
2 | - vendor/flarum/phpstan/extension.neon
3 |
4 | parameters:
5 | # The level will be increased in Flarum 2.0
6 | level: 5
7 | paths:
8 | - extend.php
9 | - src
10 | excludePaths:
11 | - *.blade.php
12 | databaseMigrationsPath: ['migrations']
13 |
--------------------------------------------------------------------------------
/resources/less/components/SearchFiled.less:
--------------------------------------------------------------------------------
1 | // Style based on TagDiscussionModal.less from flarum/tags
2 | .UserDirectorySearchInput {
3 | padding-top: 0;
4 | padding-bottom: 0;
5 | margin-bottom: 0 !important;
6 | height: auto;
7 | min-height: 36px; // Same as .FormControl
8 | position: relative;
9 |
10 | display: grid !important;
11 | grid-template-columns: max-content 1fr;
12 | column-gap: 10px;
13 |
14 | @media @phone {
15 | margin-left: 0;
16 | }
17 |
18 | // Same background as .focus to give it better visibility
19 | background-color: var(--body-bg);
20 | color: var(--text-color);
21 | border-color: var(--control-bg);
22 |
23 | &:focus-within {
24 | border-color: var(--primary-color);
25 | }
26 |
27 | input {
28 | display: inline;
29 | outline: none;
30 | margin-top: -2px;
31 | margin-bottom: -2px;
32 | border: 0 !important;
33 | padding: 0;
34 | width: 100%;
35 | margin-right: -100%;
36 | background: transparent !important;
37 | }
38 |
39 | .LoadingIndicator {
40 | float: right;
41 | pointer-events: none;
42 | }
43 |
44 | .Dropdown-menu {
45 | position: absolute;
46 | top: 100%;
47 | left: 0;
48 | right: 0;
49 | display: block;
50 | }
51 | }
52 |
53 | .UserDirectorySearchInput-selected {
54 | display: flex;
55 | align-items: center;
56 | column-gap: 5px;
57 |
58 | &:empty {
59 | display: none;
60 | }
61 |
62 | .UserDirectorySearchInput-filter {
63 | cursor: not-allowed;
64 | }
65 | }
66 |
67 | .UserDirectorySearchLabel {
68 | font-size: 85%;
69 | font-weight: 600;
70 | display: inline-block;
71 | padding: 0.1em 0.5em;
72 | border-radius: var(--border-radius);
73 | background: var(--control-bg);
74 | color: var(--control-color);
75 | text-transform: none;
76 |
77 | &.colored {
78 | color: var(--body-bg) !important;
79 | }
80 | }
81 |
82 | .UserDirectorySearchKind {
83 | display: inline-block;
84 | min-width: 5em;
85 | color: #aaa;
86 | font-style: italic;
87 | }
88 |
--------------------------------------------------------------------------------
/resources/less/components/UserDirectoryHero.less:
--------------------------------------------------------------------------------
1 | @media @tablet-up {
2 | .Hero.UserDirectoryHero {
3 | .container {
4 | padding-bottom: 40px;
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/resources/less/components/list.less:
--------------------------------------------------------------------------------
1 | .UserDirectoryList {
2 | .UserDirectoryList-users {
3 | list-style: none;
4 | padding: 0;
5 | clear: both;
6 | }
7 | .User {
8 | margin-bottom: 1px;
9 | }
10 |
11 | .UserCard--directory {
12 | width: 100%;
13 | box-shadow: 0 2px 6px var(--shadow-color);
14 | margin-bottom: 10px;
15 |
16 | &,
17 | .darkenBackground {
18 | border-radius: var(--border-radius);
19 | }
20 | .container {
21 | width: auto !important;
22 | padding: 20px !important;
23 | }
24 | .UserCard-identity {
25 | font-size: 22px;
26 | }
27 | // Workaround for https://github.com/flarum/core/issues/1792
28 | .UserCard-controls {
29 | position: relative !important;
30 | z-index: auto;
31 | }
32 | .Button {
33 | @media @phone {
34 | color: #fff !important;
35 | }
36 | }
37 |
38 | @media @tablet-up {
39 | .Dropdown-menu {
40 | // for not overlapping header
41 | z-index: calc(~"var(--zindex-header) - 1");
42 | }
43 | }
44 | }
45 |
46 | &-loadMore {
47 | text-align: center;
48 | }
49 | }
50 |
51 | @import "./smallCards";
52 |
--------------------------------------------------------------------------------
/resources/less/components/smallCards.less:
--------------------------------------------------------------------------------
1 | .UserDirectoryList {
2 | .UserCard--small {
3 | .container {
4 | padding: 8px !important;
5 | position: relative;
6 | }
7 |
8 | .UserCard-profile {
9 | padding-left: 0;
10 | grid-template-columns: 30px 1fr;
11 | gap: 10px;
12 | }
13 |
14 | .UserCard-identity {
15 | font-size: 14px;
16 | }
17 |
18 | .UserCard-avatar {
19 | margin-left: 0;
20 | margin-right: 10px;
21 |
22 | .Avatar {
23 | @avatar-size: 26px;
24 | width: @avatar-size;
25 | height: @avatar-size;
26 | line-height: @avatar-size;
27 | border-width: 2px;
28 | font-size: 14px;
29 | }
30 | }
31 |
32 | .UserCard-info {
33 | margin-left: 5px;
34 |
35 | & > li {
36 | display: block;
37 | }
38 | }
39 |
40 | @media @phone {
41 | .UserCard-avatar {
42 | margin: 0 auto 15px auto;
43 |
44 | .Avatar {
45 | @avatar-size: 36px;
46 | width: @avatar-size;
47 | height: @avatar-size;
48 | line-height: @avatar-size;
49 | font-size: 20px;
50 | }
51 | }
52 |
53 | .username {
54 | font-size: 16px;
55 | }
56 |
57 | .UserCard-badges {
58 | margin-left: 5px;
59 | margin-right: 0;
60 | }
61 | }
62 |
63 | @media (min-width: @screen-phone-max) {
64 | .UserCard-avatar {
65 | display: inline-block;
66 | }
67 |
68 | //Long usernames
69 | a {
70 | display: flex;
71 | white-space: nowrap;
72 | }
73 |
74 | .username {
75 | font-size: 18px;
76 | text-overflow: ellipsis;
77 | overflow: hidden;
78 | }
79 | }
80 |
81 | @media @tablet-up {
82 | .UserCard-badges {
83 | position: absolute;
84 | top: 0;
85 | left: 8px;
86 |
87 | .Badge {
88 | width: 18px;
89 | height: 18px;
90 | line-height: 16px;
91 |
92 | &-icon {
93 | font-size: 9px;
94 | }
95 | }
96 | }
97 |
98 | .UserCard-info {
99 | margin: 0;
100 | }
101 | }
102 | }
103 |
104 | &--small-cards {
105 | .UserDirectoryList-users {
106 | display: grid;
107 | //Equal Width Columns
108 | grid-template-columns: repeat(4, minmax(0, 1fr));
109 | grid-column-gap: 10px;
110 |
111 | @media (max-width: @screen-tablet-max) {
112 | grid-template-columns: repeat(3, minmax(0, 1fr));
113 | }
114 |
115 | @media @phone {
116 | grid-template-columns: repeat(2, minmax(0, 1fr));
117 | }
118 |
119 | @media (max-width: 400px) {
120 | grid-template-columns: 1fr;
121 | }
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/resources/less/components/toolbar.less:
--------------------------------------------------------------------------------
1 | .User--directory {
2 | .IndexPage-toolbar {
3 | display: flex;
4 | row-gap: 10px;
5 |
6 | .IndexPage-toolbar-view {
7 | display: flex;
8 | column-gap: 10px;
9 | row-gap: 5px;
10 | flex-grow: 1;
11 |
12 | > li {
13 | vertical-align: middle;
14 | width: fit-content;
15 | }
16 | }
17 |
18 | @media @phone {
19 | flex-direction: column;
20 |
21 | .item-search {
22 | flex: 0 1 100%;
23 | }
24 |
25 | .IndexPage-toolbar-view,
26 | .IndexPage-toolbar-action {
27 | display: flex;
28 | flex-wrap: wrap;
29 | }
30 | .IndexPage-toolbar-view {
31 | flex-wrap: wrap-reverse;
32 | justify-content: space-between;
33 |
34 | & > li {
35 | margin-right: 0;
36 | }
37 | }
38 |
39 | .IndexPage-toolbar-action {
40 | flex-shrink: 1;
41 | float: none;
42 |
43 | & > li {
44 | margin-left: 0;
45 | }
46 | li.item-refresh {
47 | margin-left: auto;
48 | }
49 | }
50 | }
51 | @media screen and (max-width: 370px) {
52 | .IndexPage-toolbar-view {
53 | & > li {
54 | width: 100%;
55 | }
56 | }
57 | }
58 | }
59 | }
60 |
61 | .item-filterGroups {
62 | .ButtonGroup {
63 | button {
64 | @media @phone {
65 | padding-top: 4px;
66 | }
67 | }
68 | }
69 |
70 | .GroupFilterButton {
71 | display: flex;
72 |
73 | .Button-label {
74 | width: 100%;
75 | }
76 | .ButtonCheck {
77 | margin-left: 10px;
78 | }
79 | }
80 | }
81 |
82 | @media @tablet-up {
83 | .IndexPage-toolbar-action {
84 | .item-clarkwinkelmann-mailing {
85 | .Button {
86 | & > .Button-icon {
87 | margin-right: 0;
88 | }
89 | & > .Button-label {
90 | display: none;
91 | }
92 | }
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/resources/less/forum.less:
--------------------------------------------------------------------------------
1 | @import "components/toolbar";
2 | @import "components/list";
3 | @import "components/SearchFiled";
4 | @import "components/UserDirectoryHero.less";
5 |
6 | .UserCard-info {
7 | .item-discussion-stats {
8 | display: block;
9 | margin-top: 20px;
10 | }
11 | }
12 |
13 | .UserCard {
14 | .userStat {
15 | .icon {
16 | margin-right: 5px;
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/resources/locale/en.yml:
--------------------------------------------------------------------------------
1 | fof-user-directory:
2 | forum:
3 | header:
4 | back_to_user_directory_tooltip: Back to User Directory
5 |
6 | hero:
7 | title: => fof-user-directory.forum.page.nav
8 |
9 | search:
10 | users_heading: 'Search all users for "{query}"'
11 | field:
12 | placeholder: Search all users
13 | kinds:
14 | group: Group
15 | text: Free text
16 | page:
17 | nav: User Directory
18 | refresh_tooltip: => core.forum.index.refresh_tooltip
19 | load_more_button: => core.ref.load_more
20 | empty_text: We could not find any user matching your search.
21 | filter_button: Filter Groups
22 | usercard:
23 | discussion-count: "{count, plural, one { {count} discussion} other {{count} discussions}}"
24 | post-count: "{count, plural, one { {count} post} other {{count} posts}}"
25 |
26 | admin:
27 | permissions:
28 | view_user_directory: View user directory
29 | settings:
30 | link: Add link on homepage for users able to see the directory
31 | default-sort: Default sort
32 | use-small-cards: Use small user cards
33 | disable-global-search-source: Do not add User Directory to the Flarum global search field
34 | link-group-mentions: Link group mentions in posts to the User Directory
35 |
36 | lib:
37 | sort:
38 | not_specified: Use Flarum default
39 | default: Default
40 | username_az: Username (a-z)
41 | username_za: Username (z-a)
42 | newest: Newest
43 | oldest: Oldest
44 | most_posts: Most posts
45 | least_posts: Least posts
46 | most_discussions: Most discussions
47 | least_discussions: Least discussions
48 |
--------------------------------------------------------------------------------
/resources/views/index.blade.php:
--------------------------------------------------------------------------------
1 |
4 |
17 |
--------------------------------------------------------------------------------
/src/Access/UserPolicy.php:
--------------------------------------------------------------------------------
1 | hasPermission('fof.user-directory.view') && $actor->hasPermission('searchUsers');
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Api/PermissionBasedForumSettings.php:
--------------------------------------------------------------------------------
1 | get(fn ($forum, Context $context) => $context->getActor()->can('seeUserList') && $this->settings->get('fof-user-directory-link')),
31 | Schema\Str::make('userDirectoryDefaultSort')
32 | ->get(fn () => $this->settings->get('fof-user-directory.default-sort') ?: 'default'),
33 | Schema\Boolean::make('hasSuspendPermission')
34 | // Only serialize if the actor has permission
35 | ->visible(fn ($forum, Context $context) => $context->getActor()->hasPermission('user.suspend'))
36 | ->get(fn ($forum, Context $context) => $context->getActor()->hasPermission('user.suspend')),
37 | ];
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Content/UserDirectory.php:
--------------------------------------------------------------------------------
1 | 'username',
33 | 'username_za' => '-username',
34 | 'newest' => '-joinedAt',
35 | 'oldest' => 'joinedAt',
36 | 'most_discussions' => '-discussionCount',
37 | 'least_discussions' => 'discussionCount',
38 | ];
39 |
40 | public function __construct(
41 | protected Client $api,
42 | protected Factory $view,
43 | protected SettingsRepositoryInterface $settings
44 | ) {
45 | }
46 |
47 | private function getDocument(User $actor, array $params, Request $request)
48 | {
49 | $actor->assertCan('seeUserList');
50 |
51 | // Make sure groups are included in the API request
52 | if (!isset($params['include'])) {
53 | $params['include'] = 'groups';
54 | } elseif (is_array($params['include'])) {
55 | if (!in_array('groups', $params['include'])) {
56 | $params['include'][] = 'groups';
57 | }
58 | $params['include'] = implode(',', $params['include']);
59 | } elseif (is_string($params['include']) && !str_contains($params['include'], 'groups')) {
60 | $params['include'] .= ',groups';
61 | }
62 |
63 | return json_decode($this->api->withQueryParams($params)->withParentRequest($request)->get('/users')->getBody());
64 | }
65 |
66 | /**
67 | * @throws PermissionDeniedException
68 | */
69 | public function __invoke(Document $document, Request $request): Document
70 | {
71 | $queryParams = $request->getQueryParams();
72 | $actor = RequestUtil::getActor($request);
73 |
74 | $sort = Arr::pull($queryParams, 'sort') ?: $this->settings->get('fof-user-directory.default-sort');
75 | $q = Arr::pull($queryParams, 'q');
76 | $page = Arr::pull($queryParams, 'page', 1);
77 |
78 | // Ensure the query parameter is properly formatted
79 | if ($q) {
80 | // Make sure it's a string
81 | $q = (string) $q;
82 | }
83 |
84 | $params = [
85 | // ?? used to prevent null values. null would result in the whole sortMap array being sent in the params
86 | 'sort' => Arr::get($this->sortMap, $sort ?? '', ''),
87 | 'filter' => compact('q'),
88 | 'page' => ['offset' => ($page - 1) * 20, 'limit' => 20],
89 | ];
90 |
91 | $apiDocument = $this->getDocument($actor, $params, $request);
92 |
93 | $document->content = $this->view->make('fof.user-directory::index', compact('page', 'apiDocument'));
94 |
95 | $document->payload['apiDocument'] = $apiDocument;
96 |
97 | // Add query parameters to the payload so the frontend can initialize filters
98 | $document->payload['fofUserDirectory'] = [
99 | 'q' => $q,
100 | 'sort' => $sort,
101 | ];
102 |
103 | return $document;
104 | }
105 | }
106 |
--------------------------------------------------------------------------------