今天要來實作前端基礎課表的元件,以供後續預排課表的功能使用~~
課表樣式來源
因為作者對前端實在不太熟,沒辦法直接把版面刻出來,好在現在元件發展很發達,而且網頁前端的樣式開個 F12 就可以偷了哈哈
這邊參考的來源是 Tailwind UI 提供的 Weekly calendar
不過當你開啟 F12 的時候你會發現,哭啊,class 竟然已經被 uglify 了,只好自己慢慢回溯旁邊的 styles 推測 Tailwind 的 class 了
課表 Tailwind+AlpineJS+HTML Codepen
以下是最後推敲出來的課表,另外,筆者想要做到”自行更換顏色”的功能,但為了方便展示,所以 codepen 的示範程式碼沒有那麼完整
AlpineJS
- 在之前的程式碼當中,我們並沒有使用到 AlpineJS,不過為了方便網頁上的元素 (e.g. 選單、Modal 等),我們就來使用 AlpineJS 這個輕前端框架吧
為專案加入 AlpineJS
- 安裝 AlpineJS 套件
pnpm i alpinejs
- 更改
client/bundle/main.js
讓他 bundle 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();
});
課表元件
- 在第二章節課表 HTML只是純粹的 HTML,但在實際應用時,我們應該會把裡面的內容切成更細小的元件
- 元件一:課表
- 元件二:課程 card
- 元件三:課程 card 右上角的選單
- 元件四:課程 card 選單的 Background 顏色方塊
以下我們就以小到大把程式碼 po 上吧!
課程 card 選單的 Background 顏色方塊
bg-cube.ejs
- 參數
- color: 方塊的顏色
<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
- 參數:
- uid: 當下課程 card 的選單唯一 id,用於 querySelector
- 最上面的 x-data 是取得 menu 相關的 data 以及 functions,回傳內容可以參考 client/bundle/main.js
- 裡面有個多層的 dropdown menu 只是暫存用的,也許以後會用到
<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: 上課星期
- startPeriod: 開始節次
- endPeriod: 結束節次
- courseName: 課程名稱
<!-- 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://codepen.io/umurkose/pen/oNPeBxR
- https://tailwindui.com/components/application-ui/data-display/calendars#component-89f285aeebb60ddecb7ec8b5e664d525