万圣节&圣诞节皮肤
This commit is contained in:
@@ -17,6 +17,8 @@ import { Bot, Home, MessageSquare } from "lucide-react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { useMemo } from "react";
|
||||
import { bottomNavInnerClass, bottomNavOuterClass } from "@/lib/appShell";
|
||||
import FestivalNavIcon from "@/components/festival/FestivalNavIcon";
|
||||
import { useFestival, type FestivalId } from "@/lib/festival";
|
||||
|
||||
type BottomNavLocale = NonNullable<(typeof localeZh)["bottomNav"]>;
|
||||
|
||||
@@ -24,14 +26,43 @@ interface NavItem {
|
||||
path: string;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
labelKey: keyof BottomNavLocale;
|
||||
/** 各节日替换 lucide 图标的贴纸素材,键为节日 ID。 */
|
||||
festivalIcons?: Partial<Record<FestivalId, string>>;
|
||||
}
|
||||
|
||||
const FORUM_URL = "https://forum.zidoo.tv/index.php";
|
||||
|
||||
const festivalIcon = (festival: FestivalId, name: string) =>
|
||||
`${import.meta.env.BASE_URL}images/festival/${festival}/${name}.png`;
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ path: "/community", icon: MessageSquare, labelKey: "community" },
|
||||
{ path: "/", icon: Home, labelKey: "home" },
|
||||
{ path: "/ai", icon: Bot, labelKey: "myAi" },
|
||||
{
|
||||
path: "/community",
|
||||
icon: MessageSquare,
|
||||
labelKey: "community",
|
||||
festivalIcons: {
|
||||
christmas: festivalIcon("christmas", "bell"),
|
||||
halloween: festivalIcon("halloween", "ghost"),
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
icon: Home,
|
||||
labelKey: "home",
|
||||
festivalIcons: {
|
||||
christmas: festivalIcon("christmas", "santa"),
|
||||
halloween: festivalIcon("halloween", "pumpkin"),
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/ai",
|
||||
icon: Bot,
|
||||
labelKey: "myAi",
|
||||
festivalIcons: {
|
||||
christmas: festivalIcon("christmas", "gift"),
|
||||
halloween: festivalIcon("halloween", "vampire"),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function BottomNav() {
|
||||
@@ -39,6 +70,7 @@ export default function BottomNav() {
|
||||
const isLgUp = useIsLgUp();
|
||||
const { open: aiDrawerOpen, setOpen: setAiDrawerOpen } = useAIDrawer();
|
||||
const { deviceState } = useDevice();
|
||||
const festival = useFestival();
|
||||
|
||||
const navText = useMemo((): BottomNavLocale => {
|
||||
const pack = resolveLocalePack(deviceState?.language);
|
||||
@@ -67,6 +99,7 @@ export default function BottomNav() {
|
||||
(item.path === "/" && location === "/") ||
|
||||
(item.path !== "/" && location.startsWith(item.path));
|
||||
const Icon = item.icon;
|
||||
const festivalSrc = festival ? item.festivalIcons?.[festival] : undefined;
|
||||
|
||||
const inner = (
|
||||
<div className="flex flex-col items-center justify-center relative pt-2.5 pb-1.5 px-3">
|
||||
@@ -92,13 +125,17 @@ export default function BottomNav() {
|
||||
background: isActive ? "rgba(0,255,246,0.1)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
size={isActive ? 20 : 19}
|
||||
className={cn(
|
||||
"transition-all duration-200",
|
||||
isActive ? "text-[#00FFF6]" : "text-white/30"
|
||||
)}
|
||||
/>
|
||||
{festivalSrc ? (
|
||||
<FestivalNavIcon src={festivalSrc} active={isActive} />
|
||||
) : (
|
||||
<Icon
|
||||
size={isActive ? 20 : 19}
|
||||
className={cn(
|
||||
"transition-all duration-200",
|
||||
isActive ? "text-[#00FFF6]" : "text-white/30"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRef, type CSSProperties } from "react";
|
||||
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
|
||||
/** Generous hit area around the thumb for touch. */
|
||||
const THUMB_HIT_RADIUS_PX = 24;
|
||||
@@ -42,6 +42,12 @@ export interface ThumbOnlyRangeSliderProps {
|
||||
onRelease?: (value: number) => void;
|
||||
onDragStart?: () => void;
|
||||
onDragEnd?: () => void;
|
||||
/**
|
||||
* Optional custom thumb (e.g. festival skin). When provided the native round
|
||||
* thumb is hidden and this node is rendered as an overlay centered on the
|
||||
* thumb position; the slider still owns all drag/hit-test logic.
|
||||
*/
|
||||
renderThumb?: () => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,12 +72,28 @@ export default function ThumbOnlyRangeSlider({
|
||||
onRelease,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
renderThumb,
|
||||
}: ThumbOnlyRangeSliderProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const dragRef = useRef<{ pointerId: number; grabOffsetPx: number } | null>(null);
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
// Track width (px) so a custom overlay thumb can be centered exactly like the
|
||||
// native thumb (which insets its center by THUMB_HALF_WIDTH_PX at both ends).
|
||||
const [trackW, setTrackW] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const en of entries) setTrackW(en.contentRect.width);
|
||||
});
|
||||
ro.observe(el);
|
||||
setTrackW(el.getBoundingClientRect().width);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
const thumbCenterPx = trackW > 0 ? thumbCenterXPx(trackW, min, max, value) : null;
|
||||
|
||||
const finishDrag = (pointerId: number | null) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
@@ -133,7 +155,7 @@ export default function ThumbOnlyRangeSlider({
|
||||
max={max}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className="cyan-slider block w-full"
|
||||
className={cn("cyan-slider block w-full", renderThumb && "cyan-slider--no-thumb")}
|
||||
style={{ pointerEvents: "none", touchAction: "none" }}
|
||||
onChange={(e) => {
|
||||
// Only reachable via keyboard — the input no longer receives pointer input.
|
||||
@@ -157,6 +179,19 @@ export default function ThumbOnlyRangeSlider({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{renderThumb && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute top-1/2 z-20"
|
||||
style={
|
||||
thumbCenterPx != null
|
||||
? { left: thumbCenterPx, transform: "translate(-50%, -50%)" }
|
||||
: { visibility: "hidden" }
|
||||
}
|
||||
>
|
||||
{renderThumb()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SRC = `${import.meta.env.BASE_URL}images/festival/christmas/bell.png`;
|
||||
|
||||
/**
|
||||
* Christmas skin: jingle-bell icon that replaces the min/max volume speakers.
|
||||
* `dim` renders the smaller, muted-looking variant used for the min button.
|
||||
*/
|
||||
export default function ChristmasBellIcon({
|
||||
dim = false,
|
||||
className,
|
||||
}: {
|
||||
dim?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="festival-bell-slot" aria-hidden="true">
|
||||
<img
|
||||
src={SRC}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className={cn(
|
||||
"pointer-events-none select-none object-contain",
|
||||
dim ? "festival-bell-dim h-5 w-5 opacity-70" : "festival-bell h-7 w-7",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { memo } from "react";
|
||||
|
||||
const LIGHT_POSITIONS = [4, 17, 30, 43, 56, 69, 82, 95];
|
||||
|
||||
/** 设备卡片的静态圣诞装饰:只占上沿,不接收点击或参与布局。 */
|
||||
export default memo(function ChristmasCardDecoration() {
|
||||
return (
|
||||
<div className="christmas-card-decoration" aria-hidden="true">
|
||||
<svg
|
||||
className="christmas-card-snow christmas-card-snow-left"
|
||||
viewBox="0 0 76 12"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
d="M0 10Q3 0 15 1L65 1Q75 1 76 3Q70 7 60 5Q54 4 49 7Q43 9 36 6Q29 4 23 7Q18 10 13 7Q6 6 0 10Z"
|
||||
fill="#e8eff1"
|
||||
/>
|
||||
<path d="M10 3Q30 1 49 3T69 3" fill="none" stroke="#fff" strokeWidth="1.5" />
|
||||
</svg>
|
||||
<svg
|
||||
className="christmas-card-snow christmas-card-snow-right"
|
||||
viewBox="0 0 60 10"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
d="M0 2Q10 0 21 1L46 1Q56 0 60 8Q54 5 49 7Q42 9 36 6Q29 3 23 6Q17 8 12 5Q5 5 0 2Z"
|
||||
fill="#e8eff1"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="christmas-card-lights">
|
||||
<svg
|
||||
className="christmas-card-wire"
|
||||
viewBox="0 0 100 18"
|
||||
preserveAspectRatio="none"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
d="M0 2Q50 15 100 2"
|
||||
fill="none"
|
||||
stroke="#77623c"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
{LIGHT_POSITIONS.map((position) => {
|
||||
const ratio = position / 100;
|
||||
// 与灯线的二次曲线保持一致;灯泡固定尺寸,不随宽屏拉伸。
|
||||
const top = 2 + 26 * ratio * (1 - ratio);
|
||||
return (
|
||||
<span
|
||||
key={position}
|
||||
className="christmas-card-light"
|
||||
style={{ left: `${position}%`, top }}
|
||||
>
|
||||
<span className="christmas-card-bulb" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
className="christmas-card-sprig"
|
||||
viewBox="0 0 48 44"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
>
|
||||
{/* 松枝与针叶保持小尺寸的辨识度,不覆盖下方旋钮。 */}
|
||||
<g strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M27 13L6 34M28 13L44 32M28 14L29 39" stroke="#567b50" strokeWidth="1.8" />
|
||||
<path
|
||||
d="M23 17L13 15M23 17L25 26M18 22L7 21M18 22L21 32M13 27L4 28M13 27L14 36M9 31L3 33"
|
||||
stroke="#356749"
|
||||
strokeWidth="2.8"
|
||||
/>
|
||||
<path
|
||||
d="M32 18L32 26M32 18L40 17M37 23L37 32M37 23L45 23M41 28L41 35M41 28L47 29M29 23L24 30M29 27L34 34M29 32L25 39"
|
||||
stroke="#43805a"
|
||||
strokeWidth="2.4"
|
||||
/>
|
||||
<path d="M20 19L14 19M15 24L9 25M34 21L39 21M29 30L30 36" stroke="#8da57a" strokeWidth="1" />
|
||||
</g>
|
||||
{/* 红丝带的两翼、飘带与结心均为静态。 */}
|
||||
<path d="M25 14L18 29L18 23L13 24L22 12M28 14L37 26L31 24L30 30L25 16" fill="#a92f39" stroke="#71242b" strokeWidth="1" />
|
||||
<path d="M24 13C17 3 9 3 11 11C12 18 19 18 24 13Z" fill="#d3484f" stroke="#8f2933" strokeWidth="1.2" />
|
||||
<path d="M27 13C30 3 40 2 40 10C39 17 33 17 27 13Z" fill="#c73a46" stroke="#8f2933" strokeWidth="1.2" />
|
||||
<path d="M14 8Q17 7 21 12M30 11Q35 6 37 8" stroke="#ef8b83" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<rect x="22" y="10" width="8" height="8" rx="3" fill="#e45b5b" stroke="#962b35" strokeWidth="1" />
|
||||
<circle cx="23" cy="26" r="2.1" fill="#d95150" />
|
||||
<circle cx="27" cy="29" r="1.7" fill="#b83240" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { memo } from "react";
|
||||
|
||||
const SRC = `${import.meta.env.BASE_URL}images/festival/christmas/santa.png`;
|
||||
|
||||
/**
|
||||
* Christmas skin: running-Santa overlay that replaces the volume slider thumb.
|
||||
* Positioning is handled by ThumbOnlyRangeSlider (renderThumb); this component
|
||||
* only supplies the artwork + running animation.
|
||||
*/
|
||||
export default memo(function ChristmasVolumeThumb() {
|
||||
return (
|
||||
<img
|
||||
src={SRC}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="festival-santa-thumb pointer-events-none select-none"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { FESTIVAL_GREETINGS, type FestivalId } from "@/lib/festival";
|
||||
import "./festival.css";
|
||||
|
||||
/**
|
||||
* VU 区的静态插画横幅:两端角色保持比例,中央背景平铺,文字独立缩放。
|
||||
* 按容器宽度切换横排/双行,保留顶部设备信息;不参与布局、不拦截点击。
|
||||
*/
|
||||
export default function FestivalGreeting({
|
||||
festival,
|
||||
}: {
|
||||
festival: FestivalId;
|
||||
}) {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const textBoxRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const artworkRoot = `${import.meta.env.BASE_URL}images/festival/${festival}/`;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const box = boxRef.current;
|
||||
const textBox = textBoxRef.current;
|
||||
const inner = innerRef.current;
|
||||
if (!box || !textBox || !inner) return;
|
||||
const status = box.parentElement?.querySelector<HTMLElement>(
|
||||
".festival-vu-status"
|
||||
);
|
||||
let disposed = false;
|
||||
const fit = () => {
|
||||
if (disposed) return;
|
||||
// 先应用排版再测量;使用容器宽度,兼容横屏、分屏及 legacy。
|
||||
box.dataset.layout = box.clientWidth >= 420 ? "wide" : "compact";
|
||||
// 极窄屏的格式和音量分成两行,标题跟随实际状态栏高度下移。
|
||||
textBox.style.top = `${Math.max(17, status?.offsetHeight ?? 0) + 2}px`;
|
||||
const bw = textBox.clientWidth - 8;
|
||||
const bh = textBox.clientHeight - 4;
|
||||
const tw = inner.offsetWidth;
|
||||
const th = inner.offsetHeight;
|
||||
if (bw <= 0 || bh <= 0 || tw <= 0 || th <= 0) return;
|
||||
setScale(Math.min(bw / tw, bh / th));
|
||||
};
|
||||
fit();
|
||||
const ro =
|
||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(fit);
|
||||
ro?.observe(box);
|
||||
ro?.observe(inner);
|
||||
if (status) ro?.observe(status);
|
||||
window.addEventListener("resize", fit);
|
||||
// 字体就绪后重新测量,避免初次回退字体导致文字越界。
|
||||
void document.fonts?.ready.then(fit);
|
||||
return () => {
|
||||
disposed = true;
|
||||
ro?.disconnect();
|
||||
window.removeEventListener("resize", fit);
|
||||
};
|
||||
}, [festival]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={boxRef}
|
||||
className={`festival-greeting festival-greeting--${festival}`}
|
||||
role="img"
|
||||
aria-label={FESTIVAL_GREETINGS[festival]}
|
||||
>
|
||||
<div
|
||||
className="festival-greeting-backdrop"
|
||||
style={{ backgroundImage: `url("${artworkRoot}banner-center.jpg")` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{(["left", "right"] as const).map(side => (
|
||||
<img
|
||||
key={side}
|
||||
src={`${artworkRoot}banner-${side}.jpg`}
|
||||
className={`festival-greeting-art festival-greeting-art--${side}`}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
/>
|
||||
))}
|
||||
<div className="festival-greeting-shade" aria-hidden="true" />
|
||||
<div
|
||||
ref={textBoxRef}
|
||||
className="festival-greeting-title"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
ref={innerRef}
|
||||
className={`${festival}-greeting-text festival-greeting-inner`}
|
||||
style={{ transform: `scale(${scale})` }}
|
||||
>
|
||||
{FESTIVAL_GREETINGS[festival].split(" ").map(word => (
|
||||
<span key={word} className="festival-greeting-line">
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import "./festival.css";
|
||||
|
||||
/**
|
||||
* 底部导航栏的节日贴纸图标:只替换 lucide 图标的视觉,尺寸与原图标一致(22px),
|
||||
* 选中态用青色辉光表达、未选中态压暗降饱和,不改变导航布局与点击行为。
|
||||
*/
|
||||
export default function FestivalNavIcon({
|
||||
src,
|
||||
active = false,
|
||||
}: {
|
||||
src: string;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"festival-nav-icon pointer-events-none select-none",
|
||||
active ? "festival-nav-icon-active" : "festival-nav-icon-idle",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { memo } from "react";
|
||||
|
||||
const BAT_POSITIONS = [8, 24, 40, 56, 72, 88];
|
||||
|
||||
/** 设备卡片的静态万圣节装饰:两角蜘蛛网、倒挂蝙蝠串、右上黑猫;只占上沿,不接收点击或参与布局。 */
|
||||
export default memo(function HalloweenCardDecoration() {
|
||||
return (
|
||||
<div className="halloween-card-decoration" aria-hidden="true">
|
||||
<svg
|
||||
className="halloween-card-web halloween-card-web-left"
|
||||
viewBox="0 0 34 30"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
>
|
||||
<g stroke="#d7dee0" strokeOpacity="0.5" strokeWidth="0.8">
|
||||
<path d="M0 0L34 6M0 0L30 15M0 0L20 25M0 0L8 30" />
|
||||
<path d="M12 0Q11.2 5.6 7.6 9Q4.4 12 0 13" />
|
||||
<path d="M22 0Q20.8 10.4 13.8 16.8Q7.6 22.4 0 24" />
|
||||
<path d="M32 0Q30.4 15.2 20 24.6Q10.8 32.8 0 34" />
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
className="halloween-card-web halloween-card-web-right"
|
||||
viewBox="0 0 34 30"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
>
|
||||
<g stroke="#d7dee0" strokeOpacity="0.5" strokeWidth="0.8">
|
||||
<path d="M0 0L34 6M0 0L30 15M0 0L20 25M0 0L8 30" />
|
||||
<path d="M12 0Q11.2 5.6 7.6 9Q4.4 12 0 13" />
|
||||
<path d="M22 0Q20.8 10.4 13.8 16.8Q7.6 22.4 0 24" />
|
||||
<path d="M32 0Q30.4 15.2 20 24.6Q10.8 32.8 0 34" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div className="halloween-card-bats">
|
||||
<svg
|
||||
className="halloween-card-wire"
|
||||
viewBox="0 0 100 18"
|
||||
preserveAspectRatio="none"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
d="M0 2Q50 15 100 2"
|
||||
fill="none"
|
||||
stroke="#4d4358"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
{BAT_POSITIONS.map((position) => {
|
||||
const ratio = position / 100;
|
||||
// 与挂线的二次曲线保持一致;蝙蝠固定尺寸,不随宽屏拉伸。
|
||||
const top = 2 + 26 * ratio * (1 - ratio);
|
||||
return (
|
||||
<span
|
||||
key={position}
|
||||
className="halloween-card-bat"
|
||||
style={{ left: `${position}%`, top }}
|
||||
>
|
||||
<svg viewBox="0 0 10 5" fill="#6d5a80" focusable="false">
|
||||
<path d="M0 2.6Q1.8 0.4 3.4 1.8Q4.2 0.6 5 1.6Q5.8 0.6 6.6 1.8Q8.2 0.4 10 2.6Q8.4 2.4 7.4 3.6Q6.4 4.8 5 4.6Q3.6 4.8 2.6 3.6Q1.6 2.4 0 2.6Z" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
className="halloween-card-cat"
|
||||
viewBox="0 0 40 36"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
>
|
||||
{/* 黑猫剪影:暗底上用细描边勾轮廓,琥珀色眼睛点睛。 */}
|
||||
<g fill="#101114" stroke="rgba(255,255,255,0.28)" strokeWidth="0.8">
|
||||
<path d="M15 7L13.5 1.5L18.5 4.5Z" />
|
||||
<path d="M25 7L26.5 1.5L21.5 4.5Z" />
|
||||
<circle cx="20" cy="10" r="6" />
|
||||
<path d="M13 32Q12 18 16 14Q20 12 24 14Q28 18 27 32Z" />
|
||||
</g>
|
||||
<path
|
||||
d="M27 30Q34 28 33 20Q33 16 30 15"
|
||||
stroke="#101114"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M27 30Q34 28 33 20Q33 16 30 15"
|
||||
stroke="rgba(255,255,255,0.22)"
|
||||
strokeWidth="0.8"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path d="M16.6 9.6Q18.1 8.2 19.6 9.6Q18.1 11 16.6 9.6Z" fill="#ffb347" />
|
||||
<path d="M20.6 9.6Q22.1 8.2 23.6 9.6Q22.1 11 20.6 9.6Z" fill="#ffb347" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SRC = `${import.meta.env.BASE_URL}images/festival/halloween/pumpkin.png`;
|
||||
|
||||
/** 万圣节音量两端的南瓜灯:与铃铛一样固定 24px 占位,视觉尺寸独立于布局。 */
|
||||
export default function HalloweenPumpkinIcon({
|
||||
dim = false,
|
||||
className,
|
||||
}: {
|
||||
dim?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="halloween-pumpkin-slot" aria-hidden="true">
|
||||
<img
|
||||
src={SRC}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className={cn(
|
||||
"pointer-events-none select-none object-contain",
|
||||
dim ? "halloween-pumpkin-dim h-5 w-5 opacity-70" : "halloween-pumpkin h-7 w-7",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { memo } from "react";
|
||||
|
||||
const SRC = `${import.meta.env.BASE_URL}images/festival/halloween/witch.png`;
|
||||
|
||||
/** 万圣节音量条 thumb:骑扫帚的巫婆静态贴图,与圣诞老人 thumb 同尺寸。 */
|
||||
export default memo(function HalloweenVolumeThumb() {
|
||||
return (
|
||||
<img
|
||||
src={SRC}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="halloween-witch-thumb pointer-events-none select-none"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/* modern 与 legacy 共用;只装饰圣诞组件,不改变音量条的几何与交互。 */
|
||||
.cyan-slider.cyan-slider--no-thumb::-webkit-slider-thumb {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cyan-slider.cyan-slider--no-thumb::-moz-range-thumb {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.festival-santa-thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.festival-bell {
|
||||
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.festival-bell-dim {
|
||||
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5)) saturate(0.7);
|
||||
}
|
||||
|
||||
/* 占位与原喇叭保持 24px 一致,大小铃铛仅在视觉层变化。 */
|
||||
.festival-bell-slot {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.festival-bell-slot > img {
|
||||
position: absolute;
|
||||
max-width: none;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* 装饰层限于卡片顶部;所有子元素穿透点击且不进入辅助技术阅读顺序。 */
|
||||
.christmas-card-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 42px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.christmas-card-decoration * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.christmas-card-snow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.christmas-card-snow-left {
|
||||
left: 3px;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.christmas-card-snow-right {
|
||||
right: 3px;
|
||||
width: 54px;
|
||||
}
|
||||
|
||||
/* 圣诞横幅:墨绿雪景中的暖金手写花体。 */
|
||||
.festival-greeting--christmas {
|
||||
background: #061a13;
|
||||
}
|
||||
|
||||
.christmas-greeting-text {
|
||||
font-family: "Snell Roundhand", "Savoye LET", "Segoe Script", "Brush Script MT", cursive;
|
||||
font-size: 64px;
|
||||
font-style: italic;
|
||||
letter-spacing: 0.5px;
|
||||
color: #ffe6a0;
|
||||
text-shadow:
|
||||
0 0 2px #fff6d5,
|
||||
0 0 6px rgba(255, 191, 63, 0.9),
|
||||
0 0 15px rgba(228, 139, 30, 0.65),
|
||||
0 2px 2px #63310b;
|
||||
}
|
||||
|
||||
.christmas-card-lights {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 18px;
|
||||
right: 46px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.christmas-card-wire {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.christmas-card-light {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 3px;
|
||||
background: #8c744b;
|
||||
}
|
||||
|
||||
.christmas-card-bulb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: -2px;
|
||||
width: 5px;
|
||||
height: 7px;
|
||||
border-radius: 2px 2px 50% 50%;
|
||||
background: radial-gradient(ellipse at 45% 35%, #fff8df, #f3c56d 65%, #c18b3e);
|
||||
box-shadow: 0 0 5px 1px rgba(245, 192, 92, 0.3);
|
||||
}
|
||||
|
||||
.christmas-card-sprig {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 2px;
|
||||
width: 44px;
|
||||
height: 40px;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/* modern / legacy 共用的静态横幅与导航贴纸;不改变设备卡片几何。 */
|
||||
.festival-greeting {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.festival-greeting *,
|
||||
.festival-vu-status {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 状态信息始终盖在插画上,顶部暗色渐变保证小字可读。 */
|
||||
.festival-vu-status {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-wrap: wrap;
|
||||
column-gap: 4px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
background: linear-gradient(rgba(0, 0, 0, 0.72), transparent);
|
||||
text-shadow: 0 1px 3px #000;
|
||||
}
|
||||
|
||||
.festival-vu-status > span {
|
||||
flex: none;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: rgba(255, 250, 224, 0.88);
|
||||
}
|
||||
|
||||
.festival-vu-status > span + span {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.festival-greeting-backdrop,
|
||||
.festival-greeting-shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.festival-greeting-backdrop {
|
||||
background-size: auto 100%;
|
||||
background-position: center;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
|
||||
/* 两侧插画不随横屏宽度拉伸;渐隐接缝融入中央夜空。 */
|
||||
.festival-greeting-art {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 150px;
|
||||
max-width: 50%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.festival-greeting-art--left {
|
||||
left: 0;
|
||||
object-position: left center;
|
||||
-webkit-mask-image: linear-gradient(to right, #000 65%, transparent);
|
||||
mask-image: linear-gradient(to right, #000 65%, transparent);
|
||||
}
|
||||
|
||||
.festival-greeting-art--right {
|
||||
right: 0;
|
||||
object-position: right center;
|
||||
-webkit-mask-image: linear-gradient(to left, #000 65%, transparent);
|
||||
mask-image: linear-gradient(to left, #000 65%, transparent);
|
||||
}
|
||||
|
||||
.festival-greeting-shade {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(0, 0, 0, 0.38) 40%,
|
||||
rgba(0, 0, 0, 0.38) 60%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.festival-greeting-title {
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
bottom: 5px;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 自然宽度测量不受 flex 收缩影响,缩放只作用于文字。 */
|
||||
.festival-greeting-inner {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1.05;
|
||||
white-space: nowrap;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.festival-greeting-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 以 VU 容器宽度判断横排,不依赖视口媒体查询或容器查询支持。 */
|
||||
.festival-greeting[data-layout="wide"] .festival-greeting-art {
|
||||
max-width: 30%;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.festival-greeting[data-layout="wide"] .festival-greeting-title {
|
||||
left: min(26%, 150px);
|
||||
right: min(26%, 150px);
|
||||
}
|
||||
|
||||
.festival-greeting[data-layout="wide"] .festival-greeting-inner {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.festival-greeting[data-layout="wide"]
|
||||
.festival-greeting-line
|
||||
+ .festival-greeting-line {
|
||||
margin-left: 0.22em;
|
||||
}
|
||||
|
||||
/* 导航贴纸与 lucide 图标同一 22px 视觉尺寸。 */
|
||||
.festival-nav-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
max-width: none;
|
||||
object-fit: contain;
|
||||
transition:
|
||||
filter 0.2s ease,
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.festival-nav-icon-active {
|
||||
transform: scale(1.08);
|
||||
filter: drop-shadow(0 0 6px rgba(0, 255, 246, 0.65));
|
||||
}
|
||||
|
||||
.festival-nav-icon-idle {
|
||||
opacity: 0.55;
|
||||
filter: saturate(0.75);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/* modern 与 legacy 共用;只装饰万圣节组件,不改变音量条的几何与交互。 */
|
||||
/* 万圣节横幅:紫黑夜空中的南瓜火光字。 */
|
||||
.festival-greeting--halloween {
|
||||
background: #120b1c;
|
||||
}
|
||||
|
||||
.halloween-greeting-text {
|
||||
font-family: "Chiller", "Trattatello", "Herculanum", "Papyrus", fantasy;
|
||||
font-size: 64px;
|
||||
letter-spacing: 2px;
|
||||
color: #ff9b32;
|
||||
text-shadow:
|
||||
0 -1px 1px #ffe294,
|
||||
0 1px 2px #9b2609,
|
||||
0 0 6px rgba(255, 104, 16, 0.85),
|
||||
0 -3px 14px rgba(232, 65, 8, 0.7),
|
||||
0 2px 3px #170407;
|
||||
}
|
||||
|
||||
.halloween-witch-thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.halloween-pumpkin {
|
||||
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.halloween-pumpkin-dim {
|
||||
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5)) saturate(0.7);
|
||||
}
|
||||
|
||||
/* 占位与原喇叭保持 24px 一致,大小南瓜仅在视觉层变化。 */
|
||||
.halloween-pumpkin-slot {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.halloween-pumpkin-slot > img {
|
||||
position: absolute;
|
||||
max-width: none;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* 装饰层限于卡片顶部;所有子元素穿透点击且不进入辅助技术阅读顺序。 */
|
||||
.halloween-card-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 42px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.halloween-card-decoration * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.halloween-card-web {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 34px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.halloween-card-web-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.halloween-card-web-right {
|
||||
right: 0;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.halloween-card-bats {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 40px;
|
||||
right: 46px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.halloween-card-wire {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* 1px 吊线 + 固定尺寸蝙蝠剪影。 */
|
||||
.halloween-card-bat {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 2px;
|
||||
background: #4d4358;
|
||||
}
|
||||
|
||||
.halloween-card-bat > svg {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: -4.5px;
|
||||
width: 10px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.halloween-card-cat {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 2px;
|
||||
width: 40px;
|
||||
height: 36px;
|
||||
}
|
||||
Reference in New Issue
Block a user