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 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
|
||||||
|
|
|
||||||
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
|
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",
|
||||||
|
|
|
||||||
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