refactor: clean up UI, add carousel for Projects

This commit is contained in:
2026-02-28 04:16:50 -05:00
parent cb2cc830be
commit 6ef3407173
3 changed files with 1651 additions and 172 deletions
+1651 -172
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount, tick } from "svelte";
// Portfolio Data
const profile = {
@@ -40,7 +40,7 @@
"Custom web interface for browsing and handling music, configuration, and imports",
"Open source and designed for self-hosting on personal servers",
],
image: "https://m.media-amazon.com/images/I/51i0m01RSxL.png",
image: "/jellyfin.webp",
},
{
name: "RUSwipeShare",
@@ -163,16 +163,223 @@
"Fixes inconsistent BeReal timestamps using default timezone settings",
"Flexible command-line options with ExifTool integration",
],
image: "/BeRealExportManager.webp",
image: "/bereal.webp",
},
];
type Project = (typeof projects)[number];
type ProjectCategory = "All" | "Systems" | "Mobile" | "Web" | "Open Source";
type ProjectSort = "Featured" | "Newest";
const projectCategories: ProjectCategory[] = [
"All",
"Systems",
"Mobile",
"Web",
"Open Source",
];
const featuredProjectNames = new Set<string>([
"Allstarr",
"RUSwipeShare",
"BlueBubbles Contribution",
"Maisie Heardle",
"BeReal Export Manager",
]);
const projectOrder = new Map(
projects.map((project, index) => [project.name, index]),
);
function inferProjectCategories(project: Project): ProjectCategory[] {
const categories = new Set<ProjectCategory>();
const stack = project.techStack.map((tech) => tech.toLowerCase()).join(" ");
const text = `${project.description.toLowerCase()} ${project.name.toLowerCase()}`;
if (
stack.includes("flutter") ||
stack.includes("android") ||
stack.includes("ios") ||
text.includes("android")
) {
categories.add("Mobile");
}
if (
stack.includes("svelte") ||
stack.includes("react") ||
stack.includes("html") ||
stack.includes("css") ||
stack.includes("javascript") ||
stack.includes("leaflet")
) {
categories.add("Web");
}
if (
stack.includes("c#") ||
stack.includes(".net") ||
stack.includes("python") ||
stack.includes("rust") ||
stack.includes("go") ||
text.includes("server") ||
text.includes("self-hosted") ||
text.includes("utility")
) {
categories.add("Systems");
}
if (
text.includes("open-source") ||
text.includes("open source") ||
text.includes("fork") ||
text.includes("contributed")
) {
categories.add("Open Source");
}
if (categories.size === 0) {
categories.add("Web");
}
return Array.from(categories);
}
const projectsWithMeta = projects.map((project) => ({
...project,
categories: inferProjectCategories(project),
featured: featuredProjectNames.has(project.name),
}));
const projectScreenshotMap: Record<string, string[]> = {
Allstarr: ["/jellyfin.webp"],
RUSwipeShare: ["/RuSwipeShare.webp", "/RuSwipeShare.png"],
TrackCovid19: ["/TrackCovid19.webp", "/TrackCovid19.png"],
"BlueBubbles Contribution": ["/BlueBubbles.webp", "/BlueBubbles.png"],
"Terminal Portfolio": ["/favicon.png"],
"VideoSpeed Extension (Fork)": ["/VideoSpeed.webp", "/VideoSpeed.png"],
"Maisie Heardle": ["/MaisieHeardle.webp", "/MaisieHeardle.png"],
"Fair Housing Map": ["/FairHousingMap.webp", "/FairHousingMap.png"],
"BeReal Export Manager": ["/bereal.webp", "/BeRealExportManager.webp"],
};
function screenshotKey(path: string): string {
const name = path.split("/").pop() ?? path;
return name.replace(/\.[^/.]+$/, "").toLowerCase();
}
function uniqueScreenshots(paths: string[]): string[] {
const seen = new Set<string>();
const deduped: string[] = [];
paths.forEach((path) => {
const key = screenshotKey(path);
if (seen.has(key)) return;
seen.add(key);
deduped.push(path);
});
return deduped;
}
const projectsWithCaseStudy = projectsWithMeta.map((project) => ({
...project,
caseStudy: {
problem: project.description,
contribution: project.highlights.slice(0, 3),
impactMetrics: [
`${project.techStack.length}+ technologies integrated`,
`${project.highlights.length} key capabilities shipped`,
project.featured
? "Featured portfolio project with production-ready scope"
: "Public repository with reproducible implementation",
],
screenshots: uniqueScreenshots(
projectScreenshotMap[project.name] ?? [project.image],
),
},
}));
const bookCallUrl = "mailto:joshpatra12@gmail.com?subject=Book%20a%20Call";
const nowBuilding = [
{
title: "Allstarr Provider Integrations",
status: "In Progress",
description:
"Expanding playlist imports and provider compatibility for self-hosted music workflows.",
},
{
title: "Portfolio v2 UX",
status: "In Progress",
description:
"Adding case-study storytelling, command palette, and structured project deep-dives.",
},
{
title: "Systems + Security Focus",
status: "Active",
description:
"Applying Rutgers systems/security coursework directly into production and open-source projects.",
},
];
const testimonials = [
{
source: "Internship Reference",
role: "Bergen's Promise",
text: "Reference available on request. Focus areas included healthcare data analysis and quality compliance support.",
},
{
source: "Open Source Collaboration",
role: "Community Projects",
text: "Reference available on request. Contributions emphasized UI polish, infrastructure migration, and release support.",
},
{
source: "Academic Mentorship",
role: "Rutgers CS / Philosophy",
text: "Reference available on request. Emphasis on systems rigor, communication, and project delivery discipline.",
},
];
const devNotes = [
{
title: "Designing a Better Project Carousel",
date: "February 2026",
summary:
"How I shifted from oversized cards to a compact track with smoother interaction and clearer hierarchy.",
link: "#projects",
},
{
title: "Lessons from Self-Hosted Music Infrastructure",
date: "January 2026",
summary:
"Tradeoffs in API integration, metadata sync, and reliable playback architecture in Allstarr.",
link: "https://github.com/SoPat712/allstarr",
},
{
title: "Why Systems Thinking Improves Product UX",
date: "December 2025",
summary:
"Applying low-level engineering constraints to create faster, more dependable user-facing experiences.",
link: "https://github.com/SoPat712",
},
];
type NavSection = { id: string; label: string };
const navSections: NavSection[] = [
{ id: "home", label: "home" },
{ id: "projects", label: "projects" },
{ id: "now", label: "now" },
{ id: "activity", label: "activity" },
{ id: "experience", label: "experience" },
{ id: "skills", label: "skills" },
{ id: "contact", label: "contact" },
];
// Education Info
const education = {
university: "Rutgers, The State University of New Jersey - New Brunswick",
degree: "Bachelor of Arts in Computer Science and Philosophy",
graduation: "Expected May 2026",
gpa: "3.7/4.0",
gpa: "3.75/4.0",
courses: [
{
code: "01:198:428",
@@ -266,9 +473,16 @@
};
const currentYear = new Date().getFullYear();
const quickStats = [
{ label: "Years Coding", value: "10+" },
{ label: "Open-source Commits", value: "999+" },
{ label: "Languages Known", value: `${skills.languages.length}+` },
];
// Active section for navigation
let activeSection: string = "home";
let sectionObserver: IntersectionObserver | null = null;
function navigateTo(section: string) {
activeSection = section;
const el = document.getElementById(section);
@@ -277,12 +491,41 @@
}
}
function initSectionObserver() {
const sectionIds = navSections.map((item) => item.id);
sectionObserver = new IntersectionObserver(
(entries) => {
const visibleEntries = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
if (visibleEntries[0]?.target.id) {
activeSection = visibleEntries[0].target.id;
}
},
{
root: null,
rootMargin: "-30% 0px -50% 0px",
threshold: [0.25, 0.5, 0.75],
},
);
sectionIds.forEach((id) => {
const node = document.getElementById(id);
if (node) {
sectionObserver?.observe(node);
}
});
}
// Terminal
let terminalHistory: Array<{ command: string; output: string }> = [
{ command: "whoami", output: profile.name },
{
command: "ls -la",
output: "projects education achievements experience skills contact",
output:
"projects now activity education achievements experience skills notes contact",
},
];
let currentCommand: string = "";
@@ -294,6 +537,463 @@
let typedRole: string = "";
let bioVisible: boolean = false;
// Projects carousel state
let selectedProjectCategory: ProjectCategory = "All";
let projectSort: ProjectSort = "Featured";
let currentProjectIndex = 0;
let expandedProjectName: string | null = null;
let touchStartX = 0;
let touchEndX = 0;
let carouselViewport: HTMLDivElement | null = null;
let projectSlideElements: Array<HTMLElement | null> = [];
let scrollRaf = 0;
let scrollSettleTimer: ReturnType<typeof setTimeout> | null = null;
let pendingProjectIndex = 0;
let isProjectModalOpen = false;
let selectedProject: (typeof projectsWithCaseStudy)[number] | null = null;
// Command palette
let isCommandPaletteOpen = false;
let commandQuery = "";
let commandPaletteIndex = 0;
let commandPaletteInput: HTMLInputElement | null = null;
type CommandPaletteItem = {
id: string;
label: string;
description: string;
type: "section" | "project" | "link";
target: string;
};
const sectionCommandItems: CommandPaletteItem[] = navSections.map((item) => ({
id: `section-${item.id}`,
label: `Go to ${item.label}`,
description: "Jump to section",
type: "section",
target: item.id,
}));
const projectCommandItems: CommandPaletteItem[] = projectsWithCaseStudy.map(
(project) => ({
id: `project-${project.name}`,
label: project.name,
description: "Open case study modal",
type: "project",
target: project.name,
}),
);
const linkCommandItems: CommandPaletteItem[] = [
{
id: "link-resume",
label: "Download Resume",
description: "Open resume PDF",
type: "link",
target: "/Josh_Patra_Resume.pdf",
},
{
id: "link-book-call",
label: "Book a Call",
description: "Open scheduling contact",
type: "link",
target: bookCallUrl,
},
];
$: commandPaletteItems = [
...sectionCommandItems,
...projectCommandItems,
...linkCommandItems,
];
$: filteredCommandPaletteItems = commandPaletteItems.filter((item) => {
const q = commandQuery.trim().toLowerCase();
if (!q) return true;
return (
item.label.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q)
);
});
$: commandPaletteIndex = Math.min(
commandPaletteIndex,
Math.max(filteredCommandPaletteItems.length - 1, 0),
);
// GitHub snapshot
type GithubRepo = {
name: string;
html_url: string;
description: string | null;
language: string | null;
stargazers_count: number;
updated_at: string;
};
const fallbackGithubRepos: GithubRepo[] = [
{
name: "allstarr",
html_url: "https://github.com/SoPat712/allstarr",
description:
"Self-hosted music streaming server with Jellyfin integration",
language: "C#",
stargazers_count: 0,
updated_at: new Date().toISOString(),
},
{
name: "my-portfolio",
html_url: "https://github.com/SoPat712/my-portfolio",
description: "Terminal-inspired Svelte portfolio",
language: "TypeScript",
stargazers_count: 0,
updated_at: new Date().toISOString(),
},
];
let githubRepos: GithubRepo[] = fallbackGithubRepos;
let githubLoading = true;
let githubError = "";
const projectImageSourceSizes: Record<
string,
{ width: number; height: number }
> = {
Allstarr: { width: 500, height: 500 },
RUSwipeShare: { width: 1024, height: 1024 },
TrackCovid19: { width: 500, height: 500 },
"BlueBubbles Contribution": { width: 1024, height: 1024 },
"Terminal Portfolio": { width: 512, height: 512 },
"VideoSpeed Extension (Fork)": { width: 128, height: 128 },
"Maisie Heardle": { width: 1500, height: 1500 },
"Fair Housing Map": { width: 1500, height: 1500 },
"BeReal Export Manager": { width: 249, height: 243 },
};
const knownProjectImageWidths = Object.values(projectImageSourceSizes).map(
(size) => size.width,
);
const minKnownProjectImageWidth = Math.min(...knownProjectImageWidths);
const maxKnownProjectImageWidth = Math.max(...knownProjectImageWidths);
const swipeThreshold = 40;
function sortedProjects<
T extends Project & { categories: ProjectCategory[]; featured: boolean },
>(list: T[]) {
const copy = [...list];
if (projectSort === "Featured") {
return copy.sort((a, b) => {
const featuredDiff = Number(b.featured) - Number(a.featured);
if (featuredDiff !== 0) return featuredDiff;
return (
(projectOrder.get(a.name) ?? 0) - (projectOrder.get(b.name) ?? 0)
);
});
}
return copy.sort(
(a, b) =>
(projectOrder.get(a.name) ?? 0) - (projectOrder.get(b.name) ?? 0),
);
}
$: filteredProjects =
selectedProjectCategory === "All"
? projectsWithCaseStudy
: projectsWithCaseStudy.filter((project) =>
project.categories.includes(selectedProjectCategory),
);
$: carouselProjects = sortedProjects(filteredProjects);
$: currentProject = carouselProjects[currentProjectIndex] ?? null;
$: projectSlideElements = projectSlideElements.slice(
0,
carouselProjects.length,
);
$: if (currentProjectIndex > Math.max(carouselProjects.length - 1, 0)) {
currentProjectIndex = 0;
}
function setProjectCategory(category: ProjectCategory) {
selectedProjectCategory = category;
currentProjectIndex = 0;
expandedProjectName = null;
void scrollToProject(0, "auto");
}
function setProjectSort(sort: ProjectSort) {
projectSort = sort;
currentProjectIndex = 0;
expandedProjectName = null;
void scrollToProject(0, "auto");
}
function previousCarouselProject() {
if (carouselProjects.length < 2) return;
currentProjectIndex =
(currentProjectIndex - 1 + carouselProjects.length) %
carouselProjects.length;
expandedProjectName = null;
void scrollToProject(currentProjectIndex);
}
function nextCarouselProject() {
if (carouselProjects.length < 2) return;
currentProjectIndex = (currentProjectIndex + 1) % carouselProjects.length;
expandedProjectName = null;
void scrollToProject(currentProjectIndex);
}
function jumpToProject(index: number) {
currentProjectIndex = index;
expandedProjectName = null;
void scrollToProject(index);
}
function toggleProjectDetails(name: string) {
expandedProjectName = expandedProjectName === name ? null : name;
}
function handleDetailsButtonClick(index: number, name: string) {
if (currentProjectIndex !== index) {
jumpToProject(index);
return;
}
toggleProjectDetails(name);
}
function openProjectModal(project: (typeof projectsWithCaseStudy)[number]) {
selectedProject = project;
isProjectModalOpen = true;
}
function closeProjectModal() {
isProjectModalOpen = false;
selectedProject = null;
}
function handleProjectCardClick(index: number) {
const project = carouselProjects[index];
if (!project) return;
if (currentProjectIndex !== index) {
jumpToProject(index);
return;
}
openProjectModal(project);
}
function openCommandPalette() {
isCommandPaletteOpen = true;
commandQuery = "";
commandPaletteIndex = 0;
void tick().then(() => {
commandPaletteInput?.focus();
});
}
function closeCommandPalette() {
isCommandPaletteOpen = false;
commandQuery = "";
commandPaletteIndex = 0;
}
function executeCommandPaletteItem(item: CommandPaletteItem) {
if (item.type === "section") {
navigateTo(item.target);
} else if (item.type === "project") {
const project = projectsWithCaseStudy.find((p) => p.name === item.target);
if (project) {
openProjectModal(project);
}
} else if (item.type === "link") {
window.open(item.target, "_blank", "noopener,noreferrer");
}
closeCommandPalette();
}
async function loadGithubSnapshot() {
githubLoading = true;
githubError = "";
try {
const perPage = 100;
let page = 1;
let allRepos: GithubRepo[] = [];
while (true) {
const response = await fetch(
`https://api.github.com/users/SoPat712/repos?sort=updated&direction=desc&per_page=${perPage}&page=${page}`,
);
if (!response.ok) {
throw new Error(`GitHub API returned ${response.status}`);
}
const reposPage = (await response.json()) as GithubRepo[];
if (!reposPage.length) {
break;
}
allRepos = [...allRepos, ...reposPage];
if (reposPage.length < perPage || page > 25) {
break;
}
page += 1;
}
githubRepos = allRepos
.sort(
(a, b) =>
b.stargazers_count - a.stargazers_count ||
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
)
.slice(0, 6);
} catch (error) {
githubError =
"Live GitHub data unavailable. Showing cached portfolio repos.";
githubRepos = fallbackGithubRepos;
console.error("GitHub snapshot load failed", error);
} finally {
githubLoading = false;
}
}
function getProjectImageSourceSize(projectName: string) {
return projectImageSourceSizes[projectName] ?? { width: 512, height: 512 };
}
function getProjectImagePaneWidth(projectName: string) {
const { width } = getProjectImageSourceSize(projectName);
if (maxKnownProjectImageWidth <= minKnownProjectImageWidth) return 220;
const normalized =
(width - minKnownProjectImageWidth) /
(maxKnownProjectImageWidth - minKnownProjectImageWidth);
// Keep card width stable, but bias more horizontal space to images.
return Math.round(290 + normalized * 210);
}
function handleCarouselTouchStart(e: TouchEvent) {
touchStartX = e.changedTouches[0]?.clientX ?? 0;
}
function handleCarouselTouchEnd(e: TouchEvent) {
touchEndX = e.changedTouches[0]?.clientX ?? 0;
if (Math.abs(touchStartX - touchEndX) < swipeThreshold) return;
if (touchStartX > touchEndX) {
nextCarouselProject();
return;
}
previousCarouselProject();
}
async function scrollToProject(
index: number,
behavior: ScrollBehavior = "smooth",
) {
await tick();
const projectSlide = projectSlideElements[index];
projectSlide?.scrollIntoView({
behavior,
block: "nearest",
inline: "center",
});
}
function handleCarouselScroll() {
if (!carouselViewport || projectSlideElements.length === 0) return;
if (scrollRaf) {
cancelAnimationFrame(scrollRaf);
}
scrollRaf = requestAnimationFrame(() => {
if (!carouselViewport) return;
const viewportBounds = carouselViewport.getBoundingClientRect();
const viewportCenter = viewportBounds.left + viewportBounds.width / 2;
let bestIndex = currentProjectIndex;
let minDistance = Number.POSITIVE_INFINITY;
projectSlideElements.forEach((slide, index) => {
if (!slide) return;
const bounds = slide.getBoundingClientRect();
const slideCenter = bounds.left + bounds.width / 2;
const distance = Math.abs(slideCenter - viewportCenter);
if (distance < minDistance) {
minDistance = distance;
bestIndex = index;
}
});
pendingProjectIndex = bestIndex;
if (scrollSettleTimer) {
clearTimeout(scrollSettleTimer);
}
scrollSettleTimer = setTimeout(() => {
currentProjectIndex = pendingProjectIndex;
}, 120);
scrollRaf = 0;
});
}
function handleGlobalKeydown(e: KeyboardEvent) {
const isMetaK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
if (isMetaK) {
e.preventDefault();
if (isCommandPaletteOpen) {
closeCommandPalette();
} else {
openCommandPalette();
}
return;
}
if (isCommandPaletteOpen) {
if (e.key === "Escape") {
closeCommandPalette();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
commandPaletteIndex = Math.min(
commandPaletteIndex + 1,
Math.max(filteredCommandPaletteItems.length - 1, 0),
);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
commandPaletteIndex = Math.max(commandPaletteIndex - 1, 0);
return;
}
if (e.key === "Enter") {
const item = filteredCommandPaletteItems[commandPaletteIndex];
if (item) {
e.preventDefault();
executeCommandPaletteItem(item);
}
return;
}
}
if (isProjectModalOpen && e.key === "Escape") {
closeProjectModal();
return;
}
if (isProjectModalOpen) {
return;
}
const activeTag = (document.activeElement?.tagName || "").toLowerCase();
if (
activeTag === "input" ||
activeTag === "textarea" ||
activeTag === "select"
)
return;
if (e.key === "ArrowLeft") {
previousCarouselProject();
} else if (e.key === "ArrowRight") {
nextCarouselProject();
}
}
function typeWriter(
text: string,
setter: (val: string) => void,
@@ -312,28 +1012,48 @@
});
}
onMount(async () => {
if (terminalInput) {
terminalInput.focus();
}
// Type the name
await typeWriter(
profile.name,
(val: string) => {
typedName = val;
},
150,
);
// Type the role
await typeWriter(
profile.role,
(val: string) => {
typedRole = val;
},
50,
);
// Once typing is done, show the bio
bioVisible = true;
onMount(() => {
let isActive = true;
window.addEventListener("keydown", handleGlobalKeydown);
const startTypeAnimation = async () => {
if (terminalInput) {
terminalInput.focus();
}
await typeWriter(
profile.name,
(val: string) => {
if (isActive) typedName = val;
},
150,
);
await typeWriter(
profile.role,
(val: string) => {
if (isActive) typedRole = val;
},
50,
);
if (isActive) {
bioVisible = true;
}
};
startTypeAnimation();
initSectionObserver();
void loadGithubSnapshot();
return () => {
isActive = false;
if (scrollRaf) {
cancelAnimationFrame(scrollRaf);
}
if (scrollSettleTimer) {
clearTimeout(scrollSettleTimer);
}
sectionObserver?.disconnect();
window.removeEventListener("keydown", handleGlobalKeydown);
};
});
function executeCommand() {
@@ -348,7 +1068,7 @@
- clear: Clear terminal
- whoami: Display name
- ls: List sections
- cat [section]: View section (projects, education, achievements, experience, skills)
- cat [section]: View section (projects, now, activity, education, achievements, experience, skills, notes)
- contact: Display contact info`;
} else if (cmd === "clear") {
terminalHistory = [];
@@ -357,12 +1077,19 @@
} else if (cmd === "whoami") {
output = profile.name;
} else if (cmd === "ls" || cmd === "ls -la") {
output = "projects education achievements experience skills contact";
output =
"projects now activity education achievements experience skills notes contact";
} else if (cmd.startsWith("cat ")) {
const section = cmd.substring(4);
if (section === "projects") {
navigateTo("projects");
output = "Navigating to projects section...";
} else if (section === "now") {
navigateTo("now");
output = "Navigating to now section...";
} else if (section === "activity") {
navigateTo("activity");
output = "Navigating to activity section...";
} else if (section === "education") {
navigateTo("education");
output = "Navigating to education section...";
@@ -375,6 +1102,9 @@
} else if (section === "skills") {
navigateTo("skills");
output = "Navigating to skills section...";
} else if (section === "notes") {
navigateTo("notes");
output = "Navigating to notes section...";
} else {
output = `cat: ${section}: No such file or directory`;
}
@@ -414,14 +1144,52 @@
let userName: string = "";
let userEmail: string = "";
let userMessage: string = "";
function sendMail() {
const subject = `Portfolio Contact from ${userName}`;
const body = `Name: ${userName}, Email: ${userEmail}\n\n${userMessage}`;
let contactErrors: { name: string; email: string; message: string } = {
name: "",
email: "",
message: "",
};
function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function validateContactForm(): boolean {
contactErrors = { name: "", email: "", message: "" };
if (userName.trim().length < 2) {
contactErrors.name = "Please enter your full name.";
}
if (!validateEmail(userEmail.trim())) {
contactErrors.email = "Please enter a valid email address.";
}
if (userMessage.trim().length < 20) {
contactErrors.message = "Message should be at least 20 characters.";
}
return (
!contactErrors.name && !contactErrors.email && !contactErrors.message
);
}
function submitContactForm() {
if (!validateContactForm()) {
return;
}
const trimmedName = userName.trim();
const trimmedEmail = userEmail.trim();
const trimmedMessage = userMessage.trim();
const subject = `Portfolio Contact from ${trimmedName}`;
const body = `Hi,\n\nI'm ${trimmedName}\n\nI'm reaching out about the below:\n\n${trimmedMessage}\n\nThanks,\n${trimmedName}\n${trimmedEmail}`;
const mailtoUrl = `mailto:joshpatra12@gmail.com?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = mailtoUrl;
userName = "";
userEmail = "";
userMessage = "";
contactErrors = { name: "", email: "", message: "" };
}
</script>
@@ -447,61 +1215,27 @@
</div>
<!-- Navigation -->
<nav class="mt-3 md:mt-0 w-full md:w-auto">
<ul class="flex flex-wrap md:space-x-6">
<li class="mr-6">
<button
class="text-blue-400 hover:underline"
on:click={() => navigateTo("home")}
>
home
</button>
</li>
<li class="mr-6">
<button
class="text-blue-400 hover:underline"
on:click={() => navigateTo("projects")}
>
projects
</button>
</li>
<li class="mr-6">
<button
class="text-blue-400 hover:underline"
on:click={() => navigateTo("education")}
>
education
</button>
</li>
<li class="mr-6">
<button
class="text-blue-400 hover:underline"
on:click={() => navigateTo("achievements")}
>
achievements
</button>
</li>
<li class="mr-6">
<button
class="text-blue-400 hover:underline"
on:click={() => navigateTo("experience")}
>
experience
</button>
</li>
<li class="mr-6">
<button
class="text-blue-400 hover:underline"
on:click={() => navigateTo("skills")}
>
skills
</button>
</li>
<ul class="flex flex-wrap items-center gap-2 md:gap-3">
{#each navSections as section}
<li>
<button
class={`px-2.5 py-1 rounded-md transition ${
activeSection === section.id
? "text-green-300 bg-green-900/40 border border-green-700/60"
: "text-blue-300 hover:text-blue-200 border border-transparent hover:border-gray-700"
}`}
on:click={() => navigateTo(section.id)}
>
{section.label}
</button>
</li>
{/each}
<li>
<button
class="text-blue-400 hover:underline"
on:click={() => navigateTo("contact")}
class="px-2.5 py-1 rounded-md text-gray-300 border border-gray-700 hover:border-green-600"
on:click={openCommandPalette}
>
contact
⌘K
</button>
</li>
</ul>
@@ -546,110 +1280,367 @@
<!-- Content Sections -->
<div class="max-w-6xl mx-auto px-4">
<!-- Home/About Section -->
<section id="home" class="py-16 border-b border-gray-800">
<div class="flex flex-col md:flex-row gap-8 items-center">
<div class="md:w-1/3">
<img
src={profile.avatar}
alt={profile.name}
class="rounded-lg w-64 h-64 object-cover mx-auto border-2 border-green-500"
/>
</div>
<div class="md:w-2/3">
<!-- Typewriter effect for Name and Role -->
<h1 class="text-4xl md:text-5xl font-bold mb-4">
<span class="text-green-400"> {typedName}</span>
<section
id="home"
class="pt-6 md:pt-8 pb-20 border-b border-gray-800 scroll-mt-16"
>
<div class="grid lg:grid-cols-[1.35fr_0.9fr] gap-8">
<div
class="bg-gray-900/70 border border-gray-700 rounded-2xl p-6 md:p-8"
>
<p class="text-green-400 mb-3 text-sm md:text-base">
joshp@portfolio:~$ intro --profile
</p>
<h1 class="text-4xl md:text-5xl font-bold mb-3 tracking-tight">
<span class="text-green-300"> {typedName}</span>
</h1>
<h2 class="text-xl md:text-2xl text-gray-400 mb-6">
{typedRole}
</h2>
<!-- Static bio (no fade in effect) -->
<p class="text-gray-300 leading-relaxed mb-8">
<h2 class="text-xl md:text-2xl text-gray-300 mb-5">{typedRole}</h2>
<p class="text-gray-300 leading-relaxed mb-6 max-w-3xl">
{profile.bio}
</p>
<div class="flex flex-wrap gap-4">
<a
href="https://cloud.joshpatra.me/s/7TcAf4FfwEiXcrF"
target="_blank"
rel="noopener noreferrer"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition"
>
View Resume
</a>
<div class="quick-stats-row mb-7">
{#each quickStats as stat}
<div class="stat-chip">
<span class="stat-chip-label">{stat.label}</span>
<span class="stat-chip-value">{stat.value}</span>
</div>
{/each}
</div>
<div class="flex flex-wrap gap-3">
<button
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition"
class="px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-md transition"
on:click={() => navigateTo("projects")}
>
View Projects
</button>
<button
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-md transition"
on:click={() => navigateTo("contact")}
<a
href="/Josh_Patra_Resume.pdf"
target="_blank"
rel="noopener noreferrer"
class="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-white rounded-md transition"
>
Contact Me
</button>
Download Resume
</a>
<a
href={bookCallUrl}
class="px-4 py-2.5 bg-gray-800 hover:bg-gray-700 text-white rounded-md transition border border-gray-600"
>
Book a Call
</a>
</div>
</div>
<aside class="bg-gray-900/70 border border-gray-700 rounded-2xl p-6">
<img
src={profile.avatar}
alt={profile.name}
class="rounded-xl w-full max-w-[340px] h-[340px] object-cover mx-auto border-2 border-green-500/70"
/>
<div class="mt-5 space-y-3 text-sm">
<div class="bg-gray-800/80 border border-gray-700 rounded-md p-3">
<p class="text-green-300">status</p>
<p class="text-gray-300">
Building systems-focused products and open-source tools.
</p>
</div>
<div class="bg-gray-800/80 border border-gray-700 rounded-md p-3">
<p class="text-green-300">focus</p>
<p class="text-gray-300">
Architecture, infrastructure, and production UX quality.
</p>
</div>
</div>
</aside>
</div>
</section>
<!-- Projects Section -->
<section id="projects" class="py-16 border-b border-gray-800 scroll-mt-16">
<h2 class="text-3xl font-bold mb-8 text-green-400"> Projects</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
{#each projects as project}
<div
class="project-card bg-gray-900 rounded-lg overflow-hidden border border-gray-700 transition-all duration-300"
<section id="projects" class="py-20 border-b border-gray-800 scroll-mt-16">
<div class="flex flex-col gap-6 mb-8">
<div
class="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6"
>
<div>
<h2 class="text-3xl font-bold text-green-400"> Projects</h2>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex flex-wrap gap-2">
{#each projectCategories as category}
<button
class={`px-3 py-1.5 rounded-full border text-sm transition ${
selectedProjectCategory === category
? "bg-green-600/20 border-green-500 text-green-300"
: "bg-gray-900 border-gray-700 text-gray-300 hover:border-green-600"
}`}
on:click={() => setProjectCategory(category)}
>
{category}
</button>
{/each}
</div>
<select
class="bg-gray-900 border border-gray-700 rounded-md px-3 py-2 text-sm text-gray-200"
bind:value={projectSort}
on:change={(e) =>
setProjectSort(
(e.currentTarget as HTMLSelectElement).value as ProjectSort,
)}
>
<option value="Featured">Sort: Featured</option>
<option value="Newest">Sort: Newest</option>
</select>
</div>
</div>
</div>
{#if currentProject}
<div class="relative">
<button
class="carousel-arrow left-0"
on:click={previousCarouselProject}
aria-label="Previous project"
>
<div class="h-full flex flex-col">
<div class="h-48">
<img
src={project.image}
alt={project.name}
class="w-full h-full object-cover"
loading="lazy"
/>
</div>
<div class="p-6 flex flex-col flex-grow">
<h3 class="text-2xl font-semibold mb-2">
<a
href={project.link}
target="_blank"
class="text-blue-400 hover:underline"
>
{project.name}
</a>
</h3>
<p class="text-gray-400 mb-4 flex-grow">
{project.description}
</p>
<div class="mb-4">
<h4 class="text-green-400 mb-2">Tech Stack:</h4>
<div class="flex flex-wrap gap-2">
{#each project.techStack as tech}
<span class="bg-gray-800 text-xs px-2 py-1 rounded"
>{tech}</span
&#8249;
</button>
<button
class="carousel-arrow right-0"
on:click={nextCarouselProject}
aria-label="Next project"
>
&#8250;
</button>
<div
class="carousel-viewport"
bind:this={carouselViewport}
on:touchstart={handleCarouselTouchStart}
on:touchend={handleCarouselTouchEnd}
on:scroll={handleCarouselScroll}
>
<div class="carousel-track">
{#each carouselProjects as project, index (project.name)}
<div
class={`carousel-project-card bg-gray-900/90 rounded-xl border overflow-hidden ${
currentProjectIndex === index
? "is-active border-green-600/40"
: "is-inactive border-gray-700"
}`}
style={`--image-pane-width: ${getProjectImagePaneWidth(project.name)}px;`}
bind:this={projectSlideElements[index]}
on:click={() => handleProjectCardClick(index)}
on:keydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleProjectCardClick(index);
}
}}
role="button"
tabindex="0"
aria-current={currentProjectIndex === index
? "true"
: undefined}
>
<div class="project-card-grid">
<div class="project-image-pane">
<img
src={project.image}
alt={project.name}
class="w-full h-full object-cover"
loading="lazy"
/>
</div>
<div class="project-text-pane p-5 md:p-6">
<div class="project-text-content">
<div class="flex flex-wrap items-center gap-2 mb-3">
{#if project.featured}
<span
class="bg-green-600/20 text-green-300 border border-green-700 text-xs px-2 py-1 rounded"
>Featured</span
>
{/if}
{#each project.categories as category}
<span
class="bg-gray-800 text-gray-300 text-xs px-2 py-1 rounded"
>{category}</span
>
{/each}
</div>
<h3 class="text-2xl font-semibold mb-2">
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:underline"
on:click|stopPropagation
>
{project.name}
</a>
</h3>
<p
class="project-description text-gray-300 leading-relaxed"
>
{project.description}
</p>
<div class="mt-4 flex flex-wrap gap-2">
{#each project.techStack.slice(0, 4) as tech}
<span class="bg-gray-800 text-xs px-2 py-1 rounded"
>{tech}</span
>
{/each}
{#if project.techStack.length > 4}
<span class="bg-gray-800 text-xs px-2 py-1 rounded">
+{project.techStack.length - 4} more
</span>
{/if}
</div>
{#if expandedProjectName === project.name && currentProjectIndex === index}
<div
class="mt-5 pt-4 border-t border-gray-700 details-panel"
>
<h4 class="text-green-400 mb-2">Key Features</h4>
<ul class="list-disc pl-5 text-gray-300 space-y-1">
{#each project.highlights as highlight}
<li>{highlight}</li>
{/each}
</ul>
</div>
{/if}
</div>
<div
class="project-actions mt-4 pt-3 border-t border-gray-700/70"
>
{/each}
<button
class="px-3 py-1.5 rounded-md border border-gray-600 hover:border-green-500 text-sm text-gray-200"
on:click|stopPropagation={() =>
handleDetailsButtonClick(index, project.name)}
>
{expandedProjectName === project.name &&
currentProjectIndex === index
? "Hide details"
: "Show details"}
</button>
</div>
</div>
</div>
</div>
<div>
<h4 class="text-green-400 mb-2">Key Features:</h4>
<ul class="list-disc pl-5 text-gray-300">
{#each project.highlights as highlight}
<li>{highlight}</li>
{/each}
</ul>
</div>
</div>
{/each}
</div>
</div>
</div>
<div class="mt-5 flex justify-center gap-2 flex-wrap">
{#each carouselProjects as project, index}
<button
class={`h-2 rounded-full transition-all ${
currentProjectIndex === index
? "w-8 bg-green-500"
: "w-2 bg-gray-600 hover:bg-gray-500"
}`}
on:click={() => jumpToProject(index)}
aria-label={`Jump to ${project.name}`}
title={project.name}
></button>
{/each}
</div>
{:else}
<div
class="bg-gray-900 border border-gray-700 rounded-lg p-6 text-gray-400"
>
No projects found for this filter.
</div>
{/if}
</section>
<!-- Now / Currently Building -->
<section id="now" class="py-20 border-b border-gray-800 scroll-mt-16">
<h2 class="text-3xl font-bold mb-8 text-green-400"> Now / Currently Building</h2>
<div class="grid md:grid-cols-3 gap-5">
{#each nowBuilding as item}
<article class="bg-gray-900 border border-gray-700 rounded-xl p-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-blue-300">{item.title}</h3>
<span class="text-xs px-2 py-1 rounded bg-green-900/50 text-green-300 border border-green-700/60">
{item.status}
</span>
</div>
<p class="text-gray-300">{item.description}</p>
</article>
{/each}
</div>
</section>
<!-- GitHub Activity -->
<section id="activity" class="py-20 border-b border-gray-800 scroll-mt-16">
<div class="flex items-center justify-between mb-8">
<h2 class="text-3xl font-bold text-green-400">
GitHub Activity Snapshot
{#if !githubLoading}
<span class="text-base text-gray-400 ml-2">({githubRepos.length} repos)</span>
{/if}
</h2>
<a
href="https://github.com/SoPat712"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-blue-300 hover:underline"
>
View full profile
</a>
</div>
{#if githubLoading}
<div class="bg-gray-900 border border-gray-700 rounded-xl p-6 text-gray-400">
Loading latest repositories...
</div>
{:else}
{#if githubError}
<p class="text-amber-300 text-sm mb-4">{githubError}</p>
{/if}
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each githubRepos as repo}
<a
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
class="bg-gray-900 border border-gray-700 rounded-xl p-5 hover:border-green-500/70 transition"
>
<h3 class="text-lg font-semibold text-blue-300 mb-2">{repo.name}</h3>
<p class="text-gray-300 text-sm mb-4 repo-description-clamp">
{repo.description || "No description provided."}
</p>
<div class="flex justify-between items-center text-xs text-gray-400">
<span>{repo.language || "N/A"}</span>
<span class="repo-stars">
<span class="repo-stars-icon" aria-hidden="true"></span>
<span class="repo-stars-count">{repo.stargazers_count}</span>
</span>
</div>
</a>
{/each}
</div>
{/if}
</section>
<!-- Testimonials -->
<section id="testimonials" class="py-20 border-b border-gray-800 scroll-mt-16">
<h2 class="text-3xl font-bold mb-8 text-green-400"> References</h2>
<div class="grid md:grid-cols-3 gap-5">
{#each testimonials as testimonial}
<article class="bg-gray-900 border border-gray-700 rounded-xl p-5">
<p class="text-gray-300 leading-relaxed mb-4">{testimonial.text}</p>
<p class="text-green-300 text-sm font-semibold">{testimonial.source}</p>
<p class="text-gray-400 text-xs">{testimonial.role}</p>
</article>
{/each}
</div>
</section>
<!-- Education Section -->
<section id="education" class="py-16 border-b border-gray-800 scroll-mt-16">
<section id="education" class="py-20 border-b border-gray-800 scroll-mt-16">
<h2 class="text-3xl font-bold mb-8 text-green-400"> Education</h2>
<div
class="bg-gray-900 rounded-lg overflow-hidden border border-gray-700 p-6"
@@ -686,7 +1677,7 @@
<!-- Achievements Section -->
<section
id="achievements"
class="py-16 border-b border-gray-800 scroll-mt-16"
class="py-20 border-b border-gray-800 scroll-mt-16"
>
<h2 class="text-3xl font-bold mb-8 text-green-400"> Achievements</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -707,7 +1698,7 @@
<!-- Experience Section -->
<section
id="experience"
class="py-16 border-b border-gray-800 scroll-mt-16"
class="py-20 border-b border-gray-800 scroll-mt-16"
>
<h2 class="text-3xl font-bold mb-8 text-green-400"> Experience</h2>
<div class="space-y-8">
@@ -732,7 +1723,7 @@
</section>
<!-- Skills Section -->
<section id="skills" class="py-16 border-b border-gray-800 scroll-mt-16">
<section id="skills" class="py-20 border-b border-gray-800 scroll-mt-16">
<h2 class="text-3xl font-bold mb-8 text-green-400"> Skills</h2>
<div class="space-y-8">
<div>
@@ -805,7 +1796,26 @@
</section>
<!-- Contact Section -->
<section id="contact" class="py-16 scroll-mt-16">
<!-- Dev Notes -->
<section id="notes" class="py-20 border-b border-gray-800 scroll-mt-16">
<h2 class="text-3xl font-bold mb-8 text-green-400"> Dev Notes</h2>
<div class="grid md:grid-cols-3 gap-5">
{#each devNotes as note}
<a
href={note.link}
target={note.link.startsWith("#") ? undefined : "_blank"}
rel={note.link.startsWith("#") ? undefined : "noopener noreferrer"}
class="bg-gray-900 border border-gray-700 rounded-xl p-5 hover:border-green-500/70 transition"
>
<p class="text-xs uppercase tracking-wide text-gray-400 mb-2">{note.date}</p>
<h3 class="text-lg font-semibold text-blue-300 mb-3">{note.title}</h3>
<p class="text-gray-300 text-sm leading-relaxed">{note.summary}</p>
</a>
{/each}
</div>
</section>
<section id="contact" class="py-20 scroll-mt-16">
<h2 class="text-3xl font-bold mb-8 text-green-400"> Contact</h2>
<div class="bg-gray-900 rounded-lg border border-gray-700 p-6">
<div class="flex flex-col md:flex-row gap-8">
@@ -851,7 +1861,7 @@
<h3 class="text-xl font-semibold mb-4 text-blue-400">
Send a Message
</h3>
<form class="space-y-4" on:submit|preventDefault={sendMail}>
<form class="space-y-4" on:submit|preventDefault={submitContactForm}>
<div>
<label for="name" class="block text-gray-400 mb-1">Name</label>
<input
@@ -861,6 +1871,9 @@
placeholder="Your name"
bind:value={userName}
/>
{#if contactErrors.name}
<p class="text-red-300 text-xs mt-1">{contactErrors.name}</p>
{/if}
</div>
<div>
<label for="email" class="block text-gray-400 mb-1">Email</label
@@ -872,6 +1885,9 @@
placeholder="Your email"
bind:value={userEmail}
/>
{#if contactErrors.email}
<p class="text-red-300 text-xs mt-1">{contactErrors.email}</p>
{/if}
</div>
<div>
<label for="message" class="block text-gray-400 mb-1"
@@ -884,20 +1900,146 @@
placeholder="Your message"
bind:value={userMessage}
></textarea>
{#if contactErrors.message}
<p class="text-red-300 text-xs mt-1">{contactErrors.message}</p>
{/if}
</div>
<button
type="submit"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition"
>
Send Message
Open Email Draft
</button>
</form>
<p class="text-xs text-gray-500 mt-3">
Prefer direct scheduling?
<a href={bookCallUrl} class="text-blue-300 hover:underline">Book a call</a>
</p>
</div>
</div>
</div>
</section>
</div>
{#if isCommandPaletteOpen}
<div class="overlay-shell">
<button
class="overlay-backdrop"
aria-label="Close command palette"
on:click={closeCommandPalette}
></button>
<div class="overlay-panel command-panel">
<div class="command-header">
<input
bind:this={commandPaletteInput}
bind:value={commandQuery}
class="command-input"
placeholder="Type a command or search projects..."
/>
<span class="text-xs text-gray-500">ESC to close</span>
</div>
<div class="command-results">
{#if filteredCommandPaletteItems.length === 0}
<p class="text-sm text-gray-400 p-3">No command matches your query.</p>
{:else}
{#each filteredCommandPaletteItems as item, index}
<button
class={`command-result-item ${index === commandPaletteIndex ? "is-active" : ""}`}
on:click={() => executeCommandPaletteItem(item)}
>
<span class="command-result-label">{item.label}</span>
<span class="command-result-description">{item.description}</span>
</button>
{/each}
{/if}
</div>
</div>
</div>
{/if}
{#if isProjectModalOpen && selectedProject}
<div class="overlay-shell">
<button
class="overlay-backdrop"
aria-label="Close case study modal"
on:click={closeProjectModal}
></button>
<article class="overlay-panel case-study-modal">
<header class="flex items-start justify-between gap-4 mb-5">
<div>
<p class="text-xs uppercase tracking-wide text-green-300 mb-2">Case Study</p>
<h3 class="text-2xl md:text-3xl font-bold text-blue-300">{selectedProject.name}</h3>
</div>
<button
class="rounded-md border border-gray-600 px-3 py-1.5 text-gray-200 hover:border-green-500"
on:click={closeProjectModal}
>
Close
</button>
</header>
<div class="grid md:grid-cols-2 gap-4 mb-6">
{#each selectedProject.caseStudy.screenshots as shot}
<img
src={shot}
alt={`${selectedProject.name} screenshot`}
class="w-full h-48 md:h-56 object-cover rounded-lg border border-gray-700"
loading="lazy"
/>
{/each}
</div>
<div class="space-y-6">
<section>
<h4 class="text-green-300 text-lg mb-2">Problem</h4>
<p class="text-gray-300 leading-relaxed">{selectedProject.caseStudy.problem}</p>
</section>
<section>
<h4 class="text-green-300 text-lg mb-2">Contribution</h4>
<ul class="list-disc pl-5 text-gray-300 space-y-1">
{#each selectedProject.caseStudy.contribution as item}
<li>{item}</li>
{/each}
</ul>
</section>
<section>
<h4 class="text-green-300 text-lg mb-2">Stack</h4>
<div class="flex flex-wrap gap-2">
{#each selectedProject.techStack as tech}
<span class="bg-gray-800 text-xs px-2 py-1 rounded border border-gray-700">{tech}</span>
{/each}
</div>
</section>
<section>
<h4 class="text-green-300 text-lg mb-2">Impact Metrics</h4>
<ul class="list-disc pl-5 text-gray-300 space-y-1">
{#each selectedProject.caseStudy.impactMetrics as metric}
<li>{metric}</li>
{/each}
</ul>
</section>
</div>
<footer class="mt-7 flex flex-wrap gap-3">
<a
href={selectedProject.link}
target="_blank"
rel="noopener noreferrer"
class="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-500 text-white"
>
View Repository
</a>
<button
class="px-4 py-2 rounded-md border border-gray-600 hover:border-green-500 text-gray-200"
on:click={closeProjectModal}
>
Back to Carousel
</button>
</footer>
</article>
</div>
{/if}
<!-- Footer -->
<footer class="bg-gray-900 border-t border-gray-800 mt-16 py-8">
<div class="max-w-6xl mx-auto px-4 text-center">
@@ -963,11 +2105,348 @@
}
}
.project-card:hover {
transform: translateY(-10px) scale(1.03);
.stat-chip {
display: inline-flex;
flex-direction: column;
gap: 0.2rem;
background: rgba(31, 41, 55, 0.9);
border: 1px solid rgba(55, 65, 81, 1);
border-radius: 0.65rem;
padding: 0.65rem 0.8rem;
min-width: 0;
width: 100%;
padding: 0.7rem 0.95rem;
}
.quick-stats-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.stat-chip-label {
color: #9ca3af;
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.stat-chip-value {
color: #d1d5db;
font-size: 1.03rem;
font-weight: 600;
}
.overlay-shell {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.overlay-backdrop {
position: absolute;
inset: 0;
background: rgba(3, 7, 18, 0.82);
border: 0;
}
.overlay-panel {
position: relative;
z-index: 1;
width: min(920px, 96vw);
max-height: min(88vh, 920px);
overflow-y: auto;
border: 1px solid #374151;
border-radius: 1rem;
background: #0b1224;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.45);
}
.command-panel {
width: min(760px, 96vw);
}
.command-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 1px solid #1f2937;
padding: 0.85rem;
}
.command-input {
width: 100%;
border: 1px solid #374151;
border-radius: 0.6rem;
background: #0f172a;
color: #f3f4f6;
padding: 0.65rem 0.75rem;
outline: none;
}
.command-input:focus {
border-color: #10b981;
}
.command-results {
padding: 0.65rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.command-result-item {
width: 100%;
text-align: left;
border: 1px solid #1f2937;
border-radius: 0.6rem;
padding: 0.7rem;
background: transparent;
color: #d1d5db;
display: flex;
justify-content: space-between;
gap: 0.75rem;
}
.command-result-item:hover,
.command-result-item.is-active {
border-color: rgba(16, 185, 129, 0.7);
background: rgba(17, 24, 39, 0.9);
}
.command-result-label {
font-weight: 600;
color: #c7d2fe;
}
.command-result-description {
color: #9ca3af;
font-size: 0.8rem;
}
.case-study-modal {
padding: 1.1rem 1.1rem 1.3rem;
}
.repo-description-clamp {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
line-clamp: 3;
}
.repo-stars {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: #facc15;
font-weight: 700;
}
.repo-stars-icon {
font-size: 1.1rem;
line-height: 1;
}
.repo-stars-count {
font-size: 0.98rem;
line-height: 1;
}
.carousel-viewport {
overflow-x: auto;
overflow-y: visible;
padding: 0.5rem 14%;
scroll-snap-type: x mandatory;
scrollbar-width: none;
-ms-overflow-style: none;
}
.carousel-viewport::-webkit-scrollbar {
display: none;
}
.carousel-track {
display: flex;
gap: 1rem;
align-items: stretch;
width: max-content;
}
.carousel-project-card {
flex: 0 0 auto;
width: min(92vw, 980px);
scroll-snap-align: center;
cursor: pointer;
box-shadow:
0 0 15px rgba(52, 211, 153, 0.3),
0 0 30px rgba(52, 211, 153, 0.2);
0 0 10px rgba(52, 211, 153, 0.08),
0 0 30px rgba(34, 197, 94, 0.04);
transition:
transform 0.45s cubic-bezier(0.22, 0.61, 0.36, 1),
opacity 0.45s cubic-bezier(0.22, 0.61, 0.36, 1),
box-shadow 0.35s ease,
border-color 0.35s ease;
}
.carousel-project-card.is-active {
opacity: 1;
transform: scale(1);
box-shadow:
0 0 20px rgba(52, 211, 153, 0.2),
0 0 50px rgba(52, 211, 153, 0.12);
}
.carousel-project-card.is-inactive {
opacity: 0.42;
transform: scale(0.94);
}
.carousel-project-card.is-inactive:hover {
opacity: 0.55;
border-color: rgba(52, 211, 153, 0.5);
}
.project-card-grid {
display: flex;
align-items: stretch;
height: 100%;
gap: 0.9rem;
padding-inline: 0.8rem;
padding-block: 0.55rem;
}
.project-image-pane {
flex: 0 0 var(--image-pane-width, 220px);
overflow: hidden;
position: relative;
min-height: 100%;
--image-inset: 0;
}
.project-image-pane img {
position: absolute;
inset: var(--image-inset);
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 0.45rem;
}
.project-text-pane {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
}
.project-text-content {
min-height: 0;
}
.project-actions {
flex-shrink: 0;
}
.project-description {
transition: color 0.25s ease;
}
.details-panel {
max-height: none;
overflow: visible;
}
.carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
height: 2rem;
width: 2rem;
font-size: 1rem;
line-height: 1;
border-radius: 9999px;
border: 1px solid #374151;
background: rgba(17, 24, 39, 0.92);
color: #e5e7eb;
z-index: 3;
transition:
border-color 0.2s ease,
transform 0.2s ease;
}
.carousel-arrow:hover {
border-color: #10b981;
transform: translateY(-50%) scale(1.04);
}
.carousel-arrow.left-0 {
left: 0.5rem;
}
.carousel-arrow.right-0 {
right: 0.5rem;
}
@media (min-width: 768px) {
.carousel-arrow.left-0 {
left: 1rem;
}
.carousel-arrow.right-0 {
right: 1rem;
}
}
@media (max-width: 1023px) {
.carousel-viewport {
padding-inline: 8%;
}
}
@media (min-width: 768px) {
.project-text-pane {
height: auto;
overflow: visible;
}
.project-text-content {
overflow: visible;
}
}
@media (max-width: 767px) {
.carousel-viewport {
padding-inline: 3%;
}
.project-card-grid {
display: block;
padding-inline: 0.45rem;
padding-block: 0.45rem;
gap: 0;
}
.project-image-pane {
width: 100%;
height: 12rem;
}
.project-text-pane {
width: auto;
height: auto;
}
.project-text-content {
overflow: visible;
}
.carousel-arrow {
display: none;
}
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB