重啟北護課程查詢系統 Day 4 - 基礎課表元件

Posted on Sun, Feb 18, 2024 Node.JS Web JS

今天要來實作前端基礎課表的元件,以供後續預排課表的功能使用~~

課表樣式來源

因為作者對前端實在不太熟,沒辦法直接把版面刻出來,好在現在元件發展很發達,而且網頁前端的樣式開個 F12 就可以偷了哈哈

meme

這邊參考的來源是 Tailwind UI 提供的 Weekly calendar

Tailwind UI Components Weekly calendar

不過當你開啟 F12 的時候你會發現,哭啊,class 竟然已經被 uglify 了,只好自己慢慢回溯旁邊的 styles 推測 Tailwind 的 class 了

Tailwind UI Components Weekly calendar F12

課表 Tailwind+AlpineJS+HTML Codepen

以下是最後推敲出來的課表,另外,筆者想要做到”自行更換顏色”的功能,但為了方便展示,所以 codepen 的示範程式碼沒有那麼完整

AlpineJS

為專案加入 AlpineJS

pnpm i alpinejs
import "htmx.org";
import Alpine from "alpinejs";
import { NtunhsCourseHubFns } from "../bundle/course-hub.js";

window.Alpine = Alpine;

// Start Alpine when the page is ready.
window.addEventListener("DOMContentLoaded", (event) => {
    Alpine.start();
});

// Restart Alpine when the DOM is altered by HTMX.
document.body.addEventListener("htmx:afterSwap", () => {
    Alpine.start();
});

課表元件

以下我們就以小到大把程式碼 po 上吧!

課程 card 選單的 Background 顏色方塊

bg-cube.ejs

<div
    class="bg-<%= color %>-50 justify-center items-center w-5 h-5 text-center cursor-pointer rounded text-inherit fill-inherit"
    :class="color==='<%= color %>' ? 'border border-black' : ''"
    @click="changeColor('<%= color %>')"
    style="box-shadow: rgba(15, 15, 15, 0.1) 0px 0px 0px 1px inset"
></div>

課程 card 右上角的選單

menu.ejs

<div class="flex">
    <div x-data="getCourseCardMenuData('<%= uid %>')">
        <button
            class="text-sm py-1 border-0 rounded-md outline-none bg-transparent"
            :class="NtunhsCourseHubFns.getColorByName(color).bg.hoverColor"
            x-on:click="show = !show"
        >
            <svg
                class="w-5 h-5 fill-gray-600"
                aria-hidden="true"
                xmlns="http://www.w3.org/2000/svg"
                fill="currentColor"
                viewBox="0 0 4 15"
            >
                <path
                    d="M3.5 1.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm0 6.041a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm0 5.959a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z"
                />
            </svg>
        </button>
        <div class="relative h-screen">
            <div
                class="bg-white rounded-md p-3 min-w-[220px] top-1 w-full absolute z-10 shadow-md"
                x-show="show"
                x-cloak
                @click.away="show = false"
                x-transition:enter="transition ease-out duration-100"
                x-transition:enter-start="transform opacity-0 scale-95"
            >
                <ul
                    class="[&>li]:text-black [&>li]:text-sm [&>li]:cursor-pointer [&>li]:px-2 [&>li]:py-1 [&>li]:rounded-md [&>li]:transition-all hover:[&>li]:bg-gray-600 hover:[&>li]:bg-opacity-5 active:[&>li]:bg-gray-700 active:[&>li]:bg-opacity-5 active:[&>li]:scale-[0.99]"
                >
                    <div class="flex flex-row flex-wrap">
                        <span class="text-xs w-full text-gray-500 mb-2"
                            >Background</span
                        >
                        <div class="grid grid-cols-4 gap-4 mb-2">
                            <%- include('bg-cube', { color: 'gray' }) %>
                            <%- include('bg-cube', { color: 'orange' }) %>
                            <%- include('bg-cube', { color: 'yellow' }) %>
                            <%- include('bg-cube', { color: 'green' }) %>
                            <%- include('bg-cube', { color: 'blue' }) %>
                            <%- include('bg-cube', { color: 'purple' }) %>
                            <%- include('bg-cube', { color: 'pink' }) %>
                            <%- include('bg-cube', { color: 'red' }) %>
                        </div>
                    </div>
                    <div class="w-full border border-gray-100 my-1"></div>
                    <li
                        class="flex items-center justify-between"
                        x-on:click="menu = !menu"
                        @click.away="menu = false"
                    >
                        Services
                        <i class="arrow"></i>
                    </li>
                    <div
                        class="bg-white rounded-md max-w-[180px] w-full p-3 absolute -right-[185px] -bottom-4 [&>li]:text-black [&>li]:text-sm [&>li]:cursor-pointer [&>li]:px-2 [&>li]:py-1 [&>li]:rounded-md [&>li]:transition-all hover:[&>li]:bg-gray-600 hover:[&>li]:bg-opacity-5 active:[&>li]:bg-gray-700 active:[&>li]:bg-opacity-5 active:[&>li]:scale-[0.99] shadow-md"
                        x-show="menu"
                        x-transition:enter="transition ease-out duration-100"
                        x-transition:enter-start="transform opacity-0 scale-95"
                    >
                        <li>List Item #1</li>
                        <li>List Item #2</li>
                        <li>List Item #3</li>
                    </div>
                    <div class="w-full border border-gray-100 my-1"></div>
                    <li class="!text-red-500">Delete</li>
                    <li x-text="color"></li>
                </ul>
            </div>
        </div>
    </div>
</div>
<style>
    .arrow {
        border: solid black;
        border-width: 0 2px 2px 0;
        display: inline-block;
        padding: 3px;
        transform: rotate(-45deg);
        -webkit-transform: rotate(-45deg);
    }
</style>

課程 card

calendar-course-card.ejs

<!-- dayNum, start period, end period -->
<% const periodStartTimeMapping = ["0810", "0910", "1010", "1110", "1240", "1340", "1440", "1540", "1640", "1740", "1835", "1730", "2025", "2120"] %>
<% const periodEndTimeMapping = ["0900", "1000", "1100", "1200", "1330", "1430", "1530", "1630", "1730", "1830", "1925", "2020", "2115", "2210"] %>

<% const periodStartTime = periodStartTimeMapping[startPeriod] %>
<% const periodEndTime = periodEndTimeMapping[endPeriod] %>
<% const periodLength = parseInt(endPeriod) - parseInt(startPeriod) %>
<% const liCardClass = `sm:col-start-${dayNum} flex mt-[1px] relative calendar-course-card-${uid}` %>
<% const rowStart = (parseInt(startPeriod)+1)*6+2 %>

<li
    class="<%= liCardClass %>"
    style="grid-row: <%= rowStart %> / span <%= periodLength * 12 %>"
>
    <div class="absolute top-1 right-1 z-50">
        <%- include("./course-card-menu/menu.ejs", { courseCardUid: uid }) %>
    </div>
    <a
        href="#"
        class="leading-5 text-sm p-2 bg-pink-50 rounded-lg overflow-y-auto sm:flex flex-col absolute inset-1 hover:bg-pink-100 group"
    >
        <p class="font-bold order-1 text-pink-700"><%= courseName %></p>
        <p class="text-pink-400 group-hover:text-pink-600">
            <span
                ><%= periodStartTime %>
                -
                <%= periodEndTime %></span
            >
        </p>
    </a>
</li>

課表

weekly-calendar.js

<main class="overflow-auto flex flex-col isolate bg-white">
    <div class="md:max-w-full sm:max-w-none flex flex-none flex-col max-w-full">
        <div
            class="pr-2 ring-opacity-5 ring-black ring-0 shadow bg-white flex-none top-0 sticky z-30"
        >
            <div
                class="grid grid-cols-7 sm:hidden text-gray-500 leading-6 text-sm weekly-calendar-mobile-days"
            >
                <button class="pt-2 pb-3 items-center flex-col flex"></button>
                <button class="pt-2 pb-3 items-center flex-col flex"></button>
                <button class="pt-2 pb-3 items-center flex-col flex"></button>
                <button class="pt-2 pb-3 items-center flex-col flex"></button>
                <button class="pt-2 pb-3 items-center flex-col flex"></button>
                <button class="pt-2 pb-3 items-center flex-col flex"></button>
                <button class="pt-2 pb-3 items-center flex-col flex"></button>
            </div>
            <div
                class="mr-[-1px] hidden grid-cols-7 divide-x border-gray-100 border-r-[1px] sm:grid text-gray-500 leading-6 weekly-calendar-days"
            >
                <div class="w-14 col-end-1"></div>
                <div
                    class="divide-x border-gray-100 py-3 justify-center items-center flex"
                >
                    <span></span>
                </div>
                <div
                    class="divide-x border-gray-100 py-3 justify-center items-center flex"
                >
                    <span></span>
                </div>
                <div
                    class="divide-x border-gray-100 py-3 justify-center items-center flex"
                >
                    <span></span>
                </div>
                <div
                    class="divide-x border-gray-100 py-3 justify-center items-center flex"
                >
                    <span></span>
                </div>
                <div
                    class="divide-x border-gray-100 py-3 justify-center items-center flex"
                >
                    <span></span>
                </div>
                <div
                    class="divide-x border-gray-100 py-3 justify-center items-center flex"
                >
                    <span></span>
                </div>
                <div
                    class="divide-x border-gray-100 py-3 justify-center items-center flex"
                >
                    <span></span>
                </div>
            </div>
        </div>
        <div class="flex-auto flex">
            <div
                class="ring-gray-100 ring-1 bg-white flex-none w-14 z-10 left-0 sticky"
            ></div>
            <div class="grid grid-cols-1 grid-rows-1 flex-auto">
                <div
                    class="grid grid-rows-[repeat(28,_minmax(3.5rem,_1fr))] row-start-1 col-start-1 col-end-2 weekly-calendar-rows-divide"
                >
                    <div class="h-7 row-end-1"></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            1
                        </div>
                    </div>
                    <div></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            2
                        </div>
                    </div>
                    <div></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            3
                        </div>
                    </div>
                    <div></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            4
                        </div>
                    </div>
                    <div></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            5
                        </div>
                    </div>
                    <div></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            6
                        </div>
                    </div>
                    <div></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            7
                        </div>
                    </div>
                    <div></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            8
                        </div>
                    </div>
                    <div></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            9
                        </div>
                    </div>
                    <div></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            10
                        </div>
                    </div>
                    <div></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            11
                        </div>
                    </div>
                    <div></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            12
                        </div>
                    </div>
                    <div></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            13
                        </div>
                    </div>
                    <div></div>
                    <div>
                        <div
                            class="leading-5 text-xs text-right pr-2 w-14 mt-[-0.625rem] ml-[-3.5rem] z-20 left-0 sticky text-gray-400"
                        >
                            14
                        </div>
                    </div>
                    <div></div>
                </div>
                <div
                    class="sm:grid sm:grid-cols-7 grid-rows-1 grid-cols-7 col-start-1 col-end-2 row-start-1 hidden weekly-calendar-cols-divide"
                >
                    <div class="row-span-full col-start-1"></div>
                    <div class="row-span-full col-start-2"></div>
                    <div class="row-span-full col-start-3"></div>
                    <div class="row-span-full col-start-4"></div>
                    <div class="row-span-full col-start-5"></div>
                    <div class="row-span-full col-start-6"></div>
                    <div class="row-span-full col-start-7"></div>
                </div>
                <ol
                    class="weekly-courses grid-rows-[1.75rem_repeat(168,_minmax(0px,_1fr))_auto] sm:grid-cols-7 grid row-start-1 col-end-2 col-start-1"
                >
                    <% for( let index = 0; index < courses.length; index++ ) { %>
                        <% let course = courses[index]; %>
                        <%- include('calendar-course-card', { 
                                dayNum: course.dayNum,
                                startPeriod: course.startPeriod,
                                endPeriod: course.endPeriod, 
                                courseName: course.courseName, 
                                uid: course.courseCardUid
                            }) 
                        %>
                    <% } %>
                </ol>
            </div>
        </div>
    </div>
</main>

client/bundle/main.js

import "htmx.org";
import Alpine from "alpinejs";
import { NtunhsCourseHubFns } from "../bundle/course-hub.js";

window.Alpine = Alpine;

// Start Alpine when the page is ready.
window.addEventListener("DOMContentLoaded", (event) => {
    Alpine.start();
});

window.addEventListener("alpine:init", () => {
    Alpine.data("getCourseCardMenuData", function (courseCardUid) {
        let courseCard = document.querySelector(`.calendar-course-card-${courseCardUid}`);
        let courseCardItem = courseCard.querySelector("a");

        return {
            show: false,
            menu: false, 
            color: NtunhsCourseHubFns.getCourseCardColor(courseCardUid).name,
            changeColor(colorName) {
                NtunhsCourseHubFns.persistent.setCourseCardColor(colorName);
                let oldColor = NtunhsCourseHubFns.getColorByName(this.color);
                let targetColor = NtunhsCourseHubFns.getColorByName(colorName);
                this.changeCourseCardColor(oldColor, targetColor);
                this.show = false;
                this.menu = false;
            },
            changeCourseCardColor(oldColor, targetColor) {
                courseCardItem.classList.remove(oldColor.bg.color);
                courseCardItem.classList.remove(oldColor.bg.hoverColor);
                courseCardItem.classList.add(targetColor.bg.color);
                courseCardItem.classList.add(targetColor.bg.hoverColor);
                this.color = window.NtunhsCourseHubFns.getCourseCardColor(courseCardUid).name;

                this.changeCourseCardCourseNameColor(oldColor, targetColor);
                this.changeCourseCardTimeColor(oldColor, targetColor);
            },
            changeCourseCardCourseNameColor(oldColor, targetColor) {
                let courseNameElement = courseCardItem.querySelector("p:nth-child(1)");
                courseNameElement.classList.remove(oldColor.courseText.color);
                courseNameElement.classList.add(targetColor.courseText.color);
            },
            changeCourseCardTimeColor(oldColor, targetColor) {
                let timeElement = courseCardItem.querySelector("p:nth-child(2)");
                timeElement.classList.remove(oldColor.timeText.color);
                timeElement.classList.remove(oldColor.timeText.hoverColor);
                timeElement.classList.add(targetColor.timeText.color);
                timeElement.classList.add(targetColor.timeText.hoverColor);
            }
        };
    });
});

// Restart Alpine when the DOM is altered by HTMX.
document.body.addEventListener("htmx:afterSwap", () => {
    Alpine.start();
});

client/bundle/course-hub.js

export const colors = [
    {
        name: "gray",
        bg: {
            color: "bg-gray-50",
            hoverColor: "hover:bg-gray-100"
        },
        courseText: {
            color: "text-gray-700"
        },
        timeText: {
            color: "text-gray-400",
            hoverColor: "group-hover:text-gray-600"
        }
    },
    {
        name: "orange",
        bg: {
            color: "bg-orange-50",
            hoverColor: "hover:bg-orange-100"
        },
        courseText: {
            color: "text-orange-700"
        },
        timeText: {
            color: "text-orange-400",
            hoverColor: "group-hover:text-orange-600"
        }
    },
    {
        name: "yellow",
        bg: {
            color: "bg-yellow-50",
            hoverColor: "hover:bg-yellow-100"
        },
        courseText: {
            color: "text-yellow-700"
        },
        timeText: {
            color: "text-yellow-400",
            hoverColor: "group-hover:text-yellow-600"
        }
    },
    {
        name: "green",
        bg: {
            color: "bg-green-50",
            hoverColor: "hover:bg-green-100"
        },
        courseText: {
            color: "text-green-700"
        },
        timeText: {
            color: "text-green-600",
            hoverColor: "group-hover:text-green-700"
        }
    },
    {
        name: "blue",
        bg: {
            color: "bg-blue-50",
            hoverColor: "hover:bg-blue-100"
        },
        courseText: {
            color: "text-blue-700"
        },
        timeText: {
            color: "text-blue-400",
            hoverColor: "group-hover:text-blue-600"
        }
    },
    {
        name: "purple",
        bg: {
            color: "bg-purple-50",
            hoverColor: "hover:bg-purple-100"
        },
        courseText: {
            color: "text-purple-700"
        },
        timeText: {
            color: "text-purple-400",
            hoverColor: "group-hover:text-purple-600"
        }
    },
    {
        name: "pink",
        bg: {
            color: "bg-pink-50",
            hoverColor: "hover:bg-pink-100"
        },
        courseText: {
            color: "text-pink-700"
        },
        timeText: {
            color: "text-pink-400",
            hoverColor: "group-hover:text-pink-600"
        }
    },
    {
        name: "red",
        bg: {
            color: "bg-red-50",
            hoverColor: "hover:bg-red-100"
        },
        courseText: {
            color: "text-red-700"
        },
        timeText: {
            color: "text-red-400",
            hoverColor: "group-hover:text-red-600"
        }
    }
];

export const NtunhsCourseHubFns = {
    persistent: {
        /**
         * 
         * @param { "gray" | "orange" | "yellow" | "green" | "blue" | "purple" | "pink" } color 
         */
        setCourseCardColor: (color) => {
            window.localStorage.setItem("ntunhs-course-hub-card-color", color);
        }
    },
    getCourseCardColor: (courseCardUid) => {
        let courseCard = document.querySelector(`.calendar-course-card-${courseCardUid}`);
        let courseCardItem = courseCard.querySelector("a");
        for (let i = 0; i < colors.length; i++) {
            if (courseCardItem.classList.contains(colors[i].bg.color)) {
                return colors[i];
            }
        }
    },
    getColorByName: (colorName) => {
        return colors.find(c => c.name === colorName);
    }
};

window.addEventListener("DOMContentLoaded", (event) => {
    window.NtunhsCourseHubFns = NtunhsCourseHubFns;

    let cardColor = window.localStorage.getItem("ntunhs-course-hub-card-color");
    if (!cardColor) {
        cardColor = "green";
        window.localStorage.setItem("ntunhs-course-hub-card-color", cardColor);
    }
});

server/modules/weekly-calendar.js

因為我們是 sever side render,並且這邊只是要展示用,所以還是要更改 server 的 code

import { uid } from "uid/secure";

/**
 * 
 * @param {import("fastify").FastifyInstance} fastify 
 * @param {import("fastify").FastifyPluginOptions} opts 
 */
export default async function (fastify, opts) {
    fastify.get("/weekly-calendar", {
        preHandler: async (request, reply) => {
            if (!request.session.count && request.session.count !== 0) {
                request.session.count = 0;
            }
        }
    }, async (request, reply) => {
        return reply.view("components/layout", {
            page: {
                file: "weekly-calendar.ejs",
                data: {
                    courses: [
                        {
                            dayNum: 1,
                            startPeriod: "5",
                            endPeriod: "7",
                            courseName: "休閒與生活",
                            courseCardUid: uid()
                        }
                    ]
                }
            }
        });
    });

    fastify.get("/context-menu", async (request, reply) => {
        return reply.view("components/layout", {
            page: {
                file: "course-card-context-menu",
                data: {}
            }
        });
    });
}

Github

https://github.com/ntunhs-course-ecosystem/course-hub/tree/day4

參考資料