Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions app/components/ActivityTicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"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;
};

/**
* 首页活动轮播组件:
* - 读取 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 = 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>
);
}
10 changes: 9 additions & 1 deletion app/components/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Link from "next/link";
import ZoteroFeedLazy from "@/app/components/ZoteroFeedLazy";
import { Contribute } from "@/app/components/Contribute";
import Image from "next/image";
import { ActivityTicker } from "@/app/components/ActivityTicker";

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

return (
<section className="relative">
<div className="container mx-auto px-6 pt-12 pb-0 text-center">
<div className="container relative mx-auto px-6 pt-12 pb-0 text-center">
{/* 首页活动轮播浮窗:桌面端右上角,移动端底部居中 */}
<div className="absolute right-4 top-24 z-20 hidden sm:block w-72 lg:w-80">
<ActivityTicker />
</div>
<div className="absolute left-1/2 bottom-6 z-20 w-[min(90vw,320px)] -translate-x-1/2 sm:hidden">
<ActivityTicker />
</div>
<div className="relative mx-auto max-w-5xl mt-12">
<Image
src="/mascot.webp"
Expand Down
34 changes: 34 additions & 0 deletions app/types/event.ts
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[];
}
3 changes: 3 additions & 0 deletions data/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# 配置文件

这个文件夹用于活动的配置
22 changes: 22 additions & 0 deletions data/event.json
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
}
]
}
15 changes: 15 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ const config = {
hostname: "*.coly.cc",
pathname: "/**",
},
{
protocol: "https",
hostname: "cdn.discordapp.com",
pathname: "/**",
},
{
protocol: "https",
hostname: "media.discordapp.net",
pathname: "/**",
},
{
protocol: "https",
hostname: "placehold.co",
pathname: "/**",
},
],
unoptimized: true,
formats: ["image/avif", "image/webp"],
Expand Down
Binary file added public/event/coffeeChat.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/event/mockInterview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.