diff --git a/.gitignore b/.gitignore index 6d4c0aa..50c7030 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ pnpm-debug.log* # macOS-specific files .DS_Store + +.contentlayer/ \ No newline at end of file diff --git a/app/components/ErrorBoundary.tsx b/app/components/ErrorBoundary.tsx new file mode 100644 index 0000000..17a921c --- /dev/null +++ b/app/components/ErrorBoundary.tsx @@ -0,0 +1,111 @@ +import { isRouteErrorResponse, useRouteError } from "@remix-run/react"; +import { RootLayout } from "./layouts/RootLayout"; + +export function ErrorBoundary() { + const error = useRouteError(); + + if (isRouteErrorResponse(error)) { + switch (error.status) { + case 404: + return ( + +
+
+

Page Not Found

+

+ The page you're looking for doesn't exist. Please check the + URL and try again. +

+

+ + ← Go back home + +

+
+
+
+ ); + case 401: + return ( + +
+
+

Unauthorized

+

+ You don't have permission to access this page. Please log in + and try again. +

+

+ + ← Go back home + +

+
+
+
+ ); + default: + return ( + +
+
+

Error

+

+ Something went wrong. Please try again later. If the problem + persists, please contact support. +

+

+ + ← Go back home + +

+ {process.env.NODE_ENV === "development" && ( +
+                    {error.data.message || JSON.stringify(error.data, null, 2)}
+                  
+ )} +
+
+
+ ); + } + } + + return ( + +
+
+

Error

+

+ Something went wrong. Please try again later. If the problem + persists, please contact support. +

+

+ + ← Go back home + +

+ {process.env.NODE_ENV === "development" && ( +
+              {error instanceof Error
+                ? error.message
+                : "Unknown error occurred"}
+            
+ )} +
+
+
+ ); +} diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx new file mode 100644 index 0000000..3523b98 --- /dev/null +++ b/app/components/Footer.tsx @@ -0,0 +1,32 @@ +import { Link } from "@remix-run/react"; +import { SocialLinks } from "./SocialLinks"; + +const footerLinks = [ + { name: "Colophon", href: "/colophon" }, + { name: "Privacy Policy", href: "/privacy-policy" }, + { name: "Imprint", href: "/imprint" }, +] as const; + +export function Footer() { + return ( + + ); +} diff --git a/app/components/Header.tsx b/app/components/Header.tsx new file mode 100644 index 0000000..1cd6d10 --- /dev/null +++ b/app/components/Header.tsx @@ -0,0 +1,74 @@ +import { Link, useLocation } from "@remix-run/react"; +import { type ActiveTabType, navigationLinks } from "~/utils/navigation"; +import { SocialLinks } from "./SocialLinks"; +import clsx from "clsx"; + +interface HeaderProps { + activeTab?: ActiveTabType; +} + +export function Header({ activeTab }: HeaderProps) { + const location = useLocation(); + + return ( +
+ + Image of Christian Cito, a 26 year old white man. I'm wearing a loose blue shirt and am looking to the side. + + + +
+ ); +} diff --git a/app/components/Particles.tsx b/app/components/Particles.tsx new file mode 100644 index 0000000..5966cc1 --- /dev/null +++ b/app/components/Particles.tsx @@ -0,0 +1,85 @@ +import { useCallback } from "react"; +import type { Container, Engine } from "tsparticles-engine"; +import { loadFull } from "tsparticles"; +import Particles from "react-tsparticles"; + +export function Particles({ className }: { className?: string }) { + const particlesInit = useCallback(async (engine: Engine) => { + await loadFull(engine); + }, []); + + const particlesLoaded = useCallback( + async (container: Container | undefined) => { + await container?.refresh(); + }, + [], + ); + + return ( + + ); +} diff --git a/app/components/RelatedContent.tsx b/app/components/RelatedContent.tsx new file mode 100644 index 0000000..118e993 --- /dev/null +++ b/app/components/RelatedContent.tsx @@ -0,0 +1,55 @@ +import { Link } from "@remix-run/react"; + +interface RelatedContentItem { + title: string; + description: string; + slug: string; + type: "article" | "book" | "project"; + tags: string[]; +} + +interface RelatedContentProps { + items: RelatedContentItem[]; + title?: string; +} + +export function RelatedContent({ + items, + title = "Related Content", +}: RelatedContentProps) { + if (items.length === 0) return null; + + return ( +
+

{title}

+ +
+ ); +} diff --git a/app/components/SocialIcon.tsx b/app/components/SocialIcon.tsx new file mode 100644 index 0000000..3fc50c9 --- /dev/null +++ b/app/components/SocialIcon.tsx @@ -0,0 +1,69 @@ +import { type SocialType } from "~/data/socials"; +import clsx from "clsx"; + +interface SocialIconProps { + type: SocialType; + size: number; + className?: string; +} + +export function SocialIcon({ type, size, className }: SocialIconProps) { + const iconProps = { + className: clsx("lucide", `lucide-${type}`, className), + xmlns: "http://www.w3.org/2000/svg", + width: size, + height: size, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2", + strokeLinecap: "round", + strokeLinejoin: "round", + }; + + switch (type) { + case "github": + return ( + + + + + ); + + case "twitter": + return ( + + + + ); + + case "linkedin": + return ( + + + + + + ); + + case "youtube": + return ( + + + + + ); + + case "instagram": + return ( + + + + + + ); + + default: + return null; + } +} diff --git a/app/components/SocialLinks.tsx b/app/components/SocialLinks.tsx new file mode 100644 index 0000000..d177570 --- /dev/null +++ b/app/components/SocialLinks.tsx @@ -0,0 +1,33 @@ +import { socialLinks } from "~/data/socials"; +import { SocialIcon } from "./SocialIcon"; +import clsx from "clsx"; + +interface SocialLinksProps { + size?: number; + className?: string; +} + +export function SocialLinks({ size = 16, className }: SocialLinksProps) { + return ( + + ); +} diff --git a/app/components/Toast.tsx b/app/components/Toast.tsx new file mode 100644 index 0000000..a6a4906 --- /dev/null +++ b/app/components/Toast.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from "react"; + +interface ToastProps { + message: string; + type?: "success" | "error" | "info"; + duration?: number; + onClose?: () => void; +} + +export function Toast({ + message, + type = "info", + duration = 3000, + onClose, +}: ToastProps) { + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => { + setIsVisible(false); + onClose?.(); + }, duration); + + return () => clearTimeout(timer); + }, [duration, onClose]); + + if (!isVisible) return null; + + const bgColor = { + success: "bg-green-500", + error: "bg-red-500", + info: "bg-blue-500", + }[type]; + + return ( +
+
+ {message} + +
+
+ ); +} + +interface ToastManagerProps { + children: React.ReactNode; +} + +interface ToastState { + id: number; + message: string; + type?: "success" | "error" | "info"; + duration?: number; +} + +export function ToastManager({ children }: ToastManagerProps) { + const [toasts, setToasts] = useState([]); + + const addToast = ( + message: string, + type: "success" | "error" | "info" = "info", + duration = 3000, + ) => { + const id = Date.now(); + setToasts((prev) => [...prev, { id, message, type, duration }]); + }; + + const removeToast = (id: number) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }; + + return ( + <> + {children} +
+ {toasts.map((toast) => ( + removeToast(toast.id)} + /> + ))} +
+ + ); +} diff --git a/app/components/embeds/EmbedOverlay.tsx b/app/components/embeds/EmbedOverlay.tsx new file mode 100644 index 0000000..39a90d6 --- /dev/null +++ b/app/components/embeds/EmbedOverlay.tsx @@ -0,0 +1,57 @@ +import { useFetcher } from "@remix-run/react"; +import { type EmbedType } from "~/utils/embed-consent"; +import clsx from "clsx"; + +interface EmbedOverlayProps { + type: EmbedType; + title: string; + description: string; + hasConsent: boolean; + children: React.ReactNode; +} + +export function EmbedOverlay({ + type, + title, + description, + hasConsent, + children, +}: EmbedOverlayProps) { + const fetcher = useFetcher(); + const isLoading = fetcher.state !== "idle"; + + if (hasConsent) { + return <>{children}; + } + + return ( +
+
+

{title}

+

{description}

+
+ + + + + + + +

+ By clicking "Load Embed", you consent to loading content from{" "} + {type}. This setting + will be remembered for future embeds. +

+
+ ); +} diff --git a/app/components/embeds/InstagramEmbed.tsx b/app/components/embeds/InstagramEmbed.tsx new file mode 100644 index 0000000..5fc93f4 --- /dev/null +++ b/app/components/embeds/InstagramEmbed.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef } from "react"; +import { EmbedOverlay } from "./EmbedOverlay"; + +interface InstagramEmbedProps { + postId: string; + hasConsent: boolean; +} + +declare global { + interface Window { + instgrm?: { + Embeds: { + process: () => void; + }; + }; + } +} + +export function InstagramEmbed({ postId, hasConsent }: InstagramEmbedProps) { + const containerRef = useRef(null); + + useEffect(() => { + if (!hasConsent) return; + + // Load Instagram embed script if not already loaded + if (!window.instgrm) { + const script = document.createElement("script"); + script.src = "//www.instagram.com/embed.js"; + script.async = true; + document.body.appendChild(script); + } else { + // If script is already loaded, process the embed + window.instgrm.Embeds.process(); + } + }, [hasConsent]); + + return ( + +
+
+ +
+
+
+ ); +} diff --git a/app/components/embeds/RedditEmbed.tsx b/app/components/embeds/RedditEmbed.tsx new file mode 100644 index 0000000..15817d1 --- /dev/null +++ b/app/components/embeds/RedditEmbed.tsx @@ -0,0 +1,49 @@ +import { useEffect, useRef } from "react"; +import { EmbedOverlay } from "./EmbedOverlay"; + +interface RedditEmbedProps { + postUrl: string; + hasConsent: boolean; +} + +declare global { + interface Window { + rembeddit?: { + init: () => void; + }; + } +} + +export function RedditEmbed({ postUrl, hasConsent }: RedditEmbedProps) { + const containerRef = useRef(null); + + useEffect(() => { + if (!hasConsent) return; + + // Load Reddit embed script if not already loaded + if (!window.rembeddit) { + const script = document.createElement("script"); + script.src = "https://embed.reddit.com/widgets.js"; + script.async = true; + document.body.appendChild(script); + } else { + // If script is already loaded, initialize the embed + window.rembeddit.init(); + } + }, [hasConsent]); + + return ( + + + + ); +} diff --git a/app/components/embeds/TwitterEmbed.tsx b/app/components/embeds/TwitterEmbed.tsx new file mode 100644 index 0000000..240c421 --- /dev/null +++ b/app/components/embeds/TwitterEmbed.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef } from "react"; +import { EmbedOverlay } from "./EmbedOverlay"; + +interface TwitterEmbedProps { + tweetId: string; + hasConsent: boolean; +} + +declare global { + interface Window { + twttr?: { + widgets: { + load: (element?: HTMLElement) => void; + }; + }; + } +} + +export function TwitterEmbed({ tweetId, hasConsent }: TwitterEmbedProps) { + const containerRef = useRef(null); + + useEffect(() => { + if (!hasConsent) return; + + // Load Twitter widget script if not already loaded + if (!window.twttr) { + const script = document.createElement("script"); + script.src = "https://platform.twitter.com/widgets.js"; + script.async = true; + document.body.appendChild(script); + } else { + // If script is already loaded, render the tweet + window.twttr.widgets.load(containerRef.current); + } + }, [hasConsent]); + + return ( + + + + ); +} diff --git a/app/components/embeds/YouTubeEmbed.tsx b/app/components/embeds/YouTubeEmbed.tsx new file mode 100644 index 0000000..26d9838 --- /dev/null +++ b/app/components/embeds/YouTubeEmbed.tsx @@ -0,0 +1,32 @@ +import { EmbedOverlay } from "./EmbedOverlay"; + +interface YouTubeEmbedProps { + videoId: string; + hasConsent: boolean; + title?: string; +} + +export function YouTubeEmbed({ + videoId, + hasConsent, + title, +}: YouTubeEmbedProps) { + return ( + +
+