diff --git a/README.md b/README.md index 2315391..126d6eb 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Moving an existing shadcn (Radix) app over: see [MIGRATING.md](MIGRATING.md) for | `focus` | `registry:block` | The one loud thing on a page, under its header: a label, a title with a highlight and a row of facts | | `avatar-stack` | `registry:block` | Several people in the space of one; the avatars spread apart when pointed at | | `countdown` | `registry:block` | Time left until a moment, to the minute, with rolling digits | +| `week-calendar` | `registry:block` | One week in 7 columns like a month view of Calendar: all-day events as pills, timed ones with a bar and the start time, today circled; one row per day below `md` | | `claude-code` | `registry:block` | Claude Code's screen in a `terminal` for demos and install guides: welcome, prompts, MCP calls, permission questions, the `/plugins` menus (marketplaces, the plugin list with its search box, plugin details) and `/mcp`; content through props; colors are `claude-*` tokens from Claude Code's dark theme | | `typing` | `registry:lib` | Timed keystrokes for typing animations: a steady pace (100ms a key, about 120 WPM) with jitter; Chinese typed as zhuyin that turns into the character on its tone key; `InputBox` shows the zhuyin with `composing` | | `expand-row` | `registry:block` | A list row that opens in place to show its details | diff --git a/app/_docs/demos/index.ts b/app/_docs/demos/index.ts index 1272f8f..51c1dd8 100644 --- a/app/_docs/demos/index.ts +++ b/app/_docs/demos/index.ts @@ -47,6 +47,7 @@ import { Demo as Sonner } from "@/app/_docs/demos/sonner" import { Demo as FocusDemo } from "@/app/_docs/demos/focus" import { Demo as AvatarStackDemo } from "@/app/_docs/demos/avatar-stack" import { Demo as CountdownDemo } from "@/app/_docs/demos/countdown" +import { Demo as WeekCalendarDemo } from "@/app/_docs/demos/week-calendar" import { Demo as ExpandRowDemo } from "@/app/_docs/demos/expand-row" import { Demo as Chart } from "@/app/_docs/demos/chart" import { Demo as Progress } from "@/app/_docs/demos/progress" @@ -100,6 +101,7 @@ export const demos: Record = { focus: FocusDemo, "avatar-stack": AvatarStackDemo, countdown: CountdownDemo, + "week-calendar": WeekCalendarDemo, "expand-row": ExpandRowDemo, "number-ticker": NumberTicker, progress: Progress, diff --git a/app/_docs/demos/week-calendar.tsx b/app/_docs/demos/week-calendar.tsx new file mode 100644 index 0000000..37f8514 --- /dev/null +++ b/app/_docs/demos/week-calendar.tsx @@ -0,0 +1,39 @@ +import { WeekCalendar } from "@/registry/winlab/blocks/week-calendar/week-calendar" + +const at = (day: string, time: string) => `2026-11-${day}T${time}:00+08:00` + +const events = [ + { title: "Lab Meeting", start: at("02", "15:30"), end: at("02", "17:30") }, + { title: "專題討論", start: at("02", "19:30"), end: at("02", "20:30") }, + { title: "6G 雙週會議", start: at("03", "10:00"), end: at("03", "11:00") }, + { + title: "校慶", + start: "2026-11-04T00:00:00+08:00", + end: "2026-11-05T00:00:00+08:00", + allDay: true, + }, + { title: "電腦動畫與特效", start: at("05", "10:10"), end: at("05", "13:00") }, + { + title: "忙碌", + start: at("05", "15:30"), + end: at("05", "17:20"), + quiet: true, + }, + { + title: "研討會", + start: "2026-11-06T00:00:00+08:00", + end: "2026-11-08T00:00:00+08:00", + allDay: true, + }, +] + +export function Demo() { + return ( + + ) +} diff --git a/app/_docs/docs.ts b/app/_docs/docs.ts index 4d151d8..7eaf237 100644 --- a/app/_docs/docs.ts +++ b/app/_docs/docs.ts @@ -75,6 +75,14 @@ export const docs: Doc[] = [ description: "距離某個時間還有多久,到分鐘,數字會滾動。", item: "countdown", }, + { + group: "區塊", + slug: "week-calendar", + title: "單週行事曆", + description: "一週七天並排,每天列出行程:整天的在上,有時間的在下。", + item: "week-calendar", + layout: "wide", + }, { group: "區塊", slug: "expand-row", diff --git a/deploy/smoke.sh b/deploy/smoke.sh index 4f99dc0..eed5299 100755 --- a/deploy/smoke.sh +++ b/deploy/smoke.sh @@ -6,32 +6,35 @@ set -euo pipefail trap 'docker compose logs' EXIT # A negated test (! cmd) never trips set -e, so absence is checked here. absent() { if grep -qiF "$1"; then echo "unexpected $1 in $2" >&2; exit 1; fi; } +# Read the whole body first: grep -q stops at the first match, and under +# pipefail curl then fails with 23 (write error) on a large page. +has() { local body; body=$(curl -sf "$1"); grep -q -- "$2" <<<"$body"; } printf 'UI_VERSION=ci\n' > .env docker compose up -d --no-build --wait -curl -sf localhost:8080/ | grep -q "WinLab UI" -curl -sf localhost:8080/button | grep -q "npx shadcn@latest add @winlab/button" -curl -sf localhost:8080/colors | grep -q "primary" -curl -sf localhost:8080/r/registry.json | grep -q '"name": "winlab"' -curl -sfI localhost:8080/r/base.json | grep -qi "content-type: application/json" +has localhost:8080/ "WinLab UI" +has localhost:8080/button "npx shadcn@latest add @winlab/button" +has localhost:8080/colors "primary" +has localhost:8080/r/registry.json '"name": "winlab"' +grep -qi "content-type: application/json" <<<"$(curl -sfI localhost:8080/r/base.json)" test "$(curl -s -o /dev/null -w '%{http_code}' localhost:8080/missing)" = 404 -curl -s localhost:8080/missing | grep -q '找不到這個頁面' +grep -q '找不到這個頁面' <<<"$(curl -s localhost:8080/missing)" test "$(curl -s -o /dev/null -w '%{http_code}' localhost:8080/404)" = 404 test "$(curl -s -o /dev/null -w '%{http_code}' localhost:8080/404.html)" = 404 -curl -s localhost:8080/404 | grep -q '找不到這個頁面' -curl -sI localhost:8080/ | absent "server: nginx/" headers +grep -q '找不到這個頁面' <<<"$(curl -s localhost:8080/404)" +absent "server: nginx/" headers <<<"$(curl -sI localhost:8080/)" # Not a sha- version: the corner reads dev, and no placeholder is left. -curl -sf localhost:8080/ | grep -q '>dev<' -curl -sf localhost:8080/ | absent __BUILD_SHA__ / +has localhost:8080/ '>dev<' +absent __BUILD_SHA__ / <<<"$(curl -sf localhost:8080/)" # A release: the corner reads the commit, in the page and in its scripts. docker compose run --rm -d -e UI_VERSION=sha-abc1234 -p 127.0.0.1:8081:8080 web >/dev/null for i in $(seq 1 20); do curl -sf localhost:8081/ >/dev/null && break; sleep 0.5; done -curl -sf localhost:8081/ | grep -q '>abc1234<' -curl -sf localhost:8081/ | absent __BUILD_SHA__ / +has localhost:8081/ '>abc1234<' +absent __BUILD_SHA__ / <<<"$(curl -sf localhost:8081/)" for js in $(curl -sf localhost:8081/ | grep -o '/_next/static/[^"]*\.js' | sort -u); do - curl -sf "localhost:8081$js" | absent __BUILD_SHA__ "$js" + absent __BUILD_SHA__ "$js" <<<"$(curl -sf "localhost:8081$js")" done echo "smoke passed" diff --git a/registry.json b/registry.json index 7d91176..64b3fdb 100644 --- a/registry.json +++ b/registry.json @@ -1293,6 +1293,24 @@ } ] }, + { + "name": "week-calendar", + "type": "registry:block", + "title": "Week Calendar", + "description": "One week of days side by side, each listing its events: all-day ones on top, timed ones under them.", + "dependencies": [ + "cn" + ], + "registryDependencies": [ + "https://ui.winlab.tw/r/utils.json" + ], + "files": [ + { + "path": "registry/winlab/blocks/week-calendar/week-calendar.tsx", + "type": "registry:component" + } + ] + }, { "name": "expand-row", "type": "registry:block", diff --git a/registry/winlab/blocks/week-calendar/week-calendar.tsx b/registry/winlab/blocks/week-calendar/week-calendar.tsx new file mode 100644 index 0000000..222dc8a --- /dev/null +++ b/registry/winlab/blocks/week-calendar/week-calendar.tsx @@ -0,0 +1,151 @@ +import { cn } from "@/registry/winlab/lib/utils" + +export type WeekEvent = { + /** Unique within the week; defaults to start, end and title. */ + id?: string + title: string + start: string | Date + end: string | Date + allDay?: boolean + /** Shown but not the member's to read, such as a busy block. */ + quiet?: boolean +} + +const WEEKDAYS = "日一二三四五六" +const DAY = 24 * 60 * 60 * 1000 + +// Calendar days are YYYY-MM-DD strings, so they compare as text. +const addDays = (day: string, n: number) => + new Date(Date.parse(`${day}T00:00:00Z`) + n * DAY).toISOString().slice(0, 10) +const weekday = (day: string) => new Date(`${day}T00:00:00Z`).getUTCDay() + +// One week of days in columns, each listing its events the way a month view +// of Calendar does: all-day events as pills on top, timed ones under them +// with a bar, the title and the start time (the whole span in the tip). +// Today's date sits in a primary circle. +// Below md the columns fold into one row per day. +function WeekCalendar({ + from, + events, + today, + days = 7, + timeZone = "Asia/Taipei", + className, +}: { + /** The first day, YYYY-MM-DD in timeZone. */ + from: string + events: WeekEvent[] + /** YYYY-MM-DD in timeZone; that day's date is circled. */ + today?: string + days?: number + timeZone?: string + className?: string +}) { + const dayOf = new Intl.DateTimeFormat("en-CA", { timeZone }) + const clock = new Intl.DateTimeFormat("en-GB", { + timeZone, + hour: "2-digit", + minute: "2-digit", + }) + const list = Array.from({ length: days }, (_, i) => addDays(from, i)) + const placed = events.map((event) => { + const start = new Date(event.start) + const end = new Date(event.end) + // An event ending at midnight belongs to the day before. + const last = new Date(Math.max(start.getTime(), end.getTime() - 1)) + return { + ...event, + key: + event.id ?? + `${start.toISOString()}-${end.toISOString()}-${event.title}`, + first: dayOf.format(start), + last: dayOf.format(last), + time: clock.format(start), + span: `${clock.format(start)} 到 ${clock.format(end)}`, + at: start.getTime(), + } + }) + + return ( +
+ {list.map((day, index) => { + const on = placed + .filter((event) => event.first <= day && event.last >= day) + .sort( + (a, b) => Number(!!b.allDay) - Number(!!a.allDay) || a.at - b.at + ) + const date = Number(day.slice(8)) + const label = + index === 0 || date === 1 + ? `${Number(day.slice(5, 7))}/${date}` + : date + return ( +
+
+ + {WEEKDAYS[weekday(day)]} + + + {label} + +
+
    + {on.map((event) => + event.allDay ? ( +
  • + {event.title} +
  • + ) : ( +
  • + + {event.title} + + + {event.time} + +
  • + ) + )} +
+
+ ) + })} +
+ ) +} + +export { WeekCalendar }