From 3e671ccbe041448f4ecb89636e6bd5c2d7b44232 Mon Sep 17 00:00:00 2001 From: Mackie Date: Mon, 1 Jun 2026 18:20:52 +0800 Subject: [PATCH] styles --- src/components/HeroBackground.tsx | 277 +++++++++++------------------- src/heros/LowImpact/index.tsx | 32 +++- src/heros/config.ts | 22 +++ src/payload-types.ts | 112 ++++++------ 4 files changed, 212 insertions(+), 231 deletions(-) diff --git a/src/components/HeroBackground.tsx b/src/components/HeroBackground.tsx index c7e581e..5609598 100644 --- a/src/components/HeroBackground.tsx +++ b/src/components/HeroBackground.tsx @@ -1,122 +1,64 @@ -"use client"; +'use client' import { useThemeMode } from '@/hooks/useThemeMode' -import { useEffect, useRef } from "react"; +import { useEffect, useRef } from 'react' -const STAR_TONES = [255, 210, 160, 110, 65]; -const WAVE_TONES = [55, 90, 125, 165, 205]; +const PARTICLE_COUNT = 120 -const WAVE_CONFIGS = [ - { amp: 26, freq: 0.011, speed: 0.35, phase: 0.0, yBase: 0.38, width: 1.6, toneIdx: 0 }, - { amp: 20, freq: 0.015, speed: 0.50, phase: 1.3, yBase: 0.52, width: 1.8, toneIdx: 1 }, - { amp: 30, freq: 0.008, speed: 0.28, phase: 2.6, yBase: 0.63, width: 2.2, toneIdx: 2 }, - { amp: 16, freq: 0.019, speed: 0.65, phase: 0.8, yBase: 0.74, width: 2.0, toneIdx: 3 }, - { amp: 22, freq: 0.013, speed: 0.42, phase: 3.2, yBase: 0.85, width: 2.6, toneIdx: 4 }, -]; - -interface Star { - x: number; y: number; r: number; - v: number; baseAlpha: number; - phase: number; twinkleSpeed: number; - vx: number; vy: number; +interface Particle { + x: number + y: number + r: number + vx: number + vy: number + alpha: number + phase: number + twinkleSpeed: number } -interface Wave { - amp: number; freq: number; speed: number; - phase: number; yBase: number; width: number; - v: number; +function initParticles(W: number, H: number): Particle[] { + return Array.from({ length: PARTICLE_COUNT }, () => ({ + x: Math.random() * W, + y: Math.random() * H, + r: 0.5 + Math.random() * 2.0, + vx: (Math.random() - 0.5) * 0.25, + vy: (Math.random() - 0.5) * 0.15, + alpha: 0.15 + Math.random() * 0.35, + phase: Math.random() * Math.PI * 2, + twinkleSpeed: 0.4 + Math.random() * 1.2, + })) } -function initStars(W: number, H: number): Star[] { - return Array.from({ length: 180 }, () => { - const toneIdx = Math.floor(Math.random() * STAR_TONES.length); - return { - x: Math.random() * W, - y: Math.random() * H, - r: 0.4 + Math.random() * 1.6, - v: STAR_TONES[toneIdx], - baseAlpha: 0.5 + toneIdx * 0.1, - phase: Math.random() * Math.PI * 2, - twinkleSpeed: 0.8 + Math.random() * 2.0, - vx: (Math.random() - 0.5) * 0.3, - vy: (Math.random() - 0.5) * 0.15, - }; - }); -} +function drawParticles( + ctx: CanvasRenderingContext2D, + particles: Particle[], + W: number, + H: number, + isDark: boolean, +) { + const now = Date.now() / 1000 + // dark mode: light particles on dark bg; light mode: mid-gray particles on light bg + const tone = isDark ? 220 : 140 -function initWaves(): Wave[] { - return WAVE_CONFIGS.map(c => ({ ...c, v: WAVE_TONES[c.toneIdx] })); -} + for (const p of particles) { + p.x += p.vx + p.y += p.vy + if (p.x < 0) p.x = W + if (p.x > W) p.x = 0 + if (p.y < 0) p.y = H + if (p.y > H) p.y = 0 -function drawStars(ctx: CanvasRenderingContext2D, stars: Star[], W: number, H: number, ga: number) { - const now = Date.now() / 1000; - for (const s of stars) { - s.x += s.vx; s.y += s.vy; - if (s.x < 0) s.x = W; if (s.x > W) s.x = 0; - if (s.y < 0) s.y = H; if (s.y > H) s.y = 0; - const twinkle = 0.35 + 0.65 * ((Math.sin(now * s.twinkleSpeed + s.phase) + 1) / 2); - ctx.beginPath(); - ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); - ctx.fillStyle = `rgba(${s.v},${s.v},${s.v},${s.baseAlpha * twinkle * ga})`; - ctx.fill(); + const twinkle = 0.4 + 0.6 * ((Math.sin(now * p.twinkleSpeed + p.phase) + 1) / 2) + ctx.beginPath() + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2) + ctx.fillStyle = `rgba(${tone},${tone},${tone},${p.alpha * twinkle})` + ctx.fill() } } -function buildWavePts(w: Wave, W: number, H: number, now: number) { - const pts: { x: number; y: number }[] = []; - for (let x = 0; x <= W; x += 8) { - pts.push({ x, y: H * w.yBase + Math.sin(x * w.freq + w.phase + now * w.speed) * w.amp }); - } - return pts; -} - -function drawWavePath(ctx: CanvasRenderingContext2D, pts: { x: number; y: number }[], W: number, H: number) { - ctx.beginPath(); - ctx.moveTo(pts[0].x, pts[0].y); - for (let i = 0; i < pts.length - 1; i++) { - const mx = (pts[i].x + pts[i + 1].x) / 2; - const my = (pts[i].y + pts[i + 1].y) / 2; - ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my); - } - ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y); -} - -function drawWaves(ctx: CanvasRenderingContext2D, waves: Wave[], W: number, H: number, ga: number, bgR: number) { - const now = Date.now() / 1000; - const bg = Math.round(bgR); - - for (const w of waves) { - const v = w.v; - const yBase = H * w.yBase; - const pts = buildWavePts(w, W, H, now); - - const grad = ctx.createLinearGradient(0, yBase - w.amp, 0, yBase + w.amp * 3); - grad.addColorStop(0, `rgba(${v},${v},${v},${0.030 * ga})`); - grad.addColorStop(0.4, `rgba(${v},${v},${v},${0.012 * ga})`); - grad.addColorStop(1, `rgba(${bg},${bg},${bg},0)`); - - drawWavePath(ctx, pts, W, H); - ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath(); - ctx.fillStyle = grad; - ctx.fill(); - - drawWavePath(ctx, pts, W, H); - ctx.strokeStyle = `rgba(${v},${v},${v},${0.85 * ga})`; - ctx.lineWidth = w.width; - ctx.lineJoin = "round"; - ctx.lineCap = "round"; - ctx.stroke(); - } -} - -// ── Component ──────────────────────────────────────────────────────────────── - interface HeroBackgroundProps { - /** Controlled from outside if you have a global theme toggle. */ - isDark?: boolean; - /** Fires when the internal toggle is clicked (omit to hide the button). */ - onToggle?: (isDark: boolean) => void; - /** Show the built-in toggle button. Defaults to true. */ - showToggle?: boolean; + isDark?: boolean + onToggle?: (isDark: boolean) => void + showToggle?: boolean } export default function HeroBackground({ @@ -124,108 +66,89 @@ export default function HeroBackground({ onToggle, showToggle = true, }: HeroBackgroundProps) { - const canvasRef = useRef(null); -// REPLACE WITH: -const { isDark: hookDark, toggle } = useThemeMode(); + const canvasRef = useRef(null) + const { isDark: hookDark, toggle } = useThemeMode() + const isDark = externalIsDark !== undefined ? externalIsDark : hookDark -// Use external control if provided, otherwise hook -const isDark = externalIsDark !== undefined ? externalIsDark : hookDark; + function handleToggle() { + toggle() + onToggle?.(!isDark) + } -// REPLACE WITH: -function handleToggle() { - toggle(); - onToggle?.(!isDark); -} + const darkRef = useRef(isDark) + useEffect(() => { + darkRef.current = isDark + }, [isDark]) useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return - let W = 0, H = 0; - let stars: Star[] = []; - let waves: Wave[] = []; - let rafId: number; - - // Animated bg colour - let bgR = 0, bgG = 0, bgB = 0; - let crossfade = 0; + let W = 0, + H = 0 + let particles: Particle[] = [] + let rafId: number + let bgR = 0 function resize() { - W = canvas!.width = canvas!.offsetWidth; - H = canvas!.height = canvas!.offsetHeight; - stars = initStars(W, H); - waves = initWaves(); + W = canvas!.width = canvas!.offsetWidth + H = canvas!.height = canvas!.offsetHeight + particles = initParticles(W, H) } function loop() { - // Read current target from closure — updated via ref below - const dark = darkRef.current; - const targetBg = dark ? 0 : 255; - const targetCross = dark ? 0 : 1; + const dark = darkRef.current + const targetBg = dark ? 0 : 255 + bgR += (targetBg - bgR) * 0.04 - bgR += (targetBg - bgR) * 0.04; - bgG += (targetBg - bgG) * 0.04; - bgB += (targetBg - bgB) * 0.04; - crossfade += (targetCross - crossfade) * 0.03; + const bg = Math.round(bgR) + ctx!.fillStyle = `rgb(${bg},${bg},${bg})` + ctx!.fillRect(0, 0, W, H) - ctx!.fillStyle = `rgb(${Math.round(bgR)},${Math.round(bgG)},${Math.round(bgB)})`; - ctx!.fillRect(0, 0, W, H); + drawParticles(ctx!, particles, W, H, dark) - const sa = 1 - crossfade; - const wa = crossfade; - if (sa > 0.01) drawStars(ctx!, stars, W, H, sa); - if (wa > 0.01) drawWaves(ctx!, waves, W, H, wa, bgR); - - rafId = requestAnimationFrame(loop); + rafId = requestAnimationFrame(loop) } - const ro = new ResizeObserver(resize); - ro.observe(canvas); - resize(); - rafId = requestAnimationFrame(loop); + const ro = new ResizeObserver(resize) + ro.observe(canvas) + resize() + rafId = requestAnimationFrame(loop) return () => { - cancelAnimationFrame(rafId); - ro.disconnect(); - }; - }, []); - - // Keep a ref so the loop closure always reads the latest isDark - const darkRef = useRef(isDark); - useEffect(() => { darkRef.current = isDark; }, [isDark]); + cancelAnimationFrame(rafId) + ro.disconnect() + } + }, []) return ( -
- - +
+ {showToggle && ( )}
- ); + ) } diff --git a/src/heros/LowImpact/index.tsx b/src/heros/LowImpact/index.tsx index 965f4ab..6307c3f 100644 --- a/src/heros/LowImpact/index.tsx +++ b/src/heros/LowImpact/index.tsx @@ -9,11 +9,15 @@ type LowImpactHeroType = children?: React.ReactNode richText?: never theme?: never + heroBackground?: never + heroBackgroundOpacity?: never } | (Omit & { children?: never richText?: Page['hero']['richText'] theme?: string + heroBackground?: any + heroBackgroundOpacity?: number }) const bgMap: Record = { @@ -23,12 +27,34 @@ const bgMap: Record = { secondary: 'bg-secondary', } -export const LowImpactHero: React.FC = ({ children, richText, theme }) => { +export const LowImpactHero: React.FC = ({ + children, + richText, + theme, + heroBackground, + heroBackgroundOpacity, +}) => { + const isImage = theme === 'image' && heroBackground + const overlayOpacity = (heroBackgroundOpacity ?? 60) / 100 const bg = bgMap[theme ?? 'default'] ?? 'bg-transparent' return ( -
-
+
+ {isImage && ( +
+ )} +
{children || (richText && )}
diff --git a/src/heros/config.ts b/src/heros/config.ts index 27571b7..448d54c 100644 --- a/src/heros/config.ts +++ b/src/heros/config.ts @@ -39,8 +39,30 @@ export const hero: Field = { { label: 'Muted', value: 'muted' }, { label: 'Card', value: 'card' }, { label: 'Secondary', value: 'secondary' }, + { label: 'Image', value: 'image' }, ], }, + { + name: 'heroBackground', + type: 'upload', + relationTo: 'media', + admin: { + condition: (_, { type, theme } = {}) => type === 'lowImpact' && theme === 'image', + description: 'Background image for the hero section', + }, + }, + { + name: 'heroBackgroundOpacity', + type: 'number', + defaultValue: 60, + min: 0, + max: 100, + admin: { + condition: (_, { type, theme } = {}) => type === 'lowImpact' && theme === 'image', + description: 'Overlay opacity 0 (transparent) to 100 (solid)', + step: 5, + }, + }, { name: 'richText', type: 'richText', diff --git a/src/payload-types.ts b/src/payload-types.ts index 4310331..c399030 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -160,7 +160,15 @@ export interface Page { title: string; hero: { type: 'none' | 'highImpact' | 'mediumImpact' | 'lowImpact'; - theme?: ('default' | 'muted' | 'card' | 'secondary') | null; + theme?: ('default' | 'muted' | 'card' | 'secondary' | 'image') | null; + /** + * Background image for the hero section + */ + heroBackground?: (string | null) | Media; + /** + * Overlay opacity 0 (transparent) to 100 (solid) + */ + heroBackgroundOpacity?: number | null; richText?: { root: { type: string; @@ -273,56 +281,6 @@ export interface Page { createdAt: string; _status?: ('draft' | 'published') | null; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "posts". - */ -export interface Post { - id: string; - title: string; - heroImage?: (string | null) | Media; - content: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - }; - relatedPosts?: (string | Post)[] | null; - categories?: (string | Category)[] | null; - meta?: { - title?: string | null; - /** - * Maximum upload file size: 12MB. Recommended file size for images is <500KB. - */ - image?: (string | null) | Media; - description?: string | null; - }; - publishedAt?: string | null; - authors?: (string | User)[] | null; - populatedAuthors?: - | { - id?: string | null; - name?: string | null; - }[] - | null; - /** - * When enabled, the slug will auto-generate from the title field on save and autosave. - */ - generateSlug?: boolean | null; - slug: string; - updatedAt: string; - createdAt: string; - _status?: ('draft' | 'published') | null; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "media". @@ -442,6 +400,56 @@ export interface FolderInterface { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts". + */ +export interface Post { + id: string; + title: string; + heroImage?: (string | null) | Media; + content: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + }; + relatedPosts?: (string | Post)[] | null; + categories?: (string | Category)[] | null; + meta?: { + title?: string | null; + /** + * Maximum upload file size: 12MB. Recommended file size for images is <500KB. + */ + image?: (string | null) | Media; + description?: string | null; + }; + publishedAt?: string | null; + authors?: (string | User)[] | null; + populatedAuthors?: + | { + id?: string | null; + name?: string | null; + }[] + | null; + /** + * When enabled, the slug will auto-generate from the title field on save and autosave. + */ + generateSlug?: boolean | null; + slug: string; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "categories". @@ -1117,6 +1125,8 @@ export interface PagesSelect { | { type?: T; theme?: T; + heroBackground?: T; + heroBackgroundOpacity?: T; richText?: T; links?: | T