-
Notifications
You must be signed in to change notification settings - Fork 43
新增活动浮窗 #219
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
新增活动浮窗 #219
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| "use client"; | ||
|
|
||
| import Image from "next/image"; | ||
| import Link from "next/link"; | ||
| import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| import { ChevronLeft, ChevronRight } from "lucide-react"; | ||
| import eventsData from "@/data/event.json"; | ||
| import type { ActivityEvent, ActivityEventsConfig } from "@/app/types/event"; | ||
| import { cn } from "@/lib/utils"; | ||
|
|
||
| const { | ||
| events: rawEvents, | ||
| settings: { | ||
| maxItems: configuredMaxItems = 3, | ||
| rotationIntervalMs: configuredRotationIntervalMs = 8000, | ||
| }, | ||
| } = eventsData as ActivityEventsConfig; | ||
|
|
||
| // 默认配置,从data/event.json中读取配置 | ||
| const MAX_ITEMS = configuredMaxItems; | ||
| const ROTATION_INTERVAL_MS = configuredRotationIntervalMs; | ||
|
|
||
| /** ActivityTicker 外部传入的样式配置 */ | ||
| type ActivityTickerProps = { | ||
| /** 容器额外类名,用于控制宽度与定位 */ | ||
| className?: string; | ||
| }; | ||
|
|
||
| /** | ||
| * 将 JSON 内的 coverUrl 解析为可供 next/image 使用的绝对路径。 | ||
| * - 保留 http 链接(常见于 Discord CDN) | ||
| * - 将 `./assets/` 前缀映射到 `public/event-assets/` | ||
| * - 其它情况按原值返回,方便未来扩展 | ||
| */ | ||
| function resolveCoverUrl(coverUrl: string): string { | ||
| if (coverUrl.startsWith("http")) { | ||
| return coverUrl; | ||
| } | ||
|
|
||
| if (coverUrl.startsWith("./assets/")) { | ||
| const filename = coverUrl.replace("./assets/", ""); | ||
| return `/event-assets/${filename}`; | ||
| } | ||
|
|
||
| return coverUrl; | ||
| } | ||
|
|
||
| /** | ||
| * 首页活动轮播组件: | ||
| * - 读取 event.json 配置的活动数量 | ||
| * - 自动轮播封面图,顶部指示器支持手动切换 | ||
| * - 底部两个毛玻璃按钮:Discord 永远可见,Playback 仅在 deprecated=true 时显示 | ||
| */ | ||
| export function ActivityTicker({ className }: ActivityTickerProps) { | ||
| // 预处理活动列表,保持初次渲染后的引用稳定 | ||
| const events = useMemo<ActivityEvent[]>(() => { | ||
| return rawEvents.slice(0, MAX_ITEMS); | ||
| }, []); | ||
|
|
||
| // 当前展示的活动索引 | ||
| const [activeIndex, setActiveIndex] = useState(0); | ||
| const totalEvents = events.length; | ||
|
|
||
| useEffect(() => { | ||
| if (totalEvents <= 1) { | ||
| return; | ||
| } | ||
|
|
||
| // 定时轮播,间隔 ROTATION_INTERVAL_MS | ||
| const timer = window.setInterval(() => { | ||
| setActiveIndex((prev) => (prev + 1) % totalEvents); | ||
| }, ROTATION_INTERVAL_MS); | ||
|
|
||
| return () => window.clearInterval(timer); | ||
| }, [totalEvents, activeIndex]); | ||
|
|
||
| const handlePrev = useCallback(() => { | ||
| if (totalEvents <= 1) { | ||
| return; | ||
| } | ||
| setActiveIndex((prev) => (prev - 1 + totalEvents) % totalEvents); | ||
| }, [totalEvents]); | ||
|
|
||
| const handleNext = useCallback(() => { | ||
| if (totalEvents <= 1) { | ||
| return; | ||
| } | ||
| setActiveIndex((prev) => (prev + 1) % totalEvents); | ||
| }, [totalEvents]); | ||
|
|
||
| if (totalEvents === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| const activeEvent = events[activeIndex]; | ||
| const coverSrc = resolveCoverUrl(activeEvent.coverUrl); | ||
| const showPlayback = activeEvent.deprecated && Boolean(activeEvent.playback); | ||
|
|
||
| return ( | ||
| <aside | ||
| className={cn( | ||
| "relative w-full overflow-hidden rounded-2xl border border-border bg-background/70 text-left shadow-md backdrop-blur supports-[backdrop-filter]:bg-background/50", | ||
| className, | ||
| )} | ||
| > | ||
| <div className="group relative aspect-[5/4] w-full overflow-hidden"> | ||
| <Image | ||
| src={coverSrc} | ||
| alt={activeEvent.name} | ||
| fill | ||
| sizes="(min-width: 1024px) 320px, (min-width: 640px) 288px, 90vw" | ||
| priority | ||
| className="object-contain object-top" | ||
| /> | ||
| {/* 下半透明渐变,用于保证文字与按钮对比度 */} | ||
| <div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/70 via-black/30 to-transparent" /> | ||
| {events.length > 1 && ( | ||
| <> | ||
| {/* 多条活动时显示手动切换指示器 */} | ||
| <div className="absolute inset-x-0 top-0 flex justify-end gap-1 p-3"> | ||
| {events.map((event, idx) => ( | ||
| <button | ||
| key={`${event.name}-${idx}`} | ||
| type="button" | ||
| onClick={() => setActiveIndex(idx)} | ||
| aria-label={`切换到 ${event.name}`} | ||
| className={cn( | ||
| "h-1.5 w-6 rounded-full transition-opacity", | ||
| idx === activeIndex | ||
| ? "bg-white/90 opacity-100" | ||
| : "bg-white/40 opacity-60 hover:opacity-85", | ||
| )} | ||
| /> | ||
| ))} | ||
| </div> | ||
| <button | ||
| type="button" | ||
| aria-label="上一条活动" | ||
| onClick={handlePrev} | ||
| className="absolute left-3 top-1/2 z-30 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/35 text-white shadow-sm transition hover:bg-black/55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70" | ||
| > | ||
| <ChevronLeft className="h-4 w-4" /> | ||
| </button> | ||
| <button | ||
| type="button" | ||
| aria-label="下一条活动" | ||
| onClick={handleNext} | ||
| className="absolute right-3 top-1/2 z-30 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/35 text-white shadow-sm transition hover:bg-black/55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70" | ||
| > | ||
| <ChevronRight className="h-4 w-4" /> | ||
| </button> | ||
| </> | ||
| )} | ||
| {/* 底部毛玻璃按钮,根据 deprecated 控制回放按钮可见性 */} | ||
| <div | ||
| className={cn( | ||
| "absolute inset-x-0 bottom-0 top-3/4 z-10 grid border-t border-white/15 bg-white/20 text-sm font-medium text-white shadow-lg backdrop-blur-md", | ||
| showPlayback ? "grid-cols-2" : "grid-cols-1", | ||
| )} | ||
| > | ||
| <Link | ||
| href={activeEvent.discord} | ||
| prefetch={false} | ||
| className="flex h-full items-center justify-center px-3 text-white transition-colors hover:bg-white/25 hover:text-white" | ||
| > | ||
| Discord | ||
| </Link> | ||
| {showPlayback && ( | ||
| <Link | ||
| href={activeEvent.playback as string} | ||
| prefetch={false} | ||
| className="flex h-full items-center justify-center border-l border-white/15 px-3 text-white transition-colors hover:bg-white/25 hover:text-white" | ||
| > | ||
| Playback | ||
| </Link> | ||
| )} | ||
| </div> | ||
| </div> | ||
| </aside> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| /** | ||
| * @description: 活动横幅所需要的类型 | ||
| * @param name 活动名称 | ||
| * @param discord discord活动链接 | ||
| * @param playback 回放链接 | ||
| * @param coverUrl 封面地址 | ||
| * @param deprecated 是否已经结束 | ||
| */ | ||
| export interface ActivityEvent { | ||
| /** 活动名称,用于轮播标题 */ | ||
| name: string; | ||
| /** Discord 活动入口链接 */ | ||
| discord: string; | ||
| /** 活动回放链接,deprecated 为 true 时展示 */ | ||
| playback?: string; | ||
| /** 活动封面,可以是静态资源相对路径或完整 URL */ | ||
| coverUrl: string; | ||
| /** 是否为已结束活动,true 时展示 Playback 按钮 */ | ||
| deprecated: boolean; | ||
| } | ||
|
|
||
| /** 活动轮播可配置参数 */ | ||
| export interface ActivityTickerSettings { | ||
| /** 首屏最多展示的活动数量 */ | ||
| maxItems: number; | ||
| /** 自动轮播的间隔时间(毫秒) */ | ||
| rotationIntervalMs: number; | ||
| } | ||
|
|
||
| /** event.json 的整体结构 */ | ||
| export interface ActivityEventsConfig { | ||
| settings: ActivityTickerSettings; | ||
| events: ActivityEvent[]; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # 配置文件 | ||
|
|
||
| 这个文件夹用于活动的配置 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| { | ||
| "settings": { | ||
| "maxItems": 3, | ||
| "rotationIntervalMs": 8000 | ||
| }, | ||
| "events": [ | ||
| { | ||
| "name": "Mock Interview", | ||
| "discord": "https://discord.gg/QHsjqezfC?event=1430500169299922965", | ||
| "playback": "https://involutionhell.com/docs/jobs/event-keynote/event-takeway", | ||
| "coverUrl": "./event/mockInterview.png", | ||
| "deprecated": true | ||
| }, | ||
| { | ||
| "name": "Coffee Chat", | ||
| "discord": "https://discord.com/invite/8AQZj7sa?event=1432010537402761348", | ||
| "playback": "https://involutionhell.com/docs/jobs/event-keynote/coffee-chat", | ||
| "coverUrl": "./event/coffeeChat.png", | ||
| "deprecated": true | ||
| } | ||
| ] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new ticker reads
coverUrlstrings fromdata/event.jsonsuch as"./event/mockInterview.png", butresolveCoverUrlreturns those values unchanged.next/imagerejects non-remote sources that don’t start with/, so the first render will throwInvalid src prop (./event/mockInterview.png)and no image is displayed. Either the JSON should use absolute/event/...paths or the resolver needs to strip the./prefix before passing the value intoImage.Useful? React with 👍 / 👎.