From e1fa4b3aff9fb3b2b8f7ca3243a9139bbccbb2de Mon Sep 17 00:00:00 2001 From: IanM Date: Fri, 17 Apr 2026 22:00:13 +0100 Subject: [PATCH] fix: mobile positioning, ts refactor --- extend.php | 3 +- js/package.json | 1 + js/src/admin/components/EditLinkModal.js | 293 ----------------- js/src/admin/components/EditLinkModal.tsx | 304 ++++++++++++++++++ .../{LinksPage.js => LinksPage.tsx} | 114 +++---- js/src/admin/components/index.ts | 7 - js/src/admin/extend.ts | 15 +- js/src/admin/index.js | 19 -- js/src/admin/index.ts | 1 + js/src/common/models/index.ts | 5 - js/src/common/utils/index.ts | 5 - js/src/common/utils/sortLinks.js | 7 - js/src/common/utils/sortLinks.ts | 9 + js/src/forum/components/LinkItem.tsx | 2 +- js/src/forum/components/index.ts | 7 - js/src/forum/index.ts | 4 - js/yarn.lock | 5 + less/forum.less | 9 +- 18 files changed, 395 insertions(+), 415 deletions(-) delete mode 100755 js/src/admin/components/EditLinkModal.js create mode 100644 js/src/admin/components/EditLinkModal.tsx rename js/src/admin/components/{LinksPage.js => LinksPage.tsx} (52%) mode change 100755 => 100644 delete mode 100644 js/src/admin/components/index.ts delete mode 100755 js/src/admin/index.js create mode 100755 js/src/admin/index.ts delete mode 100644 js/src/common/models/index.ts delete mode 100644 js/src/common/utils/index.ts delete mode 100755 js/src/common/utils/sortLinks.js create mode 100644 js/src/common/utils/sortLinks.ts delete mode 100644 js/src/forum/components/index.ts diff --git a/extend.php b/extend.php index 71606cc..7826d37 100755 --- a/extend.php +++ b/extend.php @@ -30,7 +30,8 @@ (new Extend\Frontend('admin')) ->js(__DIR__.'/js/dist/admin.js') - ->css(__DIR__.'/less/admin.less'), + ->css(__DIR__.'/less/admin.less') + ->jsDirectory(__DIR__.'/js/dist/admin'), // Custom permission endpoint (keep this as-is since it's custom logic) (new Extend\Routes('api')) diff --git a/js/package.json b/js/package.json index a5b5631..27aa196 100644 --- a/js/package.json +++ b/js/package.json @@ -9,6 +9,7 @@ "devDependencies": { "prettier": "^3.0.2", "@flarum/prettier-config": "^1.0.0", + "@types/sortablejs": "^1.15.9", "flarum-tsconfig": "^2.0.0", "flarum-webpack-config": "^3.0.0", "webpack": "^5.65.0", diff --git a/js/src/admin/components/EditLinkModal.js b/js/src/admin/components/EditLinkModal.js deleted file mode 100755 index 4ed795b..0000000 --- a/js/src/admin/components/EditLinkModal.js +++ /dev/null @@ -1,293 +0,0 @@ -import Form from 'flarum/common/components/Form'; -/* global m*/ -/* global confirm*/ - -import app from 'flarum/admin/app'; -import FormModal from 'flarum/common/components/FormModal'; -import Button from 'flarum/common/components/Button'; -import Stream from 'flarum/common/utils/Stream'; -import Icon from 'flarum/common/components/Icon'; -import withAttr from 'flarum/common/utils/withAttr'; -import ItemList from 'flarum/common/utils/ItemList'; -import PermissionDropdown from 'flarum/admin/components/PermissionDropdown'; -import Alert from 'flarum/common/components/Alert'; -import Group from 'flarum/common/models/Group'; -import Link from 'flarum/common/components/Link'; -/** - * The `EditlinksModal` component shows a modal dialog which allows the user - * to create or edit a link. - */ -export default class EditlinksModal extends FormModal { - oninit(vnode) { - super.oninit(vnode); - - this.link = this.attrs.link || app.store.createRecord('links'); - - this.itemTitle = Stream(this.link.title() || ''); - this.icon = Stream(this.link.icon() || ''); - this.url = Stream(this.link.url() || ''); - this.isInternal = Stream(this.link.isInternal() && true); - this.isNewtab = Stream(this.link.isNewtab() && true); - this.useRelMe = Stream(this.link.useRelMe() && true); - this.guestOnly = Stream(this.link.guestOnly() && true); - - if (this.isInternal()) { - this.updateInternalUrl(); - } - } - - className() { - return 'EditLinkModal Modal--medium'; - } - - title() { - const title = this.itemTitle(); - - if (!title) { - return app.translator.trans('fof-links.admin.edit_link.title'); - } - - const iconClass = this.icon(); - - if (iconClass) { - return ( - <> - {title} - - ); - } - - return title; - } - - content() { - return ( -
-
{this.items().toArray()}
-
- ); - } - - getGroup(id) { - return app.store.getById('groups', id); - } - - items() { - const items = new ItemList(); - - const permissionPriority = 200; - if (this.link.exists) { - const adminLabel = this.getGroup(Group.ADMINISTRATOR_ID).nameSingular(); - const guestLabel = this.getGroup(Group.GUEST_ID).namePlural(); - const everyoneLabel = app.translator.trans('core.admin.permissions_controls.everyone_button'); - - items.add( - 'visibility-permission', - [ -
- -

{app.translator.trans('fof-links.admin.edit_link.visibility.help', { admin: adminLabel })}

- -
, -
- -

- {app.translator.trans('fof-links.admin.edit_link.visibility.guest-only.help', { guest: guestLabel, everyone: everyoneLabel })} -

-
, - ], - permissionPriority - ); - } else { - items.add( - 'visibility-permission-disabled', - [ -
- - - {app.translator.trans('fof-links.admin.edit_link.visibility.help-disabled')} - -
, - ], - permissionPriority - ); - } - - items.add( - 'title', - [ -
- - -
, - ], - 100 - ); - - items.add( - 'icon', - [ -
- -
- {app.translator.trans('fof-links.admin.edit_link.icon_text', { - a: ( - - ), - })} -
- {app.translator.trans('fof-links.admin.edit_link.icon_additional_text')} -
- -
, - ], - 80 - ); - - items.add( - 'url', - [ -
- - - -
, - ], - 60 - ); - - items.add( - 'checkboxes', - [ -
-
- - - -
-
, - ], - 40 - ); - - items.add( - 'actions', - [ -
- {Button.component( - { - type: 'submit', - className: 'Button Button--primary EditLinkModal-save', - loading: this.loading, - }, - app.translator.trans('fof-links.admin.edit_link.submit_button') - )} - {this.link.exists ? ( - - ) : ( - '' - )} -
, - ], - 0 - ); - - return items; - } - - submitData() { - return { - title: this.itemTitle(), - icon: this.icon(), - url: this.url(), - isInternal: this.isInternal(), - isNewtab: this.isNewtab(), - useRelMe: this.useRelMe(), - guestOnly: this.guestOnly(), - }; - } - - onsubmit(e) { - e.preventDefault(); - - this.loading = true; - - this.link.save(this.submitData()).then( - () => this.hide(), - (response) => { - this.loading = false; - this.handleErrors(response); - } - ); - } - - delete() { - if (confirm(app.translator.trans('fof-links.admin.edit_link.delete_link_confirmation'))) { - this.link.delete().then(() => m.redraw()); - this.hide(); - } - } - - updateInternalUrl() { - const base = app.forum.attribute('baseUrl'); - const url = this.url(); - - if (this.isInternal()) { - this.url(url.replace(base, '')); - } else if (url.startsWith('/')) { - this.url(base + url); - } - } -} diff --git a/js/src/admin/components/EditLinkModal.tsx b/js/src/admin/components/EditLinkModal.tsx new file mode 100644 index 0000000..c8689f5 --- /dev/null +++ b/js/src/admin/components/EditLinkModal.tsx @@ -0,0 +1,304 @@ +import app from 'flarum/admin/app'; +import FormModal, { IFormModalAttrs } from 'flarum/common/components/FormModal'; +import Form from 'flarum/common/components/Form'; +import Button from 'flarum/common/components/Button'; +import Stream from 'flarum/common/utils/Stream'; +import Icon from 'flarum/common/components/Icon'; +import withAttr from 'flarum/common/utils/withAttr'; +import ItemList from 'flarum/common/utils/ItemList'; +import PermissionDropdown from 'flarum/admin/components/PermissionDropdown'; +import Alert from 'flarum/common/components/Alert'; +import Group from 'flarum/common/models/Group'; +import LinkComponent from 'flarum/common/components/Link'; +import extractText from 'flarum/common/utils/extractText'; +import type Mithril from 'mithril'; + +import type Link from '../../common/models/Link'; + +export interface IEditLinkModalAttrs extends IFormModalAttrs { + link?: Link; +} + +/** + * The `EditLinkModal` component shows a modal dialog which allows the user + * to create or edit a link. + */ +export default class EditLinkModal extends FormModal { + link!: Link; + itemTitle!: Stream; + icon!: Stream; + url!: Stream; + isInternal!: Stream; + isNewtab!: Stream; + useRelMe!: Stream; + guestOnly!: Stream; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.link = this.attrs.link || app.store.createRecord('links'); + + this.itemTitle = Stream(this.link.title() || ''); + this.icon = Stream(this.link.icon() || ''); + this.url = Stream(this.link.url() || ''); + this.isInternal = Stream(!!this.link.isInternal()); + this.isNewtab = Stream(!!this.link.isNewtab()); + this.useRelMe = Stream(!!this.link.useRelMe()); + this.guestOnly = Stream(!!this.link.guestOnly()); + + if (this.isInternal()) { + this.updateInternalUrl(); + } + } + + className() { + return 'EditLinkModal Modal--medium'; + } + + title(): Mithril.Children { + const title = this.itemTitle(); + + if (!title) { + return app.translator.trans('fof-links.admin.edit_link.title'); + } + + const iconClass = this.icon(); + + if (iconClass) { + return ( + <> + {title} + + ); + } + + return title; + } + + content(): Mithril.Children { + return ( +
+
{this.items().toArray()}
+
+ ); + } + + getGroup(id: string): Group | undefined { + return app.store.getById('groups', id); + } + + items(): ItemList { + const items = new ItemList(); + + const permissionPriority = 200; + if (this.link.exists) { + const adminLabel = this.getGroup(Group.ADMINISTRATOR_ID)?.nameSingular(); + const guestLabel = this.getGroup(Group.GUEST_ID)?.namePlural(); + const everyoneLabel = app.translator.trans('core.admin.permissions_controls.everyone_button'); + + items.add( + 'visibility-permission', + [ +
+ +

{app.translator.trans('fof-links.admin.edit_link.visibility.help', { admin: adminLabel })}

+ +
, +
+ +

+ {app.translator.trans('fof-links.admin.edit_link.visibility.guest-only.help', { guest: guestLabel, everyone: everyoneLabel })} +

+
, + ], + permissionPriority + ); + } else { + items.add( + 'visibility-permission-disabled', + [ +
+ + + {app.translator.trans('fof-links.admin.edit_link.visibility.help-disabled')} + +
, + ], + permissionPriority + ); + } + + items.add( + 'title', +
+ + +
, + 100 + ); + + items.add( + 'icon', +
+ +
+ {app.translator.trans('fof-links.admin.edit_link.icon_text', { + a: ( + + ), + })} +
+ {app.translator.trans('fof-links.admin.edit_link.icon_additional_text')} +
+ +
, + 80 + ); + + items.add( + 'url', +
+ + + +
, + 60 + ); + + items.add( + 'checkboxes', +
+
+ + + +
+
, + 40 + ); + + items.add( + 'actions', +
+ + {this.link.exists && ( + + )} +
, + 0 + ); + + return items; + } + + submitData(): Record { + return { + title: this.itemTitle(), + icon: this.icon(), + url: this.url(), + isInternal: this.isInternal(), + isNewtab: this.isNewtab(), + useRelMe: this.useRelMe(), + guestOnly: this.guestOnly(), + }; + } + + onsubmit(e: SubmitEvent) { + e.preventDefault(); + + this.loading = true; + + this.link + .save(this.submitData(), { errorHandler: this.onerror.bind(this) }) + .then(this.hide.bind(this)) + .catch(() => { + this.loading = false; + m.redraw(); + }); + } + + delete() { + if (confirm(extractText(app.translator.trans('fof-links.admin.edit_link.delete_link_confirmation')))) { + this.link.delete().then(() => m.redraw()); + this.hide(); + } + } + + updateInternalUrl() { + const base = app.forum.attribute('baseUrl'); + const url = this.url(); + + if (this.isInternal()) { + this.url(url.replace(base, '')); + } else if (url.startsWith('/')) { + this.url(base + url); + } + } +} diff --git a/js/src/admin/components/LinksPage.js b/js/src/admin/components/LinksPage.tsx old mode 100755 new mode 100644 similarity index 52% rename from js/src/admin/components/LinksPage.js rename to js/src/admin/components/LinksPage.tsx index 9831781..cc38118 --- a/js/src/admin/components/LinksPage.js +++ b/js/src/admin/components/LinksPage.tsx @@ -1,35 +1,38 @@ import app from 'flarum/admin/app'; -import ExtensionPage from 'flarum/admin/components/ExtensionPage'; +import ExtensionPage, { ExtensionPageAttrs } from 'flarum/admin/components/ExtensionPage'; import Button from 'flarum/common/components/Button'; import Icon from 'flarum/common/components/Icon'; import Placeholder from 'flarum/common/components/Placeholder'; +import ItemList from 'flarum/common/utils/ItemList'; import sortable from 'sortablejs'; +import type Mithril from 'mithril'; -import EditLinkModal from './EditLinkModal'; import sortLinks from '../../common/utils/sortLinks'; +import type Link from '../../common/models/Link'; + +interface LinkOrder { + id: string; + children: string[]; +} + +function linkItem(link: Link): Mithril.Children { + const icon = link.icon(); -function linkItem(link) { return (
  • - {link.icon() ? ( + {icon && ( - {' '} + {' '} - ) : ( - '' )} {link.title()} - {Button.component({ - className: 'Button Button--link', - icon: 'fas fa-pencil-alt', - onclick: () => app.modal.show(EditLinkModal, { link }), - })} +
    {!link.isChild() && (
      - {sortLinks(app.store.all('links')) + {sortLinks(app.store.all('links')) .filter((child) => child.parent() === link) .map(linkItem)}
    @@ -39,23 +42,18 @@ function linkItem(link) { } export default class LinksPage extends ExtensionPage { - oninit(vnode) { - super.oninit(vnode); - - this.forcedRefreshKey = 0; - } + forcedRefreshKey = 0; - sections() { - const items = super.sections(); + sections(vnode: Mithril.VnodeDOM): ItemList { + const items = super.sections(vnode); items.setPriority('content', 100); - items.add('links', this.links(), 80); return items; } - links() { + links(): Mithril.Children { return (
    @@ -68,33 +66,24 @@ export default class LinksPage extends ExtensionPage { ); } - linksPreset() { - return ( - <> - - - ); + linksPreset(): Mithril.Children { + return ; } - linksContent() { + linksContent(): Mithril.Children { return ( <>
    - {Button.component( - { - className: 'Button Button--primary', - icon: 'fas fa-plus', - onclick: () => app.modal.show(EditLinkModal), - }, - app.translator.trans('fof-links.admin.links.create_button') - )} +
      - {sortLinks(app.store.all('links')) + {sortLinks(app.store.all('links')) .filter((link) => !link.isChild()) .map(linkItem)}
    @@ -105,34 +94,33 @@ export default class LinksPage extends ExtensionPage { ); } - onListOnCreate(vnode) { - this.$('.LinkList') - .get() - .map((e) => { - sortable.create(e, { - group: 'links', - animation: 150, - swapThreshold: 0.65, - dragClass: 'sortable-dragging', - ghostClass: 'sortable-placeholder', - onSort: (e) => this.onSortUpdate(e), - }); + onListOnCreate(vnode: Mithril.VnodeDOM): void { + vnode.dom.querySelectorAll('.LinkList').forEach((el) => { + sortable.create(el, { + group: 'links', + animation: 150, + swapThreshold: 0.65, + dragClass: 'sortable-dragging', + ghostClass: 'sortable-placeholder', + onSort: () => this.onSortUpdate(), }); + }); } - onSortUpdate(e) { - const order = this.$('.LinkList--primary > li') - .map((i, el) => ({ - id: $(el).data('id'), - children: $(el) - .find('li') - .map((i, el) => $(el).data('id')) - .get(), - })) - .get(); + onSortUpdate(): void { + const root = this.element?.querySelector('.LinkList--primary'); + + if (!root) return; + + const order: LinkOrder[] = Array.from(root.querySelectorAll(':scope > li')).map((el) => ({ + id: String(el.dataset.id), + children: Array.from(el.querySelectorAll('li')).map((child) => String(child.dataset.id)), + })); order.forEach((link, i) => { - const parent = app.store.getById('links', link.id); + const parent = app.store.getById('links', link.id); + + if (!parent) return; parent.pushData({ attributes: { @@ -142,8 +130,8 @@ export default class LinksPage extends ExtensionPage { relationships: { parent: null }, }); - link.children.forEach((child, j) => { - app.store.getById('links', child).pushData({ + link.children.forEach((childId, j) => { + app.store.getById('links', childId)?.pushData({ attributes: { position: j, isChild: true, diff --git a/js/src/admin/components/index.ts b/js/src/admin/components/index.ts deleted file mode 100644 index abe1024..0000000 --- a/js/src/admin/components/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import EditLinkModal from './EditLinkModal'; -import LinksPage from './LinksPage'; - -export const components = { - EditLinkModal, - LinksPage, -}; diff --git a/js/src/admin/extend.ts b/js/src/admin/extend.ts index 93caee0..02adc1b 100644 --- a/js/src/admin/extend.ts +++ b/js/src/admin/extend.ts @@ -1,3 +1,16 @@ +import app from 'flarum/admin/app'; import commonExtend from '../common/extend'; +import Extend from 'flarum/common/extenders'; +import LinksPage from './components/LinksPage'; -export default [...commonExtend]; +export default [ + ...commonExtend, + + new Extend.Admin() // + .page(LinksPage) + .setting(() => ({ + setting: 'fof-links.show_icons_only_on_mobile', + label: app.translator.trans('fof-links.admin.settings.show_icons_only_on_tablet'), + type: 'boolean', + })), +]; diff --git a/js/src/admin/index.js b/js/src/admin/index.js deleted file mode 100755 index b3f2d27..0000000 --- a/js/src/admin/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import app from 'flarum/admin/app'; -import LinksPage from './components/LinksPage'; - -export * from './components'; -export * from '../common/utils'; -export * from '../common/models'; - -export { default as extend } from './extend'; - -app.initializers.add('fof-links', () => { - app.registry - .for('fof-links') - .registerPage(LinksPage) - .registerSetting({ - setting: 'fof-links.show_icons_only_on_mobile', - label: app.translator.trans('fof-links.admin.settings.show_icons_only_on_tablet'), - type: 'boolean', - }); -}); diff --git a/js/src/admin/index.ts b/js/src/admin/index.ts new file mode 100755 index 0000000..6d2293d --- /dev/null +++ b/js/src/admin/index.ts @@ -0,0 +1 @@ +export { default as extend } from './extend'; diff --git a/js/src/common/models/index.ts b/js/src/common/models/index.ts deleted file mode 100644 index a793107..0000000 --- a/js/src/common/models/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Link from './Link'; - -export const models = { - Link, -}; diff --git a/js/src/common/utils/index.ts b/js/src/common/utils/index.ts deleted file mode 100644 index acec831..0000000 --- a/js/src/common/utils/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import sortLinks from './sortLinks'; - -export const utils = { - sortLinks, -}; diff --git a/js/src/common/utils/sortLinks.js b/js/src/common/utils/sortLinks.js deleted file mode 100755 index 4f32133..0000000 --- a/js/src/common/utils/sortLinks.js +++ /dev/null @@ -1,7 +0,0 @@ -export default function sortLinks(links) { - return links.slice(0).sort((a, b) => { - const aPos = a.position(); - const bPos = b.position(); - return aPos > bPos ? 1 : aPos < bPos ? -1 : 0; - }); -} diff --git a/js/src/common/utils/sortLinks.ts b/js/src/common/utils/sortLinks.ts new file mode 100644 index 0000000..744b3ba --- /dev/null +++ b/js/src/common/utils/sortLinks.ts @@ -0,0 +1,9 @@ +import type Link from '../models/Link'; + +export default function sortLinks(links: Link[]): Link[] { + return links.slice(0).sort((a, b) => { + const aPos = a.position() ?? 0; + const bPos = b.position() ?? 0; + return aPos - bPos; + }); +} diff --git a/js/src/forum/components/LinkItem.tsx b/js/src/forum/components/LinkItem.tsx index b012b2f..0fca476 100755 --- a/js/src/forum/components/LinkItem.tsx +++ b/js/src/forum/components/LinkItem.tsx @@ -117,7 +117,7 @@ export default class LinkItem extends LinkButton { } get class(): string { - return classList('LinksButton', this.attrs.className || 'Button Button--link', { + return classList('LinksButton', 'Button Button--link', this.attrs.className, { 'LinksButton--inDropdown': this.attrs.inDropdown, active: this.isLinkCurrentPage, }); diff --git a/js/src/forum/components/index.ts b/js/src/forum/components/index.ts deleted file mode 100644 index 6dca43d..0000000 --- a/js/src/forum/components/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import LinkDropdown from './LinkDropdown'; -import LinkItem from './LinkItem'; - -export const components = { - LinkDropdown, - LinkItem, -}; diff --git a/js/src/forum/index.ts b/js/src/forum/index.ts index ddc5568..5733294 100755 --- a/js/src/forum/index.ts +++ b/js/src/forum/index.ts @@ -1,10 +1,6 @@ import app from 'flarum/forum/app'; import extendHeader from './extendHeader'; -export * from './components'; -export * from '../common/utils'; -export * from '../common/models'; - export { default as extend } from './extend'; app.initializers.add('fof-links', () => { diff --git a/js/yarn.lock b/js/yarn.lock index e63fc28..02b08eb 100644 --- a/js/yarn.lock +++ b/js/yarn.lock @@ -1248,6 +1248,11 @@ resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.9.tgz#d4597dbd4618264c414d7429363e3f50acb66ea2" integrity sha512-xzLEyKB50yqCUPUJkIsrVvoWNfFUbIZI+RspLWt8u+tIW/BetMBZtgV2LY/2o+tYH8dRvQ+eoPf3NdhQCcLE2w== +"@types/sortablejs@^1.15.9": + version "1.15.9" + resolved "https://registry.yarnpkg.com/@types/sortablejs/-/sortablejs-1.15.9.tgz#82d2337f54d4db827914d80dee5cf65539ae3c8b" + integrity sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ== + "@types/throttle-debounce@^2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@types/throttle-debounce/-/throttle-debounce-2.1.0.tgz#1c3df624bfc4b62f992d3012b84c56d41eab3776" diff --git a/less/forum.less b/less/forum.less index c822bb9..24b8c39 100644 --- a/less/forum.less +++ b/less/forum.less @@ -94,8 +94,13 @@ } @media @phone { - .SplitDropdown-button { - width: calc(100% - 8.75px) !important; + // Let the main dropdown button size to its content so the caret sits next to + // it rather than being stranded at the far right of the drawer row. + .LinkDropdown { + .SplitDropdown-button { + width: auto !important; + flex: 0 1 auto; + } } }