Skip to content

Commit 97d7520

Browse files
authored
Merge pull request #219 from InvolutionHell/activity
新增活动浮窗
2 parents 7a3d095 + 4ad0d9e commit 97d7520

File tree

8 files changed

+245
-1
lines changed

8 files changed

+245
-1
lines changed

app/components/ActivityTicker.tsx

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"use client";
2+
3+
import Image from "next/image";
4+
import Link from "next/link";
5+
import { useCallback, useEffect, useMemo, useState } from "react";
6+
import { ChevronLeft, ChevronRight } from "lucide-react";
7+
import eventsData from "@/data/event.json";
8+
import type { ActivityEvent, ActivityEventsConfig } from "@/app/types/event";
9+
import { cn } from "@/lib/utils";
10+
11+
const {
12+
events: rawEvents,
13+
settings: {
14+
maxItems: configuredMaxItems = 3,
15+
rotationIntervalMs: configuredRotationIntervalMs = 8000,
16+
},
17+
} = eventsData as ActivityEventsConfig;
18+
19+
// 默认配置,从data/event.json中读取配置
20+
const MAX_ITEMS = configuredMaxItems;
21+
const ROTATION_INTERVAL_MS = configuredRotationIntervalMs;
22+
23+
/** ActivityTicker 外部传入的样式配置 */
24+
type ActivityTickerProps = {
25+
/** 容器额外类名,用于控制宽度与定位 */
26+
className?: string;
27+
};
28+
29+
/**
30+
* 首页活动轮播组件:
31+
* - 读取 event.json 配置的活动数量
32+
* - 自动轮播封面图,顶部指示器支持手动切换
33+
* - 底部两个毛玻璃按钮:Discord 永远可见,Playback 仅在 deprecated=true 时显示
34+
*/
35+
export function ActivityTicker({ className }: ActivityTickerProps) {
36+
// 预处理活动列表,保持初次渲染后的引用稳定
37+
const events = useMemo<ActivityEvent[]>(() => {
38+
return rawEvents.slice(0, MAX_ITEMS);
39+
}, []);
40+
41+
// 当前展示的活动索引
42+
const [activeIndex, setActiveIndex] = useState(0);
43+
const totalEvents = events.length;
44+
45+
useEffect(() => {
46+
if (totalEvents <= 1) {
47+
return;
48+
}
49+
50+
// 定时轮播,间隔 ROTATION_INTERVAL_MS
51+
const timer = window.setInterval(() => {
52+
setActiveIndex((prev) => (prev + 1) % totalEvents);
53+
}, ROTATION_INTERVAL_MS);
54+
55+
return () => window.clearInterval(timer);
56+
}, [totalEvents, activeIndex]);
57+
58+
const handlePrev = useCallback(() => {
59+
if (totalEvents <= 1) {
60+
return;
61+
}
62+
setActiveIndex((prev) => (prev - 1 + totalEvents) % totalEvents);
63+
}, [totalEvents]);
64+
65+
const handleNext = useCallback(() => {
66+
if (totalEvents <= 1) {
67+
return;
68+
}
69+
setActiveIndex((prev) => (prev + 1) % totalEvents);
70+
}, [totalEvents]);
71+
72+
if (totalEvents === 0) {
73+
return null;
74+
}
75+
76+
const activeEvent = events[activeIndex];
77+
const coverSrc = activeEvent.coverUrl;
78+
const showPlayback = activeEvent.deprecated && Boolean(activeEvent.playback);
79+
80+
return (
81+
<aside
82+
className={cn(
83+
"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",
84+
className,
85+
)}
86+
>
87+
<div className="group relative aspect-[5/4] w-full overflow-hidden">
88+
<Image
89+
src={coverSrc}
90+
alt={activeEvent.name}
91+
fill
92+
sizes="(min-width: 1024px) 320px, (min-width: 640px) 288px, 90vw"
93+
priority
94+
className="object-contain object-top"
95+
/>
96+
{/* 下半透明渐变,用于保证文字与按钮对比度 */}
97+
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
98+
{events.length > 1 && (
99+
<>
100+
{/* 多条活动时显示手动切换指示器 */}
101+
<div className="absolute inset-x-0 top-0 flex justify-end gap-1 p-3">
102+
{events.map((event, idx) => (
103+
<button
104+
key={`${event.name}-${idx}`}
105+
type="button"
106+
onClick={() => setActiveIndex(idx)}
107+
aria-label={`切换到 ${event.name}`}
108+
className={cn(
109+
"h-1.5 w-6 rounded-full transition-opacity",
110+
idx === activeIndex
111+
? "bg-white/90 opacity-100"
112+
: "bg-white/40 opacity-60 hover:opacity-85",
113+
)}
114+
/>
115+
))}
116+
</div>
117+
<button
118+
type="button"
119+
aria-label="上一条活动"
120+
onClick={handlePrev}
121+
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"
122+
>
123+
<ChevronLeft className="h-4 w-4" />
124+
</button>
125+
<button
126+
type="button"
127+
aria-label="下一条活动"
128+
onClick={handleNext}
129+
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"
130+
>
131+
<ChevronRight className="h-4 w-4" />
132+
</button>
133+
</>
134+
)}
135+
{/* 底部毛玻璃按钮,根据 deprecated 控制回放按钮可见性 */}
136+
<div
137+
className={cn(
138+
"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",
139+
showPlayback ? "grid-cols-2" : "grid-cols-1",
140+
)}
141+
>
142+
<Link
143+
href={activeEvent.discord}
144+
prefetch={false}
145+
className="flex h-full items-center justify-center px-3 text-white transition-colors hover:bg-white/25 hover:text-white"
146+
>
147+
Discord
148+
</Link>
149+
{showPlayback && (
150+
<Link
151+
href={activeEvent.playback as string}
152+
prefetch={false}
153+
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"
154+
>
155+
Playback
156+
</Link>
157+
)}
158+
</div>
159+
</div>
160+
</aside>
161+
);
162+
}

app/components/Hero.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Link from "next/link";
22
import ZoteroFeedLazy from "@/app/components/ZoteroFeedLazy";
33
import { Contribute } from "@/app/components/Contribute";
44
import Image from "next/image";
5+
import { ActivityTicker } from "@/app/components/ActivityTicker";
56

67
export function Hero() {
78
const categories: { title: string; desc: string; href: string }[] = [
@@ -29,7 +30,14 @@ export function Hero() {
2930

3031
return (
3132
<section className="relative">
32-
<div className="container mx-auto px-6 pt-12 pb-0 text-center">
33+
<div className="container relative mx-auto px-6 pt-12 pb-0 text-center">
34+
{/* 首页活动轮播浮窗:桌面端右上角,移动端底部居中 */}
35+
<div className="absolute right-4 top-24 z-20 hidden sm:block w-72 lg:w-80">
36+
<ActivityTicker />
37+
</div>
38+
<div className="absolute left-1/2 bottom-6 z-20 w-[min(90vw,320px)] -translate-x-1/2 sm:hidden">
39+
<ActivityTicker />
40+
</div>
3341
<div className="relative mx-auto max-w-5xl mt-12">
3442
<Image
3543
src="/mascot.webp"

app/types/event.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @description: 活动横幅所需要的类型
3+
* @param name 活动名称
4+
* @param discord discord活动链接
5+
* @param playback 回放链接
6+
* @param coverUrl 封面地址
7+
* @param deprecated 是否已经结束
8+
*/
9+
export interface ActivityEvent {
10+
/** 活动名称,用于轮播标题 */
11+
name: string;
12+
/** Discord 活动入口链接 */
13+
discord: string;
14+
/** 活动回放链接,deprecated 为 true 时展示 */
15+
playback?: string;
16+
/** 活动封面,可以是静态资源相对路径或完整 URL */
17+
coverUrl: string;
18+
/** 是否为已结束活动,true 时展示 Playback 按钮 */
19+
deprecated: boolean;
20+
}
21+
22+
/** 活动轮播可配置参数 */
23+
export interface ActivityTickerSettings {
24+
/** 首屏最多展示的活动数量 */
25+
maxItems: number;
26+
/** 自动轮播的间隔时间(毫秒) */
27+
rotationIntervalMs: number;
28+
}
29+
30+
/** event.json 的整体结构 */
31+
export interface ActivityEventsConfig {
32+
settings: ActivityTickerSettings;
33+
events: ActivityEvent[];
34+
}

data/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# 配置文件
2+
3+
这个文件夹用于活动的配置

data/event.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"settings": {
3+
"maxItems": 3,
4+
"rotationIntervalMs": 8000
5+
},
6+
"events": [
7+
{
8+
"name": "Mock Interview",
9+
"discord": "https://discord.gg/QHsjqezfC?event=1430500169299922965",
10+
"playback": "https://involutionhell.com/docs/jobs/event-keynote/event-takeway",
11+
"coverUrl": "./event/mockInterview.png",
12+
"deprecated": true
13+
},
14+
{
15+
"name": "Coffee Chat",
16+
"discord": "https://discord.com/invite/8AQZj7sa?event=1432010537402761348",
17+
"playback": "https://involutionhell.com/docs/jobs/event-keynote/coffee-chat",
18+
"coverUrl": "./event/coffeeChat.png",
19+
"deprecated": true
20+
}
21+
]
22+
}

next.config.mjs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ const config = {
4646
hostname: "*.coly.cc",
4747
pathname: "/**",
4848
},
49+
{
50+
protocol: "https",
51+
hostname: "cdn.discordapp.com",
52+
pathname: "/**",
53+
},
54+
{
55+
protocol: "https",
56+
hostname: "media.discordapp.net",
57+
pathname: "/**",
58+
},
59+
{
60+
protocol: "https",
61+
hostname: "placehold.co",
62+
pathname: "/**",
63+
},
4964
],
5065
unoptimized: true,
5166
formats: ["image/avif", "image/webp"],

public/event/coffeeChat.png

200 KB
Loading

public/event/mockInterview.png

1.43 MB
Loading

0 commit comments

Comments
 (0)