mirror of
https://github.com/soconnor0919/personal-website.git
synced 2026-02-04 15:56:31 -05:00
refactor: fix linting and typechecking errors
This commit is contained in:
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
4
public/pdf.worker.min.js
vendored
4
public/pdf.worker.min.js
vendored
@@ -30356,7 +30356,6 @@ class PostScriptLexer {
|
|||||||
;
|
;
|
||||||
(t = this.nextChar()) >= 0 &&
|
(t = this.nextChar()) >= 0 &&
|
||||||
((t >= 65 && t <= 90) || (t >= 97 && t <= 122));
|
((t >= 65 && t <= 90) || (t >= 97 && t <= 122));
|
||||||
|
|
||||||
)
|
)
|
||||||
i.push(String.fromCharCode(t));
|
i.push(String.fromCharCode(t));
|
||||||
const a = i.join("");
|
const a = i.join("");
|
||||||
@@ -30378,7 +30377,6 @@ class PostScriptLexer {
|
|||||||
;
|
;
|
||||||
(e = this.nextChar()) >= 0 &&
|
(e = this.nextChar()) >= 0 &&
|
||||||
((e >= 48 && e <= 57) || 45 === e || 46 === e);
|
((e >= 48 && e <= 57) || 45 === e || 46 === e);
|
||||||
|
|
||||||
)
|
)
|
||||||
t.push(String.fromCharCode(e));
|
t.push(String.fromCharCode(e));
|
||||||
const i = parseFloat(t.join(""));
|
const i = parseFloat(t.join(""));
|
||||||
@@ -37773,7 +37771,6 @@ class XMLParserBase {
|
|||||||
for (
|
for (
|
||||||
;
|
;
|
||||||
a < e.length && !isWhitespace(e, a) && ">" !== e[a] && "/" !== e[a];
|
a < e.length && !isWhitespace(e, a) && ">" !== e[a] && "/" !== e[a];
|
||||||
|
|
||||||
)
|
)
|
||||||
++a;
|
++a;
|
||||||
const s = e.substring(t, a);
|
const s = e.substring(t, a);
|
||||||
@@ -37810,7 +37807,6 @@ class XMLParserBase {
|
|||||||
">" !== e[i] &&
|
">" !== e[i] &&
|
||||||
"?" !== e[i] &&
|
"?" !== e[i] &&
|
||||||
"/" !== e[i];
|
"/" !== e[i];
|
||||||
|
|
||||||
)
|
)
|
||||||
++i;
|
++i;
|
||||||
const a = e.substring(t, i);
|
const a = e.substring(t, i);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { readFile } from "fs/promises";
|
import { readFile } from "fs/promises";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowUpRight, Newspaper } from "lucide-react";
|
import { ArrowUpRight, Newspaper } from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -22,12 +22,12 @@ export default function ArticlesPage() {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
fetchArticles();
|
void fetchArticles();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="animate-fade-in-up prose prose-zinc dark:prose-invert max-w-none">
|
<section className="animate-fade-in-up prose prose-zinc max-w-none dark:prose-invert">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Newspaper className="h-8 w-8 text-primary" />
|
<Newspaper className="h-8 w-8 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,82 +1,92 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { BreadcrumbUpdater } from "~/components/BreadcrumbUpdater";
|
import { BreadcrumbUpdater } from "~/components/BreadcrumbUpdater";
|
||||||
|
|
||||||
|
interface BlogPostMetadata {
|
||||||
|
title: string;
|
||||||
|
publishedAt: string;
|
||||||
|
tags?: string[];
|
||||||
|
summary?: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const contentDir = path.join(process.cwd(), "src/content/blog");
|
const contentDir = path.join(process.cwd(), "src/content/blog");
|
||||||
const files = fs.readdirSync(contentDir);
|
const files = fs.readdirSync(contentDir);
|
||||||
|
|
||||||
return files
|
return files
|
||||||
.filter((file) => file.endsWith(".mdx"))
|
.filter((file) => file.endsWith(".mdx"))
|
||||||
.map((file) => ({
|
.map((file) => ({
|
||||||
slug: file.replace(".mdx", ""),
|
slug: file.replace(".mdx", ""),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps) {
|
export async function generateMetadata({ params }: PageProps) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
try {
|
try {
|
||||||
const { metadata } = await import(`~/content/blog/${slug}.mdx`);
|
const { metadata } = (await import(`~/content/blog/${slug}.mdx`)) as {
|
||||||
return metadata;
|
metadata: BlogPostMetadata;
|
||||||
} catch (e) {
|
};
|
||||||
return {
|
return metadata;
|
||||||
title: "Post Not Found",
|
} catch {
|
||||||
};
|
return {
|
||||||
}
|
title: "Post Not Found",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogPost({ params }: PageProps) {
|
export default async function BlogPost({ params }: PageProps) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
let Post;
|
let Post;
|
||||||
let metadata;
|
let metadata;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await import(`~/content/blog/${slug}.mdx`);
|
const content = (await import(`~/content/blog/${slug}.mdx`)) as {
|
||||||
Post = content.default;
|
default: React.ComponentType;
|
||||||
metadata = content.metadata;
|
metadata: BlogPostMetadata;
|
||||||
} catch (e) {
|
};
|
||||||
notFound();
|
Post = content.default;
|
||||||
}
|
metadata = content.metadata;
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="animate-fade-in-up space-y-8">
|
<article className="animate-fade-in-up space-y-8">
|
||||||
<BreadcrumbUpdater title={metadata.title} />
|
<BreadcrumbUpdater title={metadata.title} />
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
{/* <Button variant="ghost" asChild className="-ml-4 text-muted-foreground mb-4">
|
{/* <Button variant="ghost" asChild className="-ml-4 text-muted-foreground mb-4">
|
||||||
<Link href="/blog">
|
<Link href="/blog">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Blog
|
Back to Blog
|
||||||
</Link>
|
</Link>
|
||||||
</Button> */}
|
</Button> */}
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold mb-4">{metadata.title}</h1>
|
<h1 className="mb-4 text-3xl font-bold">{metadata.title}</h1>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 items-center text-muted-foreground mb-6">
|
<div className="mb-6 flex flex-wrap items-center gap-4 text-muted-foreground">
|
||||||
<time dateTime={metadata.publishedAt}>{metadata.publishedAt}</time>
|
<time dateTime={metadata.publishedAt}>{metadata.publishedAt}</time>
|
||||||
{metadata.tags && (
|
{metadata.tags && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{metadata.tags.map((tag: string) => (
|
{metadata.tags.map((tag: string) => (
|
||||||
<Badge key={tag} variant="secondary">
|
<Badge key={tag} variant="secondary">
|
||||||
{tag}
|
{tag}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="prose prose-zinc dark:prose-invert max-w-none">
|
<div className="prose prose-zinc max-w-none dark:prose-invert">
|
||||||
<Post />
|
<Post />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +1,107 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { BookOpen } from "lucide-react";
|
import { BookOpen } from "lucide-react";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
interface BlogPost {
|
||||||
|
title: string;
|
||||||
|
publishedAt: string;
|
||||||
|
summary: string;
|
||||||
|
tags?: string[];
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to get blog posts
|
// Helper to get blog posts
|
||||||
async function getBlogPosts() {
|
async function getBlogPosts(): Promise<BlogPost[]> {
|
||||||
const contentDir = path.join(process.cwd(), "src/content/blog");
|
const contentDir = path.join(process.cwd(), "src/content/blog");
|
||||||
const files = fs.readdirSync(contentDir);
|
const files = fs.readdirSync(contentDir);
|
||||||
|
|
||||||
const posts = await Promise.all(
|
const posts = await Promise.all(
|
||||||
files
|
files
|
||||||
.filter((file) => file.endsWith(".mdx"))
|
.filter((file) => file.endsWith(".mdx"))
|
||||||
.map(async (file) => {
|
.map(async (file) => {
|
||||||
const slug = file.replace(".mdx", "");
|
const slug = file.replace(".mdx", "");
|
||||||
// Dynamic import to get metadata
|
// Dynamic import to get metadata
|
||||||
const { metadata } = await import(`~/content/blog/${file}`);
|
const { metadata } = (await import(`~/content/blog/${file}`)) as {
|
||||||
return {
|
metadata: Omit<BlogPost, "slug">;
|
||||||
slug,
|
};
|
||||||
...metadata,
|
return {
|
||||||
};
|
slug,
|
||||||
})
|
...metadata,
|
||||||
);
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return posts.sort((a, b) => {
|
return posts.sort((a, b) => {
|
||||||
if (new Date(a.publishedAt) > new Date(b.publishedAt)) {
|
if (new Date(a.publishedAt) > new Date(b.publishedAt)) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Blog",
|
title: "Blog",
|
||||||
description: "Thoughts, tutorials, and project deep-dives.",
|
description: "Thoughts, tutorials, and project deep-dives.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function BlogPage() {
|
export default async function BlogPage() {
|
||||||
const posts = await getBlogPosts();
|
const posts = await getBlogPosts();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<section className="animate-fade-in-up prose prose-zinc dark:prose-invert max-w-none">
|
<section className="animate-fade-in-up prose prose-zinc max-w-none dark:prose-invert">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<BookOpen className="h-8 w-8 text-primary" />
|
<BookOpen className="h-8 w-8 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="mb-2 text-2xl font-bold">Blog</h1>
|
<h1 className="mb-2 text-2xl font-bold">Blog</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-lg text-muted-foreground">
|
|
||||||
Deep dives into my projects, tutorials, and thoughts on technology.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="grid gap-6 animate-fade-in-up-delay-2">
|
|
||||||
{posts.map((post) => (
|
|
||||||
<Link key={post.slug} href={`/blog/${post.slug}`} className="block card-hover">
|
|
||||||
<Card className="h-full transition-colors hover:bg-muted/50">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<CardTitle className="text-xl mb-2">{post.title}</CardTitle>
|
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap ml-4">
|
|
||||||
{post.publishedAt}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<CardDescription className="text-base">
|
|
||||||
{post.summary}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{post.tags?.map((tag: string) => (
|
|
||||||
<Badge key={tag} variant="secondary">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<p className="mt-2 text-lg text-muted-foreground">
|
||||||
|
Deep dives into my projects, tutorials, and thoughts on technology.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="animate-fade-in-up-delay-2 grid gap-6">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<Link
|
||||||
|
key={post.slug}
|
||||||
|
href={`/blog/${post.slug}`}
|
||||||
|
className="card-hover block"
|
||||||
|
>
|
||||||
|
<Card className="h-full transition-colors hover:bg-muted/50">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<CardTitle className="mb-2 text-xl">{post.title}</CardTitle>
|
||||||
|
<span className="ml-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
|
{post.publishedAt}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
{post.summary}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{post.tags?.map((tag: string) => (
|
||||||
|
<Badge key={tag} variant="secondary">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -22,8 +22,6 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Maximize2,
|
|
||||||
Minimize2,
|
|
||||||
Eye,
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -116,7 +114,7 @@ function PDFViewer({ url, title, type }: PDFViewerProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadPDF();
|
void loadPDF();
|
||||||
}, [url, isClient]);
|
}, [url, isClient]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -177,7 +175,7 @@ function PDFViewer({ url, title, type }: PDFViewerProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
renderPage();
|
void renderPage();
|
||||||
}, [pdfDoc, pageNum, scale, rotation]);
|
}, [pdfDoc, pageNum, scale, rotation]);
|
||||||
|
|
||||||
const nextPage = () => {
|
const nextPage = () => {
|
||||||
@@ -481,7 +479,7 @@ export default function CVPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="animate-fade-in-up prose prose-zinc dark:prose-invert max-w-none">
|
<section className="animate-fade-in-up prose prose-zinc max-w-none dark:prose-invert">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<FileText className="h-8 w-8 text-primary" />
|
<FileText className="h-8 w-8 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
@@ -520,8 +518,6 @@ export default function CVPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function ExperiencePage() {
|
|||||||
const renderExperienceSection = (
|
const renderExperienceSection = (
|
||||||
title: string,
|
title: string,
|
||||||
experiences: typeof researchExperience,
|
experiences: typeof researchExperience,
|
||||||
delay: number = 1,
|
delay = 1,
|
||||||
) => (
|
) => (
|
||||||
<section className="animate-fade-in-up space-y-6">
|
<section className="animate-fade-in-up space-y-6">
|
||||||
<h2 className="text-2xl font-bold">{title}</h2>
|
<h2 className="text-2xl font-bold">{title}</h2>
|
||||||
@@ -118,7 +118,7 @@ export default function ExperiencePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<section className="animate-fade-in-up prose prose-zinc dark:prose-invert max-w-none">
|
<section className="animate-fade-in-up prose prose-zinc max-w-none dark:prose-invert">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Building className="h-8 w-8 text-primary" />
|
<Building className="h-8 w-8 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
@@ -26,20 +25,20 @@ export default function RootLayout({ children }: React.PropsWithChildren) {
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body
|
<body
|
||||||
className="flex min-h-screen flex-col bg-background font-sans text-foreground relative"
|
className="relative flex min-h-screen flex-col bg-background font-sans text-foreground"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
{/* Background Elements */}
|
{/* Background Elements */}
|
||||||
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
|
<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="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="w-[800px] h-[800px] bg-neutral-400/40 dark:bg-neutral-500/30 rounded-full blur-3xl animate-blob"></div>
|
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
|
{env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
|
||||||
<Script
|
<Script
|
||||||
defer
|
defer
|
||||||
src={
|
src={
|
||||||
env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ||
|
env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ??
|
||||||
"https://analytics.umami.is/script.js"
|
"https://analytics.umami.is/script.js"
|
||||||
}
|
}
|
||||||
data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||||
@@ -48,9 +47,9 @@ export default function RootLayout({ children }: React.PropsWithChildren) {
|
|||||||
|
|
||||||
<BreadcrumbProvider>
|
<BreadcrumbProvider>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<div className="flex flex-1 pt-24 flex-col lg:flex-row">
|
<div className="flex flex-1 flex-col pt-24 lg:flex-row">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex-1 min-w-0 lg:pl-96">
|
<div className="min-w-0 flex-1 lg:pl-96">
|
||||||
<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-6 sm:px-8 lg:pl-0 lg:pr-8">
|
||||||
<main className="pb-8 pt-4">
|
<main className="pb-8 pt-4">
|
||||||
<BreadcrumbWrapper />
|
<BreadcrumbWrapper />
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Code,
|
|
||||||
FlaskConical,
|
|
||||||
Users,
|
|
||||||
GraduationCap,
|
|
||||||
Building,
|
|
||||||
MapPin,
|
|
||||||
Mail,
|
|
||||||
ExternalLink,
|
|
||||||
BookOpen,
|
|
||||||
School,
|
|
||||||
Award,
|
Award,
|
||||||
Calendar,
|
BookOpen,
|
||||||
|
Building,
|
||||||
|
Code,
|
||||||
|
ExternalLink,
|
||||||
|
FlaskConical,
|
||||||
|
GraduationCap,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
School,
|
||||||
|
Users
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -21,9 +22,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { awards, education, experiences, researchInterests } from "~/lib/data";
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { researchInterests, education, experiences, awards } from "~/lib/data";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const researchExperience = experiences.filter(
|
const researchExperience = experiences.filter(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
@@ -14,21 +13,14 @@ import Link from "next/link";
|
|||||||
import { ArrowUpRight, Play, BookOpen, FolderGit2, Github } from "lucide-react";
|
import { ArrowUpRight, Play, BookOpen, FolderGit2, Github } from "lucide-react";
|
||||||
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";
|
||||||
import { CardSkeleton } from "~/components/ui/skeletons";
|
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const featuredProjects = projects.filter((p) => p.featured);
|
const featuredProjects = projects.filter((p) => p.featured);
|
||||||
const otherProjects = projects.filter((p) => !p.featured);
|
const otherProjects = projects.filter((p) => !p.featured);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<section className="prose prose-zinc dark:prose-invert max-w-none">
|
<section className="prose prose-zinc max-w-none dark:prose-invert">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<FolderGit2 className="h-8 w-8 text-primary" />
|
<FolderGit2 className="h-8 w-8 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
@@ -45,87 +37,207 @@ export default function ProjectsPage() {
|
|||||||
<section className="animate-fade-in-up space-y-6">
|
<section className="animate-fade-in-up space-y-6">
|
||||||
<h2 className="text-2xl font-bold">Featured Work</h2>
|
<h2 className="text-2xl font-bold">Featured Work</h2>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{loading ? (
|
{featuredProjects.map((project, index) => (
|
||||||
<>
|
<div
|
||||||
<CardSkeleton />
|
key={index}
|
||||||
<CardSkeleton />
|
className={`animate-fade-in-up-delay-${Math.min(index + 1, 4)} card-hover`}
|
||||||
<CardSkeleton />
|
>
|
||||||
</>
|
<Card className="overflow-hidden">
|
||||||
) : (
|
<div className="flex flex-col lg:flex-row">
|
||||||
featuredProjects.map((project, index) => (
|
{/* Project Image */}
|
||||||
|
{project.image && (
|
||||||
|
<div className="lg:w-1/3">
|
||||||
|
<div className="flex items-center justify-center p-4 lg:h-full">
|
||||||
|
<ImageWithSkeleton
|
||||||
|
src={project.image}
|
||||||
|
alt={project.imageAlt ?? project.title}
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
className="h-auto w-full object-contain"
|
||||||
|
containerClassName="w-full rounded-xl shadow-md overflow-hidden"
|
||||||
|
priority={index === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Project Content */}
|
||||||
|
<div className="card-content-stretch flex flex-1 flex-col p-6">
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="break-words text-xl leading-tight">
|
||||||
|
{project.title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-2 break-words text-base leading-relaxed">
|
||||||
|
{project.description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="break-words leading-relaxed text-muted-foreground">
|
||||||
|
{project.longDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{project.tags.map((tag) => (
|
||||||
|
<Badge
|
||||||
|
key={tag}
|
||||||
|
variant="secondary"
|
||||||
|
className="break-words"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 sm:flex-shrink-0">
|
||||||
|
{project.link && project.link.startsWith("/") && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
asChild
|
||||||
|
className="button-hover"
|
||||||
|
>
|
||||||
|
<Link href={project.link}>
|
||||||
|
{project.title ===
|
||||||
|
"LaTeX Introduction Tutorial" ? (
|
||||||
|
<>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Watch Tutorial
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BookOpen className="mr-2 h-4 w-4" />
|
||||||
|
Learn More
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.websiteLink && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
asChild
|
||||||
|
className="button-hover"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={project.websiteLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="mr-2 h-4 w-4" />
|
||||||
|
Visit Site
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.gitLink && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
asChild
|
||||||
|
className="button-hover"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={project.gitLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Github className="mr-2 h-4 w-4" />
|
||||||
|
View Code
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.link &&
|
||||||
|
!project.link.startsWith("/") &&
|
||||||
|
!project.websiteLink &&
|
||||||
|
!project.gitLink && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
asChild
|
||||||
|
className="button-hover"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={project.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="mr-2 h-4 w-4" />
|
||||||
|
View Project
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Other Projects */}
|
||||||
|
{otherProjects.length > 0 && (
|
||||||
|
<section className="animate-fade-in-up space-y-6">
|
||||||
|
<h2 className="text-2xl font-bold">Additional Projects</h2>
|
||||||
|
<div className="grid-equal-height grid gap-6 md:grid-cols-2">
|
||||||
|
{otherProjects.map((project, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
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="overflow-hidden">
|
<Card className="card-full-height flex flex-col">
|
||||||
<div className="flex flex-col lg:flex-row">
|
{project.image && (
|
||||||
{/* Project Image */}
|
<div className="flex h-48 items-center justify-center p-4">
|
||||||
{project.image && (
|
<ImageWithSkeleton
|
||||||
<div className="lg:w-1/3">
|
src={project.image}
|
||||||
<div className="flex items-center justify-center p-4 lg:h-full">
|
alt={project.imageAlt ?? project.title}
|
||||||
<ImageWithSkeleton
|
width={400}
|
||||||
src={project.image}
|
height={250}
|
||||||
alt={project.imageAlt || project.title}
|
className="h-auto max-h-full w-full object-contain"
|
||||||
width={400}
|
containerClassName="w-full h-full flex items-center justify-center rounded-xl shadow-md overflow-hidden"
|
||||||
height={300}
|
/>
|
||||||
className="h-auto w-full object-contain"
|
</div>
|
||||||
containerClassName="w-full rounded-xl shadow-md overflow-hidden"
|
)}
|
||||||
priority={index === 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Project Content */}
|
<div className="flex flex-1 flex-col p-6">
|
||||||
<div className="card-content-stretch flex flex-1 flex-col p-6">
|
<div className="flex flex-1 flex-col">
|
||||||
<div className="flex-1 space-y-4">
|
<CardHeader className="p-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="break-words text-xl leading-tight">
|
<CardTitle className="break-words text-lg leading-tight">
|
||||||
{project.title}
|
{project.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-2 break-words text-base leading-relaxed">
|
|
||||||
{project.description}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
|
<CardDescription className="mt-2 break-words leading-relaxed">
|
||||||
|
{project.description}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
<p className="break-words leading-relaxed text-muted-foreground">
|
<CardContent className="flex flex-1 flex-col p-0 pt-4">
|
||||||
{project.longDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{project.tags.map((tag) => (
|
{project.tags.map((tag) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tag}
|
key={tag}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="break-words"
|
className="break-words text-xs"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 sm:flex-shrink-0">
|
<div className="mt-auto flex gap-2 pt-4">
|
||||||
{project.link && project.link.startsWith("/") && (
|
{project.link && project.link.startsWith("/") && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
asChild
|
asChild
|
||||||
className="button-hover"
|
className="button-hover sm:flex-shrink-0"
|
||||||
>
|
>
|
||||||
<Link href={project.link}>
|
<Link href={project.link}>
|
||||||
{project.title ===
|
<BookOpen className="mr-2 h-4 w-4" />
|
||||||
"LaTeX Introduction Tutorial" ? (
|
Learn More
|
||||||
<>
|
|
||||||
<Play className="mr-2 h-4 w-4" />
|
|
||||||
Watch Tutorial
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<BookOpen className="mr-2 h-4 w-4" />
|
|
||||||
Learn More
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -134,7 +246,7 @@ export default function ProjectsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
asChild
|
asChild
|
||||||
className="button-hover"
|
className="button-hover sm:flex-shrink-0"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={project.websiteLink}
|
href={project.websiteLink}
|
||||||
@@ -151,7 +263,7 @@ export default function ProjectsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
asChild
|
asChild
|
||||||
className="button-hover"
|
className="button-hover sm:flex-shrink-0"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={project.gitLink}
|
href={project.gitLink}
|
||||||
@@ -171,7 +283,7 @@ export default function ProjectsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
asChild
|
asChild
|
||||||
className="button-hover"
|
className="button-hover sm:flex-shrink-0"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={project.link}
|
href={project.link}
|
||||||
@@ -184,147 +296,12 @@ export default function ProjectsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Other Projects */}
|
|
||||||
{otherProjects.length > 0 && (
|
|
||||||
<section className="animate-fade-in-up space-y-6">
|
|
||||||
<h2 className="text-2xl font-bold">Additional Projects</h2>
|
|
||||||
<div className="grid-equal-height grid gap-6 md:grid-cols-2">
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<CardSkeleton />
|
|
||||||
<CardSkeleton />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
otherProjects.map((project, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`animate-fade-in-up-delay-${Math.min(index + 1, 4)} card-hover`}
|
|
||||||
>
|
|
||||||
<Card className="card-full-height flex flex-col">
|
|
||||||
{project.image && (
|
|
||||||
<div className="flex h-48 items-center justify-center p-4">
|
|
||||||
<ImageWithSkeleton
|
|
||||||
src={project.image}
|
|
||||||
alt={project.imageAlt || project.title}
|
|
||||||
width={400}
|
|
||||||
height={250}
|
|
||||||
className="h-auto max-h-full w-full object-contain"
|
|
||||||
containerClassName="w-full h-full flex items-center justify-center rounded-xl shadow-md overflow-hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col p-6">
|
|
||||||
<div className="flex flex-1 flex-col">
|
|
||||||
<CardHeader className="p-0">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="break-words text-lg leading-tight">
|
|
||||||
{project.title}
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
<CardDescription className="mt-2 break-words leading-relaxed">
|
|
||||||
{project.description}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="flex flex-1 flex-col p-0 pt-4">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{project.tags.map((tag) => (
|
|
||||||
<Badge
|
|
||||||
key={tag}
|
|
||||||
variant="secondary"
|
|
||||||
className="break-words text-xs"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto flex gap-2 pt-4">
|
|
||||||
{project.link && project.link.startsWith("/") && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
asChild
|
|
||||||
className="button-hover sm:flex-shrink-0"
|
|
||||||
>
|
|
||||||
<Link href={project.link}>
|
|
||||||
<BookOpen className="mr-2 h-4 w-4" />
|
|
||||||
Learn More
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{project.websiteLink && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
asChild
|
|
||||||
className="button-hover sm:flex-shrink-0"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={project.websiteLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<ArrowUpRight className="mr-2 h-4 w-4" />
|
|
||||||
Visit Site
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{project.gitLink && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
asChild
|
|
||||||
className="button-hover sm:flex-shrink-0"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={project.gitLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Github className="mr-2 h-4 w-4" />
|
|
||||||
View Code
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{project.link &&
|
|
||||||
!project.link.startsWith("/") &&
|
|
||||||
!project.websiteLink &&
|
|
||||||
!project.gitLink && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
asChild
|
|
||||||
className="button-hover sm:flex-shrink-0"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={project.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<ArrowUpRight className="mr-2 h-4 w-4" />
|
|
||||||
View Project
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
BookOpenText,
|
BookOpenText,
|
||||||
FileText,
|
FileText,
|
||||||
Presentation,
|
Presentation,
|
||||||
BookOpen,
|
|
||||||
Monitor,
|
Monitor,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -18,7 +17,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
|
||||||
import { CardSkeleton } from "~/components/ui/skeletons";
|
import { CardSkeleton } from "~/components/ui/skeletons";
|
||||||
import type { Publication } from "~/lib/bibtex";
|
import type { Publication } from "~/lib/bibtex";
|
||||||
import { parseBibtex } from "~/lib/bibtex";
|
import { parseBibtex } from "~/lib/bibtex";
|
||||||
@@ -27,10 +25,10 @@ import { parseBibtex } from "~/lib/bibtex";
|
|||||||
export default function PublicationsPage() {
|
export default function PublicationsPage() {
|
||||||
const [publications, setPublications] = useState<Publication[]>([]);
|
const [publications, setPublications] = useState<Publication[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const tagsToStrip = ["paperUrl", "posterUrl"];
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/publications.bib")
|
void fetch("/publications.bib")
|
||||||
.then((res) => res.text())
|
.then((res) => res.text())
|
||||||
.then((text) => {
|
.then((text) => {
|
||||||
const pubs = parseBibtex(text);
|
const pubs = parseBibtex(text);
|
||||||
@@ -85,7 +83,7 @@ export default function PublicationsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="animate-fade-in-up prose prose-zinc dark:prose-invert max-w-none">
|
<section className="animate-fade-in-up prose prose-zinc max-w-none dark:prose-invert">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<BookOpenText className="h-8 w-8 text-primary" />
|
<BookOpenText className="h-8 w-8 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function TripsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="animate-fade-in-up prose prose-zinc dark:prose-invert max-w-none">
|
<section className="animate-fade-in-up prose prose-zinc max-w-none dark:prose-invert">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Plane className="h-8 w-8 text-primary" />
|
<Plane className="h-8 w-8 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
@@ -63,9 +63,7 @@ export default function TripsPage() {
|
|||||||
<ImageWithSkeleton
|
<ImageWithSkeleton
|
||||||
src={image}
|
src={image}
|
||||||
alt={
|
alt={
|
||||||
trip.alts && trip.alts[imgIndex]
|
trip.alts?.[imgIndex] ?? `${trip.title} - image ${imgIndex + 1}`
|
||||||
? trip.alts[imgIndex]
|
|
||||||
: `${trip.title} - image ${imgIndex + 1}`
|
|
||||||
}
|
}
|
||||||
width={250}
|
width={250}
|
||||||
height={200}
|
height={200}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function AccessibleVideo({
|
|||||||
controls
|
controls
|
||||||
src={src}
|
src={src}
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
title={posterAlt || title}
|
title={posterAlt ?? title}
|
||||||
>
|
>
|
||||||
{captionSrc && (
|
{captionSrc && (
|
||||||
<track
|
<track
|
||||||
@@ -148,7 +148,7 @@ export function AccessibleVideo({
|
|||||||
View Full Transcript
|
View Full Transcript
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 rounded-md bg-muted p-4">
|
<div className="mt-2 rounded-md bg-muted p-4">
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||||
<div dangerouslySetInnerHTML={{ __html: transcript }} />
|
<div dangerouslySetInnerHTML={{ __html: transcript }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { useEffect } from "react";
|
|||||||
import { useBreadcrumb } from "~/context/BreadcrumbContext";
|
import { useBreadcrumb } from "~/context/BreadcrumbContext";
|
||||||
|
|
||||||
export function BreadcrumbUpdater({ title }: { title: string }) {
|
export function BreadcrumbUpdater({ title }: { title: string }) {
|
||||||
const { setCustomTitle } = useBreadcrumb();
|
const { setCustomTitle } = useBreadcrumb();
|
||||||
|
|
||||||
// Use effect to set title on mount and clear on unmount
|
// Use effect to set title on mount and clear on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCustomTitle(title);
|
setCustomTitle(title);
|
||||||
return () => setCustomTitle(null);
|
return () => setCustomTitle(null);
|
||||||
}, [title, setCustomTitle]);
|
}, [title, setCustomTitle]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,189 +5,217 @@ import { cn } from "~/lib/utils";
|
|||||||
|
|
||||||
// Helper to convert HSL string (e.g., "0 0% 100%") to Hex
|
// Helper to convert HSL string (e.g., "0 0% 100%") to Hex
|
||||||
function hslToHex(hsl: string) {
|
function hslToHex(hsl: string) {
|
||||||
const [h = 0, s = 0, l = 0] = hsl.split(" ").map((val) => parseFloat(val));
|
const [h = 0, s = 0, l = 0] = hsl.split(" ").map((val) => parseFloat(val));
|
||||||
|
|
||||||
const hDecimal = h / 360;
|
const hDecimal = h / 360;
|
||||||
const sDecimal = s / 100;
|
const sDecimal = s / 100;
|
||||||
const lDecimal = l / 100;
|
const lDecimal = l / 100;
|
||||||
|
|
||||||
let r, g, b;
|
let r, g, b;
|
||||||
|
|
||||||
if (s === 0) {
|
if (s === 0) {
|
||||||
r = g = b = lDecimal; // achromatic
|
r = g = b = lDecimal; // achromatic
|
||||||
} else {
|
} else {
|
||||||
const hue2rgb = (p: number, q: number, t: number) => {
|
const hue2rgb = (p: number, q: number, t: number) => {
|
||||||
if (t < 0) t += 1;
|
if (t < 0) t += 1;
|
||||||
if (t > 1) t -= 1;
|
if (t > 1) t -= 1;
|
||||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||||
if (t < 1 / 2) return q;
|
if (t < 1 / 2) return q;
|
||||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||||
return p;
|
return p;
|
||||||
};
|
|
||||||
|
|
||||||
const q = lDecimal < 0.5 ? lDecimal * (1 + sDecimal) : lDecimal + sDecimal - lDecimal * sDecimal;
|
|
||||||
const p = 2 * lDecimal - q;
|
|
||||||
|
|
||||||
r = hue2rgb(p, q, hDecimal + 1 / 3);
|
|
||||||
g = hue2rgb(p, q, hDecimal);
|
|
||||||
b = hue2rgb(p, q, hDecimal - 1 / 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
const toHex = (x: number) => {
|
|
||||||
const hex = Math.round(x * 255).toString(16);
|
|
||||||
return hex.length === 1 ? "0" + hex : hex;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
const q =
|
||||||
|
lDecimal < 0.5
|
||||||
|
? lDecimal * (1 + sDecimal)
|
||||||
|
: lDecimal + sDecimal - lDecimal * sDecimal;
|
||||||
|
const p = 2 * lDecimal - q;
|
||||||
|
|
||||||
|
r = hue2rgb(p, q, hDecimal + 1 / 3);
|
||||||
|
g = hue2rgb(p, q, hDecimal);
|
||||||
|
b = hue2rgb(p, q, hDecimal - 1 / 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toHex = (x: number) => {
|
||||||
|
const hex = Math.round(x * 255).toString(16);
|
||||||
|
return hex.length === 1 ? "0" + hex : hex;
|
||||||
|
};
|
||||||
|
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_COLORS = {
|
const THEME_COLORS = {
|
||||||
light: {
|
light: {
|
||||||
background: "0 0% 100%",
|
background: "0 0% 100%",
|
||||||
foreground: "240 10% 3.9%",
|
foreground: "240 10% 3.9%",
|
||||||
card: "0 0% 100%",
|
card: "0 0% 100%",
|
||||||
"card-foreground": "240 10% 3.9%",
|
"card-foreground": "240 10% 3.9%",
|
||||||
popover: "0 0% 100%",
|
popover: "0 0% 100%",
|
||||||
"popover-foreground": "240 10% 3.9%",
|
"popover-foreground": "240 10% 3.9%",
|
||||||
primary: "240 5.9% 10%",
|
primary: "240 5.9% 10%",
|
||||||
"primary-foreground": "0 0% 98%",
|
"primary-foreground": "0 0% 98%",
|
||||||
secondary: "240 4.8% 90%",
|
secondary: "240 4.8% 90%",
|
||||||
"secondary-foreground": "240 5.9% 10%",
|
"secondary-foreground": "240 5.9% 10%",
|
||||||
muted: "240 4.8% 95.9%",
|
muted: "240 4.8% 95.9%",
|
||||||
"muted-foreground": "240 3.8% 46.1%",
|
"muted-foreground": "240 3.8% 46.1%",
|
||||||
accent: "240 4.8% 95.9%",
|
accent: "240 4.8% 95.9%",
|
||||||
"accent-foreground": "240 5.9% 10%",
|
"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": "0 0% 98%",
|
||||||
border: "240 5.9% 90%",
|
border: "240 5.9% 90%",
|
||||||
input: "240 5.9% 90%",
|
input: "240 5.9% 90%",
|
||||||
ring: "240 10% 3.9%",
|
ring: "240 10% 3.9%",
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
background: "240 10% 3.9%",
|
background: "240 10% 3.9%",
|
||||||
foreground: "0 0% 98%",
|
foreground: "0 0% 98%",
|
||||||
card: "240 10% 3.9%",
|
card: "240 10% 3.9%",
|
||||||
"card-foreground": "0 0% 98%",
|
"card-foreground": "0 0% 98%",
|
||||||
popover: "240 10% 3.9%",
|
popover: "240 10% 3.9%",
|
||||||
"popover-foreground": "0 0% 98%",
|
"popover-foreground": "0 0% 98%",
|
||||||
primary: "0 0% 98%",
|
primary: "0 0% 98%",
|
||||||
"primary-foreground": "240 5.9% 10%",
|
"primary-foreground": "240 5.9% 10%",
|
||||||
secondary: "240 3.7% 20%",
|
secondary: "240 3.7% 20%",
|
||||||
"secondary-foreground": "0 0% 98%",
|
"secondary-foreground": "0 0% 98%",
|
||||||
muted: "240 3.7% 15.9%",
|
muted: "240 3.7% 15.9%",
|
||||||
"muted-foreground": "240 5% 64.9%",
|
"muted-foreground": "240 5% 64.9%",
|
||||||
accent: "240 3.7% 15.9%",
|
accent: "240 3.7% 15.9%",
|
||||||
"accent-foreground": "0 0% 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": "0 0% 98%",
|
||||||
border: "240 3.7% 15.9%",
|
border: "240 3.7% 15.9%",
|
||||||
input: "240 3.7% 15.9%",
|
input: "240 3.7% 15.9%",
|
||||||
ring: "240 4.9% 83.9%",
|
ring: "240 4.9% 83.9%",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ColorSwatch = ({ hsl, title }: { hsl: string; title: string }) => {
|
const ColorSwatch = ({ hsl, title }: { hsl: string; title: string }) => {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const hex = hslToHex(hsl);
|
const hex = hslToHex(hsl);
|
||||||
|
|
||||||
const copyToClipboard = () => {
|
const copyToClipboard = () => {
|
||||||
navigator.clipboard.writeText(hex);
|
void navigator.clipboard.writeText(hex);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1000);
|
setTimeout(() => setCopied(false), 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex flex-col items-center">
|
<div className="group relative flex flex-col items-center">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-12 w-12 flex-shrink-0 rounded-lg shadow-sm cursor-pointer transition-transform active:scale-95 border border-border/20",
|
"h-12 w-12 flex-shrink-0 cursor-pointer rounded-lg border border-border/20 shadow-sm transition-transform active:scale-95",
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: hex }}
|
style={{ backgroundColor: hex }}
|
||||||
onClick={copyToClipboard}
|
onClick={copyToClipboard}
|
||||||
title={`${title} (${hex})`}
|
title={`${title} (${hex})`}
|
||||||
/>
|
/>
|
||||||
<div className="absolute -bottom-8 opacity-0 group-hover:opacity-100 transition-opacity bg-popover text-popover-foreground text-xs px-2 py-1 rounded shadow-md whitespace-nowrap z-10 pointer-events-none border border-border">
|
<div className="pointer-events-none absolute -bottom-8 z-10 whitespace-nowrap rounded border border-border bg-popover px-2 py-1 text-xs text-popover-foreground opacity-0 shadow-md transition-opacity group-hover:opacity-100">
|
||||||
{copied ? "Copied!" : hex}
|
{copied ? "Copied!" : hex}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ColorSection = ({ mode, colors }: { mode: "light" | "dark"; colors: typeof THEME_COLORS.light }) => {
|
const ColorSection = ({
|
||||||
return (
|
mode,
|
||||||
<div className="space-y-4">
|
colors,
|
||||||
<h4 className="font-heading font-bold capitalize text-lg">{mode} Mode</h4>
|
}: {
|
||||||
|
mode: "light" | "dark";
|
||||||
|
colors: typeof THEME_COLORS.light;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-heading text-lg font-bold capitalize">{mode} Mode</h4>
|
||||||
|
|
||||||
{/* Backgrounds */}
|
{/* Backgrounds */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||||
<div className="w-24 text-sm font-medium">Backgrounds</div>
|
<div className="w-24 text-sm font-medium">Backgrounds</div>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex flex-wrap gap-2">
|
||||||
<ColorSwatch hsl={colors.background} title="background" />
|
<ColorSwatch hsl={colors.background} title="background" />
|
||||||
<ColorSwatch hsl={colors.card} title="card" />
|
<ColorSwatch hsl={colors.card} title="card" />
|
||||||
<ColorSwatch hsl={colors.popover} title="popover" />
|
<ColorSwatch hsl={colors.popover} title="popover" />
|
||||||
<ColorSwatch hsl={colors.muted} title="muted" />
|
<ColorSwatch hsl={colors.muted} title="muted" />
|
||||||
<ColorSwatch hsl={colors.secondary} title="secondary" />
|
<ColorSwatch hsl={colors.secondary} title="secondary" />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Foregrounds */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
|
||||||
<div className="w-24 text-sm font-medium">Foregrounds</div>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<ColorSwatch hsl={colors.foreground} title="foreground" />
|
|
||||||
<ColorSwatch hsl={colors["card-foreground"]} title="card-foreground" />
|
|
||||||
<ColorSwatch hsl={colors["popover-foreground"]} title="popover-foreground" />
|
|
||||||
<ColorSwatch hsl={colors["muted-foreground"]} title="muted-foreground" />
|
|
||||||
<ColorSwatch hsl={colors["secondary-foreground"]} title="secondary-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Primary */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
|
||||||
<div className="w-24 text-sm font-medium">Primary</div>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<ColorSwatch hsl={colors.primary} title="primary" />
|
|
||||||
<ColorSwatch hsl={colors["primary-foreground"]} title="primary-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Destructive */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
|
||||||
<div className="w-24 text-sm font-medium">Destructive</div>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<ColorSwatch hsl={colors.destructive} title="destructive" />
|
|
||||||
<ColorSwatch hsl={colors["destructive-foreground"]} title="destructive-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* UI Elements */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
|
||||||
<div className="w-24 text-sm font-medium">UI Elements</div>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<ColorSwatch hsl={colors.border} title="border" />
|
|
||||||
<ColorSwatch hsl={colors.input} title="input" />
|
|
||||||
<ColorSwatch hsl={colors.ring} title="ring" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
|
||||||
|
{/* Foregrounds */}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||||
|
<div className="w-24 text-sm font-medium">Foregrounds</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<ColorSwatch hsl={colors.foreground} title="foreground" />
|
||||||
|
<ColorSwatch
|
||||||
|
hsl={colors["card-foreground"]}
|
||||||
|
title="card-foreground"
|
||||||
|
/>
|
||||||
|
<ColorSwatch
|
||||||
|
hsl={colors["popover-foreground"]}
|
||||||
|
title="popover-foreground"
|
||||||
|
/>
|
||||||
|
<ColorSwatch
|
||||||
|
hsl={colors["muted-foreground"]}
|
||||||
|
title="muted-foreground"
|
||||||
|
/>
|
||||||
|
<ColorSwatch
|
||||||
|
hsl={colors["secondary-foreground"]}
|
||||||
|
title="secondary-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary */}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||||
|
<div className="w-24 text-sm font-medium">Primary</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<ColorSwatch hsl={colors.primary} title="primary" />
|
||||||
|
<ColorSwatch
|
||||||
|
hsl={colors["primary-foreground"]}
|
||||||
|
title="primary-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destructive */}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||||
|
<div className="w-24 text-sm font-medium">Destructive</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<ColorSwatch hsl={colors.destructive} title="destructive" />
|
||||||
|
<ColorSwatch
|
||||||
|
hsl={colors["destructive-foreground"]}
|
||||||
|
title="destructive-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* UI Elements */}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||||
|
<div className="w-24 text-sm font-medium">UI Elements</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<ColorSwatch hsl={colors.border} title="border" />
|
||||||
|
<ColorSwatch hsl={colors.input} title="input" />
|
||||||
|
<ColorSwatch hsl={colors.ring} title="ring" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ColorPalette() {
|
export function ColorPalette() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12 not-prose my-8">
|
<div className="not-prose my-8 space-y-12">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold mb-4">Scales</h3>
|
<h3 className="mb-4 text-lg font-bold">Scales</h3>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="mb-6 text-muted-foreground">
|
||||||
The system uses semantic color scales that adapt to the current theme. Hover to see hex codes.
|
The system uses semantic color scales that adapt to the current theme.
|
||||||
</p>
|
Hover to see hex codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="grid gap-12 lg:grid-cols-2">
|
<div className="grid gap-12 lg:grid-cols-2">
|
||||||
<ColorSection mode="light" colors={THEME_COLORS.light} />
|
<ColorSection mode="light" colors={THEME_COLORS.light} />
|
||||||
<div className="h-px bg-border/50 lg:hidden" />
|
<div className="h-px bg-border/50 lg:hidden" />
|
||||||
<ColorSection mode="dark" colors={THEME_COLORS.dark} />
|
<ColorSection mode="dark" colors={THEME_COLORS.dark} />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/", label: "About", icon: Home },
|
{ href: "/", label: "About", icon: Home },
|
||||||
@@ -34,13 +34,13 @@ export function Navigation() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav
|
<nav
|
||||||
className={`fixed top-4 left-4 right-4 z-[51] rounded-2xl border bg-background/80 backdrop-blur-md shadow-sm transition-all duration-200 ${isOpen ? "border-transparent" : "border-border/60"
|
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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<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">
|
||||||
<Link href="/" className="flex items-center py-2">
|
<Link href="/" className="flex items-center py-2">
|
||||||
<span className="text-xl font-semibold font-heading tracking-tight transition-colors hover:text-primary">
|
<span className="font-heading text-xl font-semibold tracking-tight transition-colors hover:text-primary">
|
||||||
Sean O'Connor
|
Sean O'Connor
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -87,7 +87,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 backdrop-blur-md shadow-sm transition-all duration-300 lg:hidden ${isOpen ? "max-h-[calc(100vh-8rem)] opacity-100" : "max-h-0 opacity-0"
|
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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col space-y-2 p-4">
|
<div className="flex flex-col space-y-2 p-4">
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import * as React from "react";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
ChevronRight,
|
|
||||||
FolderGit2,
|
FolderGit2,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop layout - clean and elegant sidebar */}
|
{/* Desktop layout - clean and elegant sidebar */}
|
||||||
<div className="hidden fixed top-24 left-4 bottom-4 w-80 rounded-3xl border border-border/60 bg-background/80 backdrop-blur-xl lg:block overflow-hidden">
|
<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="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">
|
||||||
|
|||||||
@@ -4,92 +4,100 @@ import Link from "next/link";
|
|||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
interface CardGridProps {
|
interface CardGridProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
cols?: 2 | 3;
|
cols?: 2 | 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardGrid({ children, className, cols = 2 }: CardGridProps) {
|
export function CardGrid({ children, className, cols = 2 }: CardGridProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-6 my-6",
|
"my-6 grid gap-6",
|
||||||
cols === 2 ? "md:grid-cols-2" : "md:grid-cols-3",
|
cols === 2 ? "md:grid-cols-2" : "md:grid-cols-3",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FeatureCardProps {
|
interface FeatureCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeatureCard({ title, icon: Icon, children, className }: FeatureCardProps) {
|
export function FeatureCard({
|
||||||
return (
|
title,
|
||||||
<Card className={cn("h-full", className)}>
|
icon: Icon,
|
||||||
<CardHeader className="pb-3">
|
children,
|
||||||
<CardTitle className="flex items-center gap-2">
|
className,
|
||||||
<Icon className="h-5 w-5" />
|
}: FeatureCardProps) {
|
||||||
{title}
|
return (
|
||||||
</CardTitle>
|
<Card className={cn("h-full", className)}>
|
||||||
</CardHeader>
|
<CardHeader className="pb-3">
|
||||||
<CardContent className="pt-0 text-sm text-muted-foreground">
|
<CardTitle className="flex items-center gap-2">
|
||||||
{children}
|
<Icon className="h-5 w-5" />
|
||||||
</CardContent>
|
{title}
|
||||||
</Card>
|
</CardTitle>
|
||||||
);
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 text-sm text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResourceCardProps {
|
interface ResourceCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
href: string;
|
href: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourceCard({ title, subtitle, icon: Icon, href }: ResourceCardProps) {
|
export function ResourceCard({
|
||||||
return (
|
title,
|
||||||
<Card className="h-full group cursor-pointer transition-colors hover:bg-accent">
|
subtitle,
|
||||||
<Link
|
icon: Icon,
|
||||||
href={href}
|
href,
|
||||||
target="_blank"
|
}: ResourceCardProps) {
|
||||||
rel="noopener noreferrer"
|
return (
|
||||||
className="block p-4"
|
<Card className="group h-full cursor-pointer transition-colors hover:bg-accent">
|
||||||
>
|
<Link
|
||||||
<CardContent className="p-0">
|
href={href}
|
||||||
<div className="flex items-center justify-between">
|
target="_blank"
|
||||||
<div className="flex items-center gap-3">
|
rel="noopener noreferrer"
|
||||||
<Icon className="h-6 w-6 text-primary" />
|
className="block p-4"
|
||||||
<div>
|
>
|
||||||
<div className="font-medium">{title}</div>
|
<CardContent className="p-0">
|
||||||
<div className="text-sm text-muted-foreground">{subtitle}</div>
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<div className="flex items-center gap-3">
|
||||||
</div>
|
<Icon className="h-6 w-6 text-primary" />
|
||||||
<ExternalLink className="h-4 w-4 text-muted-foreground group-hover:text-primary" />
|
<div>
|
||||||
</div>
|
<div className="font-medium">{title}</div>
|
||||||
</CardContent>
|
<div className="text-sm text-muted-foreground">{subtitle}</div>
|
||||||
</Link>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
<ExternalLink className="h-4 w-4 text-muted-foreground group-hover:text-primary" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InfoCardProps {
|
interface InfoCardProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InfoCard({ children, className }: InfoCardProps) {
|
export function InfoCard({ children, className }: InfoCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card className={cn("my-6", className)}>
|
<Card className={cn("my-6", className)}>
|
||||||
<CardContent className="p-6 space-y-4">
|
<CardContent className="space-y-4 p-6">{children}</CardContent>
|
||||||
{children}
|
</Card>
|
||||||
</CardContent>
|
);
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ const badgeVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
VariantProps<typeof badgeVariants> {}
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ const buttonVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends
|
||||||
VariantProps<typeof buttonVariants> {
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-3xl border border-border/60 bg-background/80 backdrop-blur-xl text-card-foreground shadow-sm overflow-hidden",
|
"overflow-hidden rounded-3xl border border-border/60 bg-background/80 text-card-foreground shadow-sm backdrop-blur-xl",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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 backdrop-blur-md p-1 text-popover-foreground shadow-lg 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",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -68,7 +68,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
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 backdrop-blur-md p-1 text-popover-foreground shadow-md",
|
"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",
|
||||||
"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",
|
"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,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,37 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Image, { ImageProps } from "next/image";
|
import Image, { type ImageProps } from "next/image";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
interface ImageWithSkeletonProps extends ImageProps {
|
interface ImageWithSkeletonProps extends ImageProps {
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageWithSkeleton({
|
export function ImageWithSkeleton({
|
||||||
className,
|
className,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
onLoad,
|
onLoad,
|
||||||
...props
|
alt,
|
||||||
|
...props
|
||||||
}: ImageWithSkeletonProps) {
|
}: ImageWithSkeletonProps) {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative overflow-hidden", containerClassName)}>
|
<div className={cn("relative overflow-hidden", containerClassName)}>
|
||||||
{isLoading && <Skeleton className="absolute inset-0 h-full w-full" />}
|
{isLoading && <Skeleton className="absolute inset-0 h-full w-full" />}
|
||||||
<Image
|
<Image
|
||||||
className={cn(
|
alt={alt}
|
||||||
"duration-700 ease-in-out",
|
className={cn(
|
||||||
isLoading ? "scale-110 blur-2xl grayscale" : "scale-100 blur-0 grayscale-0",
|
"duration-700 ease-in-out",
|
||||||
className
|
isLoading
|
||||||
)}
|
? "scale-110 blur-2xl grayscale"
|
||||||
onLoad={(e) => {
|
: "scale-100 blur-0 grayscale-0",
|
||||||
setIsLoading(false);
|
className,
|
||||||
if (onLoad) onLoad(e);
|
)}
|
||||||
}}
|
onLoad={(e) => {
|
||||||
{...props}
|
setIsLoading(false);
|
||||||
/>
|
if (onLoad) onLoad(e);
|
||||||
</div>
|
}}
|
||||||
);
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-9 items-center justify-center rounded-xl bg-background/80 backdrop-blur-md border border-border/50 shadow-sm p-1 text-muted-foreground",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import Link from "next/link";
|
|||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Accessibility Features",
|
title: "Accessibility Features",
|
||||||
publishedAt: "2024-11-01",
|
publishedAt: "2024-11-01",
|
||||||
summary: "A deep dive into the process of building an inclusive web experience, from semantic HTML to custom accessible components.",
|
summary:
|
||||||
|
"A deep dive into the process of building an inclusive web experience, from semantic HTML to custom accessible components.",
|
||||||
tags: ["Accessibility", "WCAG", "Inclusive Design", "Web Standards"],
|
tags: ["Accessibility", "WCAG", "Inclusive Design", "Web Standards"],
|
||||||
image: "/images/accessibility.png"
|
image: "/images/accessibility.png",
|
||||||
};
|
};
|
||||||
|
|
||||||
# Building an Inclusive Web Experience
|
# Building an Inclusive Web Experience
|
||||||
@@ -21,22 +22,27 @@ For me, the motivation was twofold. Professionally, I wanted to demonstrate that
|
|||||||
## The Implementation Process
|
## The Implementation Process
|
||||||
|
|
||||||
### Starting with the Basics
|
### Starting with the Basics
|
||||||
|
|
||||||
The journey began with the fundamentals: **Semantic HTML**. I ensured that the site uses a logical heading hierarchy (`h1` through `h6`) so that screen reader users can easily navigate the document structure. I also made sure to use appropriate semantic elements like `<article>`, `<nav>`, and `<main>` instead of just wrapping everything in `div`s.
|
The journey began with the fundamentals: **Semantic HTML**. I ensured that the site uses a logical heading hierarchy (`h1` through `h6`) so that screen reader users can easily navigate the document structure. I also made sure to use appropriate semantic elements like `<article>`, `<nav>`, and `<main>` instead of just wrapping everything in `div`s.
|
||||||
|
|
||||||
### Visual Accessibility
|
### Visual Accessibility
|
||||||
|
|
||||||
Color and contrast were next on my list. I carefully selected a color palette that meets **WCAG AA standards** for contrast, ensuring that text is readable for users with visual impairments. I also implemented a system-aware dark mode, which isn't just a cool feature—it's essential for users with light sensitivity.
|
Color and contrast were next on my list. I carefully selected a color palette that meets **WCAG AA standards** for contrast, ensuring that text is readable for users with visual impairments. I also implemented a system-aware dark mode, which isn't just a cool feature—it's essential for users with light sensitivity.
|
||||||
|
|
||||||
I also built a strict **Image Alt System**. Every image on the site is required to have descriptive alt text. This ensures that users who rely on screen readers don't miss out on the context that images provide.
|
I also built a strict **Image Alt System**. Every image on the site is required to have descriptive alt text. This ensures that users who rely on screen readers don't miss out on the context that images provide.
|
||||||
|
|
||||||
### Interactive Elements
|
### Interactive Elements
|
||||||
|
|
||||||
One of the biggest challenges was ensuring **Keyboard Navigation**. I tested every interactive element—buttons, links, form inputs—to make sure they could be reached and operated using only a keyboard. This involved managing focus states and ensuring that the tab order was logical.
|
One of the biggest challenges was ensuring **Keyboard Navigation**. I tested every interactive element—buttons, links, form inputs—to make sure they could be reached and operated using only a keyboard. This involved managing focus states and ensuring that the tab order was logical.
|
||||||
|
|
||||||
## Overcoming Technical Challenges
|
## Overcoming Technical Challenges
|
||||||
|
|
||||||
### The Hydration Problem
|
### The Hydration Problem
|
||||||
|
|
||||||
Working with Next.js presented a unique challenge: **Hydration**. The split between server-side rendering (SSR) and client-side interactivity can sometimes cause issues with accessibility tools if the HTML structure changes during hydration. To solve this, I created client-side wrapper components for complex interactive features. This allowed me to keep the performance benefits of SSR while ensuring a stable, accessible experience on the client.
|
Working with Next.js presented a unique challenge: **Hydration**. The split between server-side rendering (SSR) and client-side interactivity can sometimes cause issues with accessibility tools if the HTML structure changes during hydration. To solve this, I created client-side wrapper components for complex interactive features. This allowed me to keep the performance benefits of SSR while ensuring a stable, accessible experience on the client.
|
||||||
|
|
||||||
### Video Accessibility
|
### Video Accessibility
|
||||||
|
|
||||||
I didn't want to just embed a standard video player. I built a custom **AccessibleVideo** component that includes closed captions with a toggle, a full transcript, and keyboard-accessible controls. This ensures that my video content is accessible to users who are deaf or hard of hearing, as well as those who prefer reading over watching.
|
I didn't want to just embed a standard video player. I built a custom **AccessibleVideo** component that includes closed captions with a toggle, a full transcript, and keyboard-accessible controls. This ensures that my video content is accessible to users who are deaf or hard of hearing, as well as those who prefer reading over watching.
|
||||||
|
|
||||||
## Ongoing Commitment
|
## Ongoing Commitment
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Designing My System: Soft, Translucent, and Alive",
|
title: "Designing My System: Soft, Translucent, and Alive",
|
||||||
publishedAt: "2025-12-10",
|
publishedAt: "2025-12-10",
|
||||||
summary: "A deep dive into the design philosophy behind my personal website's new theme, moving from rigid boxes to organic, living layers.",
|
summary:
|
||||||
|
"A deep dive into the design philosophy behind my personal website's new theme, moving from rigid boxes to organic, living layers.",
|
||||||
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
|
## The Philosophy: Soft, Translucent, and Alive
|
||||||
@@ -22,9 +23,9 @@ The heartbeat of this theme is the background. Instead of a flat color or a stat
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// src/app/layout.tsx
|
// src/app/layout.tsx
|
||||||
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
|
<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="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px)...] bg-[size:24px_24px]"></div>
|
||||||
<div className="w-[800px] h-[800px] bg-neutral-400/40 dark:bg-neutral-500/30 rounded-full blur-3xl animate-blob"></div>
|
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30"></div>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -36,10 +37,10 @@ One of the biggest structural changes was detaching the navigation and sidebar f
|
|||||||
|
|
||||||
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**.
|
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**.
|
||||||
|
|
||||||
- **Navbar**: Fixed `top-4`, `left-4`, `right-4`.
|
- **Navbar**: Fixed `top-4`, `left-4`, `right-4`.
|
||||||
- **Sidebar**: Fixed `top-24`, `left-4`, `bottom-4`.
|
- **Sidebar**: Fixed `top-24`, `left-4`, `bottom-4`.
|
||||||
|
|
||||||
This creates a sense of layering. The UI elements aren't *part* of the window; they are tools floating *above* the content.
|
This creates a sense of layering. The UI elements aren't _part_ of the window; they are tools floating _above_ the content.
|
||||||
|
|
||||||
## Design Tokens
|
## Design Tokens
|
||||||
|
|
||||||
@@ -47,9 +48,9 @@ This creates a sense of layering. The UI elements aren't *part* of the window; t
|
|||||||
|
|
||||||
I chose a base radius of `1rem` (16px) because it strikes the perfect balance between friendly and professional.
|
I chose a base radius of `1rem` (16px) because it strikes the perfect balance between friendly and professional.
|
||||||
|
|
||||||
- **Cards**: `rounded-3xl` (24px). Large containers need softer corners to feel less imposing.
|
- **Cards**: `rounded-3xl` (24px). Large containers need softer corners to feel less imposing.
|
||||||
- **Buttons**: `rounded-xl` (12px). Tactile and clickable.
|
- **Buttons**: `rounded-xl` (12px). Tactile and clickable.
|
||||||
- **Icons**: `rounded-full`.
|
- **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.
|
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.
|
||||||
|
|
||||||
@@ -57,9 +58,9 @@ This softness extends to interaction states. When you hover over a card, the `ov
|
|||||||
|
|
||||||
Instead of heavy drop shadows to show depth, I rely on **Glassmorphism**.
|
Instead of heavy drop shadows to show depth, I rely on **Glassmorphism**.
|
||||||
|
|
||||||
- **Surface**: `bg-background/80` with `backdrop-blur-md`. This allows the "Living Blob" to bleed through, tinting the UI with the background color.
|
- **Surface**: `bg-background/80` with `backdrop-blur-md`. This allows the "Living Blob" to bleed through, tinting the UI with the background color.
|
||||||
- **Border**: `border-border/60`. A subtle, semi-transparent border defines the edges.
|
- **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.
|
- **Shadow**: `shadow-sm`. A very light lift, just enough to separate the layer.
|
||||||
|
|
||||||
### 3. Color Palette
|
### 3. Color Palette
|
||||||
|
|
||||||
@@ -75,21 +76,21 @@ For typography, I wanted to blend the readability of a digital product with the
|
|||||||
|
|
||||||
### The Serif: Playfair Display
|
### The Serif: Playfair Display
|
||||||
|
|
||||||
<p className="text-4xl font-heading mb-6">Playfair Display</p>
|
<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.
|
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 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.
|
- **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
|
### The Sans: Inter
|
||||||
|
|
||||||
<p className="text-4xl font-sans mb-6">Inter</p>
|
<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.
|
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 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.
|
- **Why Inter?** It was designed specifically for computer screens, with a tall x-height that remains legible even at small sizes.
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
@@ -109,7 +110,12 @@ theme: {
|
|||||||
|
|
||||||
```css
|
```css
|
||||||
/* src/styles/globals.css */
|
/* src/styles/globals.css */
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
@apply font-heading; /* Playfair Display */
|
@apply font-heading; /* Playfair Display */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "ECEG 431 E-Portfolio",
|
title: "ECEG 431 E-Portfolio",
|
||||||
publishedAt: "2025-12-01",
|
publishedAt: "2025-12-01",
|
||||||
summary: "E-Portfolio and reflection for the Nand2Tetris course, covering the journey from NAND gates to a high-level compiler.",
|
summary:
|
||||||
|
"E-Portfolio and reflection for the Nand2Tetris course, covering the journey from NAND gates to a high-level compiler.",
|
||||||
tags: ["Simulation", "Compilers", "Python", "Assembly", "VM", "OS"],
|
tags: ["Simulation", "Compilers", "Python", "Assembly", "VM", "OS"],
|
||||||
image: "/images/nand2tetris.png"
|
image: "/images/nand2tetris.png",
|
||||||
};
|
};
|
||||||
|
|
||||||
## Assignment Prompt
|
## Assignment Prompt
|
||||||
@@ -71,7 +72,7 @@ The "oh crap" moment. A dark figure from the past comes to haunt you; a love is
|
|||||||
|
|
||||||
For the course, this could be lots of things. Work piling up? Concepts getting hard? Confusion setting in? Difficulty asking for help? Reliance on LLMs piling on? Not coming to class? Or maybe something entirely external to the course. What was it for you? There seems to be a common theme that happens to all of us in this course---we set out strong, start doing well with big dreams, but we typically all go through something and our original plans for the course start to crumble. What were those moments for you?
|
For the course, this could be lots of things. Work piling up? Concepts getting hard? Confusion setting in? Difficulty asking for help? Reliance on LLMs piling on? Not coming to class? Or maybe something entirely external to the course. What was it for you? There seems to be a common theme that happens to all of us in this course---we set out strong, start doing well with big dreams, but we typically all go through something and our original plans for the course start to crumble. What were those moments for you?
|
||||||
|
|
||||||
**[Project 5: The CPU](https://github.com/soconnor0919/eceg431/blob/main/05/CPU.hdl)**. This is where the "just wrapping logic gates" theory fell apart. It wasn't just logic anymore; it was *timing*. It was *control*. Decoding instructions was fine, but managing the control flow was a nightmare.
|
**[Project 5: The CPU](https://github.com/soconnor0919/eceg431/blob/main/05/CPU.hdl)**. This is where the "just wrapping logic gates" theory fell apart. It wasn't just logic anymore; it was _timing_. It was _control_. Decoding instructions was fine, but managing the control flow was a nightmare.
|
||||||
|
|
||||||
```hdl
|
```hdl
|
||||||
// projects/05/CPU.hdl
|
// projects/05/CPU.hdl
|
||||||
@@ -83,7 +84,7 @@ Not(in=aInstr, out=cInstr); // cInstr = 1 when instruction[15] = 1
|
|||||||
Mux16(a=instruction, b=aluOut, sel=cInstr, out=aRegIn);
|
Mux16(a=instruction, b=aluOut, sel=cInstr, out=aRegIn);
|
||||||
```
|
```
|
||||||
|
|
||||||
As I wrote in my reflection: *"The problem was taking the instruction and turning it into something the ALU could handle... I just repeatedly hit my head against a wall."* (Project 5 Reflection). The fix wasn't more code. I had to stop coding and start drawing. I couldn't "hack" my way through a hardware definition. I had to understand the signals.
|
As I wrote in my reflection: _"The problem was taking the instruction and turning it into something the ALU could handle... I just repeatedly hit my head against a wall."_ (Project 5 Reflection). The fix wasn't more code. I had to stop coding and start drawing. I couldn't "hack" my way through a hardware definition. I had to understand the signals.
|
||||||
|
|
||||||
> There are three steps to solving a problem. Write down what you know, Think really hard, Write down the answer. You're at step 2.
|
> There are three steps to solving a problem. Write down what you know, Think really hard, Write down the answer. You're at step 2.
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import Link from "next/link";
|
|||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Getting Started with LaTeX",
|
title: "Getting Started with LaTeX",
|
||||||
publishedAt: "2024-10-15",
|
publishedAt: "2024-10-15",
|
||||||
summary: "A guide to my 5-minute introduction to LaTeX, explaining why it's the gold standard for academic writing and how to get started.",
|
summary:
|
||||||
|
"A guide to my 5-minute introduction to LaTeX, explaining why it's the gold standard for academic writing and how to get started.",
|
||||||
tags: ["LaTeX", "Tutorial", "Accessibility", "Education", "Overleaf"],
|
tags: ["LaTeX", "Tutorial", "Accessibility", "Education", "Overleaf"],
|
||||||
image: "/latex-thumbnail.jpg"
|
image: "/latex-thumbnail.jpg",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const transcript = `
|
export const transcript = `
|
||||||
@@ -30,7 +31,7 @@ export const transcript = `
|
|||||||
|
|
||||||
In the world of engineering and science, clear communication is just as important as the technical work itself. I created this tutorial because I noticed many students struggling to format their mathematical equations and technical reports using standard word processors. **LaTeX** is the solution to that problem.
|
In the world of engineering and science, clear communication is just as important as the technical work itself. I created this tutorial because I noticed many students struggling to format their mathematical equations and technical reports using standard word processors. **LaTeX** is the solution to that problem.
|
||||||
|
|
||||||
It's the gold standard for academic and technical document preparation, especially in mathematics, computer science, and physics. Once you get past the initial learning curve, it allows you to focus entirely on the *content* of your work, trusting the system to handle the *presentation* professionally.
|
It's the gold standard for academic and technical document preparation, especially in mathematics, computer science, and physics. Once you get past the initial learning curve, it allows you to focus entirely on the _content_ of your work, trusting the system to handle the _presentation_ professionally.
|
||||||
|
|
||||||
## The Tutorial
|
## The Tutorial
|
||||||
|
|
||||||
|
|||||||
@@ -3,26 +3,32 @@
|
|||||||
import React, { createContext, useContext, useState } from "react";
|
import React, { createContext, useContext, useState } from "react";
|
||||||
|
|
||||||
interface BreadcrumbContextType {
|
interface BreadcrumbContextType {
|
||||||
customTitle: string | null;
|
customTitle: string | null;
|
||||||
setCustomTitle: (title: string | null) => void;
|
setCustomTitle: (title: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BreadcrumbContext = createContext<BreadcrumbContextType | undefined>(undefined);
|
const BreadcrumbContext = createContext<BreadcrumbContextType | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
export function BreadcrumbProvider({ children }: { children: React.ReactNode }) {
|
export function BreadcrumbProvider({
|
||||||
const [customTitle, setCustomTitle] = useState<string | null>(null);
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [customTitle, setCustomTitle] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BreadcrumbContext.Provider value={{ customTitle, setCustomTitle }}>
|
<BreadcrumbContext.Provider value={{ customTitle, setCustomTitle }}>
|
||||||
{children}
|
{children}
|
||||||
</BreadcrumbContext.Provider>
|
</BreadcrumbContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBreadcrumb() {
|
export function useBreadcrumb() {
|
||||||
const context = useContext(BreadcrumbContext);
|
const context = useContext(BreadcrumbContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error("useBreadcrumb must be used within a BreadcrumbProvider");
|
throw new Error("useBreadcrumb must be used within a BreadcrumbProvider");
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function parseAuthors(authorString: string): string[] {
|
|||||||
|
|
||||||
function parseBibTeXEntry(entry: string): BibTeXEntry | null {
|
function parseBibTeXEntry(entry: string): BibTeXEntry | null {
|
||||||
// Match the entry type and content
|
// Match the entry type and content
|
||||||
const typeMatch = entry.match(/^(\w+)\s*{\s*([\w\d-_]+)\s*,/);
|
const typeMatch = /^(\w+)\s*{\s*([\w\d-_]+)\s*,/.exec(entry);
|
||||||
if (!typeMatch) return null;
|
if (!typeMatch) return null;
|
||||||
|
|
||||||
const type = typeMatch[1]!.toLowerCase();
|
const type = typeMatch[1]!.toLowerCase();
|
||||||
@@ -55,7 +55,7 @@ function parseBibTeXEntry(entry: string): BibTeXEntry | null {
|
|||||||
if (!trimmedLine || trimmedLine === "}") continue;
|
if (!trimmedLine || trimmedLine === "}") continue;
|
||||||
|
|
||||||
// Try to match a new field
|
// Try to match a new field
|
||||||
const fieldMatch = trimmedLine.match(/(\w+)\s*=\s*{(.+?)},?$/);
|
const fieldMatch = /(\w+)\s*=\s*{(.+?)},?$/.exec(trimmedLine);
|
||||||
if (fieldMatch?.[1] && fieldMatch?.[2]) {
|
if (fieldMatch?.[1] && fieldMatch?.[2]) {
|
||||||
// Save previous field if exists
|
// Save previous field if exists
|
||||||
if (currentField) {
|
if (currentField) {
|
||||||
@@ -103,15 +103,15 @@ export function parseBibtex(bibtex: string): Publication[] {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: entry.fields.title?.replace(/[{}]/g, "") || "",
|
title: entry.fields.title?.replace(/[{}]/g, "") ?? "",
|
||||||
authors: parseAuthors(entry.fields.author || ""),
|
authors: parseAuthors(entry.fields.author ?? ""),
|
||||||
venue:
|
venue:
|
||||||
entry.fields.booktitle ||
|
entry.fields.booktitle ??
|
||||||
entry.fields.journal ||
|
entry.fields.journal ??
|
||||||
entry.fields.organization ||
|
entry.fields.organization ??
|
||||||
entry.fields.school ||
|
entry.fields.school ??
|
||||||
"",
|
"",
|
||||||
year: parseInt(entry.fields.year || "0", 10),
|
year: parseInt(entry.fields.year ?? "0", 10),
|
||||||
doi: entry.fields.doi,
|
doi: entry.fields.doi,
|
||||||
url: entry.fields.url,
|
url: entry.fields.url,
|
||||||
paperUrl: entry.fields.paperurl,
|
paperUrl: entry.fields.paperurl,
|
||||||
@@ -120,7 +120,7 @@ export function parseBibtex(bibtex: string): Publication[] {
|
|||||||
abstract: entry.fields.abstract,
|
abstract: entry.fields.abstract,
|
||||||
citationType: entry.type,
|
citationType: entry.type,
|
||||||
citationKey: entry.citationKey,
|
citationKey: entry.citationKey,
|
||||||
notes: entry.fields.note || entry.fields.notes,
|
notes: entry.fields.note ?? entry.fields.notes,
|
||||||
address: entry.fields.address,
|
address: entry.fields.address,
|
||||||
type: publicationType,
|
type: publicationType,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -414,14 +414,7 @@ export const projects: Project[] = [
|
|||||||
"A complete implementation of a general-purpose computer system, from NAND gates to a high-level object-oriented compiler.",
|
"A complete implementation of a general-purpose computer system, from NAND gates to a high-level object-oriented compiler.",
|
||||||
longDescription:
|
longDescription:
|
||||||
"Built a complete computer system from the ground up as part of the Nand2Tetris course (ECEG 431). Starting with a single NAND gate, I designed and simulated all hardware components including logic gates, ALU, RAM, and the CPU. On the software side, I developed an assembler, a virtual machine translator, and a compiler for a high-level object-oriented language, culminating in a fully functional Operating System. This project provided a deep, demystified understanding of how computers actually work under the hood.",
|
"Built a complete computer system from the ground up as part of the Nand2Tetris course (ECEG 431). Starting with a single NAND gate, I designed and simulated all hardware components including logic gates, ALU, RAM, and the CPU. On the software side, I developed an assembler, a virtual machine translator, and a compiler for a high-level object-oriented language, culminating in a fully functional Operating System. This project provided a deep, demystified understanding of how computers actually work under the hood.",
|
||||||
tags: [
|
tags: ["Simulation", "Compilers", "Python", "Assembly", "VM", "OS"],
|
||||||
"Simulation",
|
|
||||||
"Compilers",
|
|
||||||
"Python",
|
|
||||||
"Assembly",
|
|
||||||
"VM",
|
|
||||||
"OS",
|
|
||||||
],
|
|
||||||
link: "/blog/eceg431",
|
link: "/blog/eceg431",
|
||||||
gitLink: "https://github.com/soconnor0919/eceg431",
|
gitLink: "https://github.com/soconnor0919/eceg431",
|
||||||
image: "/images/nand2tetris.png",
|
image: "/images/nand2tetris.png",
|
||||||
@@ -443,8 +436,7 @@ export const projects: Project[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "PDF2MD",
|
title: "PDF2MD",
|
||||||
description:
|
description: "A web application that converts PDFs to Markdown files.",
|
||||||
"A web application that converts PDFs to Markdown files.",
|
|
||||||
longDescription:
|
longDescription:
|
||||||
"Uses OCR and PDF parsing to extract text and convert it to Markdown, for easy editing and formatting.",
|
"Uses OCR and PDF parsing to extract text and convert it to Markdown, for easy editing and formatting.",
|
||||||
tags: ["PDF", "Markdown", "OCR"],
|
tags: ["PDF", "Markdown", "OCR"],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { MDXComponents } from "mdx/types";
|
import type { MDXComponents } from "mdx/types";
|
||||||
|
|
||||||
export function useMDXComponents(components: MDXComponents): MDXComponents {
|
export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||||
return {
|
return {
|
||||||
...components,
|
...components,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { type Config } from "tailwindcss";
|
import { type Config } from "tailwindcss";
|
||||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||||
|
import typography from "@tailwindcss/typography";
|
||||||
|
import tailwindAnimate from "tailwindcss-animate";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
darkMode: "media",
|
darkMode: "media",
|
||||||
@@ -64,5 +66,5 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
plugins: [tailwindAnimate, typography],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
Reference in New Issue
Block a user