Added mobile image viewer
This commit is contained in:
parent
a09509581f
commit
56afa86704
|
|
@ -16,6 +16,7 @@ import Mermaid from "@/components/Mermaid";
|
|||
import ProjectShowcase from "@/components/ProjectShowcase";
|
||||
import ImageCarousel from "@/components/ImageCarousel";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import MobileStack from "@/components/MobileStack";
|
||||
|
||||
export default function ProjectDetail({
|
||||
params,
|
||||
|
|
@ -123,15 +124,20 @@ export default function ProjectDetail({
|
|||
</header>
|
||||
|
||||
<section className="mb-20">
|
||||
{/* Desktop Showcase View */}
|
||||
<div className="hidden lg:block">
|
||||
<ProjectShowcase images={project.images} />
|
||||
</div>
|
||||
|
||||
{/* Mobile Carousel View */}
|
||||
<div className="block lg:hidden">
|
||||
<ImageCarousel images={project.images} />
|
||||
</div>
|
||||
{project.category === "mobile" ? (
|
||||
<MobileStack images={project.images} />
|
||||
) : (
|
||||
<>
|
||||
{/* Display on large screens */}
|
||||
<div className="hidden lg:block">
|
||||
<ProjectShowcase images={project.images} />
|
||||
</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]">
|
||||
Interactive Gallery — Select or swipe to explore
|
||||
|
|
|
|||
10
components/MobileFrame.tsx
Normal file
10
components/MobileFrame.tsx
Normal 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
104
components/MobileStack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -244,6 +244,82 @@ graph TB
|
|||
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",
|
||||
category: "mobile",
|
||||
|
|
|
|||
BIN
public/projects/choosa/choosa-1.jpg
Normal file
BIN
public/projects/choosa/choosa-1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
public/projects/choosa/choosa-2.jpg
Normal file
BIN
public/projects/choosa/choosa-2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
BIN
public/projects/choosa/choosa-3.jpg
Normal file
BIN
public/projects/choosa/choosa-3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
BIN
public/projects/choosa/choosa-4.jpg
Normal file
BIN
public/projects/choosa/choosa-4.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
BIN
public/projects/choosa/choosa-5.jpg
Normal file
BIN
public/projects/choosa/choosa-5.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
Loading…
Reference in a new issue