Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions app/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,7 @@ const NAV_ITEMS = [
name: "Horse Plinko",
href: "https://plinko.horse",
},
{
id: "writeups",
name: "Writeups",
href: "https://hackucf-writeups.pages.dev/",
},
{ id: "writeups", name: "Writeups", href: "/writeups" },
];

export function Navbar() {
Expand Down
37 changes: 37 additions & 0 deletions app/components/ui/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";

import { cn } from "@/lib/styles";

const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);

export interface BadgeProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}

export { Badge, badgeVariants };
84 changes: 84 additions & 0 deletions app/components/writeups/writeup-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { type WriteupMeta, difficultyColours } from "@/lib/writeups";
import { Link } from "@remix-run/react";
import { Calendar, User } from "lucide-react";

export function WriteupCard({ writeup }: { writeup: WriteupMeta }) {
const date = new Date(writeup.date).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});

return (
<Card className="bg-background border-brandGold/40 border hover:border-brandGold transition-colors group">
<CardHeader className="pb-3">
<CardTitle className="text-lg">
<Link
to={`/writeups/${writeup.slug}`}
className="text-foreground group-hover:text-brandGold transition-colors"
prefetch="intent"
>
{writeup.title}
</Link>
</CardTitle>
</CardHeader>
<CardContent className="pb-3">
<div className="flex items-center gap-4 text-sm text-stone-400 mb-3">
<span className="flex items-center gap-1">
<User className="w-3.5 h-3.5" />
{writeup.author}
</span>
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
{date}
</span>
</div>
<p className="text-sm text-stone-400 line-clamp-2">
{writeup.description}
</p>
</CardContent>
<CardFooter className="flex flex-wrap gap-1.5">
{writeup.categories.map((cat) => (
<Link key={cat} to={`/writeups/category/${encodeURIComponent(cat)}`}>
<Badge
variant="outline"
className="text-brandGold border-brandGold/40 hover:bg-brandGold/10 text-xs"
>
{cat}
</Badge>
</Link>
))}
{writeup.tags
.filter((tag) => tag !== writeup.difficulty)
.map((tag) => (
<Link key={tag} to={`/writeups/tag/${encodeURIComponent(tag)}`}>
<Badge
variant="secondary"
className="bg-stone-800 text-stone-300 hover:bg-stone-700 border-stone-700 text-xs"
>
{tag}
</Badge>
</Link>
))}
{writeup.difficulty && (
<Badge
className={
difficultyColours[writeup.difficulty] ??
"bg-stone-800 text-stone-300 border-stone-700"
}
>
{writeup.difficulty}
</Badge>
)}
</CardFooter>
</Card>
);
}
9 changes: 9 additions & 0 deletions app/components/writeups/writeup-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ComponentType } from "react";

export function WriteupContent({ Component }: { Component: ComponentType }) {
return (
<article className="prose prose-invert max-w-none prose-headings:text-foreground prose-a:text-brandGold prose-a:no-underline hover:prose-a:underline prose-code:text-brandGold prose-pre:bg-stone-900 prose-pre:border prose-pre:border-stone-700 prose-strong:text-foreground prose-blockquote:border-brandGold/40 prose-blockquote:text-stone-400 prose-li:marker:text-brandGold">
<Component />
</article>
);
}
65 changes: 65 additions & 0 deletions app/components/writeups/writeup-filters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/styles";
import { Link } from "@remix-run/react";

export function WriteupFilters({
categories,
tags,
activeCategory,
activeTag,
}: {
categories: string[];
tags: string[];
activeCategory?: string;
activeTag?: string;
}) {
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-1.5">
<Link to="/writeups">
<Badge
variant="outline"
className={cn(
"cursor-pointer transition-colors",
!activeCategory && !activeTag
? "bg-brandGold text-background border-brandGold"
: "text-foreground border-stone-600 hover:border-brandGold",
)}
>
All
</Badge>
</Link>
{categories.map((cat) => (
<Link key={cat} to={`/writeups/category/${encodeURIComponent(cat)}`}>
<Badge
variant="outline"
className={cn(
"cursor-pointer transition-colors",
activeCategory?.toLowerCase() === cat.toLowerCase()
? "bg-brandGold text-background border-brandGold"
: "text-brandGold border-brandGold/40 hover:bg-brandGold/10",
)}
>
{cat}
</Badge>
</Link>
))}
{tags.map((tag) => (
<Link key={tag} to={`/writeups/tag/${encodeURIComponent(tag)}`}>
<Badge
variant="secondary"
className={cn(
"cursor-pointer transition-colors",
activeTag?.toLowerCase() === tag.toLowerCase()
? "bg-brandGold text-background border-brandGold"
: "bg-stone-800 text-stone-300 hover:bg-stone-700 border-stone-700",
)}
>
{tag}
</Badge>
</Link>
))}
</div>
</div>
);
}
73 changes: 73 additions & 0 deletions app/components/writeups/writeup-metadata.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Badge } from "@/components/ui/badge";
import { type WriteupMeta, difficultyColours } from "@/lib/writeups";
import { Link } from "@remix-run/react";
import { ArrowLeft, Calendar, User } from "lucide-react";

export function WriteupMetadata({ writeup }: { writeup: WriteupMeta }) {
const date = new Date(writeup.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});

return (
<div className="mb-8 space-y-4">
<Link
to="/writeups"
className="inline-flex items-center gap-1.5 text-sm text-stone-400 hover:text-brandGold transition-colors"
prefetch="intent"
>
<ArrowLeft className="w-4 h-4" />
Back to Writeups
</Link>

<h1 className="text-3xl md:text-4xl font-bold text-foreground">
{writeup.title}
</h1>

<div className="flex flex-wrap items-center gap-4 text-sm text-stone-400">
<span className="flex items-center gap-1.5">
<User className="w-4 h-4" />
{writeup.author}
</span>
<span className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
{date}
</span>
{writeup.difficulty && (
<Badge
className={
difficultyColours[writeup.difficulty] ??
"bg-stone-800 text-stone-300 border-stone-700"
}
>
{writeup.difficulty}
</Badge>
)}
</div>

<div className="flex flex-wrap gap-1.5">
{writeup.categories.map((cat) => (
<Link key={cat} to={`/writeups/category/${encodeURIComponent(cat)}`}>
<Badge
variant="outline"
className="text-brandGold border-brandGold/40 hover:bg-brandGold/10"
>
{cat}
</Badge>
</Link>
))}
{writeup.tags.map((tag) => (
<Link key={tag} to={`/writeups/tag/${encodeURIComponent(tag)}`}>
<Badge
variant="secondary"
className="bg-stone-800 text-stone-300 hover:bg-stone-700 border-stone-700"
>
{tag}
</Badge>
</Link>
))}
</div>
</div>
);
}
55 changes: 55 additions & 0 deletions app/components/writeups/writeup-nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { WriteupMeta } from "@/lib/writeups";
import { Link } from "@remix-run/react";
import { ChevronLeft, ChevronRight } from "lucide-react";

export function WriteupNav({
prev,
next,
}: {
prev: WriteupMeta | null;
next: WriteupMeta | null;
}) {
if (!prev && !next) return null;

return (
<nav
aria-label="Previous and next writeups"
className="flex justify-between items-stretch gap-4 mt-12 pt-8 border-t border-stone-800"
>
{prev ? (
<Link
to={`/writeups/${prev.slug}`}
className="flex items-center gap-2 text-sm text-stone-400 hover:text-brandGold transition-colors group max-w-[45%]"
prefetch="intent"
>
<ChevronLeft className="w-4 h-4 shrink-0 group-hover:-translate-x-0.5 transition-transform" />
<div className="text-left">
<p className="text-xs text-stone-500">Previous</p>
<p className="text-foreground group-hover:text-brandGold transition-colors line-clamp-1">
{prev.title}
</p>
</div>
</Link>
) : (
<div />
)}
{next ? (
<Link
to={`/writeups/${next.slug}`}
className="flex items-center gap-2 text-sm text-stone-400 hover:text-brandGold transition-colors group max-w-[45%] ml-auto"
prefetch="intent"
>
<div className="text-right">
<p className="text-xs text-stone-500">Next</p>
<p className="text-foreground group-hover:text-brandGold transition-colors line-clamp-1">
{next.title}
</p>
</div>
<ChevronRight className="w-4 h-4 shrink-0 group-hover:translate-x-0.5 transition-transform" />
</Link>
) : (
<div />
)}
</nav>
);
}
43 changes: 43 additions & 0 deletions app/components/writeups/writeup-search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Input } from "@/components/ui/input";
import { Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";

export function WriteupSearch({
value,
onChange,
}: {
value: string;
onChange: (q: string) => void;
}) {
const [local, setLocal] = useState(value);
const timerRef = useRef<ReturnType<typeof setTimeout>>();

useEffect(() => {
setLocal(value);
}, [value]);

useEffect(() => {
return () => clearTimeout(timerRef.current);
}, []);

function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const v = e.target.value;
setLocal(v);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onChange(v), 300);
}

return (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-stone-400" />
<Input
type="search"
placeholder="Search writeups..."
aria-label="Search writeups"
value={local}
onChange={handleChange}
className="pl-10 bg-stone-900 border-stone-700 focus:border-brandGold placeholder:text-stone-500"
/>
</div>
);
}
Loading