Commiting changes before upgrade
This commit is contained in:
parent
5d0a86645d
commit
471b251fd7
|
|
@ -1,5 +1,7 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
|
|
|
|||
|
|
@ -4,38 +4,19 @@ import { use } from "react";
|
|||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, ExternalLink, Github, ShieldCheck, Cpu, Users } from "lucide-react";
|
||||
import { PROJECT_REGISTRY } from "@/data/projects";
|
||||
import Mermaid from "@/components/Mermaid";
|
||||
import ProjectShowcase from "@/components/ProjectShowcase";
|
||||
import ImageCarousel from "@/components/ImageCarousel";
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
|
||||
// This would eventually move to a separate data file
|
||||
const PROJECT_DETAILS = {
|
||||
ratoong: {
|
||||
title: "Ratoong",
|
||||
subtitle: "Regulatory-Compliant Data Platform",
|
||||
role: "Lead Full-Stack Engineer",
|
||||
duration: "2022 — 2024",
|
||||
stack: ["Node.js", "PostgreSQL", "React", "Docker"],
|
||||
metrics: ["99.9% Uptime", "Zero-Data-Loss Integrity", "ISO 27001 Ready"],
|
||||
description: "Architected a high-integrity platform designed to meet rigid regulatory requirements. Focused on immutable audit trails and secure data orchestration.",
|
||||
images: ["/ratoong-1.jpg", "/ratoong-2.jpg"], // Place these in /public
|
||||
liveUrl: "https://ratoong.com",
|
||||
repoUrl: "https://git.georgew.dev/georgew/datasaur",
|
||||
},
|
||||
datasaur: {
|
||||
title: "Datasaur",
|
||||
subtitle: "Personal R&D Pipeline",
|
||||
role: "Architect & Creator",
|
||||
duration: "2025 — Present",
|
||||
stack: ["Python", "FastAPI", "Next.js", "Redis"],
|
||||
metrics: ["Sub-50ms Latency", "Automated ETL", "Self-Hosted"],
|
||||
description: "A data science pipeline tool built to explore high-speed processing and real-time visualization of large datasets.",
|
||||
images: ["/datasaur-1.jpg"],
|
||||
repoUrl: "https://git.georgew.dev/georgew/datasaur",
|
||||
liveUrl: "https://ratoong.com",
|
||||
}
|
||||
};
|
||||
|
||||
export default function ProjectDetail({ params }: { params: Promise<{ category: string, slug: string }> }) {
|
||||
const { category, slug } = use(params);
|
||||
const project = PROJECT_DETAILS[slug as keyof typeof PROJECT_DETAILS];
|
||||
const project = PROJECT_REGISTRY.find((p) => p.slug === slug);
|
||||
|
||||
if (!project) return <div>Project Not Found</div>;
|
||||
|
||||
if (!project) return <div className="p-24 text-white font-mono">Project Log Not Found.</div>;
|
||||
|
||||
|
|
@ -98,28 +79,55 @@ export default function ProjectDetail({ params }: { params: Promise<{ category:
|
|||
</motion.div>
|
||||
</header>
|
||||
|
||||
{/* Image Gallery */}
|
||||
|
||||
<section className="mb-20">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{project.images.map((img, i) => (
|
||||
<motion.div key={i} whileHover={{ scale: 1.01 }} className="aspect-video bg-neutral-900 rounded-2xl border border-neutral-800 overflow-hidden relative">
|
||||
{/* Placeholder for your actual images */}
|
||||
<div className="absolute inset-0 flex items-center justify-center text-neutral-700 font-mono text-xs italic">
|
||||
[Screenshot: {project.title} - Layer {i + 1}]
|
||||
{/* Desktop Showcase View */}
|
||||
<div className="hidden lg:block">
|
||||
<ProjectShowcase images={project.images} />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Mobile Carousel View */}
|
||||
<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
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Engineering Narrative */}
|
||||
<article className="max-w-3xl border-t border-neutral-900 pt-16">
|
||||
<h2 className="text-2xl font-bold mb-6 italic underline decoration-blue-500 underline-offset-8">The Engineering Story</h2>
|
||||
<div className="prose prose-invert prose-neutral max-w-none text-neutral-400 leading-loose">
|
||||
{/* Content drafted below */}
|
||||
<p>Detail how you approached the architecture, the specific trade-offs you made, and how you ensured regulatory compliance in the data layer.</p>
|
||||
{/* SYSTEM ARCHITECTURE (New Mermaid Section) */}
|
||||
{project.mermaidChart && (
|
||||
<section className="mb-16">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="h-px flex-1 bg-neutral-900" />
|
||||
<h3 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.3em]">
|
||||
System Architecture Log
|
||||
</h3>
|
||||
<div className="h-px flex-1 bg-neutral-900" />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="bg-neutral-900/20 rounded-3xl border border-neutral-800/50 p-4 md:p-12">
|
||||
<Mermaid chart={project.mermaidChart} />
|
||||
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Engineering Narrative */}
|
||||
<article className="w-full max-w-3xl mx-auto px-6 py-20 border-t border-neutral-900 mt-20">
|
||||
<h2 className="text-2xl font-bold mb-10 italic underline decoration-blue-500 underline-offset-8 text-left text-white">
|
||||
The Engineering Story
|
||||
</h2>
|
||||
|
||||
<div className="prose prose-invert prose-neutral max-w-none text-left
|
||||
prose-h4:text-blue-400 prose-h4:font-mono prose-h4:uppercase prose-h4:text-[10px] prose-h4:tracking-[0.2em]
|
||||
prose-p:text-neutral-400 prose-p:leading-relaxed">
|
||||
<ReactMarkdown>
|
||||
{project.engineeringStory}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
110
components/ImageCarousel.tsx
Normal file
110
components/ImageCarousel.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface GalleryProps {
|
||||
images: string[];
|
||||
}
|
||||
|
||||
export default function ImageCarousel({ images }: GalleryProps) {
|
||||
const [[page, direction], setPage] = useState([0, 0]);
|
||||
const [isAutoPlaying, setIsAutoPlaying] = useState(true);
|
||||
|
||||
const imageIndex = Math.abs(page % images.length);
|
||||
|
||||
const paginate = useCallback((newDirection: number) => {
|
||||
setPage([page + newDirection, newDirection]);
|
||||
}, [page]);
|
||||
|
||||
// AUTO-PLAY LOGIC
|
||||
useEffect(() => {
|
||||
if (!isAutoPlaying) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
paginate(1);
|
||||
}, 5000); // 5 seconds is the "sweet spot" for technical analysis
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [paginate, isAutoPlaying]);
|
||||
|
||||
const variants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 1000 : -1000,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: { zIndex: 1, x: 0, opacity: 1 },
|
||||
exit: (direction: number) => ({
|
||||
zIndex: 0,
|
||||
x: direction < 0 ? 1000 : -1000,
|
||||
opacity: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-video w-full overflow-hidden rounded-3xl border border-neutral-800 bg-neutral-900"
|
||||
onMouseEnter={() => setIsAutoPlaying(false)} // Pause on hover
|
||||
onMouseLeave={() => setIsAutoPlaying(true)} // Resume when mouse leaves
|
||||
>
|
||||
<AnimatePresence initial={false} custom={direction}>
|
||||
<motion.img
|
||||
key={page}
|
||||
src={images[imageIndex]}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{
|
||||
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 },
|
||||
}}
|
||||
drag="x"
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={1}
|
||||
onDragStart={() => setIsAutoPlaying(false)} // Kill auto-play on interaction
|
||||
onDragEnd={(e, { offset }) => {
|
||||
const swipe = Math.abs(offset.x) > 50;
|
||||
if (swipe) paginate(offset.x > 0 ? -1 : 1);
|
||||
}}
|
||||
className="absolute h-full w-full object-cover cursor-grab active:cursor-grabbing"
|
||||
/>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-between p-4 pointer-events-none">
|
||||
<button
|
||||
className="p-2 rounded-full bg-black/50 backdrop-blur-md border border-white/10 text-white pointer-events-auto hover:bg-black/80 transition-all"
|
||||
onClick={() => {
|
||||
setIsAutoPlaying(false); // Kill auto-play
|
||||
paginate(-1);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 rounded-full bg-black/50 backdrop-blur-md border border-white/10 text-white pointer-events-auto hover:bg-black/80 transition-all"
|
||||
onClick={() => {
|
||||
setIsAutoPlaying(false); // Kill auto-play
|
||||
paginate(1);
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar (Visual Timer) */}
|
||||
{isAutoPlaying && (
|
||||
<motion.div
|
||||
key={imageIndex}
|
||||
initial={{ width: "0%" }}
|
||||
animate={{ width: "100%" }}
|
||||
transition={{ duration: 5, ease: "linear" }}
|
||||
className="absolute bottom-0 left-0 h-1 bg-blue-500/50 z-20"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
components/Mermaid.tsx
Normal file
88
components/Mermaid.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import mermaid from "mermaid";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Maximize2, Minimize2 } from "lucide-react";
|
||||
|
||||
export default function Mermaid({ chart }: { chart: string }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [needsExpansion, setNeedsExpansion] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: "dark",
|
||||
securityLevel: "loose",
|
||||
fontFamily: "monospace",
|
||||
});
|
||||
mermaid.contentLoaded();
|
||||
|
||||
// Check if the rendered diagram is taller than 400px
|
||||
if (contentRef.current) {
|
||||
const height = contentRef.current.scrollHeight;
|
||||
setNeedsExpansion(height > 400);
|
||||
}
|
||||
}, [chart]);
|
||||
|
||||
return (
|
||||
<div className="relative max-w-4xl mx-auto group">
|
||||
<motion.div
|
||||
initial={false}
|
||||
onClick={() => needsExpansion && setIsExpanded(!isExpanded)}
|
||||
animate={{ height: isExpanded || !needsExpansion ? "auto" : "400px" }}
|
||||
className={`relative bg-neutral-900/20 rounded-3xl border border-neutral-800/50 p-4 md:p-12 overflow-hidden transition-colors duration-500
|
||||
${needsExpansion && !isExpanded ? "cursor-pointer hover:border-neutral-700" : "cursor-default"}`}
|
||||
>
|
||||
{/* Legend */}
|
||||
<div className="absolute top-6 right-8 flex flex-col gap-2 z-20 bg-black/40 backdrop-blur-md p-3 rounded-xl border border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-[9px] font-mono text-neutral-400 uppercase tracking-wider">Traffic Flow</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-[9px] font-mono text-neutral-400 uppercase tracking-wider">Service Node</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={contentRef} className="mermaid flex justify-center">
|
||||
{chart}
|
||||
</div>
|
||||
|
||||
{/* The "Fade to Darkness" Overlay - only show if needs expansion */}
|
||||
<AnimatePresence>
|
||||
{needsExpansion && !isExpanded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-[#0a0a0a] via-[#0a0a0a]/80 to-transparent pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Expand/Collapse Button - only show if needs expansion */}
|
||||
{needsExpansion && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
className={`absolute -bottom-5 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 px-5 py-2.5 rounded-full text-[10px] font-mono uppercase tracking-[0.2em] transition-all border shadow-xl
|
||||
${isExpanded
|
||||
? "bg-neutral-800 border-neutral-700 text-white"
|
||||
: "bg-neutral-900 border-neutral-800 text-neutral-500 group-hover:bg-neutral-800 group-hover:border-neutral-600 group-hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<> <Minimize2 size={12} /> Collapse Logic </>
|
||||
) : (
|
||||
<> <Maximize2 size={12} /> Expand Architecture </>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
components/ProjectShowcase.tsx
Normal file
57
components/ProjectShowcase.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
export default function ProjectShowcase({ images }: { images: string[] }) {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 h-[500px]">
|
||||
{/* Large Featured Image (Left 9 Columns) */}
|
||||
<div className="lg:col-span-9 relative overflow-hidden rounded-3xl border border-neutral-800 bg-neutral-900 group">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.img
|
||||
key={index}
|
||||
src={images[index]}
|
||||
initial={{ opacity: 0, scale: 1.05 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Subtle Overlay Label */}
|
||||
<div className="absolute bottom-4 left-4 bg-black/60 backdrop-blur-md px-3 py-1.5 rounded-full border border-white/10 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-[10px] font-mono uppercase tracking-widest text-white/70">
|
||||
View {index + 1} of {images.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Column (Right 3 Columns) */}
|
||||
<div className="lg:col-span-3 flex lg:flex-col gap-3 overflow-x-auto lg:overflow-y-auto pr-2 custom-scrollbar">
|
||||
{images.map((img, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`relative flex-shrink-0 w-24 lg:w-full aspect-video rounded-xl border-2 transition-all overflow-hidden ${
|
||||
i === index
|
||||
? "border-blue-500 ring-4 ring-blue-500/10"
|
||||
: "border-neutral-800 opacity-40 hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<img src={img} className="h-full w-full object-cover" alt={`Thumb ${i}`} />
|
||||
{i === index && (
|
||||
<motion.div
|
||||
layoutId="active-thumb"
|
||||
className="absolute inset-0 bg-blue-500/10 z-10"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +1,72 @@
|
|||
import { Project } from "@/types/project";
|
||||
|
||||
export const PROJECT_REGISTRY: Project[] = [
|
||||
{
|
||||
{
|
||||
slug: "ratoong",
|
||||
category: "web",
|
||||
title: "Ratoong",
|
||||
subtitle: "Regulatory-Compliant Data Platform",
|
||||
role: "Lead Full-Stack Engineer",
|
||||
duration: "2022 — 2024",
|
||||
stack: ["Node.js", "PostgreSQL", "React", "Docker"],
|
||||
metrics: ["99.9% Uptime", "Zero-Data-Loss Integrity", "ISO 27001 Ready"],
|
||||
description: "Architected a high-integrity platform designed to meet rigid regulatory requirements.",
|
||||
engineeringStory: "In this high-stakes medical environment, I implemented a custom audit logging system that ensured every state change was immutable...",
|
||||
images: ["/ratoong-hero.jpg", "/ratoong-dashboard.jpg"],
|
||||
subtitle: "High-Performance Ski & Travel Engine",
|
||||
role: "Full-Stack Engineer",
|
||||
duration: "2020 — 2022", // Adjusted based on your "long time ago" comment
|
||||
stack: ["Angular", "Firebase", "GCP Cloud Functions", "TypeScript"],
|
||||
metrics: ["< 200ms Search Latency", "10,000+ Active Data Points", "Fully Responsive Design"],
|
||||
description: "A comprehensive ski resort planning and rating platform featuring real-time weather integration and complex multi-parameter search filters.",
|
||||
engineeringStory: `
|
||||
Building Ratoong was an exercise in managing **High-Density Data** within a reactive frontend ecosystem. The core challenge was transforming thousands of resort data points—ranging from piste lengths to real-time weather—into a lightning-fast, searchable interface.
|
||||
|
||||
#### Data Orchestration & Efficiency
|
||||
Leveraging a **Document-Based Architecture (Firestore)**, I designed a schema that balanced read efficiency with real-time updates. To handle complex filtering (altitude, lift types, pricing) without taxing the client-side, I utilized **GCP Cloud Functions** as a middleware layer to process and normalize data from various 3rd-party APIs, including Google Maps and Weather services.
|
||||
|
||||
#### Modern Angular & Responsive UI
|
||||
The frontend was built using modern **Angular**, focusing on a component-based architecture that ensured high performance across both desktop and mobile. I implemented a custom state management flow to handle resort ratings and trip planning, ensuring that user interactions were instantly reflected in the UI while syncing seamlessly with **Firebase Authentication**.
|
||||
|
||||
#### Lessons in Scalability
|
||||
Working with a **Backend-as-a-Service (BaaS)** model taught me the importance of cost-effective query design and the power of event-driven triggers. I was responsible for maintaining the development, staging, and production environments, ensuring a clean CI/CD flow from localhost to the Firebase cloud.
|
||||
|
||||
#### Security & Data Governance
|
||||
A key architectural pillar was the implementation of a robust **Security Rules** layer within Firebase. By moving the logic from the client to the database level, we ensured that resort metadata was globally searchable while sensitive user planning data remained strictly isolated. This event-driven security model allowed us to scale the user base without increasing the risk surface area of the platform.
|
||||
`,
|
||||
images: [
|
||||
"/projects/ratoong/ratoong-1.jpg",
|
||||
"/projects/ratoong/ratoong-2.jpg",
|
||||
"/projects/ratoong/ratoong-3.jpg",
|
||||
"/projects/ratoong/ratoong-4.jpg",
|
||||
"/projects/ratoong/ratoong-5.jpg"
|
||||
],
|
||||
liveUrl: "https://ratoong.com",
|
||||
isPrivate: true,
|
||||
isPrivate: false,
|
||||
mermaidChart: `
|
||||
graph TD
|
||||
subgraph Client_Side [Frontend]
|
||||
A[Angular Web App]:::traffic
|
||||
end
|
||||
|
||||
subgraph Firebase_GCP [Cloud Infrastructure]
|
||||
B[Firebase Auth]:::node
|
||||
C[Firestore DB]:::node
|
||||
D[Cloud Functions]:::node
|
||||
E[Partner API Proxy]:::node
|
||||
end
|
||||
|
||||
%% Move APIs to a vertical stack to save horizontal width %%
|
||||
subgraph External [Third Party]
|
||||
F[Weather API]:::traffic
|
||||
G[Google Maps API]:::traffic
|
||||
H[Affiliate Partners]:::traffic
|
||||
end
|
||||
|
||||
A <-->|Real-time Sync| C
|
||||
A -->|Auth Request| B
|
||||
A -->|Triggers| D
|
||||
D -->|Fetch & Normalize| F
|
||||
D -->|Geocoding| G
|
||||
D -->|Affiliate Logic| H
|
||||
E -->|Read Data| C
|
||||
|
||||
%% Styles %%
|
||||
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
|
||||
classDef node fill:#16a34a,stroke:#4285F4,color:#fff
|
||||
`,
|
||||
},
|
||||
{
|
||||
slug: "datasaur",
|
||||
|
|
|
|||
7836
package-lock.json
generated
7836
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -13,17 +13,20 @@
|
|||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"next": "16.1.4",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.4",
|
||||
"tailwindcss": "^4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
public/projects/ratoong/ratoong-1.jpg
Normal file
BIN
public/projects/ratoong/ratoong-1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 345 KiB |
BIN
public/projects/ratoong/ratoong-2.jpg
Normal file
BIN
public/projects/ratoong/ratoong-2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 251 KiB |
BIN
public/projects/ratoong/ratoong-3.jpg
Normal file
BIN
public/projects/ratoong/ratoong-3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
BIN
public/projects/ratoong/ratoong-4.jpg
Normal file
BIN
public/projects/ratoong/ratoong-4.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 310 KiB |
BIN
public/projects/ratoong/ratoong-5.jpg
Normal file
BIN
public/projects/ratoong/ratoong-5.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
|
|
@ -12,5 +12,6 @@ export interface Project {
|
|||
images: string[];
|
||||
liveUrl?: string;
|
||||
repoUrl?: string;
|
||||
mermaidChart?: string;
|
||||
isPrivate: boolean;
|
||||
}
|
||||
Loading…
Reference in a new issue