diff --git a/app/controllers/Auth.scala b/app/controllers/Auth.scala index 311d3cf09ca42..d8450de7ad086 100644 --- a/app/controllers/Auth.scala +++ b/app/controllers/Auth.scala @@ -278,7 +278,7 @@ final class Auth( lila.mon.user.register.confirmEmailResult(true).increment() env.user.repo.email(user.id).flatMap { _.so: email => - authLog(user.username, email.some, s"Confirmed email ${email.value}") + authLog(user.username, email.some, s"Confirmed email") welcome(user, email, sendWelcomeEmail = false) } >> redirectNewUser(user) } diff --git a/app/controllers/Study.scala b/app/controllers/Study.scala index e3d7e00c1aacd..522c1975ab02c 100644 --- a/app/controllers/Study.scala +++ b/app/controllers/Study.scala @@ -32,23 +32,24 @@ final class Study( def search(text: String, page: Int) = OpenOrScopedBody(parse.anyContent)(_.Study.Read, _.Web.Mobile): Reasonable(page): - if text.trim.isEmpty then - for - pag <- env.study.pager.all(Orders.default, page) - _ <- preloadMembers(pag) - res <- negotiate( - Ok.page(views.study.list.all(pag, Orders.default)), - apiStudies(pag) - ) - yield res - else - env - .studySearch(ctx.me)(text.take(100), page) - .flatMap: pag => - negotiate( - Ok.page(views.study.list.search(pag, text)), + text.trim.some.filter(_.nonEmpty).filter(_.sizeIs > 2).filter(_.sizeIs < 200) match + case None => + for + pag <- env.study.pager.all(Orders.default, page) + _ <- preloadMembers(pag) + res <- negotiate( + Ok.page(views.study.list.all(pag, Orders.default)), apiStudies(pag) ) + yield res + case Some(clean) => + env + .studySearch(ctx.me)(clean.take(100), page) + .flatMap: pag => + negotiate( + Ok.page(views.study.list.search(pag, text)), + apiStudies(pag) + ) def homeLang = LangPage(routes.Study.allDefault())(allResults(Order.hot, 1)) diff --git a/app/views/mod/inquiry.scala b/app/views/mod/inquiry.scala index fce78a4e80680..d84d3b41b9b9b 100644 --- a/app/views/mod/inquiry.scala +++ b/app/views/mod/inquiry.scala @@ -20,7 +20,7 @@ object inquiry: ) def apply(in: lila.mod.Inquiry)(using ctx: Context) = - div(id := "inquiry")( + div(id := "inquiry", data("username") := in.user.user.username)( i(title := "Costello the Inquiry Octopus", cls := "costello"), div(cls := "meat")( userLink(in.user.user, withPerfRating = in.user.perfs.some, params = "?mod"), diff --git a/modules/common/src/main/HTTPRequest.scala b/modules/common/src/main/HTTPRequest.scala index 6f074e1c534d6..93c2c81308926 100644 --- a/modules/common/src/main/HTTPRequest.scala +++ b/modules/common/src/main/HTTPRequest.scala @@ -67,7 +67,7 @@ object HTTPRequest: private val crawlerMatcher = UaMatcher: // spiders/crawlers - """Googlebot|AdsBot|Google-Read-Aloud|bingbot|BingPreview|facebookexternalhit|meta-externalagent|SemrushBot|AhrefsBot|PetalBot|Applebot|YandexBot|YandexAdNet|YandexImages|Twitterbot|Baiduspider|Amazonbot|Bytespider|yacybot|ImagesiftBot|ChatGLM-Spider|YisouSpider""" + + """Googlebot|GoogleOther|AdsBot|Google-Read-Aloud|bingbot|BingPreview|facebookexternalhit|meta-externalagent|SemrushBot|AhrefsBot|PetalBot|Applebot|YandexBot|YandexAdNet|YandexImages|Twitterbot|Baiduspider|Amazonbot|Bytespider|yacybot|ImagesiftBot|ChatGLM-Spider|YisouSpider|Yeti/""" + // http libs """|HeadlessChrome|okhttp|axios|wget|curl|python-requests|aiohttp|commons-httpclient|python-urllib|python-httpx|Nessus""" diff --git a/ui/.build/src/hash.ts b/ui/.build/src/hash.ts index 3899b55cff1fd..61b0d5b48af5a 100644 --- a/ui/.build/src/hash.ts +++ b/ui/.build/src/hash.ts @@ -14,7 +14,7 @@ export async function hash(): Promise { pkg.hash.map(async hash => (await globArray(hash.glob, { cwd: env.outDir })).map(path => ({ path, - replace: hash.replace, + update: hash.update, root: pkg.root, })), ), @@ -40,22 +40,22 @@ export async function hash(): Promise { for (const key of Object.keys(env.manifest.hashed)) { if (!hashed.some(x => x.path.endsWith(key))) delete env.manifest.hashed[key]; } - // TODO find a better home for all of this - const replaceMany: Map }> = new Map(); - for (const { root, path, replace } of hashed) { - if (!replace) continue; - const replaceInOne = replaceMany.get(replace) ?? { root, mapping: {} }; + + const updates: Map }> = new Map(); + for (const { root, path, update } of hashed) { + if (!update) continue; + const updateFile = updates.get(update) ?? { root, mapping: {} }; const from = path.slice(env.outDir.length + 1); - replaceInOne.mapping[from] = asHashed(from, env.manifest.hashed[from].hash!); - replaceMany.set(replace, replaceInOne); + updateFile.mapping[from] = asHashed(from, env.manifest.hashed[from].hash!); + updates.set(update, updateFile); } - for await (const { name, hash } of [...replaceMany].map(([n, r]) => replaceAllIn(n, r.root, r.mapping))) { + for await (const { name, hash } of [...updates].map(([n, r]) => update(n, r.root, r.mapping))) { env.manifest.hashed[name] = { hash }; } updateManifest({ dirty: true }); } -async function replaceAllIn(name: string, root: string, files: Record) { +async function update(name: string, root: string, files: Record) { const result = Object.entries(files).reduce( (data, [from, to]) => data.replaceAll(from, to), await fs.promises.readFile(path.join(root, name), 'utf8'), diff --git a/ui/.build/src/parse.ts b/ui/.build/src/parse.ts index 47c411c59ee8a..0d939aa8c05d5 100644 --- a/ui/.build/src/parse.ts +++ b/ui/.build/src/parse.ts @@ -10,7 +10,7 @@ export interface Package { name: string; // dirname of package root pkg: any; // the entire package.json object bundle: { module?: string; inline?: string }[]; // TODO doc - hash: { glob: string; replace?: string }[]; // TODO doc + hash: { glob: string; update?: string }[]; // TODO doc sync: Sync[]; // pre-bundle filesystem copies from package json } diff --git a/ui/README.md b/ui/README.md index aa86ffd4b6bff..cef844555992d 100644 --- a/ui/README.md +++ b/ui/README.md @@ -127,14 +127,12 @@ Web asset distribution involves the caching of URLs, and hashes provide a repeat ui/build calculates and writes all hashes used to determine asset URLs to a manifest.\*.json file. This file is used by the lila server to tell browsers what they need. Javascript and css assets built from lichess sources are hashed automatically, but the "build" / "hash" section within package.json describes assets that must be hashed separately. These include images, fonts, and packages of js & css from the npmjs repository that we don't compile but must be exposed through our content distribution strategy. -Because these unmanaged assets originate in or are copied to the /public folder during the build process, all paths within the "hash" property resolve relative to /public. +"build" / "hash" may contain a single entry or an array of entries. an entry may be a bare string glob or filename relative to /public. It may also be an object with a "glob" property (relative to /public) and an optional "update" filename property relative to /ui/\. More on this below. -ui/build computes a sha256 checksum of each matched asset's content, using a portion of that checksum to make a hash, then creates a symlink with that hash in the name pointing back to the original file. All links are created within /public/hashed. When a source file's content changes on the filesystem, its corresponding symlink will get a new name. This changes the object's URL and forces our CDN to create a fresh cache entry that will propagate through their edge server caches in distribution. Once again, lila is kept informed by ui/build through entries it writes to manifest.\*.json. +ui/build computes a sha256 checksum of each matched asset's content and creates a symlink named with a hash from that checksum within /public/hashed. The symlink points back to the original file (somewhere in /public). When an asset's content changes on the filesystem, its corresponding symlink will get a new name. This changes the object's URL and forces our CDN to create a fresh cache entry that will propagate through their edge server caches in distribution. Once again, lila is kept informed by ui/build through entries it writes to manifest.\*.json. ```json "hash": [ - "font/lichess.woff", - "font/lichess.woff2", "lifat/background/montage*.webp", "npm/*", "javascripts/**", @@ -142,11 +140,18 @@ ui/build computes a sha256 checksum of each matched asset's content, using a por ] ``` -Above we see the hash property in [/ui/site/package.json](./site/package.json) where we match the listed web fonts as well as files in lifat/background that start with montage and end with .webp. The npm/\* glob requests hashes for all top level files in public/npm, and at this point it's helpful to note that matched assets are hashed during manifest creation AFTER "sync" operations have completed for all packages. +Note that matched assets are hashed during manifest creation AFTER "sync" operations have completed for all packages. In the ceval/package.json example we saw where stockfish wasms are synced to /public/npm. Here in site/package.json, those synced stockfish wasms are subsequently matched by the npm/* glob and symlinked in /public/hashed for efficient distribution. -In the ceval/package.json example we saw where stockfish wasms are synced to /public/npm. Here in site/package.json, those synced stockfish wasms are subsequently matched by globs at their copy destinations and symlinked in /public/hashed for efficient distribution. [/ui/site/package.json](./site/package.json) is where we hash unmanaged assets that don't really fit anywhere else. +"build" / "hash" entires that provide a filename for the "update" property will first process the "glob" property, creating symlinks in /public/hashed (same as before). Afterwards, the "update" file is transformed with all occurrence of globbed sources replaced by their hashed symlinks. The updated file is then itself content-hashed, written to /public/hashed, and remapped from its original path in manifest.json. This is useful when an asset references other files by name and those references must be updated to reflect the hashed URLs. For example: [/ui/common/css/theme/font-face.css](./common/css/theme/font-face.css) is transformed via this hash entry from [/ui/common/package.json](./common/package.json): -The double asterisk in the javascripts/\*\* glob match everything inside its folder hierarchy. +```json + "hash": [ + { + "glob": "font/*.woff2", + "update": "css/theme/font-face.css" + } + ] +``` And that's about it for package.json. The nodejs sources for ui/build script are in the [/ui/.build](./.build) folder. Have a glance if something goes wrong or you have questions beyond the scope of this readme. diff --git a/ui/bits/css/relay/_card.scss b/ui/bits/css/relay/_card.scss index fb71561d28f6a..58426bfb555aa 100644 --- a/ui/bits/css/relay/_card.scss +++ b/ui/bits/css/relay/_card.scss @@ -77,7 +77,7 @@ &__title { @include line-clamp(2); margin: 0.2em 0 0.3em 0; - font-weight: bold; + font-weight: normal; color: $c-font-clearer; font-size: 1.3em; .relay-cards--tier-best & { diff --git a/ui/common/css/component/_mini-game.scss b/ui/common/css/component/_mini-game.scss index 4661cec6b4d29..34dd51fddae96 100644 --- a/ui/common/css/component/_mini-game.scss +++ b/ui/common/css/component/_mini-game.scss @@ -61,7 +61,7 @@ } &__result { - font-weight: normal; + font-weight: bold; margin: 0 1ch; } } diff --git a/ui/common/package.json b/ui/common/package.json index 910c8ab2c2efb..8af464a53d2a0 100644 --- a/ui/common/package.json +++ b/ui/common/package.json @@ -26,7 +26,7 @@ "build": { "hash": { "glob": "font/*.woff2", - "replace": "css/theme/font-face.css" + "update": "css/theme/font-face.css" } } } diff --git a/ui/common/src/highlight.ts b/ui/common/src/highlight.ts new file mode 100644 index 0000000000000..fcf5a19c3b9dc --- /dev/null +++ b/ui/common/src/highlight.ts @@ -0,0 +1,68 @@ +/* Ported to typescript from https://github.com/marmelab/highlight-search-term/blob/main/src/index.js */ + +export const highlightSearchTerm = (search: string, selector: string): void => { + const highlightName = 'lichess-highlight'; + + console.log(search, CSS.highlights, highlightName); + + if (!CSS.highlights) return; // disable feature on Firefox as it does not support CSS Custom Highlight API + + // remove previous highlight + CSS.highlights.delete(highlightName); + if (!search) { + // nothing to highlight + return; + } + // find all text nodes containing the search term + const ranges: AbstractRange[] = []; + try { + const elements = document.querySelectorAll(selector); + Array.from(elements).map(element => { + getTextNodesInElementContainingText(element, search).forEach(node => { + node.parentElement && ranges.push(...getRangesForSearchTermInElement(node.parentElement, search)); + }); + }); + } catch (error) { + console.error(error); + } + if (ranges.length === 0) return; + // create a CSS highlight that can be styled with the ::highlight(search) pseudo-element + const highlight = new Highlight(...ranges); + CSS.highlights.set(highlightName, highlight); +}; + +const getTextNodesInElementContainingText = (element: HTMLElement, text: string) => { + const lowerCaseText = text.toLowerCase(); + const nodes = []; + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + if (node.textContent?.toLowerCase().includes(lowerCaseText)) { + nodes.push(node); + } + } + return nodes; +}; + +const getRangesForSearchTermInElement = (element: HTMLElement, search: string) => { + const ranges: AbstractRange[] = []; + const lowerCaseSearch = search.toLowerCase(); + if (element.childNodes.length === 0) return ranges; + // In some frameworks like React, when combining static text with dynamic text, the element may have multiple Text child nodes. + // To avoid errors, we must find the child node that actually contains the search term. + const childWithSearchTerm = Array.from(element.childNodes).find(node => + node.textContent?.toLowerCase().includes(lowerCaseSearch), + ); + if (!childWithSearchTerm) return ranges; + const text = childWithSearchTerm.textContent?.toLowerCase() || ''; + let start = 0; + let index; + while ((index = text.indexOf(lowerCaseSearch, start)) >= 0) { + const range = new Range(); + range.setStart(childWithSearchTerm, index); + range.setEnd(childWithSearchTerm, index + search.length); + ranges.push(range); + start = index + search.length; + } + return ranges; +}; diff --git a/ui/lobby/css/_timeline.scss b/ui/lobby/css/_timeline.scss index ec0de2c91f287..04bf1d875e57f 100644 --- a/ui/lobby/css/_timeline.scss +++ b/ui/lobby/css/_timeline.scss @@ -11,6 +11,7 @@ a { @extend %base-font, %page-font; + font-weight: normal; } &:hover a { diff --git a/ui/mod/css/_inquiry.scss b/ui/mod/css/_inquiry.scss index 6b460fca4d0aa..570b13ae8ee0d 100644 --- a/ui/mod/css/_inquiry.scss +++ b/ui/mod/css/_inquiry.scss @@ -22,6 +22,11 @@ body.no-inquiry { } } +*::highlight(lichess-highlight) { + background-color: yellow; + color: black; +} + #inquiry { height: 48px; background: #484541; diff --git a/ui/mod/src/mod.inquiry.ts b/ui/mod/src/mod.inquiry.ts index 60b287e331739..893c9c9674b40 100644 --- a/ui/mod/src/mod.inquiry.ts +++ b/ui/mod/src/mod.inquiry.ts @@ -3,11 +3,13 @@ import { formToXhr } from 'common/xhr'; import { expandMentions } from 'common/richText'; import { storage } from 'common/storage'; import { alert } from 'common/dialog'; +import { highlightSearchTerm } from 'common/highlight'; +import { pubsub } from 'common/pubsub'; site.load.then(() => { const noteStore = storage.make('inquiry-note'); const usernameNoteStore = storage.make('inquiry-note-user'); - const username = $('#inquiry .meat > .user-link').text().split(' ')[0]; + const username = $('#inquiry').data('username'); if (username !== usernameNoteStore.get()) noteStore.remove(); usernameNoteStore.set(username); const noteTextArea = $('#inquiry .notes').find('textarea')[0] as HTMLTextAreaElement; @@ -114,4 +116,8 @@ site.load.then(() => { const username = $(this).parents('tr').find('td:first-child .user-link').text().split(' ')[0]; addToNote(`Alt: @${username}`); }); + + const highlightUsername = () => highlightSearchTerm(username, '#main-wrap .user-link'); + setTimeout(highlightUsername, 300); + pubsub.on('content-loaded', highlightUsername); });