Popover displaying clickable usernames in voting value

This commit is contained in:
László Károlyi 2023-11-14 16:35:53 +01:00
parent 75e0ec90dd
commit 37b8d96a78
Signed by: karolyi
GPG key ID: 2DCAF25E55735BFE
7 changed files with 227 additions and 172 deletions

View file

@ -1,7 +1,8 @@
{%- for vote in votes -%}
{% from 'macros/forum-username.html' import forum_username with context -%}
{% for vote in votes -%}
<div class="row">
<div class="col-3 text-nowrap text-start">{{ loop.revindex|localize }}.</div>
<div class="col-6 text-nowrap text-start">{{ vote.user.username }}</div>
<div class="col-6 text-nowrap text-start">{{ forum_username(user=vote.user) }}</div>
<div class="col-3 text-nowrap text-end {% if vote.value > 0 -%}
text-success
{%- elif vote.value < 0 -%}

View file

@ -14,7 +14,8 @@ class VotingValueDetailsView(TemplateView):
obj = super().get_context_data()
votes = CommentVote.objects.filter(comment=comment_pk).order_by('-pk')
user_pks = set(x.user_id for x in votes)
users = User.objects.only('username').in_bulk(id_list=user_pks)
users = User.objects.only(
*User.F_USERNAME_DISPLAYED).in_bulk(id_list=user_pks)
for vote in votes:
vote.user = users[vote.user_id]
obj.update(votes=votes)

View file

@ -88,10 +88,60 @@ export interface TopicBoundFunctionsType {
getALink: (scrollToPk: number) => string
}
export let urlTemplates: TopicCommentListingUrltemplateType
function initializeUrls(options: TopicCommentListingOptionsType): void {
const urls = options.urls
urlTemplates = {
commentListing: template(
urls.commentListing.backend
.replace(urls.commentListing.exampleSlug, '{topicSlug}')
.replace(urls.commentListing.commentPk, '{commentPk}')
),
expandCommentsDown: template(
urls.expandCommentsDown.backend
.replace(urls.expandCommentsDown.exampleSlug, '{topicSlug}')
.replace(urls.expandCommentsDown.commentPk, '{commentPk}')
.replace(urls.expandCommentsDown.scrollToPk, '{scrollToPk}')
),
expandCommentsUp: template(
urls.expandCommentsUp.backend
.replace(urls.expandCommentsUp.exampleSlug, '{topicSlug}')
.replace(urls.expandCommentsUp.commentPk, '{commentPk}')
.replace(urls.expandCommentsUp.scrollToPk, '{scrollToPk}')
),
expandCommentsUpRecursive: template(
urls.expandCommentsUpRecursive.backend
.replace(urls.expandCommentsUpRecursive.exampleSlug, '{topicSlug}')
.replace(urls.expandCommentsUpRecursive.commentPk, '{commentPk}')
.replace(urls.expandCommentsUpRecursive.scrollToPk, '{scrollToPk}')
),
expandCommentsEntireThread: template(
urls.expandCommentsEntireThread.backend
.replace(urls.expandCommentsEntireThread.exampleSlug, '{topicSlug}')
.replace(urls.expandCommentsEntireThread.commentPk, '{commentPk}')
.replace(urls.expandCommentsEntireThread.scrollToPk, '{scrollToPk}')
),
getTopicSlugByPk: template(
urls.getTopicSlugByPk.backend
.replace(urls.getTopicSlugByPk.commentPk, '{commentPk}')
),
getVotingValueDetails: template(
urls.getVotingValueDetails.backend
.replace(urls.getVotingValueDetails.commentPk, '{commentPk}')
)
}
if (urls.commentListingPageNo) {
urlTemplates.commentListingPageNo = template(
urls.commentListingPageNo.backend
.replace(urls.commentListingPageNo.exampleSlug, '{topicSlug}')
.replace(urls.commentListingPageNo.pageId, '{pageId}')
)
}
}
class TopicCommentListing {
private readonly options: TopicCommentListingOptionsType
private urlTemplates!: TopicCommentListingUrltemplateType
private boundFunctions!: TopicBoundFunctionsType
private root!: HTMLElement
private readonly comments = new Map<number, OneComment>()
@ -170,29 +220,29 @@ class TopicCommentListing {
private getALink(scrollToPk: number): string {
switch (this.options.listingMode) {
case 'commentListing':
return this.urlTemplates.commentListing({
return urlTemplates.commentListing({
topicSlug: this.options.topicSlugOriginal, commentPk: scrollToPk
})
case 'expandCommentsDown':
return this.urlTemplates.expandCommentsDown({
return urlTemplates.expandCommentsDown({
topicSlug: this.options.topicSlugOriginal,
commentPk: this.options.commentPk,
scrollToPk
})
case 'expandCommentsUp':
return this.urlTemplates.expandCommentsUp({
return urlTemplates.expandCommentsUp({
topicSlug: this.options.topicSlugOriginal,
commentPk: this.options.commentPk,
scrollToPk
})
case 'expandCommentsUpRecursive':
return this.urlTemplates.expandCommentsUpRecursive({
return urlTemplates.expandCommentsUpRecursive({
topicSlug: this.options.topicSlugOriginal,
commentPk: this.options.commentPk,
scrollToPk
})
case 'expandCommentsEntireThread':
return this.urlTemplates.expandCommentsEntireThread({
return urlTemplates.expandCommentsEntireThread({
topicSlug: this.options.topicSlugOriginal,
commentPk: this.options.commentPk,
scrollToPk
@ -206,64 +256,13 @@ class TopicCommentListing {
history.replaceState(null, '', path)
}
private initializeUrls(): void {
const urls = this.options.urls
this.urlTemplates = {
commentListing: template(
urls.commentListing.backend
.replace(urls.commentListing.exampleSlug, '{topicSlug}')
.replace(urls.commentListing.commentPk, '{commentPk}')
),
expandCommentsDown: template(
urls.expandCommentsDown.backend
.replace(urls.expandCommentsDown.exampleSlug, '{topicSlug}')
.replace(urls.expandCommentsDown.commentPk, '{commentPk}')
.replace(urls.expandCommentsDown.scrollToPk, '{scrollToPk}')
),
expandCommentsUp: template(
urls.expandCommentsUp.backend
.replace(urls.expandCommentsUp.exampleSlug, '{topicSlug}')
.replace(urls.expandCommentsUp.commentPk, '{commentPk}')
.replace(urls.expandCommentsUp.scrollToPk, '{scrollToPk}')
),
expandCommentsUpRecursive: template(
urls.expandCommentsUpRecursive.backend
.replace(urls.expandCommentsUpRecursive.exampleSlug, '{topicSlug}')
.replace(urls.expandCommentsUpRecursive.commentPk, '{commentPk}')
.replace(urls.expandCommentsUpRecursive.scrollToPk, '{scrollToPk}')
),
expandCommentsEntireThread: template(
urls.expandCommentsEntireThread.backend
.replace(urls.expandCommentsEntireThread.exampleSlug, '{topicSlug}')
.replace(urls.expandCommentsEntireThread.commentPk, '{commentPk}')
.replace(urls.expandCommentsEntireThread.scrollToPk, '{scrollToPk}')
),
getTopicSlugByPk: template(
urls.getTopicSlugByPk.backend
.replace(urls.getTopicSlugByPk.commentPk, '{commentPk}')
),
getVotingValueDetails: template(
urls.getVotingValueDetails.backend
.replace(urls.getVotingValueDetails.commentPk, '{commentPk}')
)
}
if (urls.commentListingPageNo) {
this.urlTemplates.commentListingPageNo = template(
urls.commentListingPageNo.backend
.replace(urls.commentListingPageNo.exampleSlug, '{topicSlug}')
.replace(urls.commentListingPageNo.pageId, '{pageId}')
)
}
}
private initializeOneComment(rootEl: HTMLElement): void {
const commentPk = parseInt(<string>rootEl.dataset.forumCommentPk)
this.comments.set(commentPk, new OneComment({
root: rootEl,
commentPk,
boundFunctions: this.boundFunctions,
strings: this.options.strings,
urlTemplates: this.urlTemplates
strings: this.options.strings
}))
addOnremoveCallback(rootEl, this.teardownOneComment.bind(this))
}
@ -298,11 +297,11 @@ class TopicCommentListing {
}
private async onPaginate(pageNo: number): Promise<void> {
if (!this.urlTemplates.commentListingPageNo) {
console.error('this.urlTemplates.commentListingPageNo is unset')
if (!urlTemplates.commentListingPageNo) {
console.error('urlTemplates.commentListingPageNo is unset')
return
}
document.location.href = this.urlTemplates.commentListingPageNo({
document.location.href = urlTemplates.commentListingPageNo({
topicSlug: this.options.topicSlugOriginal,
pageId: pageNo
})
@ -347,7 +346,6 @@ class TopicCommentListing {
}
initialize(): void {
this.initializeUrls()
this.initializeBoundFunctions()
this.initializeWrappers()
this.initializePaginators()
@ -358,6 +356,7 @@ class TopicCommentListing {
export function init(options: TopicCommentListingOptionsType): void {
oneCommentInit()
initializeUrls(options)
const obj = new TopicCommentListing(options)
obj.initialize()
}

View file

@ -1,86 +1,129 @@
import { getSvgIcon, loaderTemplate } from './common'
import {
type TopicCommentListingUrltemplateType
} from '../topic-comment-listing'
import { loaderTemplate } from './common'
import {
add as popoverAdd, getFunctions, type GetFunctionsReturnType
} from './popover.ts'
import { add as newPopoverAdd } from './new-popover.ts'
type ForumPopoverEl,
type ForumPopoverTipEl,
add as newPopoverAdd,
teardown as newPopoverTeardown
} from './new-popover.ts'
import { urlTemplates } from '../topic-comment-listing'
import { mdiLinkOff } from '@mdi/js'
import { add as usernameAdd } from './username.ts'
export class VotingValue {
private readonly rootEl: HTMLAnchorElement
private readonly commentPk: number
private readonly urlTemplates: TopicCommentListingUrltemplateType
private cachedContent?: string | Element
private popoverFunctions!: GetFunctionsReturnType
const errorIcon = getSvgIcon(mdiLinkOff)
constructor (
rootEl: HTMLAnchorElement, commentPk: number,
urlTemplates: TopicCommentListingUrltemplateType
) {
this.rootEl = rootEl
this.commentPk = commentPk
this.urlTemplates = urlTemplates
}
initialize(): void {
// this.rootEl.setAttribute('title', loaderTemplate.outerHTML)
this.rootEl.addEventListener('click', ev => {
ev.preventDefault()
})
newPopoverAdd(this.rootEl, {
callbacks: {
onTipInserted: (el): void => {
console.debug('onTipInserted', this, arguments)
},
onTipRemoved: (el): void => {
console.debug('onTipRemoved', el.forumPopoverTip)
}
},
popoverOpts: {
content: (el): string | Element => {
console.debug('XXX', el)
if (this.cachedContent == null) {
this.cachedContent = <SVGElement>loaderTemplate.cloneNode(true)
setTimeout(() => {
this.cachedContent = 'asd'
if (el.forumPopoverTip) {
el.forumPopoverTip
.getElementsByClassName('popover-body').item(0)
?.replaceChildren(this.cachedContent)
el.forumPopoverConfig.popoverObj.update()
} else console.debug('NO TIP')
}, 3000)
}
return this.cachedContent
},
html: true,
sanitize: false,
container: <HTMLElement>this.rootEl.parentElement,
title: 'cucc'
}
})
// popoverAdd(this.rootEl, {
// callbacks: {
// getContent: this.getContent.bind(this),
// // getTitle: this.getTitle.bind(this),
// onDomInsertedTip: this.onDomInsertedTip.bind(this),
// onDomRemovedTip: this.onDomRemovedTip.bind(this)
// },
// html: true,
// delay: {
// show: 250,
// hide: 0
// },
// showOnClick: true,
// sanitize: false,
// initialContent: loaderTemplate.outerHTML
// })
// this.popoverFunctions = getFunctions(this.rootEl)
// newPopoverAdd(this.rootEl, {
// onTipInserted: (x) => {}
// })
}
teardown(): void { }
interface StoredObjType {
commentPk: number
cachedContent?: string | Element
}
const storedObjs = new Map<HTMLAnchorElement, StoredObjType>()
function onClickElement(e: MouseEvent | PointerEvent): void {
e.preventDefault()
}
function onClickUsernameInTip(ev: MouseEvent | PointerEvent): void {
if (!(ev.target instanceof HTMLAnchorElement)) return
const elWrapper: ForumPopoverTipEl<HTMLDivElement> | null =
ev.target.closest('.voting-value-popover-wrapper')
if (!(elWrapper instanceof HTMLDivElement)) return
elWrapper.forumPopoverEl.forumPopoverConfig.popoverObj.hide()
}
function onTipInserted(el: ForumPopoverEl<HTMLAnchorElement>): void {
const elTipBody = el.forumPopoverTip
?.getElementsByClassName('popover-body').item(0)
if (!elTipBody) return
elTipBody.querySelectorAll<HTMLAnchorElement>(
'a.forum-username'
).forEach((el) => {
usernameAdd(el)
el.addEventListener(
'click', onClickUsernameInTip, { once: true, passive: true })
})
}
function onTipRemoved(el: ForumPopoverEl<HTMLAnchorElement>): void {
const elTipBody = el.forumPopoverTip
?.getElementsByClassName('popover-body').item(0)
if (!elTipBody) return
elTipBody.querySelectorAll<HTMLAnchorElement>(
'a.forum-username'
).forEach((el) => {
el.removeEventListener('click', onClickUsernameInTip)
})
}
function displayError(el: ForumPopoverEl<HTMLAnchorElement>): void {
if (!el.forumPopoverTip) return
el.forumPopoverTip
.getElementsByClassName('popover-body').item(0)
?.replaceChildren(errorIcon.cloneNode(true))
el.forumPopoverConfig.popoverObj.update()
}
async function loadVotingContent(
el: ForumPopoverEl<HTMLAnchorElement>
): Promise<void> {
const config = storedObjs.get(el)
if (!config) {
displayError(el)
return
}
const url = urlTemplates.getVotingValueDetails({
commentPk: config.commentPk
})
const response = await fetch(url)
if (!response.ok) {
displayError(el)
return
}
config.cachedContent = await response.text()
const elTipBody = el.forumPopoverTip
?.getElementsByClassName('popover-body').item(0)
if (elTipBody instanceof HTMLElement) {
elTipBody.innerHTML = config.cachedContent
elTipBody.querySelectorAll<HTMLAnchorElement>(
'a.forum-username'
).forEach((el) => {
usernameAdd(el)
el.addEventListener(
'click', onClickUsernameInTip, { once: true, passive: true })
})
el.forumPopoverConfig.popoverObj.update()
}
}
function getContent(el: ForumPopoverEl<HTMLAnchorElement>): string | Element {
const storedObj = storedObjs.get(el)
if (!storedObj) return <SVGElement>errorIcon.cloneNode(true)
if (storedObj.cachedContent == null) {
storedObj.cachedContent = <SVGElement>loaderTemplate.cloneNode(true)
void loadVotingContent(el).then()
}
return storedObj.cachedContent
}
export function add(elRoot: HTMLAnchorElement, commentPk: number): void {
if (storedObjs.has(elRoot)) {
throw new Error(`VotingValue.add: ${elRoot.outerHTML} already initialized`)
}
storedObjs.set(elRoot, { commentPk })
newPopoverAdd(elRoot, {
callbacks: { onTipInserted, onTipRemoved },
popoverOpts: {
content: getContent,
html: true,
sanitize: false,
// title: 'cucc',
container: <HTMLElement>elRoot.parentElement,
customClass: 'voting-value-popover-wrapper'
}
})
elRoot.addEventListener('click', onClickElement)
}
export function teardown(el: HTMLAnchorElement): void {
el.removeEventListener('click', onClickElement)
newPopoverTeardown(el)
}

View file

@ -1,12 +1,12 @@
import Popover from 'bootstrap/js/src/popover.js'
import { addOnremoveCallback } from './mutation-observer'
type ForumPopoverEl<ElCls extends HTMLElement> = ElCls & {
export type ForumPopoverEl<ElCls extends HTMLElement> = ElCls & {
forumPopoverConfig: StoredConfigType<ElCls>
forumPopoverTip?: ForumPopoverTipEl<ElCls>
}
type ForumPopoverTipEl<ElCls extends HTMLElement> = ElCls & {
export type ForumPopoverTipEl<ElCls extends HTMLElement> = ElCls & {
forumPopoverEl: ForumPopoverEl<ElCls>
}
@ -97,16 +97,16 @@ function onTipInserted<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
const config = this.forumPopoverConfig
if (config.callbacks?.onTipInserted) config.callbacks.onTipInserted(this)
// @ts-expect-error `tip` exists, it's just not in the type definition
const elTip = <ForumPopoverTipEl<ElCls>>config.popoverObj.tip
elTip.forumPopoverEl = this
this.forumPopoverTip = elTip
addOnremoveCallback(elTip, onTipRemoved)
if (config.callbacks?.onTipInserted) config.callbacks.onTipInserted(this)
elTip.addEventListener('mouseenter', onMouseEnterTip)
elTip.addEventListener('mouseleave', onMouseLeaveTip)
elTip.addEventListener('focusin', onFocusinTip)
elTip.addEventListener('focusout', onFocusoutTip)
addOnremoveCallback(elTip, onTipRemoved)
}
function onFocusinElement<ElCls extends HTMLElement>(
@ -141,6 +141,13 @@ function onMouseleaveElement<ElCls extends HTMLElement>(
hidePopover(config)
}
function onPopoverHidden<ElCls extends HTMLElement>(
this: ForumPopoverEl<ElCls>
): void {
const config = this.forumPopoverConfig
config.isShown = config.isTipFocused = config.isTipHovered = false
}
// The content() function is executed twice (probable bootstrap bug):
// use this function as an umbrella to only call it once
function onGetContent<ElCls extends HTMLElement>(
@ -266,17 +273,18 @@ export function add<ElCls extends HTMLElement>(
configurable: true
})
el.addEventListener('inserted.bs.popover', onTipInserted)
el.addEventListener('hidden.bs.popover', onPopoverHidden)
el.addEventListener('focusin', onFocusinElement)
el.addEventListener('focusout', onFocusoutElement)
el.addEventListener('mouseenter', onMouseenterElement)
el.addEventListener('mouseleave', onMouseleaveElement)
// popoverConfig.popoverObj.show()
}
export function teardown<ElCls extends HTMLElement>(el: ElCls): void {
if (!isForumPopoverEl(el)) return
el.forumPopoverConfig.popoverObj.dispose()
el.removeEventListener('inserted.bs.popover', onTipInserted)
el.removeEventListener('hidden.bs.popover', onPopoverHidden)
el.removeEventListener('focusin', onFocusinElement)
el.removeEventListener('focusout', onFocusoutElement)
el.removeEventListener('mouseenter', onMouseenterElement)

View file

@ -4,9 +4,12 @@ import { add as popoverAdd } from './popover.ts'
import { add as toastAdd } from './toast.ts'
import { add as usernameAdd } from './username.ts'
import { add as timeActualizerAdd } from './time-actualizer.ts'
import { VotingValue } from './comment-voting-details.ts'
import {
type TopicCommentListingUrltemplateType, type TopicBoundFunctionsType
add as votingValueAdd, teardown as votingValueTeardown
} from './comment-voting-details.ts'
import {
type TopicBoundFunctionsType,
urlTemplates
} from '../topic-comment-listing.ts'
import {
mdiArrowLeft, mdiArrowLeftRight, mdiArrowRight, mdiCogOutline
@ -47,16 +50,13 @@ export class OneComment {
private readonly strings: PassedStringsType
private readonly elBody: HTMLDivElement
private readonly elBtnCommentActions: HTMLButtonElement
private readonly urlTemplates: TopicCommentListingUrltemplateType
private actionsPopoverReferences?: ActionsPopoverReferencesType
private readonly votingValue?: VotingValue
constructor ({ root, commentPk, boundFunctions, strings, urlTemplates }: {
constructor ({ root, commentPk, boundFunctions, strings }: {
root: HTMLElement
commentPk: number
boundFunctions: TopicBoundFunctionsType
strings: PassedStringsType
urlTemplates: TopicCommentListingUrltemplateType
}) {
this.rootEl = root
this.commentPk = commentPk
@ -65,7 +65,6 @@ export class OneComment {
onClickCommentNumber: this.onClickCommentNumber.bind(this)
}
this.strings = strings
this.urlTemplates = urlTemplates
const aEls = <HTMLCollectionOf<HTMLAnchorElement>>
root.getElementsByClassName('forum-username')
this.elBody = <HTMLDivElement>root.getElementsByClassName('card-block')[0]
@ -76,16 +75,14 @@ export class OneComment {
timeActualizerAdd(
<HTMLCollectionOf<HTMLTimeElement>>
root.getElementsByClassName('forum-time'))
this.votingValue = this.initVotingValue()
this.initVotingValue()
this.initializeCallbacks()
}
private initVotingValue(): VotingValue | undefined {
const rootEl = this.rootEl.getElementsByClassName('voting-value')[0]
if (!(rootEl instanceof HTMLAnchorElement)) return
const obj = new VotingValue(rootEl, this.commentPk, this.urlTemplates)
obj.initialize()
return obj
const elRoot = this.rootEl.getElementsByClassName('voting-value')[0]
if (!(elRoot instanceof HTMLAnchorElement)) return
votingValueAdd(elRoot, this.commentPk)
}
private onSuccessClipboardCopy(): void {
@ -189,25 +186,25 @@ export class OneComment {
if (typeof topicSlug !== 'string') return
if (this.actionsPopoverReferences?.elExpandCommentsDown) {
this.actionsPopoverReferences.elExpandCommentsDown.href =
this.urlTemplates.expandCommentsDown({
urlTemplates.expandCommentsDown({
topicSlug, commentPk: this.commentPk, scrollToPk: this.commentPk
})
}
if (this.actionsPopoverReferences?.elExpandCommentsUp) {
this.actionsPopoverReferences.elExpandCommentsUp.href =
this.urlTemplates.expandCommentsUp({
urlTemplates.expandCommentsUp({
topicSlug, commentPk: this.commentPk, scrollToPk: this.commentPk
})
}
if (this.actionsPopoverReferences?.elExpandCommentsUpRecursive) {
this.actionsPopoverReferences.elExpandCommentsUpRecursive.href =
this.urlTemplates.expandCommentsUpRecursive({
urlTemplates.expandCommentsUpRecursive({
topicSlug, commentPk: this.commentPk, scrollToPk: this.commentPk
})
}
if (this.actionsPopoverReferences?.elExpandCommentsInThread) {
this.actionsPopoverReferences.elExpandCommentsInThread.href =
this.urlTemplates.expandCommentsEntireThread({
urlTemplates.expandCommentsEntireThread({
topicSlug, commentPk: this.commentPk, scrollToPk: this.commentPk
})
}
@ -315,6 +312,12 @@ export class OneComment {
this.initializeActionsPopover(prevCommentLink)
}
private teardownVotingValue(): void {
const elRoot = this.rootEl.getElementsByClassName('voting-value')[0]
if (!(elRoot instanceof HTMLAnchorElement)) return
votingValueTeardown(elRoot)
}
teardown(): void {
this.elAnchorCommentNumber.removeEventListener(
'click', this.boundFunctions.onClickCommentNumber)
@ -332,7 +335,7 @@ export class OneComment {
}
for (const el of this.oneReplies.values()) this.teardownOneReply(el)
this.oneReplies.clear()
this.votingValue?.teardown()
this.teardownVotingValue()
}
flashHighlight(): void {

View file

@ -206,7 +206,7 @@ class Username {
html: true,
sanitize: false,
customClass: 'forum-username-tooltip-wrapper',
trigger: 'hover',
trigger: 'hover focus',
delay: {
show: 250,
hide: 0