Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.
Revision as of 14:46, 2 April 2026 by Esportsamaze (talk | contribs)

Documentation for this module may be created at Module:Bracket/doc

-- ================================================================
-- Module:Bracket v3.0 (Smart Nodes & SVG Routing)
-- ================================================================
local p = {}
local html = mw.html
local cargo = mw.ext.cargo

local function esc(s) return s and s:gsub("\\", "\\\\"):gsub("'", "\\'") or "" end
local function clean(s)
    if not s then return nil end
    local c = s:gsub("[\r\n]",""):gsub("^%s*(.-)%s*$","%1")
    return c ~= "" and c or nil
end

local teamLogoCache = {}
local function getTeamLogo(teamName)
    if not teamName or teamName == "" or teamName == "TBD" then return "" end
    if teamLogoCache[teamName] then return teamLogoCache[teamName] end
    local rows = cargo.query("Teams","image,image_dark",{where="name='"..esc(teamName).."'", limit=1})
    local t = (rows and #rows > 0) and rows[1] or false
    teamLogoCache[teamName] = t
    if not t then return "" end
    local light = t.image or ""
    local dark = (t.image_dark and t.image_dark ~= "") and t.image_dark or light
    if light == "" then return "" end
    return "[[File:"..light.."|18px|link=|class=logo-lightmode bk-team-logo]]"
        .. "[[File:"..dark .."|18px|link=|class=logo-darkmode bk-team-logo]]"
end

local function fetchMatch(event, matchID)
    if not event or event == "" then return {} end
    local rows = cargo.query("BracketMatch",
        "team1,score1,team2,score2,winner,bo,match_date,casters,vod,notes,short1,short2",
        { where = "event='"..esc(event).."' AND match_id='"..esc(matchID).."'", limit = 1 })
    if rows and #rows > 0 then return rows[1] end
    return {}
end

local function mergeMatchData(cargoData, args, prefix)
    local d = {}
    d.team1   = clean(args[prefix.."team1"])   or clean(cargoData.team1)   or "TBD"
    d.team2   = clean(args[prefix.."team2"])   or clean(cargoData.team2)   or "TBD"
    d.short1  = clean(args[prefix.."short1"])  or clean(cargoData.short1)  or d.team1
    d.short2  = clean(args[prefix.."short2"])  or clean(cargoData.short2)  or d.team2
    d.score1  = clean(args[prefix.."score1"])  or clean(cargoData.score1)  or ""
    d.score2  = clean(args[prefix.."score2"])  or clean(cargoData.score2)  or ""
    d.winner  = clean(args[prefix.."win"])     or clean(cargoData.winner)  or ""
    d.bo      = clean(args[prefix.."bo"])      or clean(cargoData.bo)      or ""
    d.date    = clean(args[prefix.."date"])    or clean(cargoData.match_date) or ""
    d.casters = clean(args[prefix.."casters"]) or clean(cargoData.casters) or ""
    d.vod     = clean(args[prefix.."vod"])     or clean(cargoData.vod)     or ""
    d.notes   = clean(args[prefix.."notes"])   or clean(cargoData.notes)   or ""
    return d
end

-- ── NEW MODERN MATCH CARD ──
local function renderMatchCard(d, matchID, label, target, isDrop, extraClass)
    local box = html.create('div'):addClass('bk-match bk-match-clickable')
        :attr('id', 'match-' .. matchID)
    
    if target and target ~= "" then box:attr('data-target', 'match-' .. target) end
    if isDrop then box:attr('data-drop', 'true') end
    if extraClass then box:addClass(extraClass) end

    -- Popup attributes
    box:attr('data-team1', d.team1):attr('data-team2', d.team2)
       :attr('data-score1', d.score1):attr('data-score2', d.score2)
       :attr('data-bo', d.bo):attr('data-date', d.date)
       :attr('data-casters', d.casters):attr('data-vod', d.vod):attr('data-notes', d.notes)

    local win1 = (d.winner == "1" or d.winner == d.team1) and d.team1 ~= "TBD"
    local win2 = (d.winner == "2" or d.winner == d.team2) and d.team2 ~= "TBD"

    -- Top Bar
    local top = box:tag('div'):addClass('bk-match-top')
    top:tag('span'):addClass('bk-match-label'):wikitext(label or matchID)
    
    -- Display Time (If exists, else Bo format)
    if d.date and d.date ~= "" then
        -- Extract just the time if it's a full ISO string
        local timeStr = string.match(d.date, "T(%d%d:%d%d)") or d.date
        top:tag('span'):addClass('bk-match-time'):wikitext(timeStr)
    elseif d.bo and d.bo ~= "" then
        top:tag('span'):addClass('bk-match-time'):css('color','var(--text-muted)'):wikitext('Bo'..d.bo)
    end

    -- Team 1
    local row1 = box:tag('div'):addClass('bk-team')
    if win1 then row1:addClass('bk-win') elseif win2 then row1:addClass('bk-lose') end
    row1:wikitext(getTeamLogo(d.team1))
    row1:tag('span'):addClass('bk-team-name'):wikitext(d.team1 ~= "TBD" and "[["..d.team1.."|"..d.short1.."]]" or "TBD")
    row1:tag('span'):addClass('bk-score'):wikitext(d.score1 ~= "" and d.score1 or "-")

    -- Team 2
    local row2 = box:tag('div'):addClass('bk-team')
    if win2 then row2:addClass('bk-win') elseif win1 then row2:addClass('bk-lose') end
    row2:wikitext(getTeamLogo(d.team2))
    row2:tag('span'):addClass('bk-team-name'):wikitext(d.team2 ~= "TBD" and "[["..d.team2.."|"..d.short2.."]]" or "TBD")
    row2:tag('span'):addClass('bk-score'):wikitext(d.score2 ~= "" and d.score2 or "-")

    return box
end

-- ================================================================
-- FORMAT: SINGLE ELIMINATION (Auto-Routing)
-- ================================================================
local function renderSingleElim(args, event, teamCount)
    local rounds = math.floor(math.log(teamCount) / math.log(2))
    local suffixNames = { "Grand Final", "Semifinals", "Quarterfinals", "Round of 16", "Round of 32" }
    
    local scroll = html.create('div'):addClass('bk-scroll')
    local wrapper = scroll:tag('div'):addClass('bk-wrapper')

    for r = 1, rounds do
        local col = wrapper:tag('div'):addClass('bk-col')
        local pos = rounds - r + 1
        col:tag('div'):addClass('bk-col-header'):wikitext(args["r"..r.."name"] or suffixNames[pos] or ("Round "..r))

        local matchCount = teamCount / (2^r)
        for m = 1, matchCount do
            local matchID = "R"..r.."M"..m
            local cd = fetchMatch(event, matchID)
            local d  = mergeMatchData(cd, args, matchID)
            
            -- Calculate target automatically
            local target = ""
            local isFinal = (r == rounds)
            if not isFinal then
                local nextMatchNum = math.ceil(m / 2)
                target = "R"..(r+1).."M"..nextMatchNum
            end

            local card = renderMatchCard(d, matchID, "Match "..m, target, false, isFinal and "bk-final-match" or "")
            col:node(card)
        end
    end
    return tostring(scroll)
end

-- ================================================================
-- FORMAT: CUSTOM MANUAL BRACKET
-- Allows user to define exactly how many columns, and target matches inline
-- ================================================================
local function renderCustom(args, event)
    local scroll = html.create('div'):addClass('bk-scroll')
    local wrapper = scroll:tag('div'):addClass('bk-wrapper')
    
    local cols = tonumber(args.columns) or 2
    for c = 1, cols do
        local col = wrapper:tag('div'):addClass('bk-col')
        col:tag('div'):addClass('bk-col-header'):wikitext(args["col"..c.."name"] or "Round "..c)
        
        -- User specifies matches per column: e.g. |col1_matches=M1,M2,M3
        local matchesStr = args["col"..c.."_matches"]
        if matchesStr then
            for mID in string.gmatch(matchesStr, '([^,]+)') do
                mID = clean(mID)
                local cd = fetchMatch(event, mID)
                local d  = mergeMatchData(cd, args, mID)
                local target = args[mID.."_target"] -- e.g. |M1_target=M4
                local isDrop = args[mID.."_drop"] == "true" -- e.g. |M1_drop=true (draws red dashed line)
                
                col:node(renderMatchCard(d, mID, args[mID.."_label"], target, isDrop))
            end
        end
    end
    return tostring(scroll)
end

-- ================================================================
-- MAIN ENTRY
-- ================================================================
function p.main(frame)
    local args = (frame:getParent() and frame:getParent().args) or frame.args
    local format = (clean(args.format) or "single"):lower()
    local event = clean(args.event) or ""
    local teamCount = tonumber(args.teams) or 8

    local root = html.create('div'):addClass('bk-root')
    local bracketHTML

    if format == "single" then
        bracketHTML = renderSingleElim(args, event, teamCount)
    elseif format == "custom" then
        bracketHTML = renderCustom(args, event)
    else
        bracketHTML = "<div style='color:red; padding:20px'>Format '"..format.."' is currently being upgraded to v3.0. Use 'single' or 'custom' for now.</div>"
    end

    root:wikitext(bracketHTML)

    -- MODAL POPUP (Reuses your excellent existing HTML structure)
    local overlay = root:tag('div'):addClass('bk-modal-overlay'):css('display','none')
    local modal = overlay:tag('div'):addClass('bk-modal')
    modal:tag('span'):addClass('bk-modal-close'):attr('role','button'):wikitext('&#10005;')
    local mheader = modal:tag('div'):addClass('bk-modal-header')
    local mteams = mheader:tag('div'):addClass('bk-modal-teams')
    mteams:tag('span'):addClass('bk-modal-t1')
    mteams:tag('span'):addClass('bk-modal-vs'):wikitext('vs')
    mteams:tag('span'):addClass('bk-modal-t2')
    local mscore = mheader:tag('div'):addClass('bk-modal-score')
    mscore:tag('span'):addClass('bk-modal-s1')
    mscore:tag('span'):addClass('bk-modal-dash'):wikitext('&#8211;')
    mscore:tag('span'):addClass('bk-modal-s2')
    local mmeta = modal:tag('div'):addClass('bk-modal-meta')
    mmeta:tag('div'):addClass('bk-modal-row bk-modal-bo')
    mmeta:tag('div'):addClass('bk-modal-row bk-modal-date')
    mmeta:tag('div'):addClass('bk-modal-row bk-modal-casters')
    mmeta:tag('div'):addClass('bk-modal-row bk-modal-notes')
    mmeta:tag('div'):addClass('bk-modal-row bk-modal-vod')

    return tostring(root)
end

return p