mirror of
https://github.com/soconnor0919/personal-website.git
synced 2025-12-12 23:04:43 -05:00
CV Caching, new dashboard
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@ const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '2mb',
|
||||
bodySizeLimit: "2mb",
|
||||
},
|
||||
},
|
||||
webpack: (config) => {
|
||||
@@ -17,32 +17,24 @@ const nextConfig = {
|
||||
config.resolve.alias.encoding = false;
|
||||
return config;
|
||||
},
|
||||
// Add this section to disable linting during build
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
// Add this section to disable type checking during build
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
// Add this section
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/content/:path*',
|
||||
destination: '/api/content/:path*',
|
||||
source: "/content/:path*",
|
||||
destination: "/api/content/:path*",
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
|
||||
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||
formats: ['image/webp', 'image/avif'],
|
||||
formats: ["image/webp", "image/avif"],
|
||||
minimumCacheTTL: 60,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
protocol: "https",
|
||||
hostname: "**",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
10504
package-lock.json
generated
10504
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -7,7 +7,12 @@
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"lint": "next lint",
|
||||
"start": "next start"
|
||||
"lint:fix": "next lint --fix",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
@@ -24,6 +29,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@react-pdf/renderer": "^4.1.5",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@types/pdfjs-dist": "^2.10.377",
|
||||
"@vercel/analytics": "^1.3.2",
|
||||
"@vercel/speed-insights": "^1.0.14",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@@ -33,7 +39,7 @@
|
||||
"geist": "^1.3.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "^15.0.2",
|
||||
"pdfjs-dist": "^4.9.155",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"radix-ui": "^1.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
28
public/pdf.worker.min.js
vendored
28
public/pdf.worker.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,12 +1,22 @@
|
||||
@inproceedings{OConnor2025,
|
||||
title = {A Web-Based Wizard-of-Oz Platform for Collaborative and Reproducible Human-Robot Interaction Research},
|
||||
author = {Sean O'Connor and L. Felipe Perrone},
|
||||
year = {2025},
|
||||
booktitle = {34th IEEE International Conference on Robot and Human Interactive Communication},
|
||||
address = {Eindhoven, The Netherlands},
|
||||
abstract = {Human-robot interaction (HRI) research plays a pivotal role in shaping how robots communicate and collaborate with humans. However, conducting HRI studies can be challenging, particularly those employing the Wizard-of-Oz (WoZ) technique. WoZ user studies can have technical and methodological complexities that may render the results irreproducible. We propose to address these challenges with HRIStudio, a modular web-based platform designed to streamline the design, the execution, and the analysis of WoZ experiments. HRIStudio offers an intuitive interface for experiment creation, real-time control and monitoring during experimental runs, and comprehensive data logging and playback tools for analysis and reproducibility. By lowering technical barriers, promoting collaboration, and offering methodological guidelines, HRIStudio aims to make human-centered robotics research easier and empower researchers to develop scientifically rigorous user studies.},
|
||||
note = {Accepted for publication}
|
||||
}
|
||||
|
||||
@inproceedings{OConnor2024,
|
||||
title = {HRIStudio: A Framework for Wizard-of-Oz Experiments in Human-Robot Interaction Studies (Late Breaking Report)},
|
||||
author = {Sean O'Connor and L. Felipe Perrone},
|
||||
year = {2024},
|
||||
organization = {33rd IEEE International Conference on Robot and Human Interactive Communication},
|
||||
address = {Pasadena, CA, USA},
|
||||
abstract = {Human-robot interaction (HRI) research plays a pivotal role in shaping how robots communicate and collaborate with humans. However, conducting HRI studies, particularly those employing the Wizard-of-Oz (WoZ) technique, can be challenging. WoZ user studies can have complexities at the technical and methodological levels that may render the results irreproducible. We propose to address these challenges with HRIStudio, a novel web-based platform designed to streamline the design, execution, and analysis of WoZ experiments. HRIStudio offers an intuitive interface for experiment creation, real-time control and monitoring during experimental runs, and comprehensive data logging and playback tools for analysis and reproducibility. By lowering technical barriers, promoting collaboration, and offering methodological guidelines, HRIStudio aims to make human-centered robotics research easier, and at the same time, empower researchers to develop scientifically rigorous user studies.},
|
||||
url = {https://soconnor.dev/publications/hristudio-lbr.pdf},
|
||||
paperUrl = {/publications/hristudio-lbr.pdf},
|
||||
posterUrl = {/publications/hristudio-lbr-poster.pdf}
|
||||
}
|
||||
|
||||
|
||||
posterUrl = {/publications/hristudio-lbr-poster.pdf},
|
||||
note = {Late breaking report}
|
||||
}
|
||||
|
||||
117
src/app/api/pdf-proxy/route.ts
Normal file
117
src/app/api/pdf-proxy/route.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get("url");
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "URL parameter is required",
|
||||
usage: "Add ?url=<github-release-url> to test the proxy",
|
||||
example:
|
||||
"/api/pdf-proxy?url=https://github.com/user/repo/releases/download/latest/file.pdf",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate that the URL is from GitHub releases
|
||||
if (!url.includes("github.com") || !url.includes("/releases/download/")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only GitHub release URLs are allowed" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Follow redirects and fetch the actual file
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; PDF-Proxy/1.0)",
|
||||
Accept: "application/pdf,*/*",
|
||||
},
|
||||
redirect: "follow", // Follow redirects
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch PDF: ${response.status}` },
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
// Get the response buffer first
|
||||
const pdfBuffer = await response.arrayBuffer();
|
||||
|
||||
// Check if it's actually a PDF by looking at the file signature
|
||||
const uint8Array = new Uint8Array(pdfBuffer);
|
||||
|
||||
// More robust PDF signature check
|
||||
const firstBytes = Array.from(uint8Array.slice(0, 10));
|
||||
const pdfSignature = String.fromCharCode(...uint8Array.slice(0, 4));
|
||||
const extendedSignature = String.fromCharCode(...uint8Array.slice(0, 8));
|
||||
|
||||
// Only log in development
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log("PDF proxy - URL:", response.url);
|
||||
console.log("PDF proxy - Size:", pdfBuffer.byteLength, "bytes");
|
||||
console.log("PDF proxy - Signature:", JSON.stringify(pdfSignature));
|
||||
}
|
||||
|
||||
// Check for PDF signature - should start with "%PDF"
|
||||
const isPDF =
|
||||
pdfSignature === "%PDF" ||
|
||||
(uint8Array[0] === 0x25 && // %
|
||||
uint8Array[1] === 0x50 && // P
|
||||
uint8Array[2] === 0x44 && // D
|
||||
uint8Array[3] === 0x46); // F
|
||||
|
||||
if (!isPDF) {
|
||||
console.error(
|
||||
"PDF proxy - Invalid signature:",
|
||||
JSON.stringify(pdfSignature),
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Resource is not a valid PDF file. Got signature: ${JSON.stringify(pdfSignature)}`,
|
||||
debug: {
|
||||
signature: pdfSignature,
|
||||
firstBytes: firstBytes.slice(0, 4),
|
||||
size: pdfBuffer.byteLength,
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Also check content-type as secondary validation (but be more lenient)
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
||||
return new NextResponse(pdfBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Length": pdfBuffer.byteLength.toString(),
|
||||
"Cache-Control": "public, max-age=7200", // Cache for 2 hours
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Content-Disposition": "inline", // Display in browser instead of download
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("PDF proxy error:", error);
|
||||
return NextResponse.json({ error: "Failed to proxy PDF" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,108 +1,534 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '~/components/ui/tabs';
|
||||
import { Badge } from '~/components/ui/badge';
|
||||
import { Card, CardHeader, CardContent, CardTitle, CardDescription } from '~/components/ui/card';
|
||||
import { Download } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
RotateCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { PDFDocumentProxy, PDFPageProxy } from "pdfjs-dist";
|
||||
|
||||
// GitHub release URLs for PDFs
|
||||
const CV_URL = "https://github.com/soconnor0919/resume-cv/releases/download/latest/cv.pdf";
|
||||
const RESUME_URL = "https://github.com/soconnor0919/resume-cv/releases/download/latest/resume.pdf";
|
||||
const CV_URL =
|
||||
"https://github.com/soconnor0919/resume-cv/releases/download/latest/cv.pdf";
|
||||
const RESUME_URL =
|
||||
"https://github.com/soconnor0919/resume-cv/releases/download/latest/resume.pdf";
|
||||
|
||||
interface PDFViewerProps {
|
||||
url: string;
|
||||
title: string;
|
||||
type: "cv" | "resume";
|
||||
}
|
||||
|
||||
function PDFViewer({ url, title, type }: PDFViewerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null);
|
||||
const [pageNum, setPageNum] = useState(1);
|
||||
const [pageCount, setPageCount] = useState(0);
|
||||
const [scale, setScale] = useState(1.2);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [pdfBlob, setPdfBlob] = useState<Uint8Array | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// Download PDF and convert to blob for PDF.js
|
||||
const downloadPDF = async (pdfUrl: string): Promise<Uint8Array> => {
|
||||
try {
|
||||
// Try direct fetch first
|
||||
const response = await fetch(pdfUrl, {
|
||||
mode: "cors",
|
||||
headers: {
|
||||
Accept: "application/pdf",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download PDF: ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return new Uint8Array(arrayBuffer);
|
||||
} catch (error) {
|
||||
// If direct fetch fails (e.g., CORS), try proxy
|
||||
console.warn("Direct fetch failed, trying proxy:", error);
|
||||
|
||||
const proxyUrl = `/api/pdf-proxy?url=${encodeURIComponent(pdfUrl)}`;
|
||||
const response = await fetch(proxyUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download PDF via proxy: ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return new Uint8Array(arrayBuffer);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClient) return;
|
||||
|
||||
const loadPDF = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
|
||||
// Download the PDF first
|
||||
const pdfData = await downloadPDF(url);
|
||||
setPdfBlob(pdfData);
|
||||
|
||||
// Dynamically import PDF.js
|
||||
const pdfjsLib = await import("pdfjs-dist");
|
||||
|
||||
// Set worker path
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
|
||||
|
||||
// Load PDF from the downloaded data
|
||||
const loadingTask = pdfjsLib.getDocument({ data: pdfData });
|
||||
const pdf = await loadingTask.promise;
|
||||
setPdfDoc(pdf);
|
||||
setPageCount(pdf.numPages);
|
||||
setPageNum(1);
|
||||
} catch (error) {
|
||||
console.error("Error loading PDF:", error);
|
||||
setHasError(true);
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load PDF. Please try downloading it directly.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPDF();
|
||||
}, [url, isClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const renderPage = async () => {
|
||||
if (!pdfDoc || !canvasRef.current) return;
|
||||
|
||||
try {
|
||||
const page: PDFPageProxy = await pdfDoc.getPage(pageNum);
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
if (!context) return;
|
||||
|
||||
// Get container width for responsive scaling
|
||||
const container = canvas.parentElement;
|
||||
const containerWidth = container
|
||||
? container.clientWidth - 32
|
||||
: window.innerWidth - 32; // Account for padding
|
||||
|
||||
// Calculate responsive scale
|
||||
const baseViewport = page.getViewport({ scale: 1, rotation });
|
||||
const responsiveScale = Math.min(
|
||||
containerWidth / baseViewport.width,
|
||||
scale,
|
||||
);
|
||||
|
||||
const viewport = page.getViewport({ scale: responsiveScale, rotation });
|
||||
|
||||
// Fix blurriness by using device pixel ratio
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
const outputScale = devicePixelRatio;
|
||||
|
||||
canvas.width = Math.floor(viewport.width * outputScale);
|
||||
canvas.height = Math.floor(viewport.height * outputScale);
|
||||
canvas.style.width = Math.floor(viewport.width) + "px";
|
||||
canvas.style.height = Math.floor(viewport.height) + "px";
|
||||
|
||||
// Enable anti-aliasing and smoothing
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = "high";
|
||||
|
||||
const transform =
|
||||
outputScale !== 1
|
||||
? [outputScale, 0, 0, outputScale, 0, 0]
|
||||
: undefined;
|
||||
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport: viewport,
|
||||
transform: transform,
|
||||
};
|
||||
|
||||
await page.render(renderContext).promise;
|
||||
} catch (error) {
|
||||
console.error("Error rendering page:", error);
|
||||
setHasError(true);
|
||||
setErrorMessage("Failed to render PDF page.");
|
||||
}
|
||||
};
|
||||
|
||||
renderPage();
|
||||
}, [pdfDoc, pageNum, scale, rotation]);
|
||||
|
||||
const nextPage = () => {
|
||||
if (pageNum < pageCount) {
|
||||
setPageNum(pageNum + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
if (pageNum > 1) {
|
||||
setPageNum(pageNum - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
if (scale < 3) {
|
||||
setScale(scale + 0.2);
|
||||
}
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
if (scale > 0.5) {
|
||||
setScale(scale - 0.2);
|
||||
}
|
||||
};
|
||||
|
||||
const rotate = () => {
|
||||
setRotation((rotation + 90) % 360);
|
||||
};
|
||||
|
||||
const downloadBlob = () => {
|
||||
if (!pdfBlob) return;
|
||||
|
||||
const blob = new Blob([pdfBlob], { type: "application/pdf" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${title.toLowerCase().replace(/\s+/g, "_")}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Action Bar */}
|
||||
<div className="bg-muted/50 flex items-center justify-between gap-4 rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h3 className="font-medium">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{type === "cv"
|
||||
? "Academic curriculum vitae"
|
||||
: "Professional resume"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-2" disabled>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Download</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-2" disabled>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Open</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
<div className="relative overflow-hidden rounded-lg border bg-background">
|
||||
<div className="flex justify-center p-4">
|
||||
<div className="h-[600px] w-full max-w-2xl animate-pulse rounded-lg bg-muted lg:h-[800px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Action Bar */}
|
||||
<div className="bg-muted/50 flex items-center justify-between gap-4 rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="font-medium">{title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={pdfBlob ? downloadBlob : undefined}
|
||||
disabled={!pdfBlob}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Download</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<Link
|
||||
href={`/api/pdf-proxy?url=${encodeURIComponent(url)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">View PDF</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<CardTitle className="text-base">Unable to load PDF</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{errorMessage}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={pdfBlob ? downloadBlob : undefined}
|
||||
disabled={!pdfBlob}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Download {title}</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<Link
|
||||
href={`/api/pdf-proxy?url=${encodeURIComponent(url)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">View PDF in New Tab</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
PDF preview not available. Please use the download or open
|
||||
buttons above.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Action Bar */}
|
||||
<div className="bg-muted/50 flex items-center justify-between gap-4 rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="font-medium">{title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={downloadBlob}
|
||||
disabled={!pdfBlob}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Download</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<Link
|
||||
href={`/api/pdf-proxy?url=${encodeURIComponent(url)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">View PDF</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PDF Controls */}
|
||||
{!isLoading && pageCount > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border bg-background p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={prevPage}
|
||||
disabled={pageNum <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{pageCount > 0 ? `${pageNum} / ${pageCount}` : "Loading..."}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={nextPage}
|
||||
disabled={pageNum >= pageCount}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={zoomOut}
|
||||
disabled={scale <= 0.5}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={zoomIn}
|
||||
disabled={scale >= 3}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={rotate}>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF Canvas */}
|
||||
<div
|
||||
className="relative overflow-hidden rounded-lg border bg-background"
|
||||
tabIndex={0}
|
||||
>
|
||||
{isLoading && (
|
||||
<>
|
||||
<div className="bg-background/80 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading {title.toLowerCase()}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Loading Skeleton */}
|
||||
<div className="flex justify-center p-4">
|
||||
<div className="h-[600px] w-full max-w-2xl animate-pulse rounded-lg bg-muted lg:h-[800px]" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center overflow-auto p-4">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="h-auto w-full max-w-4xl shadow-lg"
|
||||
style={{
|
||||
display: isLoading ? "none" : "block",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Notice */}
|
||||
<div className="block rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/50 md:hidden">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
Better on larger screens
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
For the best viewing experience, try opening this on a tablet or
|
||||
desktop. You can also download the PDF directly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CVPage() {
|
||||
const [activeTab, setActiveTab] = useState('cv');
|
||||
const [activeTab, setActiveTab] = useState("cv");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="prose prose-zinc dark:prose-invert max-w-none">
|
||||
<h1 className="text-2xl font-bold">Curriculum Vitae 📄</h1>
|
||||
<p className="text-lg text-muted-foreground mt-2">
|
||||
My academic and professional experience in computer science, robotics, and engineering.
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="h-8 w-8 text-primary" />
|
||||
<div>
|
||||
<h1 className="mb-2 text-2xl font-bold">Curriculum Vitae</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-lg text-muted-foreground">
|
||||
My academic and professional experience in computer science, robotics,
|
||||
and engineering.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="cv">CV</TabsTrigger>
|
||||
<TabsTrigger value="resume">Resume</TabsTrigger>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="space-y-6"
|
||||
>
|
||||
<TabsList className="grid w-fit grid-cols-2">
|
||||
<TabsTrigger value="cv" className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Academic CV
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="resume" className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Resume
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="cv">
|
||||
<div className="bg-background shadow-sm rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
src={`https://docs.google.com/viewer?url=${encodeURIComponent(CV_URL)}&embedded=true`}
|
||||
width="100%"
|
||||
height="600"
|
||||
style={{ border: 'none' }}
|
||||
className="w-full h-[calc(100vh-21rem)] lg:h-[calc(100vh-18rem)]"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>PDF Preview Not Supported</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-base">
|
||||
Your browser doesn't support PDF preview.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<Link
|
||||
href={CV_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
<Download className="h-4 w-4" />
|
||||
Download CV
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</iframe>
|
||||
</div>
|
||||
<TabsContent value="cv" className="space-y-0">
|
||||
<PDFViewer url={CV_URL} title="Academic CV" type="cv" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="resume">
|
||||
<div className="bg-background shadow-sm rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
src={`https://docs.google.com/viewer?url=${encodeURIComponent(RESUME_URL)}&embedded=true`}
|
||||
width="100%"
|
||||
height="600"
|
||||
style={{ border: 'none' }}
|
||||
className="w-full h-[calc(100vh-21rem)] lg:h-[calc(100vh-18rem)]"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>PDF Preview Not Supported</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-base">
|
||||
Your browser doesn't support PDF preview.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<Link
|
||||
href={RESUME_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
<Download className="h-4 w-4" />
|
||||
Download Resume
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</iframe>
|
||||
</div>
|
||||
<TabsContent value="resume" className="space-y-0">
|
||||
<PDFViewer url={RESUME_URL} title="Resume" type="resume" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Last Updated */}
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
Last updated: {new Date().toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
430
src/app/page.tsx
430
src/app/page.tsx
@@ -1,4 +1,14 @@
|
||||
import { ArrowUpRight, Code, FlaskConical, Users, Star } from "lucide-react";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Code,
|
||||
FlaskConical,
|
||||
Users,
|
||||
GraduationCap,
|
||||
Building,
|
||||
MapPin,
|
||||
Mail,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Card,
|
||||
@@ -7,123 +17,333 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { projects, name } from "~/lib/data";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
{/* About Section */}
|
||||
{/* Hero Section */}
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||||
Hi! I'm {name[0]?.first}.
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-muted-foreground">
|
||||
I am a Computer Science and Engineering student at Bucknell
|
||||
University, passionate about robotics, software development, and
|
||||
human-computer interaction. With a strong foundation in both
|
||||
academic research and practical development, I bridge the gap
|
||||
between theoretical concepts and real-world applications.
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold">Sean O'Connor</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Computer Science and Engineering student with experience in software
|
||||
development, robotics research, and technical leadership.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Mail className="h-4 w-4" />
|
||||
<a href="mailto:sean@soconnor.dev" className="hover:text-primary">
|
||||
sean@soconnor.dev
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
Bucknell University
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Lewisburg, PA
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/cv">
|
||||
View CV
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/publications">Publications</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="h-5 w-5" />
|
||||
<CardTitle>Technical Expertise</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<ul className="list-disc space-y-2 pl-5 text-muted-foreground">
|
||||
<li>
|
||||
Full-stack development with modern frameworks (React, Next.js,
|
||||
Node.js)
|
||||
</li>
|
||||
<li>Robotics development using ROS2 and C++</li>
|
||||
<li>Systems programming and architecture design</li>
|
||||
<li>Database design and optimization (SQL, PostgreSQL)</li>
|
||||
<li>Cloud infrastructure and DevOps (AWS, Docker)</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="h-5 w-5" />
|
||||
<CardTitle>Research Focus</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<ul className="list-disc space-y-2 pl-5 text-muted-foreground">
|
||||
<li>Human-Robot Interaction studies and experimental design</li>
|
||||
<li>Published researcher at IEEE RO-MAN 2024</li>
|
||||
<li>Development of experimental platforms for HRI research</li>
|
||||
<li>Integration of robotics in chemical engineering research</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
<CardTitle>Leadership</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<ul className="list-disc space-y-2 pl-5 text-muted-foreground">
|
||||
<li>President of AIChE Chem-E-Car Competition Team</li>
|
||||
<li>Treasurer of Bucknell Coffee Society</li>
|
||||
<li>Teaching Assistant for Computer Science courses</li>
|
||||
<li>Founding member of RoboLab@Bucknell</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Featured Projects Section */}
|
||||
{/* Current Focus */}
|
||||
<section className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-2xl font-bold">
|
||||
<Star className="h-6 w-6" />
|
||||
Featured Projects
|
||||
</h2>
|
||||
<Link
|
||||
href="/projects"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
View all projects
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<h2 className="text-2xl font-bold">Current Focus</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="h-5 w-5" />
|
||||
<CardTitle className="mb-1">
|
||||
Human-Robot Interaction Research
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground">
|
||||
Developing a web-based platform for HRI experiments that
|
||||
addresses reproducibility challenges in Wizard-of-Oz studies.
|
||||
Published at IEEE RO-MAN 2024 with second publication
|
||||
forthcoming.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="h-5 w-5" />
|
||||
<CardTitle className="mb-1">Academic Excellence</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground">
|
||||
Bachelor of Science in Computer Science and Engineering at
|
||||
Bucknell University. 3.86 Engineering GPA, Dean's List
|
||||
multiple semesters. Expected graduation: May 2026.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Experience Highlights */}
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-2xl font-bold">Experience Highlights</h2>
|
||||
<div className="space-y-6">
|
||||
{projects
|
||||
.filter((project) => project.featured)
|
||||
.slice(0, 2)
|
||||
.map((project, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{project.title}</CardTitle>
|
||||
{project.link && (
|
||||
<Link
|
||||
href={project.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<ArrowUpRight className="h-5 w-5" />
|
||||
</Link>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="mb-1">
|
||||
Software Developer - Riverhead Raceway
|
||||
</CardTitle>
|
||||
<CardDescription>Oct 2020 – Present</CardDescription>
|
||||
</div>
|
||||
<Building className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground">
|
||||
Transformed organizational culture by building trust in
|
||||
data-driven decision making. Revolutionized fan engagement
|
||||
through a real-time statistics platform serving 1500+ concurrent
|
||||
users. Modernized entire technical infrastructure through
|
||||
containerization and automated systems.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="mb-1">
|
||||
Computer Science Researcher - Bucknell University
|
||||
</CardTitle>
|
||||
<CardDescription>Jan 2023 – Present</CardDescription>
|
||||
</div>
|
||||
<FlaskConical className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground">
|
||||
Led research and authored first-author paper presented at
|
||||
international conference. Built framework that enables
|
||||
researchers to conduct experiments across different robot
|
||||
platforms without specialized programming knowledge.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="mb-1">
|
||||
Teaching Assistant - Computer Science
|
||||
</CardTitle>
|
||||
<CardDescription>Jan 2024 – Present</CardDescription>
|
||||
</div>
|
||||
<Users className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground">
|
||||
Mentored 150+ students in software engineering principles,
|
||||
connecting theoretical concepts to real-world applications.
|
||||
Developed learning environments that embrace productive failure.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Technical Skills */}
|
||||
<section className="space-y-6">
|
||||
<h2 className="flex items-center gap-2 text-2xl font-bold">
|
||||
<Code className="h-6 w-6" />
|
||||
Technical Skills
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="mb-1">Languages & Frameworks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground">
|
||||
Java, C/C++, Python, JavaScript/TypeScript, React, Next.js, PHP,
|
||||
SQL
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="mb-1">Backend & DevOps</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground">
|
||||
REST APIs, MySQL, PostgreSQL, Docker, Apache Web Server, NGINX,
|
||||
ROS2
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="mb-1">Cloud & Infrastructure</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground">
|
||||
AWS, GCP, Azure, Backblaze, Linux (RHEL/Debian), CI/CD
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="mb-1">Development Tools</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground">
|
||||
Git, JetBrains Suite, VS Code, Cursor, Linux CLI
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Leadership & Activities */}
|
||||
<section className="space-y-6">
|
||||
<h2 className="flex items-center gap-2 text-2xl font-bold">
|
||||
<Users className="h-6 w-6" />
|
||||
Leadership & Activities
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="mb-1">
|
||||
AIChE Chem-E-Car Competition Team
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Former President, Electrical and Mechanical Team Lead
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground">
|
||||
Pioneered team's first custom hardware solution by
|
||||
designing and fabricating a microcontroller-based control
|
||||
system. Improved team dynamics by introducing agile development
|
||||
principles and structured communication protocols.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="mb-1">Bucknell Coffee Society</CardTitle>
|
||||
<CardDescription>Treasurer</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground">
|
||||
Co-established and launched a new campus organization, managing
|
||||
financial operations and coordinating event logistics.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="mb-1">RoboLab@Bucknell</CardTitle>
|
||||
<CardDescription>Founding Member</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground">
|
||||
Led and participated in group discussions in a new lab bridging
|
||||
computer science and psychology perspectives on human-robot
|
||||
interaction, working with the complexities of human-robot trust,
|
||||
job replacement, and autonomy.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Links */}
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-2xl font-bold">Explore More</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="group cursor-pointer transition-colors hover:bg-accent">
|
||||
<Link href="/publications" className="block p-4">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FlaskConical className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">Research Publications</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
IEEE conferences and ongoing research
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="text-base">
|
||||
{project.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground group-hover:text-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card className="group cursor-pointer transition-colors hover:bg-accent">
|
||||
<Link href="/projects" className="block p-4">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Code className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">Projects</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Software development and research work
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground group-hover:text-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card className="group cursor-pointer transition-colors hover:bg-accent">
|
||||
<Link href="/cv" className="block p-4">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<GraduationCap className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">Curriculum Vitae</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Complete academic and professional record
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground group-hover:text-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Badge } from "~/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Accessibility Features",
|
||||
description: "An overview of the accessibility features implemented on this website to ensure inclusive user experience.",
|
||||
description:
|
||||
"An overview of the accessibility features implemented on this website to ensure inclusive user experience.",
|
||||
};
|
||||
|
||||
export default function AccessibilityPage() {
|
||||
@@ -12,11 +13,15 @@ export default function AccessibilityPage() {
|
||||
const project = projects.find((p) => p.title === "Accessibility Features");
|
||||
|
||||
return (
|
||||
<div className="container pt-0 pb-6">
|
||||
<div className="container pb-6 pt-0">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-4">{project?.title}</h1>
|
||||
<p className="text-lg text-muted-foreground">{project?.longDescription}</p>
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight">
|
||||
{project?.title}
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
{project?.longDescription}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{project?.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
@@ -28,117 +33,194 @@ export default function AccessibilityPage() {
|
||||
|
||||
<div className="mt-8 space-y-8">
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Why Accessibility Matters</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Why Accessibility Matters
|
||||
</h2>
|
||||
<p className="mt-4">
|
||||
As a portfolio website aimed at showcasing my technical skills and projects to potential employers and collaborators,
|
||||
ensuring accessibility is not just a legal or ethical requirement— it's a demonstration of my professional competence and
|
||||
inclusive design thinking. Here's why accessibility is particularly important for my website:
|
||||
As a portfolio website aimed at showcasing my technical skills and
|
||||
projects to potential employers and collaborators, ensuring
|
||||
accessibility is not just a legal or ethical requirement—
|
||||
it's a demonstration of my professional competence and
|
||||
inclusive design thinking. Here's why accessibility is
|
||||
particularly important for my website:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2 mt-4">
|
||||
<ul className="mt-4 list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Universal Access:</strong> A portfolio should be accessible to all potential viewers, including
|
||||
hiring managers, colleagues, and collaborators who may have disabilities or situational limitations.
|
||||
<strong>Universal Access:</strong> A portfolio should be
|
||||
accessible to all potential viewers, including hiring managers,
|
||||
colleagues, and collaborators who may have disabilities or
|
||||
situational limitations.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Professional Credibility:</strong> As a computer science and engineering student, demonstrating knowledge
|
||||
of accessibility standards reflects technical competence and attention to detail that employers value.
|
||||
<strong>Professional Credibility:</strong> As a computer science
|
||||
and engineering student, demonstrating knowledge of
|
||||
accessibility standards reflects technical competence and
|
||||
attention to detail that employers value.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Technical Showcase:</strong> Implementing accessibility features serves as a practical example of the technical skills being presented.
|
||||
<strong>Technical Showcase:</strong> Implementing accessibility
|
||||
features serves as a practical example of the technical skills
|
||||
being presented.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Academic Integrity:</strong> In an academic context, ensuring that educational resources
|
||||
(like the LaTeX tutorial) are accessible to all students reflects a commitment to educational equity.
|
||||
<strong>Academic Integrity:</strong> In an academic context,
|
||||
ensuring that educational resources (like the LaTeX tutorial)
|
||||
are accessible to all students reflects a commitment to
|
||||
educational equity.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Accessibility Features Implemented</h2>
|
||||
<div className="space-y-6 mt-4">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Accessibility Features Implemented
|
||||
</h2>
|
||||
<div className="mt-4 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">1. Comprehensive Image Alt Text</h3>
|
||||
<h3 className="text-xl font-semibold">
|
||||
1. Comprehensive Image Alt Text
|
||||
</h3>
|
||||
<p className="mt-2">
|
||||
Every image on my website has been carefully evaluated and provided with appropriate alt text. I distinguish between:
|
||||
Every image on my website has been carefully evaluated and
|
||||
provided with appropriate alt text. I distinguish between:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 my-2">
|
||||
<ul className="my-2 list-disc pl-6">
|
||||
<li>
|
||||
<strong>Decorative images:</strong> Images that are purely decorative, such as the LaTeX tutorial thumbnail, have alt text that
|
||||
indicates their decorative nature (e.g., "Decorative thumbnail showing LaTeX code and formatting example").
|
||||
<strong>Decorative images:</strong> Images that are purely
|
||||
decorative, such as the LaTeX tutorial thumbnail, have alt
|
||||
text that indicates their decorative nature (e.g.,
|
||||
"Decorative thumbnail showing LaTeX code and formatting
|
||||
example").
|
||||
</li>
|
||||
<li>
|
||||
<strong>Informative images:</strong> Images that convey information, such as project screenshots, have detailed alt text
|
||||
describing their content (e.g., "Screenshot of HRIStudio application showing the robot control dashboard on a laptop").
|
||||
<strong>Informative images:</strong> Images that convey
|
||||
information, such as project screenshots, have detailed alt
|
||||
text describing their content (e.g., "Screenshot of
|
||||
HRIStudio application showing the robot control dashboard on
|
||||
a laptop").
|
||||
</li>
|
||||
</ul>
|
||||
<strong>Implementation details:</strong> I implemented this by adding custom "imageAlt" properties to all projects
|
||||
and "alts" arrays for travel items in my data structure, ensuring consistent image descriptions throughout the site.
|
||||
|
||||
<strong>Implementation details:</strong> I implemented this by
|
||||
adding custom "imageAlt" properties to all projects
|
||||
and "alts" arrays for travel items in my data
|
||||
structure, ensuring consistent image descriptions throughout the
|
||||
site.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">2. Accessible Video Player</h3>
|
||||
<h3 className="text-xl font-semibold">
|
||||
2. Accessible Video Player
|
||||
</h3>
|
||||
<p className="mt-2">
|
||||
I developed a custom video player for the LaTeX tutorial with several accessibility features:
|
||||
I developed a custom video player for the LaTeX tutorial with
|
||||
several accessibility features:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 my-2">
|
||||
<ul className="my-2 list-disc pl-6">
|
||||
<li>Closed captions that can be toggled on/off</li>
|
||||
<li>Full transcript available with expandable details element</li>
|
||||
<li>Keyboard-accessible controls for play/pause, volume, and caption toggling</li>
|
||||
<li>ARIA labels for all controls to improve screen reader compatibility</li>
|
||||
<li>
|
||||
Full transcript available with expandable details element
|
||||
</li>
|
||||
<li>
|
||||
Keyboard-accessible controls for play/pause, volume, and
|
||||
caption toggling
|
||||
</li>
|
||||
<li>
|
||||
ARIA labels for all controls to improve screen reader
|
||||
compatibility
|
||||
</li>
|
||||
<li>Video poster image with appropriate alt text</li>
|
||||
</ul>
|
||||
<strong>Implementation details:</strong> I created a custom AccessibleVideo component that wraps the HTML5 video element
|
||||
with additional accessibility features, including captions integration, keyboard navigation, and proper ARIA attributes.
|
||||
<strong>Implementation details:</strong> I created a custom
|
||||
AccessibleVideo component that wraps the HTML5 video element
|
||||
with additional accessibility features, including captions
|
||||
integration, keyboard navigation, and proper ARIA attributes.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">3. Semantic HTML and ARIA</h3>
|
||||
<h3 className="text-xl font-semibold">
|
||||
3. Semantic HTML and ARIA
|
||||
</h3>
|
||||
<p className="mt-2">
|
||||
My website uses semantic HTML throughout to ensure proper structure and meaning:
|
||||
My website uses semantic HTML throughout to ensure proper
|
||||
structure and meaning:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 my-2">
|
||||
<li>Proper heading hierarchy (h1, h2, h3) for logical document structure</li>
|
||||
<li>Semantic elements like <nav>, <main>, <section>, and <article></li>
|
||||
<li>ARIA attributes for components without native semantics</li>
|
||||
<ul className="my-2 list-disc pl-6">
|
||||
<li>
|
||||
Proper heading hierarchy (h1, h2, h3) for logical document
|
||||
structure
|
||||
</li>
|
||||
<li>
|
||||
Semantic elements like <nav>, <main>,
|
||||
<section>, and <article>
|
||||
</li>
|
||||
<li>
|
||||
ARIA attributes for components without native semantics
|
||||
</li>
|
||||
<li>Skip-to-content links for keyboard users</li>
|
||||
</ul>
|
||||
<strong>Implementation details:</strong> I audited all pages for proper heading structure and added semantic HTML elements. For
|
||||
custom components like cards and badges, I ensured proper ARIA attributes were used to convey their purpose and state.
|
||||
<strong>Implementation details:</strong> I audited all pages for
|
||||
proper heading structure and added semantic HTML elements. For
|
||||
custom components like cards and badges, I ensured proper ARIA
|
||||
attributes were used to convey their purpose and state.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">4. Color Contrast and Dark Mode</h3>
|
||||
<h3 className="text-xl font-semibold">
|
||||
4. Color Contrast and Dark Mode
|
||||
</h3>
|
||||
<p className="mt-2">
|
||||
I carefully selected color choices on my website to ensure readability:
|
||||
I carefully selected color choices on my website to ensure
|
||||
readability:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 my-2">
|
||||
<li>All text meets WCAG AA contrast requirements (4.5:1 for normal text, 3:1 for large text)</li>
|
||||
<li>Dark mode support for users who prefer or require reduced brightness</li>
|
||||
<li>Color is never used as the sole means of conveying information</li>
|
||||
<li>Interactive elements have visual focus indicators that don't rely solely on color</li>
|
||||
<ul className="my-2 list-disc pl-6">
|
||||
<li>
|
||||
All text meets WCAG AA contrast requirements (4.5:1 for
|
||||
normal text, 3:1 for large text)
|
||||
</li>
|
||||
<li>
|
||||
Dark mode support for users who prefer or require reduced
|
||||
brightness
|
||||
</li>
|
||||
<li>
|
||||
Color is never used as the sole means of conveying
|
||||
information
|
||||
</li>
|
||||
<li>
|
||||
Interactive elements have visual focus indicators that
|
||||
don't rely solely on color
|
||||
</li>
|
||||
</ul>
|
||||
<strong>Implementation details:</strong> I used the TailwindCSS color palette with careful consideration of contrast ratios.
|
||||
The dark mode implementation respects user system preferences and provides consistent contrast ratios in both modes.
|
||||
<strong>Implementation details:</strong> I used the TailwindCSS
|
||||
color palette with careful consideration of contrast ratios. The
|
||||
dark mode implementation respects user system preferences and
|
||||
provides consistent contrast ratios in both modes.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Platform Limitations and Workarounds</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Platform Limitations and Workarounds
|
||||
</h2>
|
||||
<p className="mt-4">
|
||||
While building this website, I encountered some limitations in the Next.js framework and implemented workarounds:
|
||||
While building this website, I encountered some limitations in the
|
||||
Next.js framework and implemented workarounds:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2 mt-4">
|
||||
<ul className="mt-4 list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Next.js Client/Server Components:</strong> Next.js divides components into client and server components,
|
||||
which can create hydration issues when implementing certain interactive accessibility features. I addressed this by
|
||||
creating client-side wrapper components for features requiring interactivity, such as the video player and navigation menu.
|
||||
<strong>Next.js Client/Server Components:</strong> Next.js
|
||||
divides components into client and server components, which can
|
||||
create hydration issues when implementing certain interactive
|
||||
accessibility features. I addressed this by creating client-side
|
||||
wrapper components for features requiring interactivity, such as
|
||||
the video player and navigation menu.
|
||||
</li>
|
||||
<li>
|
||||
<strong>PDF Accessibility:</strong> The CV section uses PDF rendering which has inherent accessibility limitations.
|
||||
As a workaround, I provide the same information in HTML format elsewhere on the site, ensuring that users who cannot
|
||||
access the PDF can still view the content.
|
||||
<strong>PDF Accessibility:</strong> The CV section uses PDF
|
||||
rendering which has inherent accessibility limitations. As a
|
||||
workaround, I provide the same information in HTML format
|
||||
elsewhere on the site, ensuring that users who cannot access the
|
||||
PDF can still view the content.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -146,4 +228,4 @@ export default function AccessibilityPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@ import {
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight, Play, BookOpen, Star } from "lucide-react";
|
||||
import { ArrowUpRight, Play, BookOpen, Code } from "lucide-react";
|
||||
import { projects } from "~/lib/data";
|
||||
import Image from "next/image";
|
||||
import { CardSkeleton } from "~/components/ui/skeletons";
|
||||
@@ -24,104 +23,55 @@ export default function ProjectsPage() {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const featuredProjects = projects.filter((p) => p.featured);
|
||||
const otherProjects = projects.filter((p) => !p.featured);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="prose prose-zinc dark:prose-invert max-w-none">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||||
<Star className="h-6 w-6" />
|
||||
Featured Projects
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-muted-foreground">
|
||||
A selection of my academic and professional projects, focusing on
|
||||
robotics, web development, and embedded systems.
|
||||
</p>
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Code className="h-8 w-8 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Projects</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
A collection of my academic research, professional work, and
|
||||
personal projects spanning robotics, web development, and embedded
|
||||
systems.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-6">
|
||||
{loading ? (
|
||||
<>
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</>
|
||||
) : (
|
||||
projects.map((project, index) => (
|
||||
<Card key={index}>
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<div className="flex-1">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{project.title}</CardTitle>
|
||||
{project.link && !project.link.startsWith("/") && (
|
||||
<Link
|
||||
href={project.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<ArrowUpRight className="h-5 w-5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-base">
|
||||
{project.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.longDescription}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{project.link && project.link.startsWith("/") && (
|
||||
<CardFooter className="pt-0">
|
||||
<Link href={project.link}>
|
||||
<Button variant="default" size="sm" className="mt-0">
|
||||
{project.title === "LaTeX Introduction Tutorial" ? (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Watch the LaTeX tutorial
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
View project details
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{project.image && (
|
||||
<div className="px-6 pb-6 md:px-24 lg:w-1/3 lg:px-6 lg:py-6">
|
||||
<Link
|
||||
href={
|
||||
project.link?.startsWith("/")
|
||||
? project.link
|
||||
: project.link || "#"
|
||||
}
|
||||
>
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden transition-all hover:opacity-90">
|
||||
{/* Featured Projects */}
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-2xl font-bold">Featured Work</h2>
|
||||
<div className="space-y-8">
|
||||
{loading ? (
|
||||
<>
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</>
|
||||
) : (
|
||||
featuredProjects.map((project, index) => (
|
||||
<Card key={index} className="overflow-hidden">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
{/* Project Image */}
|
||||
{project.image && (
|
||||
<div className="relative lg:w-2/5">
|
||||
<div className="aspect-[16/10] lg:aspect-square">
|
||||
<Image
|
||||
src={project.image}
|
||||
alt={project.imageAlt || project.title}
|
||||
width={400}
|
||||
height={300}
|
||||
width={600}
|
||||
height={400}
|
||||
className="h-full w-full object-cover"
|
||||
priority={index === 0}
|
||||
/>
|
||||
{project.title === "LaTeX Introduction Tutorial" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-white/80 p-3">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<div className="flex h-16 w-16 items-center justify-center bg-white/90 transition-transform hover:scale-110">
|
||||
<Play
|
||||
className="h-8 w-8 text-primary"
|
||||
fill="currentColor"
|
||||
@@ -130,14 +80,215 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Content */}
|
||||
<div className="flex flex-1 flex-col justify-between p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">
|
||||
{project.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-2 text-base">
|
||||
{project.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{project.link && !project.link.startsWith("/") && (
|
||||
<Link
|
||||
href={project.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-4 text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowUpRight className="h-5 w-5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
{project.longDescription}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{project.link && project.link.startsWith("/") && (
|
||||
<Button asChild>
|
||||
<Link href={project.link}>
|
||||
{project.title === "LaTeX Introduction Tutorial" ? (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Watch Tutorial
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
Learn More
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{project.link && !project.link.startsWith("/") && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href={project.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ArrowUpRight className="mr-2 h-4 w-4" />
|
||||
View Project
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Other Projects */}
|
||||
{otherProjects.length > 0 && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-2xl font-bold">Additional Projects</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{loading ? (
|
||||
<>
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</>
|
||||
) : (
|
||||
otherProjects.map((project, index) => (
|
||||
<Card key={index} className="flex flex-col">
|
||||
{project.image && (
|
||||
<div className="relative aspect-[16/10] overflow-hidden">
|
||||
<Image
|
||||
src={project.image}
|
||||
alt={project.imageAlt || project.title}
|
||||
width={400}
|
||||
height={250}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col p-6">
|
||||
<CardHeader className="p-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg">
|
||||
{project.title}
|
||||
</CardTitle>
|
||||
{project.link && !project.link.startsWith("/") && (
|
||||
<Link
|
||||
href={project.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="mt-2">
|
||||
{project.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 p-0 pt-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{project.link && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
asChild
|
||||
className="w-full"
|
||||
>
|
||||
<Link
|
||||
href={project.link}
|
||||
{...(!project.link.startsWith("/") && {
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
})}
|
||||
>
|
||||
{project.link.startsWith("/") ? (
|
||||
<>
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
Learn More
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUpRight className="mr-2 h-4 w-4" />
|
||||
View Project
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Project Stats */}
|
||||
<section className="border-t pt-8">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="text-center">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">{projects.length}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Total Projects
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{[...new Set(projects.flatMap((p) => p.tags))].length}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Technologies</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{projects.filter((p) => p.featured).length}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Featured</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { ArrowUpRight, BookOpenText, FileText, Presentation } from "lucide-react";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BookOpenText,
|
||||
FileText,
|
||||
Presentation,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { CardSkeleton } from "~/components/ui/skeletons";
|
||||
import type { Publication } from "~/lib/bibtex";
|
||||
@@ -13,12 +25,12 @@ import { parseBibtex } from "~/lib/bibtex";
|
||||
export default function PublicationsPage() {
|
||||
const [publications, setPublications] = useState<Publication[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const tagsToStrip = ['paperUrl', 'posterUrl'];
|
||||
const tagsToStrip = ["paperUrl", "posterUrl"];
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/publications.bib')
|
||||
.then(res => res.text())
|
||||
.then(text => {
|
||||
fetch("/publications.bib")
|
||||
.then((res) => res.text())
|
||||
.then((text) => {
|
||||
const pubs = parseBibtex(text);
|
||||
setPublications(pubs);
|
||||
setLoading(false);
|
||||
@@ -26,16 +38,26 @@ export default function PublicationsPage() {
|
||||
}, []);
|
||||
|
||||
const downloadBibtex = (pub: Publication) => {
|
||||
const { title, authors, venue, year, doi, abstract, type, citationType, citationKey } = pub;
|
||||
const {
|
||||
title,
|
||||
authors,
|
||||
venue,
|
||||
year,
|
||||
doi,
|
||||
abstract,
|
||||
type,
|
||||
citationType,
|
||||
citationKey,
|
||||
} = pub;
|
||||
let bibtexEntry = `@${citationType}{${citationKey},\n`;
|
||||
bibtexEntry += ` title = {${title}},\n`;
|
||||
bibtexEntry += ` author = {${authors.join(' and ')}},\n`;
|
||||
bibtexEntry += ` author = {${authors.join(" and ")}},\n`;
|
||||
bibtexEntry += ` year = {${year}},\n`;
|
||||
if (type === 'conference' || type === 'workshop') {
|
||||
if (type === "conference" || type === "workshop") {
|
||||
bibtexEntry += ` organization = {${venue}},\n`;
|
||||
} else if (type === 'journal') {
|
||||
} else if (type === "journal") {
|
||||
bibtexEntry += ` journal = {${venue}},\n`;
|
||||
} else if (type === 'thesis') {
|
||||
} else if (type === "thesis") {
|
||||
bibtexEntry += ` school = {${venue}},\n`;
|
||||
}
|
||||
if (doi) {
|
||||
@@ -46,9 +68,9 @@ export default function PublicationsPage() {
|
||||
}
|
||||
bibtexEntry += `}\n`;
|
||||
|
||||
const blob = new Blob([bibtexEntry], { type: 'text/plain' });
|
||||
const blob = new Blob([bibtexEntry], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${citationKey}.bib`;
|
||||
document.body.appendChild(a);
|
||||
@@ -60,8 +82,11 @@ export default function PublicationsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="prose prose-zinc dark:prose-invert max-w-none">
|
||||
<h1 className="text-2xl font-bold">Peer-Reviewed Publications 📚</h1>
|
||||
<p className="text-lg text-muted-foreground mt-2">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||||
<BookOpen className="h-6 w-6" />
|
||||
Peer-Reviewed Publications
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-muted-foreground">
|
||||
My research publications in human-robot interaction and robotics.
|
||||
</p>
|
||||
</section>
|
||||
@@ -80,7 +105,7 @@ export default function PublicationsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{pub.title}</CardTitle>
|
||||
{pub.paperUrl && (
|
||||
<Link
|
||||
<Link
|
||||
href={pub.paperUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -91,11 +116,23 @@ export default function PublicationsPage() {
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-base">
|
||||
{pub.authors.join(', ')}
|
||||
{pub.authors.join(", ")}
|
||||
</CardDescription>
|
||||
<CardDescription className="text-sm">
|
||||
{pub.venue} ({pub.year})
|
||||
</CardDescription>
|
||||
{pub.address && (
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{pub.address}
|
||||
</CardDescription>
|
||||
)}
|
||||
{pub.notes && (
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{pub.notes}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{pub.abstract && (
|
||||
@@ -103,52 +140,52 @@ export default function PublicationsPage() {
|
||||
{pub.abstract}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{pub.type}
|
||||
</Badge>
|
||||
{pub.doi && (
|
||||
<Link
|
||||
<Link
|
||||
href={`https://doi.org/${pub.doi}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
<ArrowUpRight className="mr-1 h-3 w-3" />
|
||||
DOI
|
||||
</Badge>
|
||||
</Link>
|
||||
)}
|
||||
{pub.paperUrl && (
|
||||
<Link
|
||||
<Link
|
||||
href={pub.paperUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
<FileText className="h-4 w-4" />
|
||||
<FileText className="mr-1 h-3 w-3" />
|
||||
Paper
|
||||
</Badge>
|
||||
</Link>
|
||||
)}
|
||||
{pub.posterUrl && (
|
||||
<Link
|
||||
<Link
|
||||
href={pub.posterUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
<Presentation className="h-4 w-4" />
|
||||
<Presentation className="mr-1 h-3 w-3" />
|
||||
Poster
|
||||
</Badge>
|
||||
</Link>
|
||||
)}
|
||||
<Badge
|
||||
onClick={() => downloadBibtex(pub)}
|
||||
onClick={() => downloadBibtex(pub)}
|
||||
className="cursor-pointer capitalize"
|
||||
variant="secondary"
|
||||
>
|
||||
<BookOpenText className="h-4 w-4" />
|
||||
<BookOpenText className="mr-1 h-3 w-3" />
|
||||
BibTeX
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -1,76 +1,90 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "~/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
} from "~/components/ui/card";
|
||||
import { CardSkeleton } from "~/components/ui/skeletons";
|
||||
import Image from "next/image";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { travel } from "~/lib/data";
|
||||
export default function TripsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="prose prose-zinc dark:prose-invert max-w-none">
|
||||
<h1 className="text-2xl font-bold">My Trips & Events 🌍</h1>
|
||||
<p className="text-lg text-muted-foreground mt-2">
|
||||
A collection of memorable trips and events I've attended.
|
||||
</p>
|
||||
</section>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="prose prose-zinc dark:prose-invert max-w-none">
|
||||
<h1 className="text-2xl font-bold">My Trips & Events 🌍</h1>
|
||||
<p className="mt-2 text-lg text-muted-foreground">
|
||||
A collection of memorable trips and events I've attended.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{loading ? (
|
||||
<>
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</>
|
||||
) : (
|
||||
travel.map((trip, index) => (
|
||||
<Card key={index} className="rounded-lg overflow-hidden">
|
||||
<CardHeader className="p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex overflow-x-auto space-x-0">
|
||||
{trip.images.map((image, imgIndex) => (
|
||||
<div key={imgIndex} className="flex-shrink-0">
|
||||
<Image
|
||||
src={image}
|
||||
alt={trip.alts && trip.alts[imgIndex] ? trip.alts[imgIndex] : `${trip.title} - image ${imgIndex + 1}`}
|
||||
width={250}
|
||||
height={200}
|
||||
className="object-cover min-h-[200px] max-h-[200px]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col justify-items-start content-between">
|
||||
<CardTitle className="mt-6 mb-2">{trip.title}</CardTitle>
|
||||
<CardDescription className="">{trip.description}</CardDescription>
|
||||
{/* Show badges for tags */}
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{trip.tags.map((tag, tagIndex) => (
|
||||
<Badge key={tagIndex} variant="secondary">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{loading ? (
|
||||
<>
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</>
|
||||
) : (
|
||||
travel.map((trip, index) => (
|
||||
<Card key={index} className="overflow-hidden rounded-lg">
|
||||
<CardHeader className="p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex space-x-0 overflow-x-auto">
|
||||
{trip.images.map((image, imgIndex) => (
|
||||
<div key={imgIndex} className="flex-shrink-0">
|
||||
<Image
|
||||
src={image}
|
||||
alt={
|
||||
trip.alts && trip.alts[imgIndex]
|
||||
? trip.alts[imgIndex]
|
||||
: `${trip.title} - image ${imgIndex + 1}`
|
||||
}
|
||||
width={250}
|
||||
height={200}
|
||||
className="max-h-[200px] min-h-[200px] object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col content-between justify-items-start">
|
||||
<CardTitle className="mb-2 mt-6">{trip.title}</CardTitle>
|
||||
<CardDescription className="">
|
||||
{trip.description}
|
||||
</CardDescription>
|
||||
{/* Show badges for tags */}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{trip.tags.map((tag, tagIndex) => (
|
||||
<Badge key={tagIndex} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { BreadcrumbSkeleton } from "~/components/ui/breadcrumb-skeleton";
|
||||
|
||||
// Dynamically import PageBreadcrumb with no SSR to avoid hydration issues
|
||||
const PageBreadcrumb = dynamic(() => import("~/components/PageBreadcrumb").then(mod => mod.PageBreadcrumb), {
|
||||
ssr: false,
|
||||
});
|
||||
const PageBreadcrumb = dynamic(
|
||||
() => import("~/components/PageBreadcrumb").then((mod) => mod.PageBreadcrumb),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BreadcrumbSkeleton />,
|
||||
},
|
||||
);
|
||||
|
||||
export function BreadcrumbWrapper() {
|
||||
return <PageBreadcrumb />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Newspaper,
|
||||
Plane,
|
||||
X,
|
||||
Accessibility,
|
||||
} from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
@@ -19,11 +18,6 @@ const navItems = [
|
||||
{ href: "/", label: "About", icon: Home },
|
||||
{ href: "/articles", label: "Articles", icon: Newspaper },
|
||||
{ href: "/projects", label: "Projects", icon: FolderGit2 },
|
||||
{
|
||||
href: "/projects/accessibility",
|
||||
label: "Accessibility",
|
||||
icon: Accessibility,
|
||||
},
|
||||
{ href: "/publications", label: "Publications", icon: BookOpenText },
|
||||
{ href: "/travel", label: "Travel", icon: Plane },
|
||||
{ href: "/cv", label: "CV", icon: FileText },
|
||||
@@ -43,7 +37,7 @@ export function Navigation() {
|
||||
<div className="relative mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<Link href="/">
|
||||
<span className="text-lg font-bold">Sean O'Connor</span>
|
||||
<span className="text-lg font-bold">Sean O'Connor</span>
|
||||
</Link>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="hidden lg:flex lg:justify-end lg:space-x-4">
|
||||
|
||||
24
src/components/ui/breadcrumb-skeleton.tsx
Normal file
24
src/components/ui/breadcrumb-skeleton.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
export function BreadcrumbSkeleton() {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
{/* Home breadcrumb skeleton */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<Skeleton className="h-3.5 w-3.5" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<Skeleton className="h-4 w-4" />
|
||||
|
||||
{/* Current page skeleton */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<Skeleton className="h-3.5 w-3.5" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import { cn } from "~/lib/utils"
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
<div className={cn("bg-primary/10 animate-pulse", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
export { Skeleton };
|
||||
|
||||
@@ -10,7 +10,9 @@ export type Publication = {
|
||||
abstract?: string;
|
||||
citationType?: string;
|
||||
citationKey?: string;
|
||||
type: 'conference' | 'journal' | 'workshop' | 'thesis';
|
||||
notes?: string;
|
||||
address?: string;
|
||||
type: "conference" | "journal" | "workshop" | "thesis";
|
||||
};
|
||||
|
||||
type BibTeXEntry = {
|
||||
@@ -21,11 +23,11 @@ type BibTeXEntry = {
|
||||
|
||||
function parseAuthors(authorString: string): string[] {
|
||||
return authorString
|
||||
.split(' and ')
|
||||
.map(author => author.trim())
|
||||
.map(author => {
|
||||
if (author.includes(',')) {
|
||||
const [lastName, firstName] = author.split(',').map(s => s.trim());
|
||||
.split(" and ")
|
||||
.map((author) => author.trim())
|
||||
.map((author) => {
|
||||
if (author.includes(",")) {
|
||||
const [lastName, firstName] = author.split(",").map((s) => s.trim());
|
||||
return `${firstName} ${lastName}`;
|
||||
}
|
||||
return author;
|
||||
@@ -42,14 +44,14 @@ function parseBibTeXEntry(entry: string): BibTeXEntry | null {
|
||||
const content = entry.slice(typeMatch[0].length);
|
||||
|
||||
const fields: Record<string, string> = {};
|
||||
let currentField = '';
|
||||
let buffer = '';
|
||||
let currentField = "";
|
||||
let buffer = "";
|
||||
|
||||
// Split into lines and process each line
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine || trimmedLine === '}') continue;
|
||||
if (!trimmedLine || trimmedLine === "}") continue;
|
||||
|
||||
// Try to match a new field
|
||||
const fieldMatch = trimmedLine.match(/(\w+)\s*=\s*{(.+?)},?$/);
|
||||
@@ -63,7 +65,7 @@ function parseBibTeXEntry(entry: string): BibTeXEntry | null {
|
||||
buffer = fieldMatch[2];
|
||||
} else if (currentField) {
|
||||
// Continue previous field
|
||||
buffer += ' ' + trimmedLine.replace(/},$/, '');
|
||||
buffer += " " + trimmedLine.replace(/},$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,33 +79,38 @@ function parseBibTeXEntry(entry: string): BibTeXEntry | null {
|
||||
|
||||
export function parseBibtex(bibtex: string): Publication[] {
|
||||
const entries = bibtex
|
||||
.split('@')
|
||||
.split("@")
|
||||
.slice(1) // Skip first empty element
|
||||
.map(entry => parseBibTeXEntry(entry))
|
||||
.map((entry) => parseBibTeXEntry(entry))
|
||||
.filter((entry): entry is BibTeXEntry => entry !== null);
|
||||
|
||||
return entries.map(entry => {
|
||||
return entries.map((entry) => {
|
||||
const publicationType = (() => {
|
||||
switch (entry.type) {
|
||||
case 'inproceedings':
|
||||
case 'conference':
|
||||
return 'conference';
|
||||
case 'article':
|
||||
return 'journal';
|
||||
case 'mastersthesis':
|
||||
return 'thesis';
|
||||
case 'workshop':
|
||||
return 'workshop';
|
||||
case "inproceedings":
|
||||
case "conference":
|
||||
return "conference";
|
||||
case "article":
|
||||
return "journal";
|
||||
case "mastersthesis":
|
||||
return "thesis";
|
||||
case "workshop":
|
||||
return "workshop";
|
||||
default:
|
||||
return 'journal';
|
||||
return "journal";
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
title: entry.fields.title?.replace(/[{}]/g, '') || '',
|
||||
authors: parseAuthors(entry.fields.author || ''),
|
||||
venue: entry.fields.booktitle || entry.fields.journal || entry.fields.organization || entry.fields.school || '',
|
||||
year: parseInt(entry.fields.year || '0', 10),
|
||||
title: entry.fields.title?.replace(/[{}]/g, "") || "",
|
||||
authors: parseAuthors(entry.fields.author || ""),
|
||||
venue:
|
||||
entry.fields.booktitle ||
|
||||
entry.fields.journal ||
|
||||
entry.fields.organization ||
|
||||
entry.fields.school ||
|
||||
"",
|
||||
year: parseInt(entry.fields.year || "0", 10),
|
||||
doi: entry.fields.doi,
|
||||
url: entry.fields.url,
|
||||
paperUrl: entry.fields.paperurl,
|
||||
@@ -111,7 +118,9 @@ export function parseBibtex(bibtex: string): Publication[] {
|
||||
abstract: entry.fields.abstract,
|
||||
citationType: entry.type,
|
||||
citationKey: entry.citationKey,
|
||||
type: publicationType
|
||||
notes: entry.fields.note || entry.fields.notes,
|
||||
address: entry.fields.address,
|
||||
type: publicationType,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,49 +50,51 @@
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 12%;
|
||||
--foreground: 0 0% 98.5%;
|
||||
--card: 0 0% 18%;
|
||||
--card-foreground: 0 0% 98.5%;
|
||||
--popover: 0 0% 26.9%;
|
||||
--popover-foreground: 0 0% 98.5%;
|
||||
--primary: 0 0% 55.5%;
|
||||
--primary-foreground: 0 0% 98.5%;
|
||||
--secondary: 0 0% 26.9%;
|
||||
--secondary-foreground: 0 0% 98.5%;
|
||||
--muted: 0 0% 26.9%;
|
||||
--muted-foreground: 0 0% 71%;
|
||||
--accent: 0 0% 37.2%;
|
||||
--accent-foreground: 0 0% 98.5%;
|
||||
--destructive: 7 85% 70%;
|
||||
--destructive-foreground: 0 0% 26.9%;
|
||||
--border: 0 0% 25%;
|
||||
--input: 0 0% 43.9%;
|
||||
--ring: 0 0% 55.5%;
|
||||
--chart-1: 0 0% 55.5%;
|
||||
--chart-2: 0 0% 55.5%;
|
||||
--chart-3: 0 0% 55.5%;
|
||||
--chart-4: 0 0% 55.5%;
|
||||
--chart-5: 0 0% 55.5%;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: 0 0% 12%;
|
||||
--foreground: 0 0% 98.5%;
|
||||
--card: 0 0% 18%;
|
||||
--card-foreground: 0 0% 98.5%;
|
||||
--popover: 0 0% 26.9%;
|
||||
--popover-foreground: 0 0% 98.5%;
|
||||
--primary: 0 0% 55.5%;
|
||||
--primary-foreground: 0 0% 98.5%;
|
||||
--secondary: 0 0% 26.9%;
|
||||
--secondary-foreground: 0 0% 98.5%;
|
||||
--muted: 0 0% 26.9%;
|
||||
--muted-foreground: 0 0% 71%;
|
||||
--accent: 0 0% 37.2%;
|
||||
--accent-foreground: 0 0% 98.5%;
|
||||
--destructive: 7 85% 70%;
|
||||
--destructive-foreground: 0 0% 26.9%;
|
||||
--border: 0 0% 25%;
|
||||
--input: 0 0% 43.9%;
|
||||
--ring: 0 0% 55.5%;
|
||||
--chart-1: 0 0% 55.5%;
|
||||
--chart-2: 0 0% 55.5%;
|
||||
--chart-3: 0 0% 55.5%;
|
||||
--chart-4: 0 0% 55.5%;
|
||||
--chart-5: 0 0% 55.5%;
|
||||
|
||||
--font-sans: Geist Mono, monospace;
|
||||
--font-serif: Geist Mono, monospace;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--radius: 0rem;
|
||||
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-sm:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0);
|
||||
--shadow:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0);
|
||||
--shadow-md:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px -1px hsl(0 0% 0% / 0);
|
||||
--shadow-lg:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px -1px hsl(0 0% 0% / 0);
|
||||
--shadow-xl:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px -1px hsl(0 0% 0% / 0);
|
||||
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0);
|
||||
--font-sans: Geist Mono, monospace;
|
||||
--font-serif: Geist Mono, monospace;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--radius: 0rem;
|
||||
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-sm:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0);
|
||||
--shadow:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0);
|
||||
--shadow-md:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px -1px hsl(0 0% 0% / 0);
|
||||
--shadow-lg:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px -1px hsl(0 0% 0% / 0);
|
||||
--shadow-xl:
|
||||
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px -1px hsl(0 0% 0% / 0);
|
||||
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type Config } from "tailwindcss";
|
||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
darkMode: "media",
|
||||
content: ["./src/**/*.tsx"],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
@@ -13,11 +13,7 @@
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"checkJs": true,
|
||||
/* Bundled projects */
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"ES2022"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
@@ -31,9 +27,7 @@
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -45,7 +39,5 @@
|
||||
"**/*.js",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules", "drizzle.config.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user