mirror of
https://github.com/soconnor0919/personal-website.git
synced 2026-02-05 00:06:36 -05:00
CV Caching, new dashboard
This commit is contained in:
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 {
|
||||
|
||||
Reference in New Issue
Block a user