Found this content helpful? Log in or sign up to leave a like!

UI for weekly progression idea

jsimon3
Community Participant

UI for weekly progression idea that rest in the Schedule tab of the
**K-12 UI** of Canvas

it's incomplete and I really would not take it and push it into instance but I am going to post it here because in the past people like @James && @robotcars || @Steve_25  have had terrific input in the past...
maybe w/their eyes and knowledge this can actually be worthwhile to others...

-The screenshot depicts what it looks like
2 points here:
-the font is set by the user
and
-the theme is the dark mode for us also st by the user

jsimon3_0-1763757900974.png

The colored bars depict the progression, assignments submitted, not the grade, for the week selected.
The percentages and the bars are simply the Number of assignments due that week vs what was submitted presented as a percentage `Submitted Assignments / Total Assignments) * 100 = Completion Percentage`
I tried to make use of the users localStorage so that the list of assignments are not fetched all the time just on an interval that you set default for us is daily. the submissions are on interaction or on click
and that is about it..
The original idea sprang up when I saw this useless bar on the grades page that corresponded to a grade corresponds is actually to strong of a  term actually... but here is a screenshot of that

jsimon3_1-1763758539048.png

finally here is the code:

async function assGlance() {
    const checkIfNull = async (selector) => {
    while (document.querySelector(selector) === null) {
      await new Promise((resolve) => requestAnimationFrame(resolve));
    }
    return document.querySelector(selector);
  };
  checkIfNull("#dashboard-app-container").then(() => {
    console.log('start');
    function setWithExpiry(value, ttl) {
        const now = new Date()
        // object
        // expire
        const item = {
            value: [value],
            expiry: now.getTime() + ttl,
        }
        localStorage
            .setItem('savedAssCol', JSON.stringify(item))
    };

    function getWithExpiry() {
        const itemStr = localStorage.getItem('savedAssCol')

        // if the object doesn't exist, return null
        if (!itemStr) {
            return null
        }
        const item = JSON
        .parse(itemStr);
        const now = new Date();
        // compare the expiry time of the item with the current time
        if (now.getTime() > item.expiry) {
            // If the object is expired, throw it out and restock
            // and return null
            localStorage
                .removeItem('savedAssCol');
            fetchAssignmentsInParallel();
        }
        return item.value
    };
    
    
    const getMonday = (date) => {
        const d = new Date(date);
        d.setHours(0, 0, 0, 0);
        // Adjust day: 0 (Sunday) to -6, 1 (Monday) to 1, etc.
        const day = d.getDay();
        const diff = d.getDate() - day + (day === 0 ? -6 : 1);
        return new Date(d.setDate(diff));
    };

    const getShownDay = () => {
        try {
            // Use a more robust selector or context
            const dateText = document.querySelector('#dashboard_page_schedule .PlannerApp [data-testid="day"] h2 > div')?.textContent;
            if (!dateText) return null;

            // Append the current year to the month/day string for a valid date
            const fullDateStr = `${dateText} ${new Date().getFullYear()}`;
            const date = new Date(fullDateStr);

            // Check if date is valid
            return date instanceof Date && !isNaN(date) ? date : null;
        } catch (error) {
            console.error("Error getting shown day:", error);
            return null;
        }
    };
    //console.log("shown: " + getShownDay());

    //console.log("Mon: " + getMonday(new Date()));

    let weekToShow = () => {
        if (!getShownDay() || getShownDay() === null) {
            return getMonday(new Date());
        } else {
            return new Date(getShownDay());
        }
    };
    //console.log("weekToShow" + weekToShow());

    const currentCourses = (window.ENV?.STUDENT_PLANNER_COURSES || [])
        .filter(enr => enr.enrollmentType === "StudentEnrollment" && enr.enrollmentState === "active");

    class returnedData { // For the final data shown in the UI
        constructor(courseId, courseName, courseLink, courseColor, assCount, subCount, unSubCount) {
            Object.assign(this, { courseId, courseName, courseLink, courseColor, assCount, subCount, unSubCount });
        }
    }
    class savedAssExtension { // For the cached assignment list (all assignments for a course)
        constructor(courseId, courseName, courseLink, courseColor, ass) {
            Object.assign(this, { courseId, courseName, courseLink, courseColor, ass });
        }
    }
     class savedAss { // Individual assignment object structure
        constructor(due_at, id, html_url, workflow_state) {
            Object.assign(this, { due_at, id, html_url, workflow_state });
        }
    }
    //let assAll = [];
    const weekSelectedAss = [];
    const colorCollection = ['Sienna', 'RebeccaPurple', 'LightSeaGreen', 'Coral', 'Azure', 'FireBrick', 'GoldenRod', 'Indigo', 'LightPink'];
    let finalCollection = [];
    const savedAssCol = getWithExpiry() || [];

    function filterChop(cId, cName, cLink, cColor, fullAssignmentList) {
        // Calculate the week range once
        const weekStart = weekToShow();
        // Use setDate to efficiently calculate the end of the week (Monday + 5 days, so end is Sat/Sun)
        const weekEnd = new Date(weekStart);
        weekEnd.setDate(weekStart.getDate() + 5); // Up to 6 days after Monday (i.e., Sunday)

        // Filter assignments based on the due date range
        const dataFilter = fullAssignmentList
            .filter(assignment => {
                const dueDate = new Date(assignment.due_at);
                // Due date is greater than or equal to Monday AND less than Sunday's start
                return dueDate >= weekStart && dueDate < weekEnd && assignment.workflow_state === "published";
            });

        if (dataFilter.length > 0) {
            // Only store the IDs of the assignments needed for the submission fetch
            const assignmentIds = dataFilter.map(obj => obj.id.toString());
            weekSelectedAss.push(new savedAssExtension(cId, cName, cLink, cColor, assignmentIds));
        }
    };

    async function genUI(weekSomething, useRemove) {
    console.log('Generating UI');
    let inSchedule = window.location.href.includes("#schedule");
    
    const targetContainer = "#dashboard-app-container .flexCardProgress";
    const insertBeforeSelector = "#dashboard_page_schedule";

    // --- 1. Preparation and Removal ---
    
    // Find the existing container to remove/replace
    const existingContainer = document.querySelector(targetContainer);
    //console.log('--------------------------------------');console.log(existingContainer === null);console.log(inSchedule);console.log(useRemove);
    if (!inSchedule && existingContainer !== null) {
        existingContainer.style.display = 'none';
        return;
    }
    if (useRemove && existingContainer !== null && inSchedule) {
        // Remove the old element faster
        existingContainer.remove();
        //console.log('Removed existing progress container.');
    }
    if (!useRemove && existingContainer !== null && inSchedule) {
        // Remove the old element faster
        existingContainer.remove();
        //console.log('Removed existing progress container.');
    }

    // Create the new element container
    const newContainer = document.createElement('span');
    newContainer.classList.add('flexCardProgress');
    
    // Use a DocumentFragment to build the content outside the DOM
    const fragment = document.createDocumentFragment();

    // Start with the header and pacMan
    let contentHTML = '<h3 style="margin:0">Weekly Assignment Progress</h3><div class="gameLoader pacMan"></div>';
    
    // --- 2. Build Content ---

    if (!weekSomething 
        || 
        weekSomething.length 
        === 
        0) {
        contentHTML += `<p>Nothing to show for the selected week. I'll return again one day... </p>`;
    } else {
        const courseContent = weekSomething
                                .map((course, i) => {
            // (Submitted Assignments / Total Assignments) * 100 = Completion Percentage
            const compPerc = course.assCount > 0 ? (course.subCount / course.assCount) * 100 : 0;
            const roundedPerc = parseInt(compPerc);
            const color = course.courseColor 
                            || 
                          `linear-gradient(to right, ${colorCollection[Math.floor(Math.random() * colorCollection.length)]} 0%, ${colorCollection[Math.floor(Math.random() * colorCollection.length)]} 100%)`;
            // Ensure consistent color property setting
            document.documentElement.style.setProperty(`--course${i}`, color);
            // Return HTML string for this course
            return `
                <a href="${course.courseLink}" style="font-size:1em;text-decoration:none;cursor:pointer;">
                    ${course.courseName.substring(0, 40)}
                    <span>${roundedPerc}%</span>
                </a>
                <progress class="courseProgression course${i}" max="100" value="${compPerc}" role="listitem"
                    aria-label="${course.courseName}, ${roundedPerc}% complete of 100%">
                </progress>
            `;
        })
        .join(''); // stringOrgy
        
        contentHTML += courseContent;
    }
    
    // Set the HTML content of the new container (big money operation)
    newContainer.innerHTML = contentHTML;
    
    // Append the fully constructed container to the fragment
    fragment.appendChild(newContainer);

    // --- 3. Ass Insertion ---
    const insertTarget = document.querySelector(insertBeforeSelector);

    if (insertTarget) {
        insertTarget.before(fragment);
    } else {
        console.error(`Insertion target element (${insertBeforeSelector}) not found.`);
    }
    // Stop pacMan
    if (useRemove) {
        document.querySelector('.gameLoader.pacMan')?.style.setProperty("--playState", "paused");
    }
};



    async function subMissionsInParallel(filterResult) {
        //console.log('Fetching submissions in parallel:', filterResult);

        // Clear the global collection once at the start
        finalCollection.length = 0;

        // Check if there's any data to process
        if (filterResult.length === 0) {
            return;
        }

        // 1. Map the filterResult array to an array of Promises
        const fetchPromises = filterResult
            .filter(s => s.ass.length > 0) // Only include courses with assignments
            .map(async (s) => {
                const courseId = s.courseId;
                const assignmentIds = s.ass;

                // Build the query string for all assignment IDs in this course
                const query = assignmentIds
                                .map(id => `assignment_ids[]=${id}`).join('&');

                try {
                    let options = {'headers': {'accept': 'application/json'},'timeout': 5000};
                    const response = await fetch(`/api/v1/courses/${courseId}/students/submissions?${query}&per_page=100`, options);

                    if (!response.ok) { // Throw to be caught by the local catch block
                        throw new Error(`HTTP error! Status: ${response.status
                            } for course ${courseId}`);
                    }

                    const data = await response.json();

                    // If the response is empty, skip processing
                    if (data.length === 0) {
                        return null;
                    }

                    // 2. Process the submission data
                    let submittedCount = 0;
                    let unsubmittedCount = 0;

                    for (const sub of data) {
                        //console.log(courseId + sub.workflow_state)
                        if (sub.workflow_state !== "unsubmitted" && sub.missing !== true) {
                            submittedCount++;
                        } else {
                            unsubmittedCount++;
                        }
                    }

                    // 3. Return the processed progress data for this course
                    // This will be collected by Promise.all, hopefully
                    return new returnedData(courseId, s.courseName, s.courseLink, s.courseColor, assignmentIds.length, submittedCount, unsubmittedCount);

                } catch (error) {
                    console.error(`Error fetching submissions for course ${courseId}:`, error);
                    return null; // Return null so Promise.all doesn't fail and we can filter errors later
                }
            });

        // 4. Wait for ALL fetch operations to complete concurrently
        const results = await Promise.all(fetchPromises);

        // 5. Pop finalCollection
        // Filter out null results (error or empty response)
        const successfulResults = results.filter(result => result !== null);

        finalCollection.push(...successfulResults);
    };
    

    async function weekClicked() {
        console.log('weekClicked');
        const tbody = document.querySelector("#dashboard_page_schedule > div.PlannerApp");
        const homePage = document.querySelector("#tab-tab-homeroom");

        // Use a flag to prevent rapid, multiple executions if the kid manipulates the DOM changes quickly
        let isProcessing = false;

        if (!tbody || !homePage) {
            console.warn("Target not found.");
            return;
        }

        // If we're already processing, ignore the new mutation event.
        if (isProcessing) {
            return;
        }

        const doTheRightThing = async () => {
                //console.log('has mutants');
                isProcessing = true; // Set flag to true to block subsequent calls

                if (savedAssCol
                    ===
                    null
                    &&
                    savedAssCol
                    !==
                    '[]') {
                    fetchAssignmentsInParallel(); //fetchAssignmentsSequentially()
                    isProcessing = false; // Set flag to false cause its done *beep*
                } else {
                    let savedAssColData = getWithExpiry(); 
                    let savedAssColDataParsed = JSON.parse(savedAssColData);


                    weekSelectedAss.length = 0; //mp-t the array 
                    finalCollection.length = 0; //mp-t the array 
                    //console.log("weeSelected arrar " + weekSelectedAss.length);
                    //loading animation
                    document.querySelector('.gameLoader.pacMan').style.setProperty("--playState", "play");
                    //loading animation
                    if (savedAssColDataParsed
                        !== null
                        && savedAssColDataParsed
                        !== undefined
                        && savedAssColDataParsed.length
                        > 0) {
                        for (const course of savedAssColDataParsed) {
                            //console.log(course);
                            if (course.ass.length > 0) {
                                filterChop(course.courseId, course.courseName, course.courseLink, course.courseColor, course.ass);
                            }
                        }
                    };
                    //console.log(weekSelectedAss);
                    await subMissionsInParallel(weekSelectedAss);
                    await genUI(finalCollection, true);
                    isProcessing = false; // Set flag to false cause its done *beep*
                }
            };


        async function handleMutationsA(mutationList) {
            //console.log("A " + mutationList);
            const childList = mutationList.some(mutation => mutation.type === 'childList');
            const plannerApp = mutationList.some(mutation => mutation?.target?.classList.contains('PlannerApp'));
            
            if ((childList && plannerApp)) {
                //console.log(`top:   ${(childList && plannerApp)}`);
                doTheRightThing();   
            }
            else{
                return;
            }
        };

        async function handleMutationsB(mutationList) {
            //console.log("B " + handleMutationsB);

            const attList = mutationList.some(mutation => mutation.type === 'attributes');
            const scheduleTab = mutationList.some(mutation => mutation?.target?.id === 'tab-tab-schedule');
            const ariSelected = mutationList.some(mutation => mutation?.target?.hasAttribute('aria-selected'));


            if ((attList && scheduleTab && ariSelected)) {
                //console.log(`bottom:  ${(attList && scheduleTab && ariSelected)}`);
                if (document.querySelector("#tab-tab-schedule").hasAttribute('aria-selected')) {
                    const statusValue = document.querySelector("#tab-tab-schedule").getAttribute('aria-selected');
                    if (statusValue === 'true') {
                        //console.log(statusValue);
                        doTheRightThing();
                    }
                    else{
                        return;
                    }
                }

            } else {
                return;
            }
        }

        let config = (theThing)=>{
                if (theThing == 'a') {
                return { childList: true };
            } if (theThing == 'b') {
                return { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-selected'] };
            }
        };

        const observerA = new MutationObserver(handleMutationsA);
        const observerB = new MutationObserver(handleMutationsB);
        observerA.observe(tbody, config('a'));
        observerB.observe(document.querySelector('#dashboard-app-container [role="tablist"]'), config('b'));
    };

    const nextURL = (linkTxt) => {
        if (linkTxt) {
            let links = linkTxt.split(",");
            let nextRegEx = new RegExp('^<(.*)>; rel="next"$');

            for (let i = 0; i < links.length; i++) {
                let matches = nextRegEx.exec(links[i]);
                if (matches && matches[1]) {
                    //return right away
                    //console.log(matches[1]);
                    return matches[1];
                }
            }
        } else {
            return false;
        }
    };

    const loopy = async (response, assignmentCollection) => {
        const linkTxt = response.headers.get("Link");
        if (!linkTxt) return assignmentCollection;

        const nextURLMatch = linkTxt.match(/<([^>]+)>; rel="next"/);
        if (!nextURLMatch || !nextURLMatch[1]) return assignmentCollection;
        
        const nextUrl = nextURLMatch[1];

        try {
            const options = {credentials: "same-origin",headers:{accept: "application/json"},timeout: 5000};
            const rspns = await fetch(nextUrl, options);
            
            if (!rspns.ok) {
                console.warn(`Pagination failed for ${nextUrl}. Status: ${rspns.status}`);
                return assignmentCollection; // Return current data if next page fails
            }

            const moreData = await rspns.json();
            assignmentCollection.push(...moreData);
            
            // Recursive call with the new response
            return await loopy(rspns, assignmentCollection);
        } catch (e) {
            console.error(`Error during pagination loop for ${nextUrl}:`, e);
            return assignmentCollection; // Return current data on fetch error
        }
    };


    async function fetchAssignmentsInParallel() {
        console.log('Fetching assignments in parallel');

        // 1. Create an array of Promises for all fetch operations
        const fetchPromises = currentCourses
                                .map(async (course) => {
            try {
                const options = {'headers': {'accept': 'application/json',},'timeout': 5000,};
                const url = `/api/v1/users/self/courses/${course.id}/assignments/?order_by=due_at&per_page=100`;

                // but keeping the original structure simple.
                const response = await fetch(url, options);
                const responseJson = await response.json(); // Await parsing 
                const responseObj = {ok:response.ok,headers: response.headers};

                if (responseObj.ok) {
                    let assignmentCollection = responseJson;
                    assignmentCollection = await loopy(responseObj,assignmentCollection);
                    //console.log(assignmentCollection);
                    let assAll = new Array();
                    assignmentCollection.forEach(d => {
                        assAll.push(new savedAss(d.due_at, d.id, d.html_url, d.workflow_state));//due_at, html_url, workflow_state
                    });
                    //console.log(assAll);

                    // Return an object containing the course details and assignment data 
                    // if the fetch was successful and there is data.
                    if (responseJson.length > 0) {
                        return {
                            courseId: course.id,
                            shortName: course.shortName,
                            href: course.href,
                            color: course.color,
                            data: assAll,
                        };
                    }
                    return null; // Return null if no data to be filtered out later

                } else{
                    // Throw an error to be caught by the outer try/catch
                    throw new Error(`HTTP error! Status: ${response.status} for course ${course.id}`);
                }
                
            } catch (error) {
                console.error(`Error fetching assignments for course ${course.id}:`, error);
                return null; // Return NULL on failure so Promise.all can still resolve is null a dattype in JS?
            }
        });

        // 2. Wait for all Promises
        const results = await Promise.all(fetchPromises);

        // 3. Process the results
        // Clear the global arrays before populating them to ensure fresh minty flavor
        savedAssCol.length = 0;
        weekSelectedAss.length = 0;

        results
            .filter(result => result !== null)
            .forEach(result => {
            const { courseId, shortName, href, color, data } 
            = result;

            // Populate savedAssCol
            savedAssCol.push(new savedAssExtension(courseId, shortName, href, color, data));

            // Filter for the current week
            filterChop(courseId, shortName, href, color, data);
        });

        // 4. Update the local storage cache once
        if (savedAssCol.length > 0) {
            setWithExpiry(JSON.stringify(savedAssCol), 43200000);//EXPIRE_BY
        }

        return weekSelectedAss;
    }

    //gate way drugs are best in parallel 
    fetchAssignmentsInParallel()
        .then(async (filterResult) => {
        try {
            await subMissionsInParallel(filterResult);
        } catch (error) {
            console.error("The .catch() block handled an error:", error);
        } finally { // return weekSomething = finalCollection ;
            await genUI(finalCollection, false)
                .then(() => {
                weekClicked();
            })
        }
    })
})};
assGlance();

 The css components are as follows:
you don't need the pacman loader it is the one we use to differentiate homegrown from instructure
un-comment the flexcard section to enable a glow that circulates around the edges 

/*
    __
    ||
   ====
   |  |__
   |  |-.\
    ||   \\
  course ||
  ======__|
 ________||__
/____________\
*/
.course1::-webkit-progress-value {
  background:var(--course1);
}
.course2::-webkit-progress-value {
  background:var(--course2);
}
.course3::-webkit-progress-value {
  background:var(--course3);
}
.course4::-webkit-progress-value {
  background:var(--course4);
}
.course5::-webkit-progress-value {
  background:var(--course5);
}
.course6::-webkit-progress-value {
  background:var(--course6);
}
.course7::-webkit-progress-value {
  background:var(--course7);
}
.course8::-webkit-progress-value {
  background:var(--course8);
}
.course9::-webkit-progress-value {
  background:var(--course9);
}
.course0::-webkit-progress-value {
  background:var(--course0);
}
.courseProgression {
  padding:0;
  margin: 0;
  -webkit-appearance:none;
  -moz-appearance:none;
  appearance:none;
  border:none;
  background-size:auto;
  height:20px;
  width: calc(var(--card-progress-width) - 3rem);
}

.flexCardProgress {
padding:0;
  margin: 0;
  background: #f0f0f0;
  width: var(--card-progress-width);
  padding: .5rem 1rem;
  margin: .5rem 0;
  position: relative;
  /* border-radius: 6px; */
  display: flex;
  flex-direction: column;
  font-size: 1em;
  color: #000;
}

/* .flexCardProgress::after {
  position: absolute;
  content: "";
  top: calc(var(--card-progress-height) -1);
  left: 0;
  right: 0;
  z-index: -1;
  height: 100%;
  width: 100%;
  margin: 0 auto;
  filter: blur(calc(var(--card-progress-height) / 6));
  background-image: linear-gradient(
    var(--rotate-progress)
    , #5ddcff, #3c67e3 43%, #4e00c2);
    opacity: 1;
  transition: opacity .5s;
  animation: spin 20.5s linear infinite;
}

@keyframes spin {
  0% {
    --rotate-progress: 0deg;
  }
  100% {
    --rotate-progress: 360deg;
  }
} */

.flexCardProgress a {
padding:0;
  margin: 0;
  color: #191c29;
  text-decoration: none;
  font-weight: bold;
  margin-top: .25rem;
  cursor: pointer;
}
.flexCardProgress span{
  margin: 0;
    display: flex;
    color: #191c29;
    width: 8rem;
    padding-right: 1rem;
    position: absolute;
    left: calc(100% - 4rem);
    --bottom: 15px;
}
.flexCardProgress div{
  margin: 0;
    display: flex;
    width: 4px;
    position: absolute;
    left: calc(100% - 4rem);
    top: 1.5rem;
}



/* 
 _ __   __ _  ___ _ __ ___   __ _ _ __  
| '_ \ / _` |/ __| '_ ` _ \ / _` | '_ \ 
| |_) | (_| | (__| | | | | | (_| | | | |
| .__/ \__,_|\___|_| |_| |_|\__,_|_| |_|
| |                                     
|_|                                     
*/

.gameLoader {
  width: 15px;
  height: 15px;
  position: relative;
  display: inline-block;
  margin-left: 7px;
}
.gameLoader::before,
.gameLoader::after {
  content: "";
  position: absolute;
}
.pacMan {
  border-radius: 50%;
  width: 4px;
  height: 4px;
  -webkit-animation-name: pacmanDot;
  animation-name: pacmanDot;
  -webkit-transform: translateX(14px);
  transform: translateX(14px);
}
.pacMan::before,
.pacMan::after {
  border-radius: 50%;
  border: 14px solid #005299;
  border-right-color: transparent;
  top: -12px;
  left: -24px;
}
.pacMan,
.pacMan::before,
.pacMan::after {
  -webkit-animation-duration: 0.5s;
  animation-duration: 0.5s;
  -webkit-animation-timing-function: linear;
  animation-timing-function: linear;
  -webkit-animation-iteration-count: infinite;
  animation-iteration-count: infinite;
  animation-play-state: var(--playState, paused);
}
.pacMan::before {
  -webkit-animation-name: upperJaw;
  animation-name: upperJaw;
}
.pacMan::after {
  -webkit-animation-name: lowerJaw;
  animation-name: lowerJaw;
}
@-webkit-keyframes pacmanDot {
  0%,
  50% {
    background: #008000;
  }
  51%,
  100% {
    background: none;
  }
  0%,
  100% {
    -webkit-transform: translateX(19px);
    transform: translateX(19px);
  }
  50% {
    -webkit-transform: translateX(12px);
    transform: translateX(12px);
  }
}
@keyframes pacmanDot {
  0%,
  50% {
    background: #008000;
  }
  51%,
  100% {
    background: none;
  }
  0%,
  100% {
    -webkit-transform: translateX(19px);
    transform: translateX(19px);
  }
  50% {
    -webkit-transform: translateX(12px);
    transform: translateX(12px);
  }
}
@-webkit-keyframes upperJaw {
  50% {
    -webkit-transform: rotate(50deg) translate(2px, -2px);
    transform: rotate(50deg) translate(2px, -2px);
  }
}
@keyframes upperJaw {
  50% {
    -webkit-transform: rotate(50deg) translate(2px, -2px);
    transform: rotate(50deg) translate(2px, -2px);
  }
}
@-webkit-keyframes lowerJaw {
  50% {
    -webkit-transform: rotate(-50deg) translate(2px, 2px);
    transform: rotate(-50deg) translate(2px, 2px);
  }
}
@keyframes lowerJaw {
  50% {
    -webkit-transform: rotate(-50deg) translate(2px, 2px);
    transform: rotate(-50deg) translate(2px, 2px);
  }
}

 

Labels (5)
0 Likes