MediaWiki:ClamComponent.js

From BetaMC Wiki
Revision as of 08:48, 15 October 2026 by RobertWesner (talk | contribs) (Remove now obsolete 7AM UTC status.)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
(() => {
    /**
     * Reusable XSS-safe html-builder DSL.
     * Taken from Roberts userscripts so we can avoid insecure innerHtml nonsense.
     *
     * Example:
     *  $builder('div')
     *      .id('foo')
     *      .className('a b c')
     *      .onBuildText('<b>Test</b>')
     *      .build()
     *
     * Result:
     *  ```
     *  <div id="foo" class="a b c">
     *      &lt;b&gt;Test&lt;/b&gt;
     *  </div>;
     *  ```
     *
     * @license MIT
     * @author Robert Wesner
     * @see https://github.com/RobertWesner/YouTube-Play-All/blob/main/script.user.js
     */
    const { $builder, $style } = (()=>{const $builder=t=>(t=>{const e=t.match(/^[a-zA-Z0-9-]+/);if(!e)throw"Invalid tag supplied to parseQuery.";const n=e[0],r=(t=>{let e=[];const getOperations=()=>{const t=[...e.flat()];return e=[],t},n=new Proxy(t,{get(r,a,s){const P=t=>(...e)=>(t(...e),n),PBO=t=>P(((...n)=>e.push((()=>t(...n)))));switch(a){case"build":return()=>(getOperations().forEach((e=>e(t))),t);case"buildWithSync":return async()=>{for(const e of getOperations()){const n=e(t);n&&"function"==typeof n.then&&await n}return t};case"addClass":return P((e=>t.classList.add(e)));case"onBuild":return P((t=>e.push(t)));case"onBuildAppend":return PBO(((...e)=>t.append(...e)));case"onBuildText":return PBO((e=>t.textContent=e));case"on":return P(((e,n)=>t.addEventListener(e,n)))}const c=["hidden","style"];return e=>(!c.includes(a)&&a in t?t[a]=e:t.setAttribute(a.replaceAll("_","-"),e),n)}});return n})(document.createElement(n));if(!t.match(/^[a-zA-Z0-9-]+$/)){const e=(t=>t.match(/^[a-zA-Z0-9-]+((?:[#.][a-zA-Z0-9-]+)+)?((?:\[[a-zA-Z0-9-]+(?:=".*?")?])+)?/))(t),n=e[1]??"",a=e[2]??"";n.matchAll(/([#.])([a-zA-Z0-9-]+)/g).forEach((([t,e,n])=>{({"#":r.id,".":r.addClass})[e](n)})),a.matchAll(/\[([a-zA-Z0-9-]+)(?:="(.*?)")?]/g).forEach((([t,e,n])=>{r[e](n)}))}return r})(t);return{$builder,$style:(t,e)=>document.head.insertAdjacentElement("beforeend",$builder(`style#${t}`).onBuild((t=>t.textContent=e)).build())}})();
    const $bold = text => $builder('b').onBuildText(text).build();

    $style('clam-components', `
        .clam-component---online-badge {
            border: 1px solid #2c303624;
            background-color: white;
            color: white;
            padding: 0.12em 0.4em;
            border-radius: 0.32em;
            font-weight: bold;
        
            &[data-online="true"]::after {
                color: #11cc0e;
                content: '● online';
            }
            &[data-online="false"]::after {
                color: #cc140e;
                content: '● offline';
            }
        }
        
        .clam-component---server-status {
            font-weight: bold;
        
            &[data-status="online"]::after {
                color: #11cc0e;
                content: '● online';
            }
            &[data-status="offline"]::after {
                color: #cc140e;
                content: '● offline';
            }
        }
        
        .clam-component---mc-text > .cc-mc-text {
            &[data-color="0"] { color: #000000 }
            &[data-color="1"] { color: #0000AA }
            &[data-color="2"] { color: #00AA00 }
            &[data-color="3"] { color: #00AAAA }
            &[data-color="4"] { color: #AA0000 }
            &[data-color="5"] { color: #AA00AA }
            &[data-color="6"] { color: #FFAA00 }
            &[data-color="7"] { color: #AAAAAA }
            &[data-color="8"] { color: #555555 }
            &[data-color="9"] { color: #5555FF }
            &[data-color="a"] { color: #55FF55 }
            &[data-color="b"] { color: #55FFFF }
            &[data-color="c"] { color: #FF5555 }
            &[data-color="d"] { color: #FF55FF }
            &[data-color="e"] { color: #FFFF55 }
            &[data-color="f"] { color: #FFFFFF }
        }
        
        .clam-component---stats {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 0.6em;
            float: right;
            padding: 0.6em;
            background-color: #69696912;
            border: 1px solid #80808080;
            border-radius: 1em;
        }
    `);

    const mcText = raw => $builder('span.clam-component---mc-text')
        .onBuildAppend(
            ...raw
                .split('§')
                .map(part =>
                    $builder('span.cc-mc-text')
                        .data_color(part.charAt(0))
                        .onBuildText(part.slice(1))
                        .build(),
                ),
        )
        .build();

    const date = timestamp => new Date(timestamp)
        .toISOString()
        .replace('T', ' ')
        .slice(0, 16);

    const time = seconds => {
        const days = Math.floor(seconds / 86400000);
        const hours = Math.floor((seconds % 86400000) / 3600000);
        const minutes = Math.floor((seconds % 3600000) / 60000);

        let result = '';
        if (days) result += days + 'd ';
        if (hours) result += hours + 'h ';
        if (minutes) result += minutes + 'm';

        return result.trim();
    };

    const route = (path, callable) => {
        const matches = location.pathname.match(path);
        if (matches) callable(...matches.slice(1));
    };
    const component = (selector, callable) =>
        document.querySelectorAll(selector)
            .forEach(element => {
                const argsMatches = element.className.match(/\(([^()]+?)\)/);
                const args = argsMatches
                    ? Object.fromEntries(
                        argsMatches[1]
                            .split(',')
                            .map(arg => {
                                const parts = arg.trim().split('=', 2);

                                return [parts[0], parts[1] ?? true];
                            }),
                    ) : {};

                element.replaceWith(callable(args));
            });

    route(/^\/index.php\/Main_Page$/, async () => {
        let status = 'offline';
        try {
            if ((await statsClient.server()).unique_players) status = 'online';
        } catch (e) {}

        component('br.cc.-server-status', () =>
            $builder('span.clam-component---server-status')
                .data_status(status)
                .build(),
        );
    });

    route(/^\/index.php\/User:([^\/]+)/, async username => {
        const stats = await statsClient.player(username);

        component('br.cc.-online', () =>
            $builder('span.clam-component---online-badge')
                .data_online(stats.online)
                .build(),
        );

        component('br.cc.-stats', args => {
            const element = (title, value) => $builder('div').onBuildAppend($bold(title), ': ', value).build();

            return $builder('div.clam-component---stats')
                .onBuildAppend(...[
                    $builder('b').onBuildAppend(...[
                        mcText(stats.prefix),
                        stats.name,
                        args.online && $builder('span.clam-component---online-badge')
                            .style('margin-left: 0.48em')
                            .data_online(stats.online)
                            .build(),
                    ].filter(_ => !!_)).build(),
                    args.skin === 'face' && $builder(`img[src="https://minotar.net/avatar/${stats.uuid}/128.png"]`).build(),
                    args.skin === 'full' && $builder(`img[src="https://api.mineatar.io/body/full/${stats.uuid}?scale=8#.png"]`).build(),
                    $builder('div.cc-stats-container')
                        .onBuildAppend(...[
                            args.firstJoin && element('Joined', date(stats.first_join)),
                            args.lastSeen && element(
                                'Seen',
                                stats.online
                                    ? $builder('span').style("color: #11cc0e").onBuildText('online').build()
                                    : date(stats.last_join),
                            ),
                            args.playTime && element('Played', time(stats.playtime)),
                            args.score && element('Score', stats.overall.toFixed(2)),
                            args.balance && element('Balance', stats.balance.toFixed(2)),
                            args.blocksPlaced && element('Blocks placed', stats.blocks_placed),
                            args.blocksBroken && element('Blocks broken', stats.blocks_broken),
                            args.blocksTraveled && element('Traveled', Math.floor(stats.blocks_traveled)),
                            args.deaths && element('Deaths', stats.deaths),
                            args.playersKilled && element('Players killed', stats.players_killed),
                            args.mobsKilled && element('Mobs killed', stats.mobs_killed),
                            args.damageDealt && element('Damage dealt', stats.damage_dealt),
                            args.damageTaken && element('Damage taken', stats.damage_taken),
                        ].filter(_ => !!_))
                        .build(),
                ].filter(_ => !!_))
                .build();
        });
    });
})();