diff --git a/packages/geocities-app/README.md b/packages/geocities-app/README.md new file mode 100644 index 000000000..d8c5a3e35 --- /dev/null +++ b/packages/geocities-app/README.md @@ -0,0 +1,101 @@ +# ~*~RiChArDs HoMePaGe~*~ + +> A modern React/Next.js application that looks like it escaped from 1996 + +## 🔥 Features + +- **Modern Architecture**: Next.js 14, TypeScript, SASS modules +- **Atomic Design**: Proper component hierarchy (atoms → molecules → organisms → templates) +- **Custom Hooks**: `useIndexedDB`, `useVisitorCounter`, `useGuestbook` +- **IndexedDB Persistence**: Visitor counter and guestbook survive page reloads +- **90s Aesthetic**: All the GeoCities hallmarks you remember (or want to forget) + +## 🚀 Tech Stack + +- **Framework**: Next.js 14 (App Router) +- **Language**: TypeScript +- **Styling**: SASS/SCSS Modules +- **State Management**: React hooks + IndexedDB +- **Architecture**: Atomic Design Pattern + +## 📁 Project Structure + +``` +geocities-app/ +├── app/ +│ ├── layout.tsx # Root layout with fonts +│ └── page.tsx # Home page with splash/main routing +├── components/ +│ ├── atoms/ # Basic building blocks +│ │ ├── BlinkText/ # tag energy +│ │ ├── MarqueeText/ # Scrolling text +│ │ ├── RetroGif/ # Classic animated gifs +│ │ ├── RetroButton/ # Windows 95 style buttons +│ │ ├── RetroInput/ # Inset text inputs +│ │ ├── RetroTextarea/ # Inset textareas +│ │ └── RainbowText/ # Animated rainbow text +│ ├── molecules/ # Combined atoms +│ │ ├── VisitorCounter/ # Hit counter display +│ │ ├── GuestbookForm/ # Sign the guestbook +│ │ ├── GuestbookEntry/ # Individual guestbook entry +│ │ ├── NavLink/ # Navigation links with badges +│ │ └── WebRing/ # Remember web rings? +│ ├── organisms/ # Complex components +│ │ ├── Header/ # Site header with marquee +│ │ ├── Sidebar/ # Navigation, counter, awards +│ │ ├── MainContent/ # About, links, updates +│ │ ├── Guestbook/ # Full guestbook section +│ │ └── Footer/ # Credits and badges +│ └── templates/ # Page layouts +│ ├── SplashScreen/ # ENTER splash page +│ └── MainLayout/ # Main site layout +├── hooks/ +│ ├── useIndexedDB.ts # Generic IndexedDB hook +│ ├── useVisitorCounter.ts # Visitor counter state +│ └── useGuestbook.ts # Guestbook CRUD +└── styles/ + └── globals.scss # Global 90s styles +``` + +## 🏃 Running Locally + +```bash +npm install +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) to enter the time warp. + +## ✨ GeoCities Elements Included + +- [x] Cheesy "ENTER" splash screen +- [x] Animated cursor trail (sparkles!) +- [x] Blinking text +- [x] Rainbow text +- [x] Marquee scrolling +- [x] Under construction GIFs +- [x] Dancing baby GIF +- [x] Fire GIFs +- [x] Skull GIF +- [x] Visitor counter +- [x] Guestbook +- [x] Web ring +- [x] "Best viewed with Netscape/IE" badges +- [x] Award badges +- [x] Windows 95 style buttons +- [x] Ridge/groove borders everywhere +- [x] Tiled backgrounds +- [x] Comic Sans (via Comic Neue) +- [x] wEiRd CaPiTaLiZaTiOn + +## 🎨 Design Philosophy + +**The Ironic Stack**: Maximum modern engineering practices wrapped in maximum 90s web chaos. TypeScript strict mode enforcing the type safety of your `` tag replacement. + +Built with genuine affection for the web's weird, wonderful early days. + +--- + +*MaDe WiTh 💕 aNd Notepad.exe* + +*(AcTuAlLy MaDe WiTh Next.js, React, TypeScript, SASS & IndexedDB)* diff --git a/packages/geocities-app/app/layout.tsx b/packages/geocities-app/app/layout.tsx new file mode 100644 index 000000000..dadd1c244 --- /dev/null +++ b/packages/geocities-app/app/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from 'next'; +import '@/styles/globals.scss'; + +export const metadata: Metadata = { + title: '~*~RiChArDs HoMePaGe~*~', + description: 'WeLcOmE tO mY pErSoNaL wEbSiTe!!!1!', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + + + + {children} + + + ); +} diff --git a/packages/geocities-app/app/page.tsx b/packages/geocities-app/app/page.tsx new file mode 100644 index 000000000..c556762c8 --- /dev/null +++ b/packages/geocities-app/app/page.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { SplashScreen } from '@/components/templates/SplashScreen'; +import { MainLayout } from '@/components/templates/MainLayout'; +import { useVisitorCounter } from '@/hooks/useVisitorCounter'; +import { useGuestbook } from '@/hooks/useGuestbook'; + +export default function Home() { + const [hasEntered, setHasEntered] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const { visitorCount, incrementVisitor } = useVisitorCounter(); + const { entries, addEntry, isLoaded } = useGuestbook(); + + useEffect(() => { + const entered = sessionStorage.getItem('hasEntered'); + if (entered === 'true') { + setHasEntered(true); + } + setIsLoading(false); + }, []); + + const handleEnter = async () => { + await incrementVisitor(); + sessionStorage.setItem('hasEntered', 'true'); + setHasEntered(true); + }; + + if (isLoading) return null; + + if (!hasEntered) { + return ; + } + + return ( + + ); +} diff --git a/packages/geocities-app/components/atoms/BlinkText/BlinkText.module.scss b/packages/geocities-app/components/atoms/BlinkText/BlinkText.module.scss new file mode 100644 index 000000000..455645b4b --- /dev/null +++ b/packages/geocities-app/components/atoms/BlinkText/BlinkText.module.scss @@ -0,0 +1,26 @@ +.blink { + animation-name: blink; + animation-iteration-count: infinite; + font-weight: bold; + + &.slow { + animation-duration: 1.5s; + } + + &.normal { + animation-duration: 1s; + } + + &.fast { + animation-duration: 0.5s; + } +} + +@keyframes blink { + 0%, 49% { + opacity: 1; + } + 50%, 100% { + opacity: 0; + } +} diff --git a/packages/geocities-app/components/atoms/BlinkText/BlinkText.tsx b/packages/geocities-app/components/atoms/BlinkText/BlinkText.tsx new file mode 100644 index 000000000..070678ec0 --- /dev/null +++ b/packages/geocities-app/components/atoms/BlinkText/BlinkText.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import styles from './BlinkText.module.scss'; + +interface BlinkTextProps { + children: React.ReactNode; + color?: string; + speed?: 'slow' | 'normal' | 'fast'; + className?: string; +} + +export const BlinkText: React.FC = ({ + children, + color = '#ff0000', + speed = 'normal', + className = '' +}) => { + return ( + + {children} + + ); +}; diff --git a/packages/geocities-app/components/atoms/BlinkText/index.ts b/packages/geocities-app/components/atoms/BlinkText/index.ts new file mode 100644 index 000000000..100fbf3e0 --- /dev/null +++ b/packages/geocities-app/components/atoms/BlinkText/index.ts @@ -0,0 +1 @@ +export { BlinkText } from './BlinkText'; diff --git a/packages/geocities-app/components/atoms/MarqueeText/MarqueeText.module.scss b/packages/geocities-app/components/atoms/MarqueeText/MarqueeText.module.scss new file mode 100644 index 000000000..5c33b2f9a --- /dev/null +++ b/packages/geocities-app/components/atoms/MarqueeText/MarqueeText.module.scss @@ -0,0 +1,61 @@ +.marqueeContainer { + width: 100%; + overflow: hidden; + background: linear-gradient(90deg, #000080, #0000aa, #000080); + padding: 8px 0; + border-top: 3px ridge #c0c0c0; + border-bottom: 3px ridge #c0c0c0; +} + +.marquee { + display: flex; + width: max-content; + + span { + padding: 0 50px; + white-space: nowrap; + font-family: 'Comic Neue', 'Comic Sans MS', cursive; + font-size: 1.2rem; + font-weight: bold; + color: #ffff00; + text-shadow: 2px 2px #ff0000; + } + + &.left { + animation: scrollLeft var(--duration) linear infinite; + } + + &.right { + animation: scrollRight var(--duration) linear infinite; + } + + &.slow { + --duration: 30s; + } + + &.normal { + --duration: 15s; + } + + &.fast { + --duration: 8s; + } +} + +@keyframes scrollLeft { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } +} + +@keyframes scrollRight { + 0% { + transform: translateX(-50%); + } + 100% { + transform: translateX(0); + } +} diff --git a/packages/geocities-app/components/atoms/MarqueeText/MarqueeText.tsx b/packages/geocities-app/components/atoms/MarqueeText/MarqueeText.tsx new file mode 100644 index 000000000..fd42815e0 --- /dev/null +++ b/packages/geocities-app/components/atoms/MarqueeText/MarqueeText.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import styles from './MarqueeText.module.scss'; + +interface MarqueeTextProps { + children: React.ReactNode; + direction?: 'left' | 'right'; + speed?: 'slow' | 'normal' | 'fast'; + className?: string; +} + +export const MarqueeText: React.FC = ({ + children, + direction = 'left', + speed = 'normal', + className = '' +}) => { + return ( +
+
+ {children} + {children} +
+
+ ); +}; diff --git a/packages/geocities-app/components/atoms/MarqueeText/index.ts b/packages/geocities-app/components/atoms/MarqueeText/index.ts new file mode 100644 index 000000000..1dc47a3c7 --- /dev/null +++ b/packages/geocities-app/components/atoms/MarqueeText/index.ts @@ -0,0 +1 @@ +export { MarqueeText } from './MarqueeText'; diff --git a/packages/geocities-app/components/atoms/RainbowText/RainbowText.module.scss b/packages/geocities-app/components/atoms/RainbowText/RainbowText.module.scss new file mode 100644 index 000000000..7dab1aae5 --- /dev/null +++ b/packages/geocities-app/components/atoms/RainbowText/RainbowText.module.scss @@ -0,0 +1,27 @@ +.rainbowAnimated { + background: linear-gradient( + 90deg, + #ff0000, + #ff7f00, + #ffff00, + #00ff00, + #0000ff, + #4b0082, + #9400d3, + #ff0000 + ); + background-size: 200% auto; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: rainbow 3s linear infinite; +} + +@keyframes rainbow { + 0% { + background-position: 0% center; + } + 100% { + background-position: 200% center; + } +} diff --git a/packages/geocities-app/components/atoms/RainbowText/RainbowText.tsx b/packages/geocities-app/components/atoms/RainbowText/RainbowText.tsx new file mode 100644 index 000000000..2a7ff9e59 --- /dev/null +++ b/packages/geocities-app/components/atoms/RainbowText/RainbowText.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import styles from './RainbowText.module.scss'; + +interface RainbowTextProps { + children: string; + animated?: boolean; + className?: string; +} + +export const RainbowText: React.FC = ({ + children, + animated = true, + className = '' +}) => { + if (animated) { + return ( + + {children} + + ); + } + + // Static rainbow - each letter gets a color + const colors = ['#ff0000', '#ff7f00', '#ffff00', '#00ff00', '#0000ff', '#4b0082', '#9400d3']; + + return ( + + {children.split('').map((char, i) => ( + + {char} + + ))} + + ); +}; diff --git a/packages/geocities-app/components/atoms/RainbowText/index.ts b/packages/geocities-app/components/atoms/RainbowText/index.ts new file mode 100644 index 000000000..28e6199be --- /dev/null +++ b/packages/geocities-app/components/atoms/RainbowText/index.ts @@ -0,0 +1 @@ +export { RainbowText } from './RainbowText'; diff --git a/packages/geocities-app/components/atoms/RetroButton/RetroButton.module.scss b/packages/geocities-app/components/atoms/RetroButton/RetroButton.module.scss new file mode 100644 index 000000000..7017614b6 --- /dev/null +++ b/packages/geocities-app/components/atoms/RetroButton/RetroButton.module.scss @@ -0,0 +1,67 @@ +.retroButton { + font-family: 'VT323', 'MS Sans Serif', monospace; + cursor: pointer; + background: #c0c0c0; + border: none; + padding: 4px 16px; + position: relative; + + // Classic Windows 95/98 3D border effect + box-shadow: + inset -1px -1px #0a0a0a, + inset 1px 1px #ffffff, + inset -2px -2px #808080, + inset 2px 2px #dfdfdf; + + &:active:not(:disabled) { + box-shadow: + inset 1px 1px #0a0a0a, + inset -1px -1px #ffffff, + inset 2px 2px #808080, + inset -2px -2px #dfdfdf; + padding: 5px 15px 3px 17px; + } + + &:focus { + outline: 1px dotted #000; + outline-offset: -4px; + } + + &:disabled { + color: #808080; + cursor: not-allowed; + } + + // Sizes + &.small { + font-size: 14px; + padding: 2px 8px; + } + + &.medium { + font-size: 18px; + padding: 4px 16px; + } + + &.large { + font-size: 24px; + padding: 8px 32px; + } + + // Variants + &.primary { + background: linear-gradient(180deg, #c0c0c0 0%, #a0a0a0 100%); + color: #000; + } + + &.secondary { + background: linear-gradient(180deg, #d4d0c8 0%, #b4b0a8 100%); + color: #000080; + } + + &.danger { + background: linear-gradient(180deg, #ff6b6b 0%, #cc0000 100%); + color: #fff; + text-shadow: 1px 1px 0 #800000; + } +} diff --git a/packages/geocities-app/components/atoms/RetroButton/RetroButton.tsx b/packages/geocities-app/components/atoms/RetroButton/RetroButton.tsx new file mode 100644 index 000000000..af55715a2 --- /dev/null +++ b/packages/geocities-app/components/atoms/RetroButton/RetroButton.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import styles from './RetroButton.module.scss'; + +interface RetroButtonProps { + children: React.ReactNode; + onClick?: () => void; + variant?: 'primary' | 'secondary' | 'danger'; + size?: 'small' | 'medium' | 'large'; + disabled?: boolean; + type?: 'button' | 'submit' | 'reset'; + className?: string; +} + +export const RetroButton: React.FC = ({ + children, + onClick, + variant = 'primary', + size = 'medium', + disabled = false, + type = 'button', + className = '' +}) => { + return ( + + ); +}; diff --git a/packages/geocities-app/components/atoms/RetroButton/index.ts b/packages/geocities-app/components/atoms/RetroButton/index.ts new file mode 100644 index 000000000..1c324ff50 --- /dev/null +++ b/packages/geocities-app/components/atoms/RetroButton/index.ts @@ -0,0 +1 @@ +export { RetroButton } from './RetroButton'; diff --git a/packages/geocities-app/components/atoms/RetroGif/RetroGif.module.scss b/packages/geocities-app/components/atoms/RetroGif/RetroGif.module.scss new file mode 100644 index 000000000..eb5a95579 --- /dev/null +++ b/packages/geocities-app/components/atoms/RetroGif/RetroGif.module.scss @@ -0,0 +1,19 @@ +.retroGif { + image-rendering: pixelated; + vertical-align: middle; + + &.small { + height: 20px; + width: auto; + } + + &.medium { + height: 40px; + width: auto; + } + + &.large { + height: 80px; + width: auto; + } +} diff --git a/packages/geocities-app/components/atoms/RetroGif/RetroGif.tsx b/packages/geocities-app/components/atoms/RetroGif/RetroGif.tsx new file mode 100644 index 000000000..b0eb3fdf6 --- /dev/null +++ b/packages/geocities-app/components/atoms/RetroGif/RetroGif.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import styles from './RetroGif.module.scss'; + +// Using archive.org hosted classic 90s gifs +const GIF_SOURCES = { + underConstruction: 'https://web.archive.org/web/20091027010629im_/http://geocities.com/js_source/uc.gif', + underConstruction2: 'https://web.archive.org/web/20091027010629im_/http://geocities.com/SiliconValley/Park/6543/underconstruction.gif', + email: 'https://web.archive.org/web/20091027065743im_/http://www.geocities.com/SunsetStrip/Venue/8232/emailme2.gif', + fire: 'https://web.archive.org/web/20091027003401im_/http://geocities.com/js_source/fire.gif', + new: 'https://web.archive.org/web/20091027010844im_/http://geocities.com/ResearchTriangle/Forum/1363/new.gif', + counter: 'https://web.archive.org/web/20091027012438im_/http://www.geocities.com/Heartland/Plains/7646/counter.gif', + welcome: 'https://web.archive.org/web/20091027003221im_/http://geocities.com/SiliconValley/Way/4302/welcome5.gif', + dividerRainbow: 'https://web.archive.org/web/20091027010629im_/http://geocities.com/EnchantedForest/Glade/1274/rainbow.gif', + guestbook: 'https://web.archive.org/web/20091027010348im_/http://geocities.com/EnchantedForest/Dell/4250/guestbook.gif', + dancing: 'https://web.archive.org/web/20091027004205im_/http://geocities.com/SiliconValley/Hills/5765/babydan.gif', + skull: 'https://web.archive.org/web/20091027002812im_/http://geocities.com/Area51/Zone/7492/skullani.gif', + spinning3d: 'https://web.archive.org/web/20091027012156im_/http://geocities.com/ResearchTriangle/6640/at3.gif', + coolSite: 'https://web.archive.org/web/20091027003506im_/http://geocities.com/SunsetStrip/Palladium/9782/cool.gif', + netscape: 'https://web.archive.org/web/20091027001652im_/http://www.geocities.com/SiliconValley/Platform/4483/netscape.gif', + ielogo: 'https://web.archive.org/web/20091027011037im_/http://geocities.com/SiliconValley/Pines/6818/msielogo.gif', + hotSite: 'https://web.archive.org/web/20091026235944im_/http://geocities.com/Heartland/Hills/9498/hot.gif', +} as const; + +// Fallback inline SVGs for when archive.org is slow/unavailable +const FALLBACK_SVGS: Record = { + underConstruction: `data:image/svg+xml,${encodeURIComponent(` + + + 🚧 UNDER + CONSTRUCTION + + `)}`, + fire: `data:image/svg+xml,${encodeURIComponent(` + + 🔥 + + `)}`, + email: `data:image/svg+xml,${encodeURIComponent(` + + 📧 EMAIL + + `)}`, + default: `data:image/svg+xml,${encodeURIComponent(` + + + ? + + `)}`, +}; + +type GifType = keyof typeof GIF_SOURCES; + +interface RetroGifProps { + type: GifType; + alt?: string; + className?: string; + size?: 'small' | 'medium' | 'large'; +} + +export const RetroGif: React.FC = ({ + type, + alt, + className = '', + size = 'medium' +}) => { + const src = GIF_SOURCES[type]; + const fallback = FALLBACK_SVGS[type] || FALLBACK_SVGS.default; + const altText = alt || type.replace(/([A-Z])/g, ' $1').trim(); + + return ( + {altText} { + (e.target as HTMLImageElement).src = fallback; + }} + loading="lazy" + /> + ); +}; + +export { GIF_SOURCES }; +export type { GifType }; diff --git a/packages/geocities-app/components/atoms/RetroGif/index.ts b/packages/geocities-app/components/atoms/RetroGif/index.ts new file mode 100644 index 000000000..a061a8840 --- /dev/null +++ b/packages/geocities-app/components/atoms/RetroGif/index.ts @@ -0,0 +1,2 @@ +export { RetroGif, GIF_SOURCES } from './RetroGif'; +export type { GifType } from './RetroGif'; diff --git a/packages/geocities-app/components/atoms/RetroInput/RetroInput.module.scss b/packages/geocities-app/components/atoms/RetroInput/RetroInput.module.scss new file mode 100644 index 000000000..eb6a3e9e9 --- /dev/null +++ b/packages/geocities-app/components/atoms/RetroInput/RetroInput.module.scss @@ -0,0 +1,24 @@ +.retroInput { + font-family: 'VT323', 'Courier New', monospace; + font-size: 16px; + padding: 4px 8px; + background: #fff; + border: none; + + // Inset 3D border effect (opposite of button) + box-shadow: + inset 1px 1px #0a0a0a, + inset -1px -1px #ffffff, + inset 2px 2px #808080, + inset -2px -2px #dfdfdf; + + &:focus { + outline: none; + background: #ffffcc; + } + + &::placeholder { + color: #808080; + font-style: italic; + } +} diff --git a/packages/geocities-app/components/atoms/RetroInput/RetroInput.tsx b/packages/geocities-app/components/atoms/RetroInput/RetroInput.tsx new file mode 100644 index 000000000..e5353ebfb --- /dev/null +++ b/packages/geocities-app/components/atoms/RetroInput/RetroInput.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import styles from './RetroInput.module.scss'; + +interface RetroInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: 'text' | 'email' | 'password'; + name?: string; + required?: boolean; + className?: string; +} + +export const RetroInput: React.FC = ({ + value, + onChange, + placeholder, + type = 'text', + name, + required = false, + className = '' +}) => { + return ( + onChange(e.target.value)} + placeholder={placeholder} + required={required} + className={`${styles.retroInput} ${className}`} + /> + ); +}; diff --git a/packages/geocities-app/components/atoms/RetroInput/index.ts b/packages/geocities-app/components/atoms/RetroInput/index.ts new file mode 100644 index 000000000..630dc8412 --- /dev/null +++ b/packages/geocities-app/components/atoms/RetroInput/index.ts @@ -0,0 +1 @@ +export { RetroInput } from './RetroInput'; diff --git a/packages/geocities-app/components/atoms/RetroTextarea/RetroTextarea.module.scss b/packages/geocities-app/components/atoms/RetroTextarea/RetroTextarea.module.scss new file mode 100644 index 000000000..1b219e9df --- /dev/null +++ b/packages/geocities-app/components/atoms/RetroTextarea/RetroTextarea.module.scss @@ -0,0 +1,25 @@ +.retroTextarea { + font-family: 'VT323', 'Courier New', monospace; + font-size: 16px; + padding: 8px; + background: #fff; + border: none; + resize: vertical; + width: 100%; + + box-shadow: + inset 1px 1px #0a0a0a, + inset -1px -1px #ffffff, + inset 2px 2px #808080, + inset -2px -2px #dfdfdf; + + &:focus { + outline: none; + background: #ffffcc; + } + + &::placeholder { + color: #808080; + font-style: italic; + } +} diff --git a/packages/geocities-app/components/atoms/RetroTextarea/RetroTextarea.tsx b/packages/geocities-app/components/atoms/RetroTextarea/RetroTextarea.tsx new file mode 100644 index 000000000..72d76a45d --- /dev/null +++ b/packages/geocities-app/components/atoms/RetroTextarea/RetroTextarea.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import styles from './RetroTextarea.module.scss'; + +interface RetroTextareaProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + name?: string; + required?: boolean; + rows?: number; + className?: string; +} + +export const RetroTextarea: React.FC = ({ + value, + onChange, + placeholder, + name, + required = false, + rows = 4, + className = '' +}) => { + return ( +