Added mobile image viewer

This commit is contained in:
GeorgeWebberley 2026-02-01 20:08:09 +01:00
parent a09509581f
commit 56afa86704
9 changed files with 205 additions and 9 deletions

View file

@ -16,6 +16,7 @@ import Mermaid from "@/components/Mermaid";
import ProjectShowcase from "@/components/ProjectShowcase"; import ProjectShowcase from "@/components/ProjectShowcase";
import ImageCarousel from "@/components/ImageCarousel"; import ImageCarousel from "@/components/ImageCarousel";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import MobileStack from "@/components/MobileStack";
export default function ProjectDetail({ export default function ProjectDetail({
params, params,
@ -123,15 +124,20 @@ export default function ProjectDetail({
</header> </header>
<section className="mb-20"> <section className="mb-20">
{/* Desktop Showcase View */} {project.category === "mobile" ? (
<div className="hidden lg:block"> <MobileStack images={project.images} />
<ProjectShowcase images={project.images} /> ) : (
</div> <>
{/* Display on large screens */}
{/* Mobile Carousel View */} <div className="hidden lg:block">
<div className="block lg:hidden"> <ProjectShowcase images={project.images} />
<ImageCarousel images={project.images} /> </div>
</div> {/* Display on small screens */}
<div className="block lg:hidden">
<ImageCarousel images={project.images} />
</div>
</>
)}
<p className="mt-4 text-center text-[10px] font-mono text-neutral-600 uppercase tracking-[0.2em]"> <p className="mt-4 text-center text-[10px] font-mono text-neutral-600 uppercase tracking-[0.2em]">
Interactive Gallery Select or swipe to explore Interactive Gallery Select or swipe to explore

View file

@ -0,0 +1,10 @@
export function MobileFrame({ children }: { children: React.ReactNode }) {
return (
<div className="relative mx-auto border-neutral-800 bg-neutral-800 border-[8px] rounded-[2.5rem] h-[600px] w-[300px] shadow-2xl overflow-hidden">
<div className="absolute top-0 inset-x-0 h-6 bg-neutral-800 rounded-b-xl z-20 w-32 mx-auto" />
<div className="relative h-full w-full bg-black overflow-hidden">
{children}
</div>
</div>
);
}

104
components/MobileStack.tsx Normal file
View file

@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { MobileFrame } from "./MobileFrame";
export default function MobileStack({ images }: { images: string[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
const DRAG_THRESHOLD = -150;
const getRelativeIndex = (index: number) => {
const len = images.length;
return (index - currentIndex + len) % len;
};
const next = () => setCurrentIndex((prev) => (prev + 1) % images.length);
return (
<div className="relative h-[750px] w-full flex flex-col items-center py-20 overflow-hidden group">
<div className="relative h-[650px] w-full flex justify-center items-center">
<AnimatePresence initial={false}>
{images.map((img, index) => {
const relIndex = getRelativeIndex(index);
const isTop = relIndex === 0;
const xOffset = relIndex * 90;
if (relIndex > 5) return null;
return (
<motion.div
key={img}
style={{ zIndex: images.length - relIndex }}
initial={{ opacity: 0, x: 400 }}
animate={{
opacity: 1,
x: isTop ? 0 : xOffset,
scale: isTop ? 1 : 0.96,
filter: isTop ? "brightness(1)" : "brightness(0.4)",
pointerEvents: isTop ? "auto" : "all",
}}
exit={{
x: -1000,
opacity: 0,
transition: { duration: 0.4, ease: "easeIn" },
}}
transition={{
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
}}
whileHover={
!isTop ? { scale: 0.98, filter: "brightness(0.6)" } : {}
}
drag={isTop ? "x" : false}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.8}
onDrag={(_, info) => {
if (isTop && info.offset.x < DRAG_THRESHOLD) {
next();
}
}}
onDragEnd={(_, info) => {
// Backup check for quick flicks
if (isTop && info.offset.x < -100) {
next();
}
}}
onClick={() => !isTop && setCurrentIndex(index)}
className="absolute"
>
<div
className={`${isTop ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"}`}
>
<MobileFrame>
<img
src={img}
alt="App Screenshot"
draggable="false"
className="w-full h-full object-cover select-none"
/>
</MobileFrame>
</div>
</motion.div>
);
})}
</AnimatePresence>
{/* Navigation Button */}
<div className="absolute -bottom-12 z-[100]">
<button
onClick={next}
className="flex items-center gap-3 px-6 py-3 rounded-full bg-black/40 backdrop-blur-xl border border-white/5 text-white hover:bg-white/10 transition-all opacity-0 group-hover:opacity-100 group-hover:translate-y-[-20px]"
>
<span className="text-[10px] font-mono uppercase tracking-[0.2em]">
Next Screen
</span>
<ArrowRight size={16} />
</button>
</div>
</div>
</div>
);
}

View file

@ -244,6 +244,82 @@ graph TB
classDef node fill:#16a34a,stroke:#22c55e,color:#fff classDef node fill:#16a34a,stroke:#22c55e,color:#fff
`, `,
}, },
{
slug: "choosa",
category: "mobile",
title: "Choosa",
subtitle: "Social Content Discovery Engine",
role: "Lead Developer & Architect",
duration: "2023 — Present",
stack: [
"Flutter",
"Firebase",
"Firestore",
"Cloud Functions",
"Push Notifications",
],
metrics: [
"Real-time Match Engine",
"Cross-Platform (iOS/Android)",
"Multi-API Orchestration",
],
description:
"A social decision-making app that solves 'choice paralysis' by allowing groups to swipe on movies and TV shows, using a real-time matching algorithm to find common interests.",
storyLabel: "UX // MOBILE_SYNCHRONIZATION",
images: [
"/projects/choosa/choosa-1.jpg",
"/projects/choosa/choosa-2.jpg",
"/projects/choosa/choosa-3.jpg",
"/projects/choosa/choosa-4.jpg",
"/projects/choosa/choosa-5.jpg",
],
isPrivate: false,
engineeringStory: `
Choosa was built to solve the universal problem of "choice paralysis" in social settings. The challenge was creating a low-latency, real-time environment where group preferences could be aggregated and matched instantaneously.
#### Real-time State & Match Logic
The core engine utilizes **Firestore's** real-time listeners to sync swipe states across multiple devices simultaneously. I architected a custom matching algorithm within **Firebase Cloud Functions** that monitors group sessions; the moment a consensus is reached, the system triggers **Firebase Cloud Messaging (FCM)** to send push notifications to all participants, ensuring a seamless transition from "deciding" to "watching."
#### Data Orchestration & External APIs
To provide a rich library of content, I integrated the **TMDB** and **Movie of the Night** APIs. By utilizing a middleware layer in Cloud Functions, I was able to normalize data from different sources, filter results based on user-specific streaming subscriptions, and cache results to minimize API overhead and latency.
#### Mobile Deployment & Native Experience
Developing Choosa in **Flutter** allowed for a unified codebase while maintaining native performance on both iOS and Android. I managed the full deployment lifecycle, from configuring **Fastlane** for automated App Store and Play Store releases to handling platform-specific requirements like adaptive icons and deep-linking.
`,
mermaidChart: `
graph LR
subgraph Client_Mobile [Mobile Frontend]
A[Flutter App]:::traffic
end
subgraph Firebase_Core [Backend Services]
Hub((Firebase SDK)):::hub
B[Auth]:::node
C[Firestore DB]:::node
D[Cloud Functions]:::node
E[Cloud Messaging]:::node
end
subgraph External_Data [Content Providers]
F[TMDB API]:::traffic
G[Movie of Night API]:::traffic
end
A <--> Hub
Hub --> B
Hub <-->|Sync State| C
Hub -->|Trigger Match| D
D -->|Push Notification| E
E -->|Alert Group| A
D --> F
D --> G
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
classDef node fill:#16a34a,stroke:#22c55e,color:#fff
classDef hub fill:#f59e0b,stroke:#d97706,color:#fff
`,
},
{ {
slug: "flutter-1", slug: "flutter-1",
category: "mobile", category: "mobile",

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB