Compare commits
18 Commits
42e2898223
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e0e78c3900 | |||
| bb4957f349 | |||
| 6c8d6bce98 | |||
| 6a36cc699f | |||
| b97d20c1fc | |||
| 4b0aa4b2a8 | |||
| 6e63c992a8 | |||
| 7e3821d0fa | |||
| 1bfa45ea1b | |||
| ba9ce7d790 | |||
| ecafd683f9 | |||
| 71bf4d89fa | |||
| 94a1592dff | |||
| 98196748ae | |||
| fe52916d84 | |||
| 1e4704ed3f | |||
| 4dc9d5db9b | |||
| d5dba3cc09 |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "personal-website",
|
||||||
|
"runtimeExecutable": "bun",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+8
-3
@@ -1,20 +1,25 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "new-york",
|
"style": "radix-mira",
|
||||||
"rsc": true,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.ts",
|
"config": "tailwind.config.ts",
|
||||||
"css": "src/styles/globals.css",
|
"css": "src/styles/globals.css",
|
||||||
"baseColor": "zinc",
|
"baseColor": "mist",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "~/components",
|
"components": "~/components",
|
||||||
"utils": "~/lib/utils",
|
"utils": "~/lib/utils",
|
||||||
"ui": "~/components/ui",
|
"ui": "~/components/ui",
|
||||||
"lib": "~/lib",
|
"lib": "~/lib",
|
||||||
"hooks": "~/hooks"
|
"hooks": "~/hooks"
|
||||||
}
|
},
|
||||||
|
"menuColor": "default-translucent",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
}
|
}
|
||||||
+14
-13
@@ -15,6 +15,9 @@
|
|||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache"
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.4.1",
|
||||||
|
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||||
|
"@lucide/lab": "^0.1.2",
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
"@next/mdx": "^16.1.3",
|
"@next/mdx": "^16.1.3",
|
||||||
@@ -27,34 +30,32 @@
|
|||||||
"@types/pdfjs-dist": "2.10.378",
|
"@types/pdfjs-dist": "2.10.378",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^1.11.0",
|
||||||
"next": "^16.2.4",
|
"next": "^16.2.6",
|
||||||
"pdfjs-dist": "4.10.38",
|
"pdfjs-dist": "4.10.38",
|
||||||
"react": "^19.2.5",
|
"radix-ui": "^1.4.3",
|
||||||
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"reactdom": "^2.0.0",
|
||||||
|
"shadcn": "^4.4.0",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.3.5"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^3.25.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/node": "^22.19.7",
|
"@types/node": "^22.19.7",
|
||||||
"@types-react": "19.2.3",
|
|
||||||
"@types-react-dom": "19.2.3",
|
|
||||||
"eslint": "9.39.2",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.1.3",
|
"eslint-config-next": "16.1.3",
|
||||||
"prettier": "^3.8.0",
|
"prettier": "^3.8.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.37.0"
|
"initVersion": "7.37.0"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.12.1",
|
"packageManager": "bun@1.12.1"
|
||||||
"overrides": {
|
|
||||||
"@types/react": "19.2.3",
|
|
||||||
"@types/react-dom": "19.2.3"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
+14
-1
@@ -1,5 +1,18 @@
|
|||||||
@inproceedings{OConnor2025,
|
@mastersthesis{OConnor2026Thesis,
|
||||||
title = {A Web-Based Wizard-of-Oz Platform for Collaborative and Reproducible Human-Robot Interaction Research},
|
title = {A Web-Based Wizard-of-Oz Platform for Collaborative and Reproducible Human-Robot Interaction Research},
|
||||||
|
author = {Sean O'Connor},
|
||||||
|
year = {2026},
|
||||||
|
school = {Bucknell University},
|
||||||
|
address = {Lewisburg, PA},
|
||||||
|
note = {Bachelor's Honors Thesis},
|
||||||
|
url = {https://soconnor.dev/api/publications/honors-thesis.pdf},
|
||||||
|
paperUrl = {/api/publications/honors-thesis.pdf},
|
||||||
|
abstract = {The Wizard-of-Oz (WoZ) technique is widely used in Human-Robot Interaction (HRI) research, but two persistent problems limit its effectiveness: existing tools impose technical barriers that exclude non-engineering domain experts (the Accessibility Problem), and the fragmented landscape of robot-specific implementations makes interaction scripts difficult to port across platforms (the Reproducibility Problem — concerning execution consistency and portability, not third-party replication). Through a literature review, I identified three design principles to address both: a hierarchical specification model, an event-driven execution model, and a plugin architecture that decouples experiment logic from robot-specific implementations. I realized these principles in HRIStudio, an open-source, web-based platform providing a visual experiment designer, a guided wizard execution interface, automated timestamped logging with deviation tracking, and role-based access control. I evaluated HRIStudio in a pilot between-subjects study (N=6) against Choregraphe, the standard programming tool for the NAO robot. HRIStudio wizards achieved higher design fidelity, execution reliability, and perceived usability across all six sessions; the only unprompted specification deviation in the dataset occurred in the Choregraphe condition. While the pilot scale precludes inferential claims, the directional evidence across all measures supports the position that a tool built to realize the identified design principles can have significant impact on accessibility and reproducibility in WoZ-based HRI research.},
|
||||||
|
slidesUrl = {/api/publications/OConnor2026Thesis.pdf}
|
||||||
|
}
|
||||||
|
|
||||||
|
@inproceedings{OConnor2025,
|
||||||
|
title = {Collaborative and Reproducible HRI Research Through a Web-Based Wizard-of-Oz Platform},
|
||||||
author = {Sean O'Connor and L. Felipe Perrone},
|
author = {Sean O'Connor and L. Felipe Perrone},
|
||||||
year = {2025},
|
year = {2025},
|
||||||
booktitle = {2025 34th IEEE International Conference on Robot and Human Interactive Communication (RO-MAN)},
|
booktitle = {2025 34th IEEE International Conference on Robot and Human Interactive Communication (RO-MAN)},
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
-2
@@ -51,6 +51,7 @@ function PDFViewer({ url, title, type }: PDFViewerProps) {
|
|||||||
const [pdfBlob, setPdfBlob] = useState<Uint8Array | null>(null);
|
const [pdfBlob, setPdfBlob] = useState<Uint8Array | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setIsClient(true);
|
setIsClient(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -496,9 +497,10 @@ export default function CVPage() {
|
|||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={setActiveTab}
|
onValueChange={setActiveTab}
|
||||||
className="space-y-6"
|
orientation="horizontal"
|
||||||
|
className="flex flex-col space-y-6"
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-fit grid-cols-2">
|
<TabsList className="flex w-fit self-start">
|
||||||
<TabsTrigger value="cv" className="gap-2">
|
<TabsTrigger value="cv" className="gap-2">
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
Academic CV
|
Academic CV
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
MapPin,
|
MapPin,
|
||||||
Award,
|
Award,
|
||||||
|
ExternalLink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -68,7 +69,7 @@ export default function ExperiencePage() {
|
|||||||
className={`animate-fade-in-up-delay-${Math.min(delay + index, 4)} card-hover`}
|
className={`animate-fade-in-up-delay-${Math.min(delay + index, 4)} card-hover`}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -95,7 +96,7 @@ export default function ExperiencePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{exp.description.map((item, itemIndex) => (
|
{exp.description.map((item, itemIndex) => (
|
||||||
<li key={itemIndex} className="flex items-start gap-3">
|
<li key={itemIndex} className="flex items-start gap-3">
|
||||||
@@ -161,10 +162,10 @@ export default function ExperiencePage() {
|
|||||||
className={`animate-fade-in-up-delay-${Math.min(index + 1, 4)} card-hover`}
|
className={`animate-fade-in-up-delay-${Math.min(index + 1, 4)} card-hover`}
|
||||||
>
|
>
|
||||||
<Card className="card-full-height">
|
<Card className="card-full-height">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">{category}</CardTitle>
|
<CardTitle className="text-lg">{category}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="card-content-stretch pt-0">
|
<CardContent className="card-content-stretch">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{skills.map((skill) => (
|
{skills.map((skill) => (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -193,7 +194,7 @@ export default function ExperiencePage() {
|
|||||||
className={`animate-fade-in-up-delay-${Math.min(index + 1, 4)} card-hover`}
|
className={`animate-fade-in-up-delay-${Math.min(index + 1, 4)} card-hover`}
|
||||||
>
|
>
|
||||||
<Card className="card-full-height">
|
<Card className="card-full-height">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<CardTitle className="break-words text-lg leading-tight">
|
<CardTitle className="break-words text-lg leading-tight">
|
||||||
@@ -206,8 +207,19 @@ export default function ExperiencePage() {
|
|||||||
<Award className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
<Award className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="space-y-3">
|
||||||
<p className="text-muted-foreground">{conf.presentation}</p>
|
<p className="text-muted-foreground">{conf.presentation}</p>
|
||||||
|
{conf.youtubeUrl && (
|
||||||
|
<a
|
||||||
|
href={conf.youtubeUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
Watch talk
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +237,7 @@ export default function ExperiencePage() {
|
|||||||
className={`animate-fade-in-up-delay-${Math.min(index + 1, 4)} card-hover`}
|
className={`animate-fade-in-up-delay-${Math.min(index + 1, 4)} card-hover`}
|
||||||
>
|
>
|
||||||
<Card className="card-full-height">
|
<Card className="card-full-height">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Award className="mt-1 h-5 w-5 flex-shrink-0 text-primary" />
|
<Award className="mt-1 h-5 w-5 flex-shrink-0 text-primary" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -241,7 +253,7 @@ export default function ExperiencePage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{award.description && (
|
{award.description && (
|
||||||
<CardContent className="card-content-stretch pt-0">
|
<CardContent className="card-content-stretch">
|
||||||
<p className="break-words text-sm leading-relaxed text-muted-foreground">
|
<p className="break-words text-sm leading-relaxed text-muted-foreground">
|
||||||
{award.description}
|
{award.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -258,7 +270,7 @@ export default function ExperiencePage() {
|
|||||||
<h2 className="text-2xl font-bold">Relevant Coursework</h2>
|
<h2 className="text-2xl font-bold">Relevant Coursework</h2>
|
||||||
<div className="animate-fade-in-up-delay-1">
|
<div className="animate-fade-in-up-delay-1">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{relevantCoursework.map((course, index) => (
|
{relevantCoursework.map((course, index) => (
|
||||||
<li key={index} className="flex items-start gap-3">
|
<li key={index} className="flex items-start gap-3">
|
||||||
|
|||||||
+18
-12
@@ -7,9 +7,13 @@ import { Sidebar } from "~/components/Sidebar";
|
|||||||
import { BreadcrumbWrapper } from "~/components/BreadcrumbWrapper";
|
import { BreadcrumbWrapper } from "~/components/BreadcrumbWrapper";
|
||||||
import { BreadcrumbProvider } from "~/context/BreadcrumbContext";
|
import { BreadcrumbProvider } from "~/context/BreadcrumbContext";
|
||||||
|
|
||||||
import { inter, playfair } from "~/lib/fonts";
|
import { playfair } from "~/lib/fonts";
|
||||||
import { description, name } from "~/lib/data";
|
import { description, name } from "~/lib/data";
|
||||||
import "~/styles/globals.css";
|
import "~/styles/globals.css";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `${name[0]?.first} ${name[0]?.last}`,
|
title: `${name[0]?.first} ${name[0]?.last}`,
|
||||||
@@ -21,18 +25,13 @@ export default function RootLayout({ children }: React.PropsWithChildren) {
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${inter.variable} ${playfair.variable}`}
|
className={cn(inter.variable, playfair.variable, "font-sans", inter.variable)}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body
|
<body
|
||||||
className="relative flex min-h-screen flex-col bg-background font-sans text-foreground"
|
className="flex min-h-screen flex-col bg-background font-sans text-foreground"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
{/* Background Elements */}
|
|
||||||
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
|
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_50%,#000_70%,transparent_100%)]"></div>
|
|
||||||
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
|
{env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
|
||||||
<Script
|
<Script
|
||||||
@@ -45,13 +44,20 @@ export default function RootLayout({ children }: React.PropsWithChildren) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Living background */}
|
||||||
|
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808010_1px,transparent_1px),linear-gradient(to_bottom,#80808010_1px,transparent_1px)] bg-[size:24px_24px]" />
|
||||||
|
<div className="animate-blob absolute left-[45%] top-[30%] h-[700px] w-[700px] rounded-full bg-primary/5 blur-3xl" />
|
||||||
|
<div className="animate-blob absolute right-[20%] top-[60%] h-[500px] w-[500px] rounded-full bg-primary/4 blur-3xl [animation-delay:3s]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<BreadcrumbProvider>
|
<BreadcrumbProvider>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<div className="flex flex-1 flex-col pt-24 lg:flex-row">
|
<div className="flex flex-1 flex-col pt-16 lg:flex-row">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="min-w-0 flex-1 lg:pl-96">
|
<div className="min-w-0 flex-1 lg:pl-80">
|
||||||
<div className="mx-auto max-w-screen-xl px-6 sm:px-8 lg:pl-0 lg:pr-8">
|
<div className="mx-auto max-w-screen-xl px-4 sm:px-6 lg:pl-0 lg:pr-6">
|
||||||
<main className="pb-8 pt-4">
|
<main className="p-4 pb-6 pt-6 sm:p-6 sm:pb-8 sm:pt-8">
|
||||||
<BreadcrumbWrapper />
|
<BreadcrumbWrapper />
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+400
-358
@@ -2,15 +2,14 @@ import {
|
|||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Award,
|
Award,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Building,
|
|
||||||
Code,
|
Code,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FlaskConical,
|
FileText,
|
||||||
|
Globe,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
School,
|
Smartphone,
|
||||||
Users
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
@@ -22,69 +21,56 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { awards, education, experiences, researchInterests } from "~/lib/data";
|
import { awards, educationList } from "~/lib/data";
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const researchExperience = experiences.filter(
|
|
||||||
(exp) => exp.type === "research",
|
|
||||||
);
|
|
||||||
const teachingExperience = experiences.filter(
|
|
||||||
(exp) => exp.type === "teaching",
|
|
||||||
);
|
|
||||||
const professionalExperience = experiences.filter(
|
|
||||||
(exp) => exp.type === "professional",
|
|
||||||
);
|
|
||||||
const leadershipExperience = experiences.filter(
|
|
||||||
(exp) => exp.type === "leadership",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-16">
|
||||||
{/* Hero Section */}
|
|
||||||
<section className="animate-fade-in-up space-y-6">
|
{/* Hero */}
|
||||||
|
<section className="animate-fade-in-up space-y-5 pt-2">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="animate-fade-in-up-delay-1 text-3xl font-bold">
|
<h1 className="animate-fade-in-up-delay-1 text-4xl font-bold tracking-tight">
|
||||||
Sean O'Connor
|
Sean O'Connor
|
||||||
</h1>
|
</h1>
|
||||||
<p className="animate-fade-in-up-delay-2 text-xl text-muted-foreground">
|
<p className="animate-fade-in-up-delay-2 max-w-lg text-lg text-muted-foreground">
|
||||||
Computer Science and Engineering student passionate about
|
Researcher & builder. HRI at BU, motorsports software at Riverhead.
|
||||||
human-robot interaction and developing technologies that make robots
|
|
||||||
better collaborators with humans.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="animate-fade-in-up-delay-3 flex flex-wrap gap-4 text-sm text-muted-foreground">
|
<div className="animate-fade-in-up-delay-3 flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
<a href="mailto:sean@soconnor.dev" className="hover:text-primary">
|
<a
|
||||||
|
href="mailto:sean@soconnor.dev"
|
||||||
|
className="transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
sean@soconnor.dev
|
sean@soconnor.dev
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
<a
|
|
||||||
href="mailto:sso005@bucknell.edu"
|
|
||||||
className="hover:text-primary"
|
|
||||||
>
|
|
||||||
sso005@bucknell.edu
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<GraduationCap className="h-4 w-4" />
|
<GraduationCap className="h-4 w-4" />
|
||||||
Bucknell University
|
Boston University
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<MapPin className="h-4 w-4" />
|
<MapPin className="h-4 w-4" />
|
||||||
Lewisburg, PA
|
Boston, MA
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="animate-fade-in-up-delay-4 flex gap-3">
|
<div className="animate-fade-in-up-delay-4 flex gap-3">
|
||||||
<Button variant="outline" asChild className="button-hover">
|
<Button variant="outline" asChild>
|
||||||
<Link href="/cv">
|
<Link href="/cv">
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
View CV
|
CV
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild className="button-hover">
|
<Button variant="outline" asChild>
|
||||||
<Link href="/publications">
|
<Link href="/publications">
|
||||||
<BookOpen className="mr-2 h-4 w-4" />
|
<BookOpen className="mr-2 h-4 w-4" />
|
||||||
Publications
|
Publications
|
||||||
@@ -94,335 +80,391 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Research Interests */}
|
{/* Currently Building */}
|
||||||
<section className="animate-fade-in-up space-y-6">
|
<section className="animate-fade-in-up space-y-4">
|
||||||
<h2 className="text-2xl font-bold">Research Interests</h2>
|
<SectionLabel>Currently building</SectionLabel>
|
||||||
<div className="animate-fade-in-up-delay-1">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Card className="card-hover">
|
|
||||||
<CardContent className="pt-6">
|
<Card className="card-hover card-full-height">
|
||||||
<p className="leading-relaxed text-muted-foreground">
|
<CardHeader>
|
||||||
{researchInterests}
|
<div className="flex items-start gap-3">
|
||||||
</p>
|
<div className="rounded-xl bg-primary/10 p-2.5">
|
||||||
|
<Smartphone className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle>Riverhead Raceway Mobile App</CardTitle>
|
||||||
|
<CardDescription>racehub-app · React Native</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="card-content-stretch">
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Native mobile companion for Riverhead Raceway — race-day
|
||||||
|
results, live standings, and event info for fans and
|
||||||
|
competitors on the track.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{["React Native", "TypeScript", "tRPC"].map((t) => (
|
||||||
|
<Badge key={t} variant="secondary">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="card-hover card-full-height">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="rounded-xl bg-primary/10 p-2.5">
|
||||||
|
<Code className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle>beenCMS</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
White-label CMS engine · Next.js
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="card-content-stretch">
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Fork-once CMS engine for powering multiple client sites.
|
||||||
|
Block-based page editor, runtime theming, role-based auth,
|
||||||
|
and a full admin interface — all on SQLite with zero ops.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{["Next.js", "tRPC", "Drizzle", "SQLite"].map((t) => (
|
||||||
|
<Badge key={t} variant="secondary">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Featured Work */}
|
||||||
|
<section className="animate-fade-in-up space-y-4">
|
||||||
|
<SectionLabel>Featured work</SectionLabel>
|
||||||
|
<div className="grid-equal-height grid gap-4 md:grid-cols-2">
|
||||||
|
|
||||||
|
<Card className="card-hover card-full-height">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle>Honors Thesis</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Bucknell University · 2026
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<FileText className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="card-content-stretch">
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
A web-based Wizard-of-Oz platform for accessible, reproducible
|
||||||
|
HRI research. Pilot study showed higher design fidelity and
|
||||||
|
usability than Choregraphe across all six sessions.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{["HRI", "Wizard-of-Oz", "React", "ROS2"].map((t) => (
|
||||||
|
<Badge key={t} variant="outline">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="mt-auto w-full"
|
||||||
|
>
|
||||||
|
<Link href="/publications">
|
||||||
|
Read thesis
|
||||||
|
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="card-hover card-full-height">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle>IEEE RO-MAN 2025</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Eindhoven · First author
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<BookOpen className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="card-content-stretch">
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Collaborative and Reproducible HRI Research Through a
|
||||||
|
Web-Based Wizard-of-Oz Platform. 34th IEEE International
|
||||||
|
Conference on Robot and Human Interactive Communication.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{["IEEE", "HRI", "Research"].map((t) => (
|
||||||
|
<Badge key={t} variant="outline">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="mt-auto w-full"
|
||||||
|
>
|
||||||
|
<Link href="/publications">
|
||||||
|
View paper
|
||||||
|
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="card-hover card-full-height">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle>Riverhead Raceway</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Production · 250k+ monthly users
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Globe className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="card-content-stretch">
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Official website and CMS for Riverhead Raceway, NY.
|
||||||
|
Real-time race data, event management, standings, and a full
|
||||||
|
admin interface with 97+ granular permissions.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{["Next.js", "PostgreSQL", "tRPC"].map((t) => (
|
||||||
|
<Badge key={t} variant="outline">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="mt-auto w-full"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://riverheadraceway.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Visit site
|
||||||
|
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="card-hover card-full-height">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle>beenvoice</CardTitle>
|
||||||
|
<CardDescription>Invoicing for freelancers</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Code className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="card-content-stretch">
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Professional invoicing: client management, PDF generation,
|
||||||
|
email delivery, timesheet view, and CSV import. Built with
|
||||||
|
tRPC and Drizzle ORM on Next.js 16.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{["Next.js", "tRPC", "Drizzle"].map((t) => (
|
||||||
|
<Badge key={t} variant="outline">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="mt-auto w-full"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://beenvoice.soconnor.dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Try it
|
||||||
|
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Education */}
|
{/* Education */}
|
||||||
<section className="animate-fade-in-up space-y-6">
|
<section className="animate-fade-in-up space-y-4">
|
||||||
<h2 className="text-2xl font-bold">Education</h2>
|
<SectionLabel>Education</SectionLabel>
|
||||||
<div className="animate-fade-in-up-delay-1">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Card className="card-hover">
|
{educationList.map((edu, i) => (
|
||||||
<CardHeader className="pb-3">
|
<Card key={i} className="card-hover">
|
||||||
<div className="flex items-center justify-between">
|
<CardHeader>
|
||||||
<div>
|
<CardTitle>{edu.institution}</CardTitle>
|
||||||
<CardTitle className="mb-1">
|
<CardDescription>{edu.degree}</CardDescription>
|
||||||
{education.institution}
|
</CardHeader>
|
||||||
</CardTitle>
|
<CardContent>
|
||||||
<CardDescription>{education.location}</CardDescription>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{edu.graduated ? "" : "Expected "}
|
||||||
|
{edu.expectedGraduation} · {edu.location}
|
||||||
|
</p>
|
||||||
|
{edu.engineeringGpa && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
Eng. GPA {edu.engineeringGpa}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">Overall {edu.gpa}</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Recognition */}
|
||||||
|
<section className="animate-fade-in-up space-y-4">
|
||||||
|
<SectionLabel>Recognition</SectionLabel>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{awards.map((award, i) => (
|
||||||
|
<Card key={i} className="card-hover">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Award className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="text-sm leading-snug">
|
||||||
|
{award.title}
|
||||||
|
</CardTitle>
|
||||||
|
{award.organization && (
|
||||||
|
<CardDescription className="mt-1 text-xs">
|
||||||
|
{award.organization} · {award.year}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<School className="h-5 w-5 text-muted-foreground" />
|
</CardHeader>
|
||||||
|
{award.description && (
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{award.description}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Explore */}
|
||||||
|
<section className="animate-fade-in-up space-y-4">
|
||||||
|
<SectionLabel>Explore</SectionLabel>
|
||||||
|
<div className="grid-equal-height grid gap-4 md:grid-cols-3">
|
||||||
|
|
||||||
|
<Card className="card-hover card-full-height">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Code className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<CardTitle>Projects</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 pt-0">
|
<CardContent className="card-content-stretch">
|
||||||
<div>
|
<div className="flex h-full flex-col gap-3">
|
||||||
<p className="font-medium">{education.degree}</p>
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">
|
HRIStudio, Riverhead Raceway, machine learning, embedded
|
||||||
Expected {education.expectedGraduation}
|
systems, and more.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<Button variant="outline" asChild className="mt-auto w-full">
|
||||||
<div className="flex flex-wrap gap-2">
|
<Link href="/projects">
|
||||||
<Badge variant="secondary">
|
View projects
|
||||||
Engineering GPA: {education.gpa}
|
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||||
</Badge>
|
</Link>
|
||||||
<Badge variant="outline">
|
</Button>
|
||||||
Dean's List: {education.deansListSemesters.length}{" "}
|
|
||||||
semesters
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="card-hover card-full-height">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<CardTitle>Publications</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="card-content-stretch">
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Peer-reviewed work in human-robot interaction research.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" asChild className="mt-auto w-full">
|
||||||
|
<Link href="/publications">
|
||||||
|
View publications
|
||||||
|
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="card-hover card-full-height">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ExternalLink className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<CardTitle>CV</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="card-content-stretch">
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Complete academic and professional curriculum vitae.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" asChild className="mt-auto w-full">
|
||||||
|
<Link href="/cv">
|
||||||
|
View CV
|
||||||
|
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Research Experience */}
|
|
||||||
<section className="animate-fade-in-up space-y-6">
|
|
||||||
<h2 className="text-2xl font-bold">Research Experience</h2>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{researchExperience.map((exp, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`animate-fade-in-up-delay-${index + 1}`}
|
|
||||||
>
|
|
||||||
<Card className="card-hover">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="mb-1">{exp.title}</CardTitle>
|
|
||||||
<CardDescription>{exp.organization}</CardDescription>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
{exp.location} • {exp.period}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<FlaskConical className="h-5 w-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
|
||||||
{exp.description.map((item, itemIndex) => (
|
|
||||||
<li key={itemIndex} className="flex items-start gap-2">
|
|
||||||
<span className="mt-2 h-1 w-1 flex-shrink-0 rounded-full bg-muted-foreground" />
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Professional Experience Highlights */}
|
|
||||||
<section className="animate-fade-in-up space-y-6">
|
|
||||||
<h2 className="text-2xl font-bold">
|
|
||||||
Professional Experience Highlights
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{professionalExperience.slice(0, 2).map((exp, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`animate-fade-in-up-delay-${index + 1}`}
|
|
||||||
>
|
|
||||||
<Card className="card-hover">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="mb-1">{exp.title}</CardTitle>
|
|
||||||
<CardDescription>{exp.organization}</CardDescription>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
{exp.location} • {exp.period}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Building className="h-5 w-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
|
||||||
{exp.description.slice(0, 3).map((item, itemIndex) => (
|
|
||||||
<li key={itemIndex} className="flex items-start gap-2">
|
|
||||||
<span className="mt-2 h-1 w-1 flex-shrink-0 rounded-full bg-muted-foreground" />
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Teaching Experience */}
|
|
||||||
<section className="animate-fade-in-up space-y-6">
|
|
||||||
<h2 className="text-2xl font-bold">Teaching Experience</h2>
|
|
||||||
<div className="grid-equal-height grid gap-6 md:grid-cols-2">
|
|
||||||
{teachingExperience.slice(0, 4).map((exp, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`animate-fade-in-up-delay-${index + 1}`}
|
|
||||||
>
|
|
||||||
<Card className="card-hover card-full-height">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<GraduationCap className="h-5 w-5 flex-shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<CardTitle className="mb-1 break-words text-base leading-tight">
|
|
||||||
{exp.title}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="break-words text-xs">
|
|
||||||
{exp.organization}
|
|
||||||
</CardDescription>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
{exp.period}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="card-content-stretch pt-0">
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{exp.description[0]}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Leadership & Activities */}
|
|
||||||
<section className="animate-fade-in-up space-y-6">
|
|
||||||
<h2 className="text-2xl font-bold">Leadership & Activities</h2>
|
|
||||||
<div className="grid-equal-height grid gap-6 md:grid-cols-2">
|
|
||||||
{leadershipExperience.map((exp, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`animate-fade-in-up-delay-${index + 1}`}
|
|
||||||
>
|
|
||||||
<Card className="card-hover card-full-height">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className="h-5 w-5 flex-shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<CardTitle className="mb-1 break-words text-base leading-tight">
|
|
||||||
{exp.title}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="break-words text-xs">
|
|
||||||
{exp.organization}
|
|
||||||
</CardDescription>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
{exp.period}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="card-content-stretch pt-0">
|
|
||||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
|
||||||
{exp.description.slice(0, 2).map((item, itemIndex) => (
|
|
||||||
<li key={itemIndex} className="flex items-start gap-2">
|
|
||||||
<span className="mt-2 h-1 w-1 flex-shrink-0 rounded-full bg-muted-foreground" />
|
|
||||||
<span className="break-words">{item}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Recent Awards & Recognition */}
|
|
||||||
<section className="animate-fade-in-up space-y-6">
|
|
||||||
<h2 className="text-2xl font-bold">Recent Awards & Recognition</h2>
|
|
||||||
<div className="grid-equal-height grid gap-4 md:grid-cols-2">
|
|
||||||
{awards.map((award, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`animate-fade-in-up-delay-${index + 1}`}
|
|
||||||
>
|
|
||||||
<Card className="card-hover card-full-height">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Award className="h-5 w-5 flex-shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<CardTitle className="mb-1 break-words text-base leading-tight">
|
|
||||||
{award.title}
|
|
||||||
</CardTitle>
|
|
||||||
{award.organization && (
|
|
||||||
<CardDescription className="break-words">
|
|
||||||
{award.organization} • {award.year}
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
{award.description && (
|
|
||||||
<CardContent className="card-content-stretch pt-0">
|
|
||||||
<p className="break-words text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{award.description}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Quick Links */}
|
|
||||||
<section className="animate-fade-in-up space-y-6">
|
|
||||||
<h2 className="text-2xl font-bold">Explore More</h2>
|
|
||||||
<div className="grid-equal-height grid gap-6 md:grid-cols-3">
|
|
||||||
<div className="animate-fade-in-up-delay-1">
|
|
||||||
<Card className="card-hover card-full-height">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Code className="h-5 w-5 flex-shrink-0" />
|
|
||||||
<CardTitle className="mb-1 break-words">Projects</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="card-content-stretch pt-0">
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<p className="break-words leading-relaxed text-muted-foreground">
|
|
||||||
Explore my featured projects including HRIStudio, machine
|
|
||||||
learning research, and web applications.
|
|
||||||
</p>
|
|
||||||
<Button variant="outline" asChild className="mt-auto w-full">
|
|
||||||
<Link href="/projects">
|
|
||||||
View Projects
|
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="animate-fade-in-up-delay-2">
|
|
||||||
<Card className="card-hover card-full-height">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BookOpen className="h-5 w-5 flex-shrink-0" />
|
|
||||||
<CardTitle className="mb-1 break-words">
|
|
||||||
Publications
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="card-content-stretch pt-0">
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<p className="break-words leading-relaxed text-muted-foreground">
|
|
||||||
Read my peer-reviewed publications in human-robot
|
|
||||||
interaction research.
|
|
||||||
</p>
|
|
||||||
<Button variant="outline" asChild className="mt-auto w-full">
|
|
||||||
<Link href="/publications">
|
|
||||||
View Publications
|
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="animate-fade-in-up-delay-3">
|
|
||||||
<Card className="card-hover card-full-height">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ExternalLink className="h-5 w-5 flex-shrink-0" />
|
|
||||||
<CardTitle className="mb-1 break-words">
|
|
||||||
Complete CV
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="card-content-stretch pt-0">
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<p className="break-words leading-relaxed text-muted-foreground">
|
|
||||||
View my complete academic and professional curriculum vitae.
|
|
||||||
</p>
|
|
||||||
<Button variant="outline" asChild className="mt-auto w-full">
|
|
||||||
<Link href="/cv">
|
|
||||||
View CV
|
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowUpRight, Play, BookOpen, FolderGit2, Github } from "lucide-react";
|
import { ArrowUpRight, Play, BookOpen, FolderGit2, Presentation } from "lucide-react";
|
||||||
|
import { GithubIcon } from "~/components/BrandIcons";
|
||||||
|
const Github = GithubIcon;
|
||||||
import { projects } from "~/lib/data";
|
import { projects } from "~/lib/data";
|
||||||
import { ImageWithSkeleton } from "~/components/ui/image-with-skeleton";
|
import { ImageWithSkeleton } from "~/components/ui/image-with-skeleton";
|
||||||
|
|
||||||
@@ -149,6 +151,23 @@ export default function ProjectsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{project.slidesUrl && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
asChild
|
||||||
|
className="button-hover"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={project.slidesUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Presentation className="mr-2 h-4 w-4" />
|
||||||
|
View Slides
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{project.link &&
|
{project.link &&
|
||||||
!project.link.startsWith("/") &&
|
!project.link.startsWith("/") &&
|
||||||
!project.websiteLink &&
|
!project.websiteLink &&
|
||||||
@@ -276,6 +295,23 @@ export default function ProjectsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{project.slidesUrl && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
asChild
|
||||||
|
className="button-hover sm:flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={project.slidesUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Presentation className="mr-2 h-4 w-4" />
|
||||||
|
View Slides
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{project.link &&
|
{project.link &&
|
||||||
!project.link.startsWith("/") &&
|
!project.link.startsWith("/") &&
|
||||||
!project.websiteLink &&
|
!project.websiteLink &&
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default function PublicationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent>
|
||||||
{pub.abstract && (
|
{pub.abstract && (
|
||||||
<p className="break-words text-sm leading-relaxed text-muted-foreground">
|
<p className="break-words text-sm leading-relaxed text-muted-foreground">
|
||||||
{pub.abstract}
|
{pub.abstract}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { SiGithub } from "@icons-pack/react-simple-icons";
|
||||||
|
|
||||||
|
interface BrandIconProps {
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GithubIcon({ className, size = 24 }: BrandIconProps) {
|
||||||
|
return (
|
||||||
|
<SiGithub
|
||||||
|
className={className}
|
||||||
|
size={size}
|
||||||
|
color="currentColor"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkedInIcon({ className, size = 24 }: BrandIconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.159 4.267 4.97v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,8 +34,7 @@ export function Navigation() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav
|
<nav
|
||||||
className={`fixed left-4 right-4 top-4 z-[51] rounded-2xl border bg-background/80 shadow-sm backdrop-blur-md transition-all duration-200 ${isOpen ? "border-transparent" : "border-border/60"
|
className="fixed left-0 right-0 top-0 z-[51] border-b border-border/50 bg-background/80 backdrop-blur-md"
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="relative px-8">
|
<div className="relative px-8">
|
||||||
<div className="flex h-16 items-center justify-between">
|
<div className="flex h-16 items-center justify-between">
|
||||||
@@ -87,7 +86,7 @@ export function Navigation() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`fixed left-4 right-4 top-24 z-50 overflow-hidden rounded-2xl border border-border/50 bg-background/80 shadow-sm backdrop-blur-md transition-all duration-300 lg:hidden ${isOpen ? "max-h-[calc(100vh-8rem)] opacity-100" : "max-h-0 opacity-0"
|
className={`fixed left-0 right-0 top-16 z-50 overflow-hidden border-t border-border/50 bg-background/80 backdrop-blur-md transition-all duration-300 lg:hidden ${isOpen ? "max-h-[calc(100vh-8rem)] opacity-100" : "max-h-0 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col space-y-2 p-4">
|
<div className="flex flex-col space-y-2 p-4">
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ export function Sidebar() {
|
|||||||
<>
|
<>
|
||||||
{/* Mobile layout - horizontal intro bar only on home page */}
|
{/* Mobile layout - horizontal intro bar only on home page */}
|
||||||
{isHomePage && (
|
{isHomePage && (
|
||||||
<div className="w-full space-y-4 px-6 pb-2 pt-6 sm:px-8 lg:hidden">
|
<div className="w-full space-y-3 px-4 pb-2 pt-4 sm:px-6 lg:hidden">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center gap-4">
|
||||||
<ImageWithSkeleton
|
<ImageWithSkeleton
|
||||||
src="/headshot.png"
|
src="/headshot.png"
|
||||||
alt={`${name[0]?.first} ${name[0]?.last}`}
|
alt={`${name[0]?.first} ${name[0]?.last}`}
|
||||||
width={240}
|
width={240}
|
||||||
height={240}
|
height={240}
|
||||||
containerClassName="h-24 w-24 rounded-2xl border border-border/50 shadow-sm"
|
containerClassName="h-20 w-20 rounded-xl border border-border/50 shadow-sm"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
@@ -69,8 +69,8 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop layout - clean and elegant sidebar */}
|
{/* Desktop layout - pinned sidebar */}
|
||||||
<div className="fixed bottom-4 left-4 top-24 hidden w-80 overflow-hidden rounded-3xl border border-border/60 bg-background/80 backdrop-blur-xl lg:block">
|
<div className="fixed left-0 top-16 bottom-0 w-80 border-r border-border/50 bg-background/80 backdrop-blur-md hidden lg:block">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Profile Section */}
|
{/* Profile Section */}
|
||||||
<div className="flex-shrink-0 border-b border-border/50 px-8 py-8">
|
<div className="flex-shrink-0 border-b border-border/50 px-8 py-8">
|
||||||
|
|||||||
+106
-44
@@ -1,50 +1,112 @@
|
|||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
const Avatar = React.forwardRef<
|
function Avatar({
|
||||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
size = "default",
|
||||||
>(({ className, ...props }, ref) => (
|
...props
|
||||||
<AvatarPrimitive.Root
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
ref={ref}
|
size?: "default" | "sm" | "lg"
|
||||||
className={cn(
|
}) {
|
||||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
return (
|
||||||
className,
|
<AvatarPrimitive.Root
|
||||||
)}
|
data-slot="avatar"
|
||||||
{...props}
|
data-size={size}
|
||||||
/>
|
className={cn(
|
||||||
));
|
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const AvatarImage = React.forwardRef<
|
function AvatarImage({
|
||||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
<AvatarPrimitive.Image
|
return (
|
||||||
ref={ref}
|
<AvatarPrimitive.Image
|
||||||
className={cn("aspect-square h-full w-full", className)}
|
data-slot="avatar-image"
|
||||||
{...props}
|
className={cn(
|
||||||
/>
|
"aspect-square size-full rounded-full object-cover",
|
||||||
));
|
className
|
||||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const AvatarFallback = React.forwardRef<
|
function AvatarFallback({
|
||||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
<AvatarPrimitive.Fallback
|
return (
|
||||||
ref={ref}
|
<AvatarPrimitive.Fallback
|
||||||
className={cn(
|
data-slot="avatar-fallback"
|
||||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
className={cn(
|
||||||
className,
|
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||||
)}
|
className
|
||||||
{...props}
|
)}
|
||||||
/>
|
{...props}
|
||||||
));
|
/>
|
||||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export { Avatar, AvatarImage, AvatarFallback };
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs/relaxed text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
AvatarBadge,
|
||||||
|
}
|
||||||
|
|||||||
+31
-19
@@ -1,37 +1,49 @@
|
|||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
"group/badge inline-flex h-6 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
outline: "text-foreground",
|
outline:
|
||||||
|
"border-border bg-input/20 text-foreground dark:bg-input/30 [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
export interface BadgeProps
|
function Badge({
|
||||||
extends
|
className,
|
||||||
React.HTMLAttributes<HTMLDivElement>,
|
variant = "default",
|
||||||
VariantProps<typeof badgeVariants> {}
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
<Comp
|
||||||
);
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants };
|
export { Badge, badgeVariants }
|
||||||
|
|||||||
@@ -1,107 +1,115 @@
|
|||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "radix-ui"
|
||||||
import { cn } from "~/lib/utils";
|
|
||||||
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
|
|
||||||
|
|
||||||
const Breadcrumb = React.forwardRef<
|
import { cn } from "~/lib/utils"
|
||||||
HTMLElement,
|
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
React.ComponentPropsWithoutRef<"nav"> & {
|
|
||||||
separator?: React.ReactNode;
|
|
||||||
}
|
|
||||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
|
||||||
Breadcrumb.displayName = "Breadcrumb";
|
|
||||||
|
|
||||||
const BreadcrumbList = React.forwardRef<
|
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
HTMLOListElement,
|
return (
|
||||||
React.ComponentPropsWithoutRef<"ol">
|
<nav
|
||||||
>(({ className, ...props }, ref) => (
|
aria-label="breadcrumb"
|
||||||
<ol
|
data-slot="breadcrumb"
|
||||||
ref={ref}
|
className={cn(className)}
|
||||||
className={cn(
|
{...props}
|
||||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
/>
|
||||||
className,
|
)
|
||||||
)}
|
}
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
BreadcrumbList.displayName = "BreadcrumbList";
|
|
||||||
|
|
||||||
const BreadcrumbItem = React.forwardRef<
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
HTMLLIElement,
|
return (
|
||||||
React.ComponentPropsWithoutRef<"li">
|
<ol
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="breadcrumb-list"
|
||||||
<li
|
className={cn(
|
||||||
ref={ref}
|
"flex flex-wrap items-center gap-1.5 text-xs/relaxed wrap-break-word text-muted-foreground",
|
||||||
className={cn("inline-flex items-center gap-1.5", className)}
|
className
|
||||||
{...props}
|
)}
|
||||||
/>
|
{...props}
|
||||||
));
|
/>
|
||||||
BreadcrumbItem.displayName = "BreadcrumbItem";
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const BreadcrumbLink = React.forwardRef<
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
HTMLAnchorElement,
|
return (
|
||||||
React.ComponentPropsWithoutRef<"a"> & {
|
<li
|
||||||
asChild?: boolean;
|
data-slot="breadcrumb-item"
|
||||||
}
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
>(({ asChild, className, ...props }, ref) => {
|
{...props}
|
||||||
const Comp = asChild ? Slot : "a";
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "a"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
ref={ref}
|
data-slot="breadcrumb-link"
|
||||||
className={cn("transition-colors hover:text-foreground", className)}
|
className={cn("transition-colors hover:text-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
});
|
}
|
||||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
|
||||||
|
|
||||||
const BreadcrumbPage = React.forwardRef<
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
HTMLSpanElement,
|
return (
|
||||||
React.ComponentPropsWithoutRef<"span">
|
<span
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="breadcrumb-page"
|
||||||
<span
|
role="link"
|
||||||
ref={ref}
|
aria-disabled="true"
|
||||||
role="link"
|
aria-current="page"
|
||||||
aria-disabled="true"
|
className={cn("font-normal text-foreground", className)}
|
||||||
aria-current="page"
|
{...props}
|
||||||
className={cn("font-normal text-foreground", className)}
|
/>
|
||||||
{...props}
|
)
|
||||||
/>
|
}
|
||||||
));
|
|
||||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
|
||||||
|
|
||||||
const BreadcrumbSeparator = ({
|
function BreadcrumbSeparator({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"li">) => (
|
}: React.ComponentProps<"li">) {
|
||||||
<li
|
return (
|
||||||
role="presentation"
|
<li
|
||||||
aria-hidden="true"
|
data-slot="breadcrumb-separator"
|
||||||
className={cn("[&>svg]:h-3.5 [&>svg]:w-3.5", className)}
|
role="presentation"
|
||||||
{...props}
|
aria-hidden="true"
|
||||||
>
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
{children ?? <ChevronRightIcon />}
|
{...props}
|
||||||
</li>
|
>
|
||||||
);
|
{children ?? (
|
||||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
<ChevronRightIcon />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const BreadcrumbEllipsis = ({
|
function BreadcrumbEllipsis({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span">) => (
|
}: React.ComponentProps<"span">) {
|
||||||
<span
|
return (
|
||||||
role="presentation"
|
<span
|
||||||
aria-hidden="true"
|
data-slot="breadcrumb-ellipsis"
|
||||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
role="presentation"
|
||||||
{...props}
|
aria-hidden="true"
|
||||||
>
|
className={cn(
|
||||||
<DotsHorizontalIcon className="h-4 w-4" />
|
"flex size-4 items-center justify-center [&>svg]:size-3.5",
|
||||||
<span className="sr-only">More</span>
|
className
|
||||||
</span>
|
)}
|
||||||
);
|
{...props}
|
||||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
>
|
||||||
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -111,4 +119,4 @@ export {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
BreadcrumbEllipsis,
|
BreadcrumbEllipsis,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,58 +1,65 @@
|
|||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
"border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2",
|
default:
|
||||||
sm: "h-8 px-3 text-xs",
|
"h-9 gap-2 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3 [&_svg:not([class*='size-'])]:size-4",
|
||||||
lg: "h-10 px-8",
|
xs: "h-6 gap-1 rounded-sm px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
icon: "h-9 w-9 rounded-full",
|
sm: "h-8 gap-1.5 px-3 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-10 gap-2 px-6 text-base has-data-[icon=inline-end]:pr-4 has-data-[icon=inline-start]:pl-4 [&_svg:not([class*='size-'])]:size-5",
|
||||||
|
icon: "size-9 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"icon-xs": "size-6 rounded-sm [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
"icon-lg": "size-10 [&_svg:not([class*='size-'])]:size-5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
export interface ButtonProps
|
function Button({
|
||||||
extends
|
className,
|
||||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
variant = "default",
|
||||||
VariantProps<typeof buttonVariants> {
|
size = "default",
|
||||||
asChild?: boolean;
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
export { Button, buttonVariants }
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "button";
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Button.displayName = "Button";
|
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
|
||||||
|
|||||||
+85
-68
@@ -1,83 +1,100 @@
|
|||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
function Card({
|
||||||
HTMLDivElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
size = "default",
|
||||||
>(({ className, ...props }, ref) => (
|
...props
|
||||||
<div
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
ref={ref}
|
return (
|
||||||
className={cn(
|
<div
|
||||||
"overflow-hidden rounded-3xl border border-border/60 bg-background/80 text-card-foreground shadow-sm backdrop-blur-xl",
|
data-slot="card"
|
||||||
className,
|
data-size={size}
|
||||||
)}
|
className={cn(
|
||||||
{...props}
|
"group/card flex flex-col gap-4 overflow-hidden rounded-2xl bg-card/80 py-4 text-sm text-card-foreground ring-1 ring-foreground/10 backdrop-blur-sm has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-2xl *:[img:last-child]:rounded-b-2xl",
|
||||||
/>
|
className
|
||||||
));
|
)}
|
||||||
Card.displayName = "Card";
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="card-header"
|
||||||
<div
|
className={cn(
|
||||||
ref={ref}
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-2xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
className
|
||||||
{...props}
|
)}
|
||||||
/>
|
{...props}
|
||||||
));
|
/>
|
||||||
CardHeader.displayName = "CardHeader";
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="card-title"
|
||||||
<div
|
className={cn("font-heading text-base font-semibold leading-tight", className)}
|
||||||
ref={ref}
|
{...props}
|
||||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
/>
|
||||||
{...props}
|
)
|
||||||
/>
|
}
|
||||||
));
|
|
||||||
CardTitle.displayName = "CardTitle";
|
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="card-description"
|
||||||
<div
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
ref={ref}
|
{...props}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
/>
|
||||||
{...props}
|
)
|
||||||
/>
|
}
|
||||||
));
|
|
||||||
CardDescription.displayName = "CardDescription";
|
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="card-action"
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
className={cn(
|
||||||
));
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
CardContent.displayName = "CardContent";
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="card-content"
|
||||||
<div
|
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
ref={ref}
|
{...props}
|
||||||
className={cn("flex items-center p-6 pt-0", className)}
|
/>
|
||||||
{...props}
|
)
|
||||||
/>
|
}
|
||||||
));
|
|
||||||
CardFooter.displayName = "CardFooter";
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-lg px-4 group-data-[size=sm]/card:px-3 [.border-t]:pt-4 group-data-[size=sm]/card:[.border-t]:pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
};
|
}
|
||||||
|
|||||||
+239
-174
@@ -1,204 +1,269 @@
|
|||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
import { cn } from "~/lib/utils";
|
|
||||||
import {
|
|
||||||
CheckIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
DotFilledIcon,
|
|
||||||
} from "@radix-ui/react-icons";
|
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
import { cn } from "~/lib/utils"
|
||||||
|
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||||
|
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
function DropdownMenuItem({
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
inset,
|
||||||
inset?: boolean;
|
variant = "default",
|
||||||
}
|
...props
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
inset?: boolean
|
||||||
ref={ref}
|
variant?: "default" | "destructive"
|
||||||
className={cn(
|
}) {
|
||||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
return (
|
||||||
inset && "pl-8",
|
<DropdownMenuPrimitive.Item
|
||||||
className,
|
data-slot="dropdown-menu-item"
|
||||||
)}
|
data-inset={inset}
|
||||||
{...props}
|
data-variant={variant}
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRightIcon className="ml-auto" />
|
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
|
||||||
));
|
|
||||||
DropdownMenuSubTrigger.displayName =
|
|
||||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.SubContent
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-xl border border-border/50 bg-background/80 p-1 text-popover-foreground shadow-lg backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
DropdownMenuSubContent.displayName =
|
|
||||||
DropdownMenuPrimitive.SubContent.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Portal>
|
|
||||||
<DropdownMenuPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-xl border border-border/50 bg-background/80 p-1 text-popover-foreground shadow-md backdrop-blur-md",
|
"group/dropdown-menu-item relative flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7.5 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
"origin-[--radix-dropdown-menu-content-transform-origin] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
className
|
||||||
className,
|
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
)
|
||||||
));
|
}
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
function DropdownMenuCheckboxItem({
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean;
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
||||||
inset && "pl-8",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
checked={checked}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="h-4 w-4" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
));
|
|
||||||
DropdownMenuCheckboxItem.displayName =
|
|
||||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.RadioItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<DotFilledIcon className="h-2 w-2 fill-current" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
|
||||||
));
|
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean;
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1.5 text-sm font-semibold",
|
|
||||||
inset && "pl-8",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({
|
|
||||||
className,
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<span
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex min-h-7 cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-xs text-muted-foreground data-inset:pl-7.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-[0.625rem] tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-7 cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7.5 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn("z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuShortcut,
|
DropdownMenuShortcut,
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuSubContent,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
function Skeleton({
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("animate-pulse bg-primary/10", className)} {...props} />
|
<div
|
||||||
);
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Skeleton };
|
export { Skeleton }
|
||||||
|
|||||||
+83
-48
@@ -1,55 +1,90 @@
|
|||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root;
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const TabsList = React.forwardRef<
|
const tabsListVariants = cva(
|
||||||
React.ElementRef<typeof TabsPrimitive.List>,
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-10 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
{
|
||||||
>(({ className, ...props }, ref) => (
|
variants: {
|
||||||
<TabsPrimitive.List
|
variant: {
|
||||||
ref={ref}
|
default: "bg-muted",
|
||||||
className={cn(
|
line: "gap-1 bg-transparent",
|
||||||
"inline-flex h-9 items-center justify-center rounded-xl border border-border/50 bg-background/80 p-1 text-muted-foreground shadow-sm backdrop-blur-md",
|
},
|
||||||
className,
|
},
|
||||||
)}
|
defaultVariants: {
|
||||||
{...props}
|
variant: "default",
|
||||||
/>
|
},
|
||||||
));
|
}
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
)
|
||||||
|
|
||||||
const TabsTrigger = React.forwardRef<
|
function TabsList({
|
||||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
variant = "default",
|
||||||
>(({ className, ...props }, ref) => (
|
...props
|
||||||
<TabsPrimitive.Trigger
|
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||||
ref={ref}
|
VariantProps<typeof tabsListVariants>) {
|
||||||
className={cn(
|
return (
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
<TabsPrimitive.List
|
||||||
className,
|
data-slot="tabs-list"
|
||||||
)}
|
data-variant={variant}
|
||||||
{...props}
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
/>
|
{...props}
|
||||||
));
|
/>
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const TabsContent = React.forwardRef<
|
function TabsTrigger({
|
||||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
<TabsPrimitive.Content
|
return (
|
||||||
ref={ref}
|
<TabsPrimitive.Trigger
|
||||||
className={cn(
|
data-slot="tabs-trigger"
|
||||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
className={cn(
|
||||||
className,
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:py-2 hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
)}
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
{...props}
|
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||||
/>
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
));
|
className
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
|
|||||||
@@ -1,129 +1,123 @@
|
|||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Designing My System: Soft, Translucent, and Alive",
|
title: "Designing My Personal Site",
|
||||||
publishedAt: "2025-12-10",
|
publishedAt: "2025-12-10",
|
||||||
summary:
|
summary:
|
||||||
"A deep dive into the design philosophy behind my personal website's new theme, moving from rigid boxes to organic, living layers.",
|
"The design decisions behind this site's visual system — what I changed, why, and how it works.",
|
||||||
tags: ["Design", "UI/UX", "TailwindCSS", "Frontend"],
|
tags: ["Design", "UI/UX", "TailwindCSS", "Frontend"],
|
||||||
image: "/images/design-system.png",
|
image: "/images/design-system.png",
|
||||||
};
|
};
|
||||||
|
|
||||||
## The Philosophy: Soft, Translucent, and Alive
|
Most developer portfolios look the same: flat white, rigid grid, card borders at full opacity. That's fine — it's readable and it works — but I wanted this site to feel a bit more considered. Here's what I actually changed and why.
|
||||||
|
|
||||||
When I set out to redesign my personal website, I wanted to move away from the standard "developer portfolio" aesthetic—rigid grids, harsh borders, and flat colors. I wanted something that felt organic, something that breathed.
|
The starting point was Apple's liquid glass — the frosted, layered surfaces they've been pushing across visionOS and iOS 26. I liked the idea but not the constraints: it's a heavy effect, tied to specific rendering hardware, and not something you can cleanly port to a web project without it feeling like a theme rather than a system. I wanted something simpler, familiar enough that it doesn't distract, and performant enough that I could reuse it across multiple projects without thinking twice. The result is less "liquid glass" and more "glass-adjacent" — translucent surfaces, soft blur, a moving background that gives everything something to sit on top of.
|
||||||
|
|
||||||
I call this simply **My Design System**. It's not a product; it's a reflection of my personal aesthetic. The core philosophy rests on three pillars:
|
## Background
|
||||||
|
|
||||||
1. **Soft**: Replacing sharp corners with deep `1rem` (16px) to `1.5rem` (24px) border radii.
|
The background is a fixed layer sitting behind everything at `z-index: -10`. It has two parts.
|
||||||
2. **Translucent**: Using glassmorphism to create depth without heavy drop shadows.
|
|
||||||
3. **Alive**: Incorporating subtle, continuous motion that makes the site feel like a living organism rather than a static document.
|
|
||||||
|
|
||||||
## The "Living Blob"
|
First, a 24px line grid using a CSS background gradient:
|
||||||
|
|
||||||
The heartbeat of this theme is the background. Instead of a flat color or a static gradient, I implemented what I call the "Living Blob".
|
```css
|
||||||
|
background-image: linear-gradient(to right, #80808010 1px, transparent 1px),
|
||||||
```tsx
|
linear-gradient(to bottom, #80808010 1px, transparent 1px);
|
||||||
// src/app/layout.tsx
|
background-size: 24px 24px;
|
||||||
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
|
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px)...] bg-[size:24px_24px]"></div>
|
|
||||||
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30"></div>
|
|
||||||
</div>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This single element—a massive, blurred circle—animates on a 7-second infinite loop, gently pulsing and shifting. It sits behind a technical grid pattern, creating a juxtaposition between the organic and the engineered.
|
Second, two large blurred circles that slowly drift on a 9-second loop:
|
||||||
|
|
||||||
## Floating UI
|
```tsx
|
||||||
|
<div className="animate-blob absolute left-[45%] top-[30%] h-[700px] w-[700px] rounded-full bg-primary/5 blur-3xl" />
|
||||||
|
```
|
||||||
|
|
||||||
One of the biggest structural changes was detaching the navigation and sidebar from the viewport edges.
|
The circles use `primary/5` — 5% opacity of the site's dark navy — so they're barely perceptible in isolation. The effect is more visible through the glass surfaces (nav, sidebar, cards) because of the `backdrop-blur` applied there.
|
||||||
|
|
||||||
In a traditional layout, the sidebar is stuck to the left, and the navbar is stuck to the top. In this system, they are **floating cards**.
|
The keyframe:
|
||||||
|
|
||||||
- **Navbar**: Fixed `top-4`, `left-4`, `right-4`.
|
```css
|
||||||
- **Sidebar**: Fixed `top-24`, `left-4`, `bottom-4`.
|
@keyframes blob {
|
||||||
|
0%, 100% { transform: scale(1) translate(0px, 0px); }
|
||||||
|
25% { transform: scale(1.08) translate(40px, -30px); }
|
||||||
|
50% { transform: scale(0.94) translate(-25px, 50px); }
|
||||||
|
75% { transform: scale(1.04) translate(35px, 30px); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
This creates a sense of layering. The UI elements aren't _part_ of the window; they are tools floating _above_ the content.
|
Just shifts and scales slightly over time so the page doesn't feel completely static.
|
||||||
|
|
||||||
## Design Tokens
|
## Glass surfaces
|
||||||
|
|
||||||
### 1. Shape & Rounding
|
The nav, sidebar, and cards all use the same basic pattern: partially transparent background with a backdrop blur.
|
||||||
|
|
||||||
I chose a base radius of `1rem` (16px) because it strikes the perfect balance between friendly and professional.
|
Nav and sidebar:
|
||||||
|
|
||||||
- **Cards**: `rounded-3xl` (24px). Large containers need softer corners to feel less imposing.
|
```tsx
|
||||||
- **Buttons**: `rounded-xl` (12px). Tactile and clickable.
|
className="bg-background/80 backdrop-blur-md border-border/50"
|
||||||
- **Icons**: `rounded-full`.
|
```
|
||||||
|
|
||||||
This softness extends to interaction states. When you hover over a card, the `overflow-hidden` property ensures that any inner content (like images or background fills) respects these curves perfectly.
|
Cards:
|
||||||
|
|
||||||
### 2. Shadows & Glassmorphism
|
```tsx
|
||||||
|
className="bg-card/80 backdrop-blur-sm"
|
||||||
|
```
|
||||||
|
|
||||||
Instead of heavy drop shadows to show depth, I rely on **Glassmorphism**.
|
The blur is stronger on the nav and sidebar (`md` vs `sm`) because they sit in front of everything and need cleaner visual separation from the content layer behind them.
|
||||||
|
|
||||||
- **Surface**: `bg-background/80` with `backdrop-blur-md`. This allows the "Living Blob" to bleed through, tinting the UI with the background color.
|
For this to work, the color variables need to carry an alpha channel. In Tailwind v3, that means using `<alpha-value>` in the config:
|
||||||
- **Border**: `border-border/60`. A subtle, semi-transparent border defines the edges.
|
|
||||||
- **Shadow**: `shadow-sm`. A very light lift, just enough to separate the layer.
|
|
||||||
|
|
||||||
### 3. Color Palette
|
```ts
|
||||||
|
// tailwind.config.ts
|
||||||
|
background: "hsl(var(--background) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--foreground) / <alpha-value>)",
|
||||||
|
// and so on for every color
|
||||||
|
```
|
||||||
|
|
||||||
The system uses a strict monochrome HSL palette that adapts to the user's system preference.
|
Without this, `bg-background/80` silently renders at full opacity — the modifier does nothing. Took longer to track down than I'd like to admit.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
Two fonts: [Playfair Display](https://fonts.google.com/specimen/Playfair+Display) for headings and card titles, Inter for everything else. Both loaded via `next/font`.
|
||||||
|
|
||||||
|
The reason isn't purely aesthetic — it's contrast. Inter at body size is readable and neutral, which is what you want for dense text. Playfair at heading sizes adds visual weight without needing to push font sizes up. It also reads differently from the surrounding text, which makes the hierarchy more obvious at a glance.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/fonts.ts
|
||||||
|
export const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
|
export const playfair = Playfair_Display({ subsets: ["latin"], variable: "--font-heading" });
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// tailwind.config.ts
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||||
|
heading: ["var(--font-heading)", ...fontFamily.serif],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
Cards use `rounded-2xl` (16px) instead of the shadcn default `rounded-lg` (8px). The sharper corners looked out of place next to the blurred background — softer corners make the floating-card feel more coherent.
|
||||||
|
|
||||||
|
## What didn't work out of the box
|
||||||
|
|
||||||
|
The shadcn component suite I updated to targets dense dashboards. The defaults are too compact for a reading-oriented site:
|
||||||
|
|
||||||
|
| Component | Default | Updated |
|
||||||
|
|-------------|----------------|----------------|
|
||||||
|
| Card text | `text-xs/relaxed` (12px) | `text-sm` (14px) |
|
||||||
|
| Card title | `text-sm font-medium` (14px) | `text-base font-semibold` (16px) |
|
||||||
|
| Badge | 10px text, 20px height | 12px text, 24px height |
|
||||||
|
| Button | 12px text, 28px height | 14px text, 36px height |
|
||||||
|
|
||||||
|
The other issue was keyframes. Several accordion and slide animations were defined inside an `@theme inline { }` block — a Tailwind v4 directive. In a v3 project, the browser treats `@theme` as an unknown at-rule and skips its contents entirely, so the animations were silently broken. Moved them to top-level CSS and they worked immediately.
|
||||||
|
|
||||||
|
## Color palette
|
||||||
|
|
||||||
import { ColorPalette } from "~/components/ColorPalette";
|
import { ColorPalette } from "~/components/ColorPalette";
|
||||||
|
|
||||||
<ColorPalette />
|
<ColorPalette />
|
||||||
|
|
||||||
## Typography: Editorial Meets Digital
|
The palette is a standard HSL monochrome system that adapts to the user's system preference. Nothing exotic.
|
||||||
|
|
||||||
For typography, I wanted to blend the readability of a digital product with the elegance of an editorial magazine. This "Editorial meets Digital" philosophy relies on the interplay between two distinct typefaces.
|
---
|
||||||
|
|
||||||
### The Serif: Playfair Display
|
That's the system. Most of it is just being deliberate about which defaults to override and why.
|
||||||
|
|
||||||
<p className="mb-6 font-heading text-4xl">Playfair Display</p>
|
|
||||||
|
|
||||||
I chose **Playfair Display** for all headings (`h1`–`h6`). It's a transitional serif typeface with high contrast strokes and delicate hairlines.
|
|
||||||
|
|
||||||
- **Why Serif?** Serifs feel "human", "established", and "emotional". They break the sterile "tech" vibe common in developer portfolios.
|
|
||||||
- **Why Playfair?** Its high contrast makes it perfect for large display sizes. It commands attention and adds a layer of sophistication that a sans-serif simply cannot achieve.
|
|
||||||
|
|
||||||
### The Sans: Inter
|
|
||||||
|
|
||||||
<p className="mb-6 font-sans text-4xl">Inter</p>
|
|
||||||
|
|
||||||
I chose **Inter** for the body text. It is the gold standard for screen readability.
|
|
||||||
|
|
||||||
- **Why Sans?** Sans-serif fonts are "rational", "clean", and "invisible". They reduce cognitive load, making long-form reading (like this blog post) effortless.
|
|
||||||
- **Why Inter?** It was designed specifically for computer screens, with a tall x-height that remains legible even at small sizes.
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
The font stack is implemented using `next/font` for zero layout shift and CSS variables for Tailwind integration.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// tailwind.config.ts
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
sans: ["var(--font-sans)", ...fontFamily.sans],
|
|
||||||
heading: ["var(--font-heading)", ...fontFamily.serif],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* src/styles/globals.css */
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
@apply font-heading; /* Playfair Display */
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply font-sans; /* Inter */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
This design system is more than just a theme; it's a statement about how I view software. It shouldn't just be functional; it should be inviting. It should feel like a space you want to inhabit. By combining soft shapes, organic motion, and editorial typography, I hope I've created a digital garden that feels a little more human.
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Github, Linkedin, Mail, School, GraduationCap } from "lucide-react";
|
import { Mail, School, GraduationCap } from "lucide-react";
|
||||||
|
import { SiGithub } from "@icons-pack/react-simple-icons";
|
||||||
|
import { LinkedInIcon } from "~/components/BrandIcons";
|
||||||
|
|
||||||
|
const Github = SiGithub;
|
||||||
|
const Linkedin = LinkedInIcon;
|
||||||
|
|
||||||
// TypeScript interfaces
|
// TypeScript interfaces
|
||||||
export interface Project {
|
export interface Project {
|
||||||
@@ -11,6 +16,7 @@ export interface Project {
|
|||||||
websiteLink?: string; // For deployed website links
|
websiteLink?: string; // For deployed website links
|
||||||
image?: string;
|
image?: string;
|
||||||
imageAlt?: string;
|
imageAlt?: string;
|
||||||
|
slidesUrl?: string;
|
||||||
featured: boolean;
|
featured: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +47,9 @@ export interface Education {
|
|||||||
location: string;
|
location: string;
|
||||||
degree: string;
|
degree: string;
|
||||||
expectedGraduation: string;
|
expectedGraduation: string;
|
||||||
|
graduated?: boolean;
|
||||||
gpa: string;
|
gpa: string;
|
||||||
|
engineeringGpa?: string;
|
||||||
deansListSemesters: string[];
|
deansListSemesters: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +72,7 @@ export const description =
|
|||||||
"Computer Science and Engineering graduate pursuing a Master's at Boston University. Research interests in human-robot interaction and developing technologies that make robots better collaborators with humans.";
|
"Computer Science and Engineering graduate pursuing a Master's at Boston University. Research interests in human-robot interaction and developing technologies that make robots better collaborators with humans.";
|
||||||
|
|
||||||
export const researchInterests =
|
export const researchInterests =
|
||||||
"I'm passionate about human-robot interaction and developing technologies that make robots better collaborators with humans. My work focuses on creating reproducible research methodologies, particularly through Wizard-of-Oz experiments, and building platforms that lower barriers for HRI researchers. I'm especially interested in how we can make robot behaviors more trustworthy and explainable, and how to design effective frameworks for studying human-robot collaboration across different contexts and applications.";
|
"My research focuses on the intersection of human-robot interaction and research infrastructure. At Bucknell, I developed HRIStudio—a web-based platform that addresses the accessibility and reproducibility challenges endemic to Wizard-of-Oz HRI studies—resulting in two first-author publications at IEEE RO-MAN and a completed honors thesis. Continuing at Boston University, I'm interested in how principled software architecture and rigorous experimental methodology can accelerate our understanding of human-robot trust, collaboration, and the design of social robots that work reliably outside the lab.";
|
||||||
|
|
||||||
export const education: Education = {
|
export const education: Education = {
|
||||||
institution: "Boston University",
|
institution: "Boston University",
|
||||||
@@ -75,6 +83,27 @@ export const education: Education = {
|
|||||||
deansListSemesters: [],
|
deansListSemesters: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const educationList = [
|
||||||
|
{
|
||||||
|
institution: "Boston University",
|
||||||
|
location: "Boston, MA",
|
||||||
|
degree: "Master of Science in Computer Engineering",
|
||||||
|
expectedGraduation: "May 2027",
|
||||||
|
gpa: "TBD",
|
||||||
|
deansListSemesters: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
institution: "Bucknell University",
|
||||||
|
location: "Lewisburg, PA",
|
||||||
|
degree: "Bachelor of Science in Computer Science and Engineering, Honors",
|
||||||
|
expectedGraduation: "May 2026",
|
||||||
|
graduated: true,
|
||||||
|
gpa: "3.71",
|
||||||
|
engineeringGpa: "3.92",
|
||||||
|
deansListSemesters: ["Fall 2022", "Fall 2023", "Spring 2024", "Fall 2024", "Spring 2025", "Fall 2025", "Spring 2026"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const location = [
|
export const location = [
|
||||||
{
|
{
|
||||||
icon: School,
|
icon: School,
|
||||||
@@ -163,9 +192,9 @@ export const experiences: Experience[] = [
|
|||||||
location: "Lewisburg, PA",
|
location: "Lewisburg, PA",
|
||||||
period: "Sep 2025 – May 2026",
|
period: "Sep 2025 – May 2026",
|
||||||
description: [
|
description: [
|
||||||
"200-page solo thesis for Bachelor of Science in Computer Science and Engineering with Honors",
|
"Honors thesis for Bachelor of Science in Computer Science and Engineering with Honors",
|
||||||
"Extended two published IEEE RO-MAN papers into comprehensive research for degree honors",
|
"Extended two published IEEE RO-MAN papers into comprehensive research for degree honors",
|
||||||
"Advisor: Prof. Jeremy Perrone",
|
"Advisor: Prof. L. Felipe Perrone",
|
||||||
],
|
],
|
||||||
type: "research",
|
type: "research",
|
||||||
},
|
},
|
||||||
@@ -219,19 +248,6 @@ export const experiences: Experience[] = [
|
|||||||
],
|
],
|
||||||
type: "teaching",
|
type: "teaching",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Teaching Assistant - Software Engineering & Design",
|
|
||||||
organization: "Computer Science Department, Bucknell University",
|
|
||||||
location: "Lewisburg, PA",
|
|
||||||
period: "Jan 2024 - Present",
|
|
||||||
description: [
|
|
||||||
"Mentor 150+ students in software engineering principles, design patterns, and collaborative development practices",
|
|
||||||
"Developed automated testing frameworks with personalized feedback, improving learning outcomes while streamlining assessment processes",
|
|
||||||
"Created supplementary materials connecting theoretical concepts to real-world applications, drawing from industry experience",
|
|
||||||
"Hold regular office hours and code review sessions, fostering deep understanding of software architecture principles",
|
|
||||||
],
|
|
||||||
type: "teaching",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Computer Science Tutor",
|
title: "Computer Science Tutor",
|
||||||
organization: "Engineering Study Spot, Bucknell University",
|
organization: "Engineering Study Spot, Bucknell University",
|
||||||
@@ -304,7 +320,7 @@ export const experiences: Experience[] = [
|
|||||||
type: "professional",
|
type: "professional",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Former President, Electrical/Mechanical Team Lead",
|
title: "President, Electrical/Mechanical Team Lead",
|
||||||
organization: "AIChE Chem-E-Car Competition Team, Bucknell University",
|
organization: "AIChE Chem-E-Car Competition Team, Bucknell University",
|
||||||
location: "Lewisburg, PA",
|
location: "Lewisburg, PA",
|
||||||
period: "Jan 2023 – May 2026",
|
period: "Jan 2023 – May 2026",
|
||||||
@@ -331,10 +347,17 @@ export const experiences: Experience[] = [
|
|||||||
|
|
||||||
export const awards: Award[] = [
|
export const awards: Award[] = [
|
||||||
{
|
{
|
||||||
title: "Dean's List (5 semesters)",
|
title: "James M. Pommersheim Research and Innovation in Engineering Award",
|
||||||
|
organization: "Bucknell University College of Engineering",
|
||||||
|
year: 2026,
|
||||||
|
description:
|
||||||
|
"Awarded to the engineering student who has achieved through creative effort outstanding work of scholarship or invention",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Dean's List (7 semesters)",
|
||||||
organization: "Bucknell University",
|
organization: "Bucknell University",
|
||||||
year: 2024,
|
year: 2026,
|
||||||
description: "Fall 2022, Fall 2023, Spring 2024, Fall 2024, Spring 2025",
|
description: "Fall 2022, Fall 2023, Spring 2024, Fall 2024, Spring 2025, Fall 2025, Spring 2026",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "AIChE Mid-Atlantic Chem-E-Car Competition",
|
title: "AIChE Mid-Atlantic Chem-E-Car Competition",
|
||||||
@@ -351,6 +374,7 @@ export const conferences = [
|
|||||||
date: "Aug 2025",
|
date: "Aug 2025",
|
||||||
presentation:
|
presentation:
|
||||||
"A Web-Based Wizard-of-Oz Platform for Collaborative and Reproducible Human-Robot Interaction Research",
|
"A Web-Based Wizard-of-Oz Platform for Collaborative and Reproducible Human-Robot Interaction Research",
|
||||||
|
youtubeUrl: "https://youtu.be/relGUrO8YVo?si=LZKIeiAbu-GE6XCS&t=5333",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "IEEE RO-MAN 2024",
|
title: "IEEE RO-MAN 2024",
|
||||||
@@ -438,6 +462,28 @@ export const relevantCoursework = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const projects: Project[] = [
|
export const projects: Project[] = [
|
||||||
|
{
|
||||||
|
title: "LLM Fine-tuning & On-Device Inference Pipeline",
|
||||||
|
description:
|
||||||
|
"LoRA fine-tuning and GGUF quantization pipeline for on-device LLM inference. Fine-tuned Llama 3.2 3B improved recall by +0.192 over the generic baseline at roughly half the latency.",
|
||||||
|
longDescription:
|
||||||
|
"End-to-end pipeline for fine-tuning a small LLM on structured JSON extraction tasks, then deploying the result on-device. Used LoRA adapters on Llama 3.2 3B via MLX (Apple Silicon) and evaluated across four artifacts: generic F16, generic Q4_K_M, fine-tuned F16, and fine-tuned Q4_K_M. The deployable artifact — fine-tuned Q4_K_M GGUF — achieved 0.961 recall and 100% JSON validity at 2.91s average latency on llama.cpp, compared to 0.780 recall and 6.65s for the generic F16 baseline. Quantization after fine-tuning cost only 0.011 recall while cutting latency in half. Evaluation ran on a 106-case held-out set with automated recall, JSON validity, and schema validity scoring.",
|
||||||
|
tags: ["Python", "PyTorch", "LoRA", "llama.cpp", "GGUF", "MLX", "Jupyter"],
|
||||||
|
featured: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "F1 Halo Removal via Video Inpainting",
|
||||||
|
description:
|
||||||
|
"Removes the F1 Halo safety arch from onboard visor-cam footage using classical CV mask detection and neural inpainting. Final project for Image Processing and Analysis.",
|
||||||
|
longDescription:
|
||||||
|
"The Halo is a mandatory titanium arch on all F1 cars. It saves lives but cuts through the most interesting part of onboard footage. This project removes it cleanly from visor-cam video using two stages. Stage one: classical CV mask detection — Sobel-Y gradient detection for the arch edge, a robust probe-and-fit keel detector with outlier rejection and temporal jump guards, and explicit geometry construction to avoid over-masking. Stage two: two inpainting methods compared side by side — LaMa (Fast Fourier Convolution network, per-frame spatial inpainting) and RAFT optical flow with backward warp and distance-transform blending for temporal coherence across 300 frames at 60fps.",
|
||||||
|
tags: ["Python", "OpenCV", "Computer Vision", "LaMa", "RAFT", "Jupyter", "Inpainting"],
|
||||||
|
gitLink: "https://github.com/soconnor0919/f1-halo-removal",
|
||||||
|
image: "/images/f1-halo-removal.png",
|
||||||
|
imageAlt: "Side-by-side comparison of original F1 footage, LaMa spatial inpainting, and RAFT temporal inpainting across three frames",
|
||||||
|
slidesUrl: "/publications/f1-halo-removal.pdf",
|
||||||
|
featured: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Nand2Tetris Implementation (ECEG 431)",
|
title: "Nand2Tetris Implementation (ECEG 431)",
|
||||||
description:
|
description:
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-203
@@ -2,78 +2,102 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@keyframes accordion-down {
|
||||||
|
from { height: 0; }
|
||||||
|
to { height: var(--radix-accordion-content-height); }
|
||||||
|
}
|
||||||
|
@keyframes accordion-up {
|
||||||
|
from { height: var(--radix-accordion-content-height); }
|
||||||
|
to { height: 0; }
|
||||||
|
}
|
||||||
|
@keyframes collapsible-down {
|
||||||
|
from { height: 0; }
|
||||||
|
to { height: var(--radix-collapsible-content-height); }
|
||||||
|
}
|
||||||
|
@keyframes collapsible-up {
|
||||||
|
from { height: var(--radix-collapsible-content-height); }
|
||||||
|
to { height: 0; }
|
||||||
|
}
|
||||||
|
@keyframes slide-in-from-left {
|
||||||
|
from { transform: translateX(-100%); }
|
||||||
|
}
|
||||||
|
@keyframes slide-in-from-right {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
@keyframes slide-in-from-top {
|
||||||
|
from { transform: translateY(-100%); }
|
||||||
|
}
|
||||||
|
@keyframes slide-in-from-bottom {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
}
|
||||||
|
@keyframes slide-out-to-left {
|
||||||
|
to { transform: translateX(-100%); }
|
||||||
|
}
|
||||||
|
@keyframes slide-out-to-right {
|
||||||
|
to { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
@keyframes slide-out-to-top {
|
||||||
|
to { transform: translateY(-100%); }
|
||||||
|
}
|
||||||
|
@keyframes slide-out-to-bottom {
|
||||||
|
to { transform: translateY(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 240 10% 3.9%;
|
--foreground: 222.2 84% 4.9%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 240 10% 3.9%;
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 240 10% 3.9%;
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
--primary: 240 5.9% 10%;
|
--primary: 222.2 47.4% 11.2%;
|
||||||
--primary-foreground: 0 0% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
/* Darker secondary for better badge contrast against white bg */
|
--secondary: 210 40% 96.1%;
|
||||||
--secondary: 240 4.8% 90%;
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
--muted: 210 40% 96.1%;
|
||||||
--muted: 240 4.8% 95.9%;
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
--muted-foreground: 240 3.8% 46.1%;
|
--accent: 210 40% 96.1%;
|
||||||
--accent: 240 4.8% 95.9%;
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
--accent-foreground: 240 5.9% 10%;
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 240 5.9% 90%;
|
--border: 214.3 31.8% 91.4%;
|
||||||
--input: 240 5.9% 90%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
--ring: 240 10% 3.9%;
|
--ring: 222.2 84% 4.9%;
|
||||||
--chart-1: 12 76% 61%;
|
--radius: 0.625rem;
|
||||||
--chart-2: 173 58% 39%;
|
--chart-1: 222.2 47.4% 11.2%;
|
||||||
--chart-3: 197 37% 24%;
|
--chart-2: 210 40% 44%;
|
||||||
--chart-5: 27 87% 67%;
|
--chart-3: 210 40% 57.6%;
|
||||||
--radius: 1rem;
|
--chart-4: 182 40% 76.5%;
|
||||||
--sidebar: 0 0% 98%;
|
--chart-5: 340 75% 55%;
|
||||||
--sidebar-foreground: 240 5.3% 26.1%;
|
|
||||||
--sidebar-primary: 240 5.9% 10%;
|
|
||||||
--sidebar-primary-foreground: 0 0% 98%;
|
|
||||||
--sidebar-accent: 240 4.8% 95.9%;
|
|
||||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
|
||||||
--sidebar-border: 220 13% 91%;
|
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: 240 10% 3.9%;
|
--background: 222.2 84% 4.9%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 210 40% 98%;
|
||||||
--card: 240 10% 3.9%;
|
--card: 222.2 84% 4.9%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 210 40% 98%;
|
||||||
--popover: 240 10% 3.9%;
|
--popover: 222.2 84% 4.9%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 210 40% 98%;
|
||||||
--primary: 0 0% 98%;
|
--primary: 210 40% 98%;
|
||||||
--primary-foreground: 240 5.9% 10%;
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
--secondary: 240 3.7% 20%;
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
/* Slightly lighter for visibility */
|
--secondary-foreground: 210 40% 98%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--muted: 217.2 32.6% 17.5%;
|
||||||
--muted: 240 3.7% 15.9%;
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
--muted-foreground: 240 5% 64.9%;
|
--accent: 217.2 32.6% 17.5%;
|
||||||
--accent: 240 3.7% 15.9%;
|
--accent-foreground: 210 40% 98%;
|
||||||
--accent-foreground: 0 0% 98%;
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 240 3.7% 15.9%;
|
--border: 217.2 32.6% 17.5%;
|
||||||
--input: 240 3.7% 15.9%;
|
--input: 217.2 32.6% 17.5%;
|
||||||
--ring: 240 4.9% 83.9%;
|
--ring: 212.9 100% 50%;
|
||||||
--chart-1: 220 70% 50%;
|
--chart-1: 217.2 91.2% 59%;
|
||||||
--chart-2: 160 60% 45%;
|
--chart-2: 160 84% 39%;
|
||||||
--chart-3: 30 80% 55%;
|
--chart-3: 30 80% 50%;
|
||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%;
|
--chart-5: 340 75% 55%;
|
||||||
--sidebar: 240 5.9% 10%;
|
|
||||||
--sidebar-foreground: 240 4.8% 95.9%;
|
|
||||||
--sidebar-primary: 224.3 76.3% 48%;
|
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
|
||||||
--sidebar-accent: 240 3.7% 15.9%;
|
|
||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,156 +106,50 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground font-sans;
|
@apply bg-background text-foreground;
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
@apply font-heading;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
.card-hover {
|
||||||
.animate-blob {
|
@apply transition-all duration-200 hover:shadow-lg hover:ring-foreground/20;
|
||||||
animation: blob 7s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animation-delay-2000 {
|
|
||||||
animation-delay: 2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animation-delay-4000 {
|
|
||||||
animation-delay: 4s;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* grid-equal-height: force every direct child to fill the row */
|
||||||
|
.grid-equal-height > * {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* card-full-height: card fills its wrapper */
|
||||||
|
.card-full-height {
|
||||||
|
@apply h-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* card-content-stretch: CardContent grows to push footer content down */
|
||||||
|
.card-content-stretch {
|
||||||
|
@apply flex-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-contact-link {
|
||||||
|
@apply rounded-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up { animation: fade-in-up 0.5s ease-out both; }
|
||||||
|
.animate-fade-in-up-delay-1 { animation: fade-in-up 0.5s ease-out 0.1s both; }
|
||||||
|
.animate-fade-in-up-delay-2 { animation: fade-in-up 0.5s ease-out 0.2s both; }
|
||||||
|
.animate-fade-in-up-delay-3 { animation: fade-in-up 0.5s ease-out 0.3s both; }
|
||||||
|
.animate-fade-in-up-delay-4 { animation: fade-in-up 0.5s ease-out 0.4s both; }
|
||||||
|
|
||||||
@keyframes blob {
|
@keyframes blob {
|
||||||
0% {
|
0%, 100% { transform: scale(1) translate(0px, 0px); }
|
||||||
transform: translate(0px, 0px) scale(1);
|
25% { transform: scale(1.08) translate(40px, -30px); }
|
||||||
}
|
50% { transform: scale(0.94) translate(-25px, 50px); }
|
||||||
|
75% { transform: scale(1.04) translate(35px, 30px); }
|
||||||
33% {
|
|
||||||
transform: translate(30px, -50px) scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
66% {
|
|
||||||
transform: translate(-20px, 20px) scale(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: translate(0px, 0px) scale(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
.animate-blob { animation: blob 9s ease-in-out infinite; }
|
||||||
|
|
||||||
/* Animation utilities */
|
|
||||||
.animate-fade-in {
|
|
||||||
animation: fadeIn 0.5s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in-up {
|
|
||||||
animation: fadeInUp 0.5s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in-up-delay-1 {
|
|
||||||
animation: fadeInUp 0.5s ease-in-out 0.1s backwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in-up-delay-2 {
|
|
||||||
animation: fadeInUp 0.5s ease-in-out 0.2s backwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in-up-delay-3 {
|
|
||||||
animation: fadeInUp 0.5s ease-in-out 0.3s backwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in-up-delay-4 {
|
|
||||||
animation: fadeInUp 0.5s ease-in-out 0.4s backwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card hover effects */
|
|
||||||
.card-hover {
|
|
||||||
transition: all 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-hover:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: 0 12px 24px -10px hsl(var(--foreground) / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button hover effects */
|
|
||||||
.button-hover {
|
|
||||||
transition: all 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-hover:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Equal height cards in grid layouts */
|
|
||||||
.grid-equal-height>* {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-full-height {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content-stretch {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content-stretch p:not(:last-child) {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text wrapping and overflow utilities */
|
|
||||||
.break-words {
|
|
||||||
word-wrap: break-word;
|
|
||||||
word-break: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
hyphens: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leading-relaxed {
|
|
||||||
line-height: 1.625;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leading-tight {
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+25
-25
@@ -16,48 +16,48 @@ export default {
|
|||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
md: "calc(var(--radius) - 2px)",
|
md: "calc(var(--radius) - 2px)",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
sm: "calc(var(--radius) - 4px)",
|
||||||
xl: "calc(var(--radius) + 4px)",
|
xl: "calc(var(--radius) + 2px)",
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
background: "hsl(var(--background))",
|
background: "hsl(var(--background) / <alpha-value>)",
|
||||||
foreground: "hsl(var(--foreground))",
|
foreground: "hsl(var(--foreground) / <alpha-value>)",
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: "hsl(var(--card))",
|
DEFAULT: "hsl(var(--card) / <alpha-value>)",
|
||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: "hsl(var(--card-foreground) / <alpha-value>)",
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: "hsl(var(--popover))",
|
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
|
||||||
foreground: "hsl(var(--popover-foreground))",
|
foreground: "hsl(var(--popover-foreground) / <alpha-value>)",
|
||||||
},
|
},
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: "hsl(var(--primary))",
|
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)",
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: "hsl(var(--muted))",
|
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
foreground: "hsl(var(--muted-foreground) / <alpha-value>)",
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: "hsl(var(--accent))",
|
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
|
||||||
},
|
},
|
||||||
border: "hsl(var(--border))",
|
border: "hsl(var(--border) / <alpha-value>)",
|
||||||
input: "hsl(var(--input))",
|
input: "hsl(var(--input) / <alpha-value>)",
|
||||||
ring: "hsl(var(--ring))",
|
ring: "hsl(var(--ring) / <alpha-value>)",
|
||||||
chart: {
|
chart: {
|
||||||
"1": "hsl(var(--chart-1))",
|
"1": "hsl(var(--chart-1) / <alpha-value>)",
|
||||||
"2": "hsl(var(--chart-2))",
|
"2": "hsl(var(--chart-2) / <alpha-value>)",
|
||||||
"3": "hsl(var(--chart-3))",
|
"3": "hsl(var(--chart-3) / <alpha-value>)",
|
||||||
"4": "hsl(var(--chart-4))",
|
"4": "hsl(var(--chart-4) / <alpha-value>)",
|
||||||
"5": "hsl(var(--chart-5))",
|
"5": "hsl(var(--chart-5) / <alpha-value>)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
|
|||||||
Reference in New Issue
Block a user