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
Binary file added apps/web/public/hero/001.jpg
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 apps/web/public/hero/001.webp
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 apps/web/public/hero/002.jpg
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 apps/web/public/hero/002.webp
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 apps/web/public/hero/003.jpg
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 apps/web/public/hero/003.webp
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 apps/web/public/hero/004.jpg
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 apps/web/public/hero/004.webp
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 apps/web/public/hero/005.jpg
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 apps/web/public/hero/005.webp
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 apps/web/public/hero/006.jpg
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 apps/web/public/hero/006.webp
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 apps/web/public/hero/007.jpg
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 apps/web/public/hero/007.webp
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 apps/web/public/hero/008.jpg
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 apps/web/public/hero/008.webp
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 apps/web/public/hero/009.jpg
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 apps/web/public/hero/009.webp
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 apps/web/public/hero/010.jpg
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 apps/web/public/hero/010.webp
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 apps/web/public/hero/011.jpg
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 apps/web/public/hero/011.webp
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 apps/web/public/hero/012.jpg
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 apps/web/public/hero/012.webp
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 apps/web/public/hero/013.jpg
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 apps/web/public/hero/013.webp
Binary file added apps/web/public/hero/014.jpg
Binary file added apps/web/public/hero/014.webp
Binary file added apps/web/public/hero/015.jpg
Binary file added apps/web/public/hero/015.webp
Binary file added apps/web/public/hero/016.jpg
Binary file added apps/web/public/hero/016.webp
18 changes: 18 additions & 0 deletions apps/web/public/hero/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[
{"jpg":"/hero/001.jpg","webp":"/hero/001.webp"},
{"jpg":"/hero/002.jpg","webp":"/hero/002.webp"},
{"jpg":"/hero/003.jpg","webp":"/hero/003.webp"},
{"jpg":"/hero/004.jpg","webp":"/hero/004.webp"},
{"jpg":"/hero/005.jpg","webp":"/hero/005.webp"},
{"jpg":"/hero/006.jpg","webp":"/hero/006.webp"},
{"jpg":"/hero/007.jpg","webp":"/hero/007.webp"},
{"jpg":"/hero/008.jpg","webp":"/hero/008.webp"},
{"jpg":"/hero/009.jpg","webp":"/hero/009.webp"},
{"jpg":"/hero/010.jpg","webp":"/hero/010.webp"},
{"jpg":"/hero/011.jpg","webp":"/hero/011.webp"},
{"jpg":"/hero/012.jpg","webp":"/hero/012.webp"},
{"jpg":"/hero/013.jpg","webp":"/hero/013.webp"},
{"jpg":"/hero/014.jpg","webp":"/hero/014.webp"},
{"jpg":"/hero/015.jpg","webp":"/hero/015.webp"},
{"jpg":"/hero/016.jpg","webp":"/hero/016.webp"}
]
93 changes: 93 additions & 0 deletions apps/web/scripts/optimize-hero-photos.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Optimize a directory of source photos into the hero slideshow asset set.
#
# Usage: bash apps/web/scripts/optimize-hero-photos.sh <input-dir>
# Output: apps/web/public/hero/NNN.jpg, NNN.webp, manifest.json
#
# Re-running clears the existing hero directory and regenerates everything
# from scratch so the output is deterministic for any given input set.

set -euo pipefail

if [[ $# -ne 1 ]]; then
echo "Usage: $0 <input-dir>" >&2
exit 64
fi

input_dir="$1"

if [[ ! -d "$input_dir" ]]; then
echo "Error: input directory not found: $input_dir" >&2
exit 66
fi

script_dir="$(cd "$(dirname "$0")" && pwd)"
output_dir="$script_dir/../public/hero"

if ! command -v magick >/dev/null 2>&1; then
echo "Error: ImageMagick 'magick' not found in PATH." >&2
echo "Install with: brew install imagemagick webp" >&2
exit 69
fi

mkdir -p "$output_dir"

find "$output_dir" -maxdepth 1 -type f \
\( -name '*.jpg' -o -name '*.webp' -o -name 'manifest.json' \) -delete

inputs=()
while IFS= read -r -d '' path; do
inputs+=("$path")
done < <(find "$input_dir" -maxdepth 1 -type f \
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \) -print0 | sort -z)

if [[ ${#inputs[@]} -eq 0 ]]; then
echo "Error: no .jpg/.jpeg/.png files found in $input_dir" >&2
exit 65
fi

echo "Optimizing ${#inputs[@]} photo(s) → $output_dir"

manifest_tmp="$output_dir/manifest.json.tmp"
: > "$manifest_tmp"
printf '[\n' >> "$manifest_tmp"

i=0
last_idx=$((${#inputs[@]} - 1))
for src in "${inputs[@]}"; do
num=$(printf '%03d' "$((i + 1))")
jpg_out="$output_dir/$num.jpg"
webp_out="$output_dir/$num.webp"

magick "$src" \
-auto-orient \
-resize '1920x1280^' \
-gravity Center -extent '1920x1280' \
-strip \
-interlace JPEG \
-sampling-factor 4:2:0 \
-quality 82 \
"$jpg_out"

magick "$jpg_out" \
-define webp:method=6 \
-quality 80 \
"$webp_out"

jpg_kb=$(( $(wc -c < "$jpg_out") / 1024 ))
webp_kb=$(( $(wc -c < "$webp_out") / 1024 ))
printf ' %s — jpg %dKB · webp %dKB\n' "$num" "$jpg_kb" "$webp_kb"

if [[ $i -eq $last_idx ]]; then
printf ' {"jpg":"/hero/%s.jpg","webp":"/hero/%s.webp"}\n' "$num" "$num" >> "$manifest_tmp"
else
printf ' {"jpg":"/hero/%s.jpg","webp":"/hero/%s.webp"},\n' "$num" "$num" >> "$manifest_tmp"
fi

i=$((i + 1))
done

printf ']\n' >> "$manifest_tmp"
mv "$manifest_tmp" "$output_dir/manifest.json"

echo "Wrote $output_dir/manifest.json (${#inputs[@]} entries)"
177 changes: 177 additions & 0 deletions apps/web/src/components/HeroSlideshow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { useEffect, useState, type CSSProperties } from 'react';
import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion';
import { cn } from '@/lib/utils';

type Photo = { jpg: string; webp: string };
type PanVector = { fromX: number; fromY: number; toX: number; toY: number };

const VISIBLE_MS = 8000;
const CROSSFADE_MS = 1500;
const LAYER_LIFETIME_MS = CROSSFADE_MS + VISIBLE_MS + CROSSFADE_MS;
const PRELOAD_TIMEOUT_MS = 3000;
const PAN_RANGE_PCT = 2;

function shuffle<T>(arr: readonly T[]): T[] {
const out = arr.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = out[i] as T;
out[i] = out[j] as T;
out[j] = tmp;
}
return out;
}

function randomVector(): PanVector {
const r = () => (Math.random() * 2 - 1) * PAN_RANGE_PCT;
return { fromX: r(), fromY: r(), toX: r(), toY: r() };
}

function preload(url: string): Promise<void> {
return new Promise((resolve) => {
const img = new Image();
let settled = false;
const done = () => {
if (settled) return;
settled = true;
resolve();
};
img.onload = done;
img.onerror = done;
img.src = url;
setTimeout(done, PRELOAD_TIMEOUT_MS);
});
}

interface PanLayerProps {
photo: Photo;
vector: PanVector;
fadingIn: boolean;
fadingOut: boolean;
reducedMotion: boolean;
}

function PanLayer({ photo, vector, fadingIn, fadingOut, reducedMotion }: PanLayerProps) {
const [appeared, setAppeared] = useState(!fadingIn);

useEffect(() => {
if (!fadingIn) return;
const id = requestAnimationFrame(() => {
requestAnimationFrame(() => setAppeared(true));
});
return () => cancelAnimationFrame(id);
}, [fadingIn]);

const opacity = fadingOut ? 0 : appeared ? 1 : 0;

const style = {
opacity,
transition: `opacity ${CROSSFADE_MS}ms ease-in-out`,
animation: reducedMotion ? 'none' : `hero-ken-burns ${LAYER_LIFETIME_MS}ms linear forwards`,
transformOrigin: 'center center',
willChange: 'transform, opacity',
'--kb-from-x': `${vector.fromX}%`,
'--kb-from-y': `${vector.fromY}%`,
'--kb-to-x': `${vector.toX}%`,
'--kb-to-y': `${vector.toY}%`,
} as CSSProperties;

return (
<picture className="absolute inset-0 block" style={style}>
<source srcSet={photo.webp} type="image/webp" />
<img src={photo.jpg} alt="" className="w-full h-full object-cover" loading="eager" />
</picture>
);
}

interface HeroSlideshowProps {
className?: string;
}

export function HeroSlideshow({ className }: HeroSlideshowProps) {
const reducedMotion = usePrefersReducedMotion();
const [photos, setPhotos] = useState<Photo[]>([]);
const [tick, setTick] = useState(0);
const [currentVector, setCurrentVector] = useState<PanVector>(randomVector);
const [nextVector, setNextVector] = useState<PanVector | null>(null);

useEffect(() => {
let cancelled = false;
fetch('/hero/manifest.json')
.then((r) => (r.ok ? (r.json() as Promise<Photo[]>) : []))
.catch(() => [] as Photo[])
.then((data) => {
if (cancelled) return;
if (Array.isArray(data) && data.length > 0) {
setPhotos(shuffle(data));
}
});
return () => {
cancelled = true;
};
}, []);

useEffect(() => {
if (photos.length === 0) return;
let cancelled = false;
const timers: ReturnType<typeof setTimeout>[] = [];

const visibleTimer = setTimeout(() => {
if (cancelled) return;
const nextIdx = (tick + 1) % photos.length;
const nextPhoto = photos[nextIdx];
if (!nextPhoto) return;
preload(nextPhoto.jpg).then(() => {
if (cancelled) return;
const v = randomVector();
setNextVector(v);
const settleTimer = setTimeout(() => {
if (cancelled) return;
setCurrentVector(v);
setNextVector(null);
setTick((prev) => prev + 1);
}, CROSSFADE_MS);
timers.push(settleTimer);
});
}, VISIBLE_MS);
timers.push(visibleTimer);

return () => {
cancelled = true;
timers.forEach(clearTimeout);
};
}, [photos, tick]);

if (photos.length === 0) {
return <div className={cn('absolute inset-0 bg-neutral-900', className)} aria-hidden="true" />;
}

const currentPhoto = photos[tick % photos.length];
const nextPhoto = photos[(tick + 1) % photos.length];
if (!currentPhoto || !nextPhoto) return null;

const transitioning = nextVector !== null;

return (
<div className={cn('relative overflow-hidden', className)} aria-hidden="true">
<PanLayer
key={tick}
photo={currentPhoto}
vector={currentVector}
fadingIn={false}
fadingOut={transitioning}
reducedMotion={reducedMotion}
/>
{nextVector !== null && (
<PanLayer
key={tick + 1}
photo={nextPhoto}
vector={nextVector}
fadingIn
fadingOut={false}
reducedMotion={reducedMotion}
/>
)}
</div>
);
}
22 changes: 22 additions & 0 deletions apps/web/src/hooks/usePrefersReducedMotion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';

const QUERY = '(prefers-reduced-motion: reduce)';

function read(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia(QUERY).matches;
}

export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(read);

useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mql = window.matchMedia(QUERY);
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);

return reduced;
}
9 changes: 9 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,13 @@
[id="search-results-dropdown"] {
display: none !important;
}
}

@keyframes hero-ken-burns {
from {
transform: scale(1.05) translate(var(--kb-from-x, 0%), var(--kb-from-y, 0%));
}
to {
transform: scale(1.1) translate(var(--kb-to-x, 0%), var(--kb-to-y, 0%));
}
}
27 changes: 10 additions & 17 deletions apps/web/src/screens/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { ActivityCard, mergeActivity, type ActivityItem } from '@/components/ActivityCard';
import { HelpWantedCard } from '@/components/HelpWantedCard';
import { HeroSlideshow } from '@/components/HeroSlideshow';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/lib/api';
import { cn } from '@/lib/utils';
Expand Down Expand Up @@ -75,28 +76,20 @@ export function Home() {
return (
<div>
{/* Hero */}
<section className="relative bg-gradient-to-br from-primary/5 via-background to-primary/10 border-b border-border">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<video
autoPlay
muted
loop
playsInline
className="absolute inset-0 w-full h-full object-cover opacity-30"
aria-hidden="true"
>
<source src="/videos/video-small.mp4" type="video/mp4" />
<source src="/videos/video-small.webm" type="video/webm" />
</video>
</div>
<section className="relative border-b border-border bg-neutral-900 overflow-hidden">
<HeroSlideshow className="absolute inset-0" />
<div
className="absolute inset-0 bg-gradient-to-br from-black/55 via-black/30 to-black/55 pointer-events-none"
aria-hidden="true"
/>
<div className="relative container mx-auto px-4 py-20 text-center">
<h1 className="text-3xl md:text-5xl font-bold text-foreground mb-4 max-w-3xl mx-auto leading-tight">
<h1 className="text-3xl md:text-5xl font-bold text-white mb-4 max-w-3xl mx-auto leading-tight drop-shadow-md">
Contribute towards technology-related projects that benefit the City of Philadelphia.
</h1>
<p className="text-lg md:text-xl text-muted-foreground mb-8">
<p className="text-lg md:text-xl text-white/85 mb-8 drop-shadow">
No coding experience required.
</p>
<Button asChild size="lg" className="bg-green-600 hover:bg-green-700 text-white">
<Button asChild size="lg" className="bg-green-600 hover:bg-green-700 text-white shadow-lg">
<Link to={person ? '/projects' : '/volunteer'}>
{person ? 'Browse Projects' : 'Volunteer'}
</Link>
Expand Down
8 changes: 8 additions & 0 deletions apps/web/tests/Home.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ describe('Home', () => {
beforeEach(() => {
vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
if (input.startsWith('/api/auth/me')) return Promise.resolve(new Response(null, { status: 404 }));
if (input.startsWith('/hero/manifest.json')) {
return Promise.resolve(
new Response(JSON.stringify([]), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}
if (input.startsWith('/api/projects')) {
return Promise.resolve(
new Response(JSON.stringify(mockPaginated([], { totalItems: 42 })), {
Expand Down
Loading