set up the site

This commit is contained in:
Nicholai 2025-11-02 01:08:12 -06:00
parent a1c0a4aed9
commit ebf9b0ea64
32 changed files with 12360 additions and 283 deletions

95
CLAUDE.md Normal file
View File

@ -0,0 +1,95 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a Next.js 15 website for a Minecraft server (BiohazardVFX) deployed on Cloudflare Workers via OpenNext. The site displays real-time server status, player statistics, and server information by integrating with the Plan server analytics API.
## Development Commands
```bash
# Development server with Turbopack
npm run dev
# Build for production
npm run build
# Start production server locally
npm start
# Lint code
npm run lint
# Deploy to Cloudflare
npm run deploy
# Preview deployment locally
npm run preview
# Generate Cloudflare types
npm run cf-typegen
```
## Architecture
### Deployment Stack
- **Framework**: Next.js 15 (App Router)
- **Runtime**: Cloudflare Workers (Edge runtime)
- **Adapter**: `@opennextjs/cloudflare` - converts Next.js to Cloudflare Workers format
- **Domain**: Routes configured for `minecraft.biohazardvfx.com`
### Key Integration Points
**Plan Analytics API**
- Base URL: `https://stats.biohazardvfx.com`
- Server name: `BiohazardVFX`
- The `/api/server-status` route (src/app/api/server-status/route.ts:24) fetches from `/v1/serverOverview` endpoint
- Data refreshes every 30 seconds (both API cache and client polling)
- Returns: online players, uptime, total players, unique players (7d), average TPS
**Edge Runtime**
- All API routes use `export const runtime = 'edge'` (src/app/api/server-status/route.ts:3)
- Static assets served from `.open-next/assets` directory
- Worker entry point: `.open-next/worker.js`
### Component Architecture
**Client Components** (use 'use client' directive)
- `ServerStatus` (src/components/server-status.tsx) - Polls `/api/server-status` every 30s
- `PlayerActivityChart` (src/components/player-activity-chart.tsx) - Uses Recharts for visualization (currently mock data)
- `Hero` - Main landing section
**UI System**
- Uses shadcn/ui components (New York style)
- Path aliases configured: `@/*``./src/*`
- Components in `src/components/ui/` (Button, Card, Chart, FloatingPanel)
- Tailwind CSS v4 with CSS variables for theming
- Geist font family (Sans + Mono) loaded via `next/font/google`
**Page Structure**
- `src/app/page.tsx` - Main landing page with background image overlay
- `src/app/layout.tsx` - Root layout with fonts and Sonner toast notifications
- Background image: `/backgroundimg.avif` with gradient overlays
### Data Flow
1. Client component mounts → `useEffect` triggers initial fetch
2. Fetch → `/api/server-status` (Edge runtime)
3. API route → Plan Analytics `/v1/serverOverview`
4. Response cached for 30s via Next.js `revalidate`
5. Client polls every 30s via `setInterval`
### Important Files
- `wrangler.jsonc` - Cloudflare Workers configuration
- `open-next.config.ts` - OpenNext adapter settings (R2 cache disabled)
- `components.json` - shadcn/ui configuration
- `.dev.vars` - Environment variables for local development
## Notes
- TypeScript strict mode enabled
- Player list is currently unavailable via Plan's JSON API (only websockets/HTML parsing)
- PlayerActivityChart uses mock data - needs integration with Plan's graph API endpoints
- The site uses Cloudflare's `nodejs_compat` flag for Node.js APIs in Workers

116
SEO_STRATEGY.md Normal file
View File

@ -0,0 +1,116 @@
# SEO Strategy for BiohazardVFX Minecraft Server
## Overview
This document outlines the SEO strategy implemented for the BiohazardVFX Minecraft server website to improve search visibility and attract more players.
## On-Page SEO Elements Implemented
### 1. Title Tags
- **Primary Title**: "BiohazardVFX Minecraft Server | Join Our SMP Community"
- **Template**: "%s | BiohazardVFX Minecraft Server" for other pages
- **Rationale**: Includes primary keywords (Minecraft, Server, SMP) and unique value proposition
### 2. Meta Descriptions
- **Description**: "Join BiohazardVFX, a 1.21.x Survival Minecraft server for VFX artists and creatives. Experience vanilla-first gameplay with community and whitelist access."
- **Rationale**: Concise, compelling, includes keywords and unique selling proposition
### 3. Keywords
- Primary: Minecraft, SMP, Survival, VFX, Creative
- Secondary: Server, Community, Whitelist, Discord, 1.21.x, BiohazardVFX
- **Implementation**: Added to metadata.keywords in layout.tsx
### 4. Open Graph Tags
- **og:title**: "BiohazardVFX Minecraft Server | Join Our SMP Community"
- **og:description**: Same as meta description
- **og:image**: "/backgroundimg.avif" (1200x630px recommended size)
- **og:type**: "website"
- **og:url**: "https://minecraft.biohazardvfx.com"
### 5. Twitter Card Tags
- **twitter:card**: "summary_large_image"
- **twitter:title**: Same as OG title
- **twitter:description**: Same as meta description
- **twitter:image**: "/backgroundimg.avif"
## Technical SEO Elements Implemented
### 1. Structured Data (Schema.org)
- **Type**: VideoGameServer
- **Properties included**:
- Name: "BiohazardVFX Minecraft Server"
- URL: "https://minecraft.biohazardvfx.com"
- Description: Detailed server description
- Game: Minecraft with version 1.21.x
- ServerStatus: Online/Offline (dynamic)
- PlayersOnline: Real-time player count (dynamic)
- MaximumAttendeeCapacity: Max player count
- AreaServed: Worldwide
- Provider: BiohazardVFX organization
- Application: Minecraft deep link for easy joining
### 2. Canonical URLs
- **Implementation**: Set to "/" for homepage
- **Rationale**: Prevents duplicate content issues
### 3. Robots.txt
- **Location**: /public/robots.txt
- **Content**: Allows all crawlers, includes sitemap reference
### 4. Sitemap.xml
- **Location**: /public/sitemap.xml
- **Content**: Includes homepage with priority 1.0 and daily change frequency
## Content SEO Strategy
### 1. Primary Keywords Targeting
- **Primary**: "Minecraft Server", "SMP", "Survival Minecraft"
- **Long-tail**: "VFX Minecraft server", "Creative Minecraft community", "Whitelist Minecraft server"
### 2. Content Structure
The website content focuses on:
- Clear value proposition (VFX artist focused community)
- Server features (1.21.x, Survival, Hard + Vanilla Tweaks)
- Real-time server status
- Easy joining process (one-click copy server IP)
## Recommendations for Continued SEO Success
### 1. Content Updates
- Regular blog posts about server updates, events, and community highlights
- Player showcase content
- Server build spotlights
### 2. Link Building
- Reach out to Minecraft server listing sites
- Engage with VFX and creative communities
- Cross-promote on social media platforms
### 3. Local SEO
- Though the server is global, consider location-based keywords for US region
- Consider "US-based Minecraft server" positioning
### 4. Performance Optimization
- Page loading speed is essential for SEO
- Ensure images are optimized
- Monitor Core Web Vitals
### 5. Social Proof
- Highlight active player count in marketing
- Showcase community creations
- Gather testimonials from community members
## Next Steps
1. Monitor search console for indexing and performance
2. Track organic traffic growth
3. Consider adding more pages if the site expands (rules page, FAQ, etc.)
4. Implement rich snippets for server status if possible
5. Regularly update structured data with real-time server information
## Additional SEO Tips for Minecraft Server Websites
1. **Server IP Consistency**: Ensure server IP is consistent across all mentions
2. **Game Version Updates**: Keep version information current in metadata
3. **Community Content**: Encourage user-generated content (screenshots, builds)
4. **Seasonal Content**: Special events or challenges can generate content
5. **Accessibility**: Ensure the website works for all users, including screen readers

22
components.json Normal file
View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

662
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,9 +13,19 @@
},
"dependencies": {
"@opennextjs/cloudflare": "^1.3.0",
"@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
"lucide-react": "^0.552.0",
"minecraft-server-util": "^5.4.4",
"motion": "^12.23.24",
"next": "15.4.6",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@ -26,6 +36,7 @@
"eslint": "^9",
"eslint-config-next": "15.4.6",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"wrangler": "^4.45.3"
}

8599
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/2023-11-23_21.00.17.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

BIN
public/2025-07-13_12.59.13.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

BIN
public/2025-07-13_13.01.28.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 MiB

BIN
public/backgroundimg.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

BIN
public/backgroundimg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 MiB

8
public/robots.txt Normal file
View File

@ -0,0 +1,8 @@
# www.robotstxt.org/
# Allow all crawlers
User-agent: *
Allow: /
# Sitemap
Sitemap: https://minecraft.biohazardvfx.com/sitemap.xml

9
public/sitemap.xml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://minecraft.biohazardvfx.com/</loc>
<lastmod>2025-11-02</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
</urlset>

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -0,0 +1,82 @@
import { NextResponse } from 'next/server';
const PLAN_BASE_URL = 'https://stats.biohazardvfx.com';
const SERVER_NAME = 'BiohazardVFX';
interface PlanServerOverview {
numbers?: {
online_players?: number;
current_uptime?: number;
total_players?: number;
sessions?: number;
regular_players?: number;
};
last_7_days?: {
unique_players?: number;
unique_players_day?: number;
average_tps?: string;
};
[key: string]: unknown;
}
export async function GET() {
try {
// Fetch server overview data (includes uptime)
const overviewResponse = await fetch(
`${PLAN_BASE_URL}/v1/serverOverview?server=${SERVER_NAME}`,
{
next: { revalidate: 30 }, // Cache for 30 seconds
headers: {
'Accept': 'application/json',
}
}
);
if (!overviewResponse.ok) {
throw new Error(`Plan API returned ${overviewResponse.status}`);
}
const overviewData: PlanServerOverview = await overviewResponse.json();
// Extract uptime (Plan provides it in milliseconds in numbers.current_uptime)
const uptimeMs = overviewData.numbers?.current_uptime || 0;
const uptimeSeconds = Math.floor(uptimeMs / 1000);
// Calculate days, hours, minutes
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
return NextResponse.json({
online: true,
players: {
online: overviewData.numbers?.online_players || 0,
// Note: Plan doesn't provide a real-time list of online player names via JSON API
// Only available through websockets or by parsing the HTML
playerList: [],
},
uptime: {
days,
hours,
minutes,
formatted: days > 0 ? `${days}d ${hours}h ${minutes}m` : `${hours}h ${minutes}m`,
},
stats: {
totalPlayers: overviewData.numbers?.total_players || 0,
uniquePlayers7d: overviewData.last_7_days?.unique_players || 0,
averageTps: overviewData.last_7_days?.average_tps || 'N/A',
}
});
} catch (error) {
console.error('Error fetching Plan server status:', error);
return NextResponse.json(
{
error: 'Failed to fetch server status',
online: false,
players: { online: 0, playerList: [] },
uptime: null
},
{ status: 500 }
);
}
}

View File

@ -1,26 +1,190 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: Oxanium, sans-serif;
--font-mono: Fira Code, monospace;
--font-serif: Merriweather, serif;
--radius: 0.3rem;
--tracking-normal: 0em;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--radius: 0.3rem;
--background: oklch(0.9885 0.0057 84.5659);
--foreground: oklch(0.3660 0.0251 49.6085);
--card: oklch(0.9686 0.0091 78.2818);
--card-foreground: oklch(0.3660 0.0251 49.6085);
--popover: oklch(0.9686 0.0091 78.2818);
--popover-foreground: oklch(0.3660 0.0251 49.6085);
--primary: oklch(0.5553 0.1455 48.9975);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.8276 0.0752 74.4400);
--secondary-foreground: oklch(0.4444 0.0096 73.6390);
--muted: oklch(0.9363 0.0218 83.2637);
--muted-foreground: oklch(0.5534 0.0116 58.0708);
--accent: oklch(0.9000 0.0500 74.9889);
--accent-foreground: oklch(0.4444 0.0096 73.6390);
--destructive: oklch(0.4437 0.1613 26.8994);
--border: oklch(0.8866 0.0404 89.6994);
--input: oklch(0.8866 0.0404 89.6994);
--ring: oklch(0.5553 0.1455 48.9975);
--chart-1: oklch(0.5553 0.1455 48.9975);
--chart-2: oklch(0.5534 0.0116 58.0708);
--chart-3: oklch(0.5538 0.1207 66.4416);
--chart-4: oklch(0.5534 0.0116 58.0708);
--chart-5: oklch(0.6806 0.1423 75.8340);
--sidebar: oklch(0.9363 0.0218 83.2637);
--sidebar-foreground: oklch(0.3660 0.0251 49.6085);
--sidebar-primary: oklch(0.5553 0.1455 48.9975);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.5538 0.1207 66.4416);
--sidebar-accent-foreground: oklch(1.0000 0 0);
--sidebar-border: oklch(0.8866 0.0404 89.6994);
--sidebar-ring: oklch(0.5553 0.1455 48.9975);
--destructive-foreground: oklch(1.0000 0 0);
--font-sans: Oxanium, sans-serif;
--font-serif: Merriweather, serif;
--font-mono: Fira Code, monospace;
--shadow-color: hsl(28 18% 25%);
--shadow-opacity: 0.18;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 2px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 2px 3px 0px hsl(28 18% 25% / 0.09);
--shadow-xs: 0px 2px 3px 0px hsl(28 18% 25% / 0.09);
--shadow-sm: 0px 2px 3px 0px hsl(28 18% 25% / 0.18), 0px 1px 2px -1px hsl(28 18% 25% / 0.18);
--shadow: 0px 2px 3px 0px hsl(28 18% 25% / 0.18), 0px 1px 2px -1px hsl(28 18% 25% / 0.18);
--shadow-md: 0px 2px 3px 0px hsl(28 18% 25% / 0.18), 0px 2px 4px -1px hsl(28 18% 25% / 0.18);
--shadow-lg: 0px 2px 3px 0px hsl(28 18% 25% / 0.18), 0px 4px 6px -1px hsl(28 18% 25% / 0.18);
--shadow-xl: 0px 2px 3px 0px hsl(28 18% 25% / 0.18), 0px 8px 10px -1px hsl(28 18% 25% / 0.18);
--shadow-2xl: 0px 2px 3px 0px hsl(28 18% 25% / 0.45);
--tracking-normal: 0em;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
.dark {
--background: oklch(0.2161 0.0061 56.0434);
--foreground: oklch(0.9699 0.0013 106.4238);
--card: oklch(0.2685 0.0063 34.2976);
--card-foreground: oklch(0.9699 0.0013 106.4238);
--popover: oklch(0.2685 0.0063 34.2976);
--popover-foreground: oklch(0.9699 0.0013 106.4238);
--primary: oklch(0.7049 0.1867 47.6044);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.4444 0.0096 73.6390);
--secondary-foreground: oklch(0.9232 0.0026 48.7171);
--muted: oklch(0.2330 0.0073 67.4563);
--muted-foreground: oklch(0.7161 0.0091 56.2590);
--accent: oklch(0.3598 0.0497 229.3202);
--accent-foreground: oklch(0.9232 0.0026 48.7171);
--destructive: oklch(0.5771 0.2152 27.3250);
--border: oklch(0.3741 0.0087 67.5582);
--input: oklch(0.3741 0.0087 67.5582);
--ring: oklch(0.7049 0.1867 47.6044);
--chart-1: oklch(0.7049 0.1867 47.6044);
--chart-2: oklch(0.6847 0.1479 237.3225);
--chart-3: oklch(0.7952 0.1617 86.0468);
--chart-4: oklch(0.7161 0.0091 56.2590);
--chart-5: oklch(0.5534 0.0116 58.0708);
--sidebar: oklch(0.2685 0.0063 34.2976);
--sidebar-foreground: oklch(0.9699 0.0013 106.4238);
--sidebar-primary: oklch(0.7049 0.1867 47.6044);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.6847 0.1479 237.3225);
--sidebar-accent-foreground: oklch(0.2839 0.0734 254.5378);
--sidebar-border: oklch(0.3741 0.0087 67.5582);
--sidebar-ring: oklch(0.7049 0.1867 47.6044);
--destructive-foreground: oklch(1.0000 0 0);
--radius: 0.3rem;
--font-sans: Oxanium, sans-serif;
--font-serif: Merriweather, serif;
--font-mono: Fira Code, monospace;
--shadow-color: hsl(0 0% 5%);
--shadow-opacity: 0.18;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 2px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 2px 3px 0px hsl(0 0% 5% / 0.09);
--shadow-xs: 0px 2px 3px 0px hsl(0 0% 5% / 0.09);
--shadow-sm: 0px 2px 3px 0px hsl(0 0% 5% / 0.18), 0px 1px 2px -1px hsl(0 0% 5% / 0.18);
--shadow: 0px 2px 3px 0px hsl(0 0% 5% / 0.18), 0px 1px 2px -1px hsl(0 0% 5% / 0.18);
--shadow-md: 0px 2px 3px 0px hsl(0 0% 5% / 0.18), 0px 2px 4px -1px hsl(0 0% 5% / 0.18);
--shadow-lg: 0px 2px 3px 0px hsl(0 0% 5% / 0.18), 0px 4px 6px -1px hsl(0 0% 5% / 0.18);
--shadow-xl: 0px 2px 3px 0px hsl(0 0% 5% / 0.18), 0px 8px 10px -1px hsl(0 0% 5% / 0.18);
--shadow-2xl: 0px 2px 3px 0px hsl(0 0% 5% / 0.45);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}

View File

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from 'sonner';
import "./globals.css";
const geistSans = Geist({
@ -13,8 +14,55 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: {
default: "BiohazardVFX Minecraft Server | Join Our SMP Community",
template: "%s | BiohazardVFX Minecraft Server"
},
description: "Join BiohazardVFX, a 1.21.x Survival Minecraft server for VFX artists and creatives. Experience vanilla-first gameplay with community and whitelist access.",
keywords: ["Minecraft", "SMP", "Survival", "VFX", "Creative", "Community", "Server", "Minecraft Server", "Whitelist", "Discord", "1.21.x", "BiohazardVFX"],
authors: [{ name: "BiohazardVFX Team" }],
creator: "BiohazardVFX Team",
publisher: "BiohazardVFX Team",
metadataBase: new URL('https://minecraft.biohazardvfx.com'),
alternates: {
canonical: '/',
},
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://minecraft.biohazardvfx.com',
title: 'BiohazardVFX Minecraft Server | Join Our SMP Community',
description: 'Join BiohazardVFX, a 1.21.x Survival Minecraft server for VFX artists and creatives. Experience vanilla-first gameplay with community and whitelist access.',
siteName: 'BiohazardVFX Minecraft Server',
images: [
{
url: '/backgroundimg.avif',
width: 1200,
height: 630,
alt: 'BiohazardVFX Minecraft Server',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'BiohazardVFX Minecraft Server | Join Our SMP Community',
description: 'Join BiohazardVFX, a 1.21.x Survival Minecraft server for VFX artists and creatives. Experience vanilla-first gameplay with community and whitelist access.',
images: ['/backgroundimg.avif'],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
}
},
verification: {
// Add any verification meta tags here if needed
}
};
export default function RootLayout({
@ -28,6 +76,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<Toaster position="bottom-right" />
</body>
</html>
);

View File

@ -1,103 +1,176 @@
'use client';
import { useEffect, useState } from 'react';
import Image from "next/image";
import { motion } from "motion/react";
import { Button } from "@/components/ui/button";
import { Hero } from "@/components/hero";
import { ServerStatus } from "@/components/server-status";
import { MinecraftServerStructuredData } from "@/components/structured-data";
interface ServerData {
online: boolean;
players: {
online: number;
playerList?: string[];
};
uptime?: {
days: number;
hours: number;
minutes: number;
formatted: string;
} | null;
stats?: {
totalPlayers: number;
uniquePlayers7d: number;
averageTps: string;
};
error?: string;
}
export default function Home() {
const [serverData, setServerData] = useState<ServerData | null>(null);
useEffect(() => {
const fetchServerData = async () => {
try {
const response = await fetch('/api/server-status');
if (!response.ok) {
throw new Error('Failed to fetch server status');
}
const data = await response.json() as ServerData;
setServerData(data);
} catch (err) {
console.error('Error fetching server data for structured data:', err);
}
};
fetchServerData();
// Refresh every 30 seconds to keep structured data updated
const interval = setInterval(fetchServerData, 30000);
return () => clearInterval(interval);
}, []);
const structuredDataProps = serverData ? {
playersOnline: serverData.players.online,
maxPlayers: 20, // Assuming max players is 20, you can adjust this
version: "1.21.x",
status: serverData.online ? "Online" : "Offline"
} : undefined;
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<>
<MinecraftServerStructuredData serverData={structuredDataProps} />
<div className="relative min-h-svh overflow-hidden">
<div className="absolute inset-0">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
src="/backgroundimg.avif"
alt="Minecraft background"
fill
className="object-cover"
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="absolute inset-0 bg-gradient-to-br from-black/52 via-black/28 to-black/10 backdrop-blur-[1.5px]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.32),_transparent_60%)]" />
</div>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<div className="relative z-10 flex min-h-svh flex-col font-sans">
<main className="flex flex-1 items-center justify-center px-6 py-16 sm:px-10 lg:px-14">
<div className="w-full max-w-5xl space-y-10">
<section className="rounded-3xl border border-white/10 bg-background/96 p-8 shadow-2xl backdrop-blur-xl sm:p-10 lg:p-12">
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<Hero />
<div className="flex flex-col gap-6">
<div className="rounded-3xl border border-border/40 bg-background p-6 shadow-lg">
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between text-foreground">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-muted-foreground">
Live snapshot
</p>
<h2 className="text-2xl font-semibold tracking-tight">
Server Pulse
</h2>
</div>
<span className="text-xs uppercase tracking-[0.3em] text-muted-foreground">
Auto refresh 30s
</span>
</div>
<div className="mt-6">
<ServerStatus className="h-full" />
</div>
</div>
<motion.div
className="rounded-3xl border border-border/50 bg-background/50 p-6 shadow-xl backdrop-blur"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 1.2, ease: [0.33, 1, 0.68, 1] }}
style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden", transform: "translateZ(0)" }}
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<motion.div
className="flex items-center justify-between"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 1.3 }}
>
Read our docs
</a>
<p className="text-xs font-bold uppercase tracking-[0.15em] text-muted-foreground">
Quick Facts
</p>
<span className="text-[11px] font-medium text-muted-foreground/70">
Join-ready snapshot
</span>
</motion.div>
<motion.dl
className="mt-6 grid gap-3 text-sm text-muted-foreground sm:grid-cols-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 1.4, ease: [0.33, 1, 0.68, 1] }}
style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden" }}
>
{[
{ label: "Version", value: "1.21.x Survival" },
{ label: "Difficulty", value: "Hard + Vanilla Tweaks" },
{ label: "Region", value: "United States" },
{ label: "Community", value: "Discord & Whitelist" },
].map((item, index) => (
<motion.div
key={item.label}
className="group rounded-xl border border-border/50 bg-background/95 p-4 shadow-md transition-all hover:border-primary/30 hover:shadow-lg"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 1.5 + index * 0.1, ease: [0.33, 1, 0.68, 1] }}
style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden", transform: "translateZ(0)" }}
>
<dt className="text-xs font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{item.label}
</dt>
<dd className="mt-2 text-base font-bold text-foreground">
{item.value}
</dd>
</motion.div>
))}
</motion.dl>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 1.9, ease: "easeOut" }}
style={{ willChange: "opacity, transform" }}
>
<Button size="lg" className="mt-6 w-full font-bold shadow-lg transition-all hover:shadow-xl">
Join the Server
</Button>
</motion.div>
</motion.div>
</div>
</div>
</section>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
</div>
</>
);
}

166
src/components/hero.tsx Normal file
View File

@ -0,0 +1,166 @@
'use client';
import { useState, useEffect } from 'react';
import Image from 'next/image';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { Copy, Check } from 'lucide-react';
import { HoverRollingText } from '@/components/hover-rolling-text';
import { motion } from 'motion/react';
const screenshots = [
'/2023-11-23_21.00.17.png',
'/2025-07-13_12.59.13.png',
'/2025-07-13_13.01.28.png',
'/2025-10-22_17.07.42.png',
];
export function Hero() {
const [copied, setCopied] = useState(false);
const [currentImage, setCurrentImage] = useState(0);
const serverAddress = 'minecraft.biohazardvfx.com';
useEffect(() => {
const interval = setInterval(() => {
setCurrentImage((prev) => (prev + 1) % screenshots.length);
}, 5000); // Change image every 5 seconds
return () => clearInterval(interval);
}, []);
const copyServerAddress = async () => {
try {
await navigator.clipboard.writeText(serverAddress);
setCopied(true);
toast.success('Server address copied to clipboard!');
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error(error);
toast.error('Failed to copy address');
}
};
return (
<div className="w-full max-w-xl space-y-8">
<motion.div
className="flex flex-col gap-6 text-foreground"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<motion.span
className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/30 bg-primary/10 px-5 py-2 text-xs font-bold uppercase tracking-[0.25em] text-primary shadow-sm"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
Biohazard SMP
</motion.span>
<motion.h1
className="text-6xl font-bold leading-[1.1] tracking-tight sm:text-7xl lg:text-8xl"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<span className="block text-primary">
<HoverRollingText
text="Build."
transition={{ duration: 0.6, delay: 0.05 }}
/>
</span>
<span className="block text-secondary">
<HoverRollingText
text="Explore."
transition={{ duration: 0.6, delay: 0.05 }}
/>
</span>
<span className="block text-accent">
<HoverRollingText
text="Survive."
transition={{ duration: 0.6, delay: 0.05 }}
/>
</span>
</motion.h1>
<motion.p
className="max-w-md text-base leading-relaxed text-muted-foreground sm:text-lg"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.5 }}
>
A vanilla-first Minecraft experience for VFX artists and creatives.
</motion.p>
</motion.div>
{/* Server Address */}
<motion.div
className="space-y-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.7 }}
>
<div className="group rounded-2xl border border-border/50 bg-card/50 p-6 shadow-xl backdrop-blur transition-all hover:border-primary/30 hover:shadow-2xl">
<span className="text-xs font-bold uppercase tracking-[0.2em] text-muted-foreground">
Server IP
</span>
<div className="mt-4 flex items-center justify-between gap-4 rounded-xl border border-border/50 bg-background/90 px-5 py-4 shadow-sm">
<code className="text-base font-mono font-bold text-foreground sm:text-lg">
{serverAddress}
</code>
<Button
onClick={copyServerAddress}
size="icon-sm"
variant="outline"
className="shrink-0 border-primary/30 bg-primary/10 hover:bg-primary/20 hover:border-primary/50 transition-all"
aria-label={copied ? 'Server address copied' : 'Copy server address'}
>
{copied ? <Check className="h-4 w-4 text-primary" /> : <Copy className="h-4 w-4 text-primary" />}
</Button>
</div>
</div>
</motion.div>
{/* Screenshot Showcase */}
<motion.div
className="group relative aspect-video overflow-hidden rounded-3xl border border-border/50 bg-card/50 shadow-2xl backdrop-blur transition-all hover:shadow-3xl"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.9, ease: [0.33, 1, 0.68, 1] }}
style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden", transform: "translateZ(0)" }}
>
{screenshots.map((screenshot, index) => (
<div
key={screenshot}
className={`absolute inset-0 transition-opacity duration-1000 ${index === currentImage ? 'opacity-100' : 'opacity-0'
}`}
>
<Image
src={screenshot}
alt={`Minecraft server screenshot ${index + 1}`}
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
priority={index === 0}
/>
</div>
))}
{/* Image overlay gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/10 to-transparent" />
{/* Carousel indicators */}
<div className="absolute bottom-5 left-1/2 -translate-x-1/2 flex gap-2.5 rounded-full border border-white/20 bg-black/40 px-4 py-2.5 shadow-lg backdrop-blur-md">
{screenshots.map((_, index) => (
<button
key={index}
onClick={() => setCurrentImage(index)}
className={`h-2 rounded-full transition-all duration-300 ${index === currentImage
? 'w-8 bg-white shadow-lg shadow-primary/50'
: 'w-2 bg-white/40 hover:bg-white/70 hover:w-3'
}`}
aria-label={`View screenshot ${index + 1}`}
/>
))}
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,40 @@
'use client';
import { useState } from 'react';
import { RollingText } from '@/components/ui/shadcn-io/rolling-text';
interface HoverRollingTextProps {
text: string;
className?: string;
transition?: { duration: number; delay: number };
}
export function HoverRollingText({ text, transition }: HoverRollingTextProps) {
const [isHovered, setIsHovered] = useState(false);
const [key, setKey] = useState(0);
const handleMouseEnter = () => {
setIsHovered(true);
setKey(prev => prev + 1); // Force re-render to restart animation
};
const handleMouseLeave = () => {
setIsHovered(false);
};
return (
<span
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className="cursor-pointer"
>
<RollingText
key={key}
text={text}
inView={isHovered}
inViewOnce={false}
transition={transition}
/>
</span>
);
}

View File

@ -0,0 +1,164 @@
'use client';
import { useEffect, useState } from 'react';
import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import { cn } from '@/lib/utils';
const chartConfig = {
players: {
label: 'Players',
color: 'hsl(var(--chart-1))',
},
} satisfies ChartConfig;
interface ChartData {
date: string;
players: number;
}
interface PlayerActivityChartProps {
className?: string;
}
export function PlayerActivityChart({ className }: PlayerActivityChartProps = {}) {
const [chartData, setChartData] = useState<ChartData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Generate sample data for the last 7 days
// In a real implementation, this would fetch from Plan API
const generateData = () => {
const data: ChartData[] = [];
const now = new Date();
for (let i = 6; i >= 0; i--) {
const date = new Date(now);
date.setDate(date.getDate() - i);
data.push({
date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
players: Math.floor(Math.random() * 5) + 1, // Random for demo
});
}
return data;
};
setChartData(generateData());
setLoading(false);
}, []);
if (loading) {
return (
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
Loading chart...
</div>
);
}
const peakPlayers = chartData.reduce((max, entry) => Math.max(max, entry.players), 0);
const averagePlayers =
chartData.length > 0
? Math.round(
chartData.reduce((sum, entry) => sum + entry.players, 0) / chartData.length
)
: 0;
const latestPlayers =
chartData.length > 0 ? chartData[chartData.length - 1].players : 0;
return (
<Card
className={cn(
"rounded-2xl border border-border bg-card text-card-foreground shadow-lg",
className
)}
>
<CardHeader className="p-0 pb-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<CardTitle className="text-sm font-semibold">Player Activity</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
Last 7 days
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
<span>
Peak<span className="ml-1 font-semibold text-foreground">{peakPlayers}</span>
</span>
<span>
Average
<span className="ml-1 font-semibold text-foreground">
{averagePlayers}
</span>
</span>
<span>
Latest
<span className="ml-1 font-semibold text-foreground">{latestPlayers}</span>
</span>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[220px] w-full rounded-xl border border-border/60 bg-background/95 p-4"
>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 12,
right: 12,
top: 12,
bottom: 12,
}}
>
<defs>
<linearGradient id="players-gradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--chart-1))" stopOpacity={0.6} />
<stop offset="95%" stopColor="hsl(var(--chart-1))" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid
vertical={false}
strokeDasharray="4 4"
stroke="rgba(148, 163, 184, 0.35)"
/>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value}
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12, fontWeight: 500 }}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
/>
<Area
dataKey="players"
type="natural"
fill="url(#players-gradient)"
stroke="var(--color-players)"
strokeWidth={2.5}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,212 @@
'use client';
import { useEffect, useState } from 'react';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
interface ServerStatus {
online: boolean;
players: {
online: number;
playerList?: string[];
};
uptime?: {
days: number;
hours: number;
minutes: number;
formatted: string;
} | null;
stats?: {
totalPlayers: number;
uniquePlayers7d: number;
averageTps: string;
};
error?: string;
}
interface ServerStatusProps {
className?: string;
}
type StatTile = {
label: string;
value: string;
hint?: string;
};
export function ServerStatus({ className }: ServerStatusProps = {}) {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStatus = async () => {
try {
const response = await fetch('/api/server-status');
if (!response.ok) {
throw new Error('Failed to fetch server status');
}
const data = await response.json() as ServerStatus;
setStatus(data);
setError(null);
} catch (err) {
setError('Failed to load server status');
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStatus();
// Refresh every 30 seconds
const interval = setInterval(fetchStatus, 30000);
return () => clearInterval(interval);
}, []);
if (loading) {
return (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="h-2 w-2 rounded-full bg-muted" />
<span>Loading server status...</span>
</div>
);
}
if (error || !status) {
return (
<div className="flex items-center gap-2 text-destructive">
<div className="h-2 w-2 rounded-full bg-destructive" />
<span>Unable to connect to server</span>
</div>
);
}
if (!status.online) {
return (
<div className="flex items-center gap-2 text-destructive">
<div className="h-2 w-2 rounded-full bg-destructive" />
<span>Server offline</span>
</div>
);
}
const statTiles: StatTile[] = [
{
label: 'Players Online',
value: status.players.online.toString(),
hint: 'right now',
},
];
if (status.uptime) {
statTiles.push({
label: 'Uptime',
value: status.uptime.formatted,
hint: 'since last restart',
});
}
if (status.stats) {
statTiles.push(
{
label: 'Total Players',
value: status.stats.totalPlayers.toString(),
hint: 'all-time',
},
{
label: 'Unique (7d)',
value: status.stats.uniquePlayers7d.toString(),
hint: 'last week',
}
);
}
return (
<motion.div
className={cn(
"space-y-6 rounded-2xl border border-border/50 bg-card/50 p-6 text-card-foreground shadow-xl backdrop-blur",
className
)}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.3, ease: [0.33, 1, 0.68, 1] }}
style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden", transform: "translateZ(0)" }}
>
<motion.div
className="flex flex-wrap items-center justify-between gap-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.5 }}
>
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/50" />
<span className="text-sm font-bold text-emerald-600 dark:text-emerald-400">
Server Online
</span>
</div>
<span className="rounded-full border border-border/40 bg-background/70 px-3 py-1 text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground shadow-sm">
Refreshes every 30s
</span>
</motion.div>
<motion.dl
className="grid min-w-0 gap-3 sm:grid-cols-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.6, ease: [0.33, 1, 0.68, 1] }}
style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden" }}
>
{statTiles.map((tile, index) => (
<motion.div
key={tile.label}
className="group min-w-0 overflow-hidden rounded-xl border border-border/50 bg-background/95 p-4 shadow-md transition-all hover:border-primary/30 hover:shadow-lg"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.7 + index * 0.1, ease: [0.33, 1, 0.68, 1] }}
style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden", transform: "translateZ(0)" }}
>
<dt className="truncate text-xs font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{tile.label}
</dt>
<dd className="mt-2 truncate text-2xl font-bold text-foreground">
{tile.value}
</dd>
{tile.hint ? (
<p className="mt-1 truncate text-[11px] font-medium uppercase tracking-[0.05em] text-muted-foreground/70">
{tile.hint}
</p>
) : null}
</motion.div>
))}
</motion.dl>
{status.players.playerList && status.players.playerList.length > 0 && (
<div className="rounded-xl border border-border/50 bg-background/95 p-4 shadow-md">
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.08em] text-muted-foreground">
Currently Online
</p>
<div className="flex flex-wrap gap-2">
{status.players.playerList.map((player) => (
<span
key={player}
className="rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary shadow-sm"
>
{player}
</span>
))}
</div>
</div>
)}
{status.stats?.averageTps ? (
<div className="flex items-center justify-between rounded-xl border border-border/50 bg-background/95 px-4 py-3.5 shadow-md">
<span className="text-xs font-semibold uppercase tracking-[0.08em] text-muted-foreground">Average TPS (7d)</span>
<span className="text-xl font-bold text-foreground">
{status.stats.averageTps}
</span>
</div>
) : null}
</motion.div>
);
}

View File

@ -0,0 +1,74 @@
'use client';
import Script from 'next/script';
interface MinecraftServerStructuredData {
'@context': string;
'@type': 'VideoGameServer';
name: string;
url: string;
description: string;
game: {
'@type': 'VideoGame';
name: string;
softwareVersion: string;
};
serverStatus: string;
playersOnline: number;
maximumAttendeeCapacity: number;
areaServed: string;
provider: {
'@type': 'Organization';
name: string;
};
application: {
'@type': 'EntryPoint';
urlTemplate: string;
name: string;
};
}
interface StructuredDataProps {
serverData?: {
playersOnline: number;
maxPlayers?: number;
version?: string;
status?: string;
};
}
export function MinecraftServerStructuredData({ serverData }: StructuredDataProps = {}) {
const structuredData: MinecraftServerStructuredData = {
'@context': 'https://schema.org',
'@type': 'VideoGameServer',
name: 'BiohazardVFX Minecraft Server',
url: 'https://minecraft.biohazardvfx.com',
description: 'Join BiohazardVFX, a 1.21.x Survival Minecraft server for VFX artists and creatives. Experience vanilla-first gameplay with community and whitelist access.',
game: {
'@type': 'VideoGame',
name: 'Minecraft',
softwareVersion: serverData?.version || '1.21.x',
},
serverStatus: serverData?.status || 'Online',
playersOnline: serverData?.playersOnline || 0,
maximumAttendeeCapacity: serverData?.maxPlayers || 20,
areaServed: 'Worldwide',
provider: {
'@type': 'Organization',
name: 'BiohazardVFX',
},
application: {
'@type': 'EntryPoint',
urlTemplate: 'minecraft://connect?ip=minecraft.biohazardvfx.com',
name: 'Minecraft',
},
};
return (
<Script
id="structured-data"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
);
}

View File

@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

357
src/components/ui/chart.tsx Normal file
View File

@ -0,0 +1,357 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -0,0 +1,950 @@
"use client"
import React, {
createContext,
useContext,
useEffect,
useId,
useRef,
useState,
} from "react"
import { ArrowLeftIcon } from "lucide-react"
import { AnimatePresence, MotionConfig, Variants, motion } from "motion/react"
import { cn } from "@/lib/utils"
const TRANSITION = {
type: "spring" as const,
bounce: 0.1,
duration: 0.4,
}
interface FloatingPanelContextType {
isOpen: boolean
openFloatingPanel: (rect: DOMRect) => void
closeFloatingPanel: () => void
uniqueId: string
note: string
setNote: (note: string) => void
triggerRect: DOMRect | null
title: string
setTitle: (title: string) => void
}
const FloatingPanelContext = createContext<
FloatingPanelContextType | undefined
>(undefined)
function useFloatingPanel() {
const context = useContext(FloatingPanelContext)
if (!context) {
throw new Error(
"useFloatingPanel must be used within a FloatingPanelProvider"
)
}
return context
}
function useFloatingPanelLogic() {
const uniqueId = useId()
const [isOpen, setIsOpen] = useState(false)
const [note, setNote] = useState("")
const [triggerRect, setTriggerRect] = useState<DOMRect | null>(null)
const [title, setTitle] = useState("")
const openFloatingPanel = (rect: DOMRect) => {
setTriggerRect(rect)
setIsOpen(true)
}
const closeFloatingPanel = () => {
setIsOpen(false)
setNote("")
}
return {
isOpen,
openFloatingPanel,
closeFloatingPanel,
uniqueId,
note,
setNote,
triggerRect,
title,
setTitle,
}
}
interface FloatingPanelRootProps {
children: React.ReactNode
className?: string
}
export function FloatingPanelRoot({
children,
className,
}: FloatingPanelRootProps) {
const floatingPanelLogic = useFloatingPanelLogic()
return (
<FloatingPanelContext.Provider value={floatingPanelLogic}>
<MotionConfig transition={TRANSITION}>
<div className={cn("relative", className)}>{children}</div>
</MotionConfig>
</FloatingPanelContext.Provider>
)
}
interface FloatingPanelTriggerProps {
children: React.ReactNode
className?: string
title: string
}
export function FloatingPanelTrigger({
children,
className,
title,
}: FloatingPanelTriggerProps) {
const { openFloatingPanel, uniqueId, setTitle } = useFloatingPanel()
const triggerRef = useRef<HTMLButtonElement>(null)
const handleClick = () => {
if (triggerRef.current) {
openFloatingPanel(triggerRef.current.getBoundingClientRect())
setTitle(title)
}
}
return (
<motion.button
ref={triggerRef}
layoutId={`floating-panel-trigger-${uniqueId}`}
className={cn(
"flex h-9 items-center border border-zinc-950/10 bg-white px-3 text-zinc-950 dark:border-zinc-50/10 dark:bg-zinc-700 dark:text-zinc-50",
className
)}
style={{ borderRadius: 8 }}
onClick={handleClick}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-haspopup="dialog"
aria-expanded={false}
>
<motion.div
layoutId={`floating-panel-label-container-${uniqueId}`}
className="flex items-center"
>
<motion.span
layoutId={`floating-panel-label-${uniqueId}`}
className="text-sm font-semibold"
>
{children}
</motion.span>
</motion.div>
</motion.button>
)
}
interface FloatingPanelContentProps {
children: React.ReactNode
className?: string
}
export function FloatingPanelContent({
children,
className,
}: FloatingPanelContentProps) {
const { isOpen, closeFloatingPanel, uniqueId, triggerRect, title } =
useFloatingPanel()
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
contentRef.current &&
!contentRef.current.contains(event.target as Node)
) {
closeFloatingPanel()
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [closeFloatingPanel])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeFloatingPanel()
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [closeFloatingPanel])
const variants: Variants = {
hidden: { opacity: 0, scale: 0.9, y: 10 },
visible: { opacity: 1, scale: 1, y: 0 },
}
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ backdropFilter: "blur(0px)" }}
animate={{ backdropFilter: "blur(4px)" }}
exit={{ backdropFilter: "blur(0px)" }}
className="fixed inset-0 z-40"
/>
<motion.div
ref={contentRef}
layoutId={`floating-panel-${uniqueId}`}
className={cn(
"fixed z-50 overflow-hidden border border-zinc-950/10 bg-white shadow-lg outline-none dark:border-zinc-50/10 dark:bg-zinc-800",
className
)}
style={{
borderRadius: 12,
left: triggerRect ? triggerRect.left : "50%",
top: triggerRect ? triggerRect.bottom + 8 : "50%",
transformOrigin: "top left",
}}
initial="hidden"
animate="visible"
exit="hidden"
variants={variants}
role="dialog"
aria-modal="true"
aria-labelledby={`floating-panel-title-${uniqueId}`}
>
<FloatingPanelTitle>{title}</FloatingPanelTitle>
{children}
</motion.div>
</>
)}
</AnimatePresence>
)
}
interface FloatingPanelTitleProps {
children: React.ReactNode
}
function FloatingPanelTitle({ children }: FloatingPanelTitleProps) {
const { uniqueId } = useFloatingPanel()
return (
<motion.div
layoutId={`floating-panel-label-container-${uniqueId}`}
className="px-4 py-2 bg-white dark:bg-zinc-800"
>
<motion.div
layoutId={`floating-panel-label-${uniqueId}`}
className="text-sm font-semibold text-zinc-900 dark:text-zinc-100"
id={`floating-panel-title-${uniqueId}`}
>
{children}
</motion.div>
</motion.div>
)
}
interface FloatingPanelFormProps {
children: React.ReactNode
onSubmit?: (note: string) => void
className?: string
}
export function FloatingPanelForm({
children,
onSubmit,
className,
}: FloatingPanelFormProps) {
const { note, closeFloatingPanel } = useFloatingPanel()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit?.(note)
closeFloatingPanel()
}
return (
<form
className={cn("flex h-full flex-col", className)}
onSubmit={handleSubmit}
>
{children}
</form>
)
}
interface FloatingPanelLabelProps {
children: React.ReactNode
htmlFor: string
className?: string
}
export function FloatingPanelLabel({
children,
htmlFor,
className,
}: FloatingPanelLabelProps) {
const { note } = useFloatingPanel()
return (
<motion.label
htmlFor={htmlFor}
style={{ opacity: note ? 0 : 1 }}
className={cn(
"block mb-2 text-sm font-medium text-zinc-900 dark:text-zinc-100",
className
)}
>
{children}
</motion.label>
)
}
interface FloatingPanelTextareaProps {
className?: string
id?: string
}
export function FloatingPanelTextarea({
className,
id,
}: FloatingPanelTextareaProps) {
const { note, setNote } = useFloatingPanel()
return (
<textarea
id={id}
className={cn(
"h-full w-full resize-none rounded-md bg-transparent px-4 py-3 text-sm outline-none",
className
)}
autoFocus
value={note}
onChange={(e) => setNote(e.target.value)}
/>
)
}
interface FloatingPanelHeaderProps {
children: React.ReactNode
className?: string
}
export function FloatingPanelHeader({
children,
className,
}: FloatingPanelHeaderProps) {
return (
<motion.div
className={cn(
"px-4 py-2 font-semibold text-zinc-900 dark:text-zinc-100",
className
)}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
{children}
</motion.div>
)
}
interface FloatingPanelBodyProps {
children: React.ReactNode
className?: string
}
export function FloatingPanelBody({
children,
className,
}: FloatingPanelBodyProps) {
return (
<motion.div
className={cn("p-4", className)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
{children}
</motion.div>
)
}
interface FloatingPanelFooterProps {
children: React.ReactNode
className?: string
}
export function FloatingPanelFooter({
children,
className,
}: FloatingPanelFooterProps) {
return (
<motion.div
className={cn("flex justify-between px-4 py-3", className)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
{children}
</motion.div>
)
}
interface FloatingPanelCloseButtonProps {
className?: string
}
export function FloatingPanelCloseButton({
className,
}: FloatingPanelCloseButtonProps) {
const { closeFloatingPanel } = useFloatingPanel()
return (
<motion.button
type="button"
className={cn("flex items-center", className)}
onClick={closeFloatingPanel}
aria-label="Close floating panel"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<ArrowLeftIcon size={16} className="text-zinc-900 dark:text-zinc-100" />
</motion.button>
)
}
interface FloatingPanelSubmitButtonProps {
className?: string
}
export function FloatingPanelSubmitButton({
className,
}: FloatingPanelSubmitButtonProps) {
return (
<motion.button
className={cn(
"relative ml-1 flex h-8 shrink-0 scale-100 select-none appearance-none items-center justify-center rounded-lg border border-zinc-950/10 bg-transparent px-2 text-sm text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-800 focus-visible:ring-2 active:scale-[0.98] dark:border-zinc-50/10 dark:text-zinc-50 dark:hover:bg-zinc-800",
className
)}
type="submit"
aria-label="Submit note"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Submit Note
</motion.button>
)
}
interface FloatingPanelButtonProps {
children: React.ReactNode
onClick?: () => void
className?: string
}
export function FloatingPanelButton({
children,
onClick,
className,
}: FloatingPanelButtonProps) {
return (
<motion.button
className={cn(
"flex w-full items-center gap-2 rounded-md px-4 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700",
className
)}
onClick={onClick}
whileHover={{ backgroundColor: "rgba(0, 0, 0, 0.05)" }}
whileTap={{ scale: 0.98 }}
>
{children}
</motion.button>
)
}
const FloatingPanel = {
Root: FloatingPanelRoot,
Trigger: FloatingPanelTrigger,
Content: FloatingPanelContent,
Form: FloatingPanelForm,
Label: FloatingPanelLabel,
Textarea: FloatingPanelTextarea,
Header: FloatingPanelHeader,
Body: FloatingPanelBody,
Footer: FloatingPanelFooter,
CloseButton: FloatingPanelCloseButton,
SubmitButton: FloatingPanelSubmitButton,
Button: FloatingPanelButton,
}
export default FloatingPanel
// "use client"
// import React, {
// createContext,
// useContext,
// useEffect,
// useId,
// useRef,
// useState,
// } from "react"
// import { AnimatePresence, MotionConfig, motion } from "motion/react"
// import { ArrowLeftIcon } from "lucide-react"
// import { cn } from "@/lib/utils"
// const TRANSITION = {
// type: "spring",
// bounce: 0.1,
// duration: 0.4,
// }
// interface FloatingPanelContextType {
// isOpen: boolean
// openFloatingPanel: (rect: DOMRect) => void
// closeFloatingPanel: () => void
// uniqueId: string
// note: string
// setNote: (note: string) => void
// triggerRect: DOMRect | null
// title: string
// setTitle: (title: string) => void
// }
// const FloatingPanelContext = createContext<
// FloatingPanelContextType | undefined
// >(undefined)
// function useFloatingPanel() {
// const context = useContext(FloatingPanelContext)
// if (!context) {
// throw new Error(
// "useFloatingPanel must be used within a FloatingPanelProvider"
// )
// }
// return context
// }
// function useFloatingPanelLogic() {
// const uniqueId = useId()
// const [isOpen, setIsOpen] = useState(false)
// const [note, setNote] = useState("")
// const [triggerRect, setTriggerRect] = useState<DOMRect | null>(null)
// const [title, setTitle] = useState("")
// const openFloatingPanel = (rect: DOMRect) => {
// setTriggerRect(rect)
// setIsOpen(true)
// }
// const closeFloatingPanel = () => {
// setIsOpen(false)
// setNote("")
// }
// return {
// isOpen,
// openFloatingPanel,
// closeFloatingPanel,
// uniqueId,
// note,
// setNote,
// triggerRect,
// title,
// setTitle,
// }
// }
// interface FloatingPanelRootProps {
// children: React.ReactNode
// className?: string
// }
// export function FloatingPanelRoot({
// children,
// className,
// }: FloatingPanelRootProps) {
// const floatingPanelLogic = useFloatingPanelLogic()
// return (
// <FloatingPanelContext.Provider value={floatingPanelLogic}>
// <MotionConfig transition={TRANSITION}>
// <div className={cn("relative", className)}>{children}</div>
// </MotionConfig>
// </FloatingPanelContext.Provider>
// )
// }
// interface FloatingPanelTriggerProps {
// children: React.ReactNode
// className?: string
// title: string
// }
// export function FloatingPanelTrigger({
// children,
// className,
// title,
// }: FloatingPanelTriggerProps) {
// const { openFloatingPanel, uniqueId, setTitle } = useFloatingPanel()
// const triggerRef = useRef<HTMLButtonElement>(null)
// const handleClick = () => {
// if (triggerRef.current) {
// openFloatingPanel(triggerRef.current.getBoundingClientRect())
// setTitle(title)
// }
// }
// return (
// <motion.button
// ref={triggerRef}
// layoutId={`floating-panel-trigger-${uniqueId}`}
// className={cn(
// "flex h-9 items-center border border-zinc-950/10 bg-white px-3 text-zinc-950 dark:border-zinc-50/10 dark:bg-zinc-700 dark:text-zinc-50",
// className
// )}
// style={{ borderRadius: 8 }}
// onClick={handleClick}
// whileHover={{ scale: 1.05 }}
// whileTap={{ scale: 0.95 }}
// >
// <motion.span
// layoutId={`floating-panel-label-${uniqueId}`}
// className="text-sm"
// >
// {children}
// </motion.span>
// </motion.button>
// )
// }
// interface FloatingPanelContentProps {
// children: React.ReactNode
// className?: string
// }
// export function FloatingPanelContent({
// children,
// className,
// }: FloatingPanelContentProps) {
// const { isOpen, closeFloatingPanel, uniqueId, triggerRect, title } =
// useFloatingPanel()
// const contentRef = useRef<HTMLDivElement>(null)
// useEffect(() => {
// const handleClickOutside = (event: MouseEvent) => {
// if (
// contentRef.current &&
// !contentRef.current.contains(event.target as Node)
// ) {
// closeFloatingPanel()
// }
// }
// document.addEventListener("mousedown", handleClickOutside)
// return () => document.removeEventListener("mousedown", handleClickOutside)
// }, [closeFloatingPanel])
// useEffect(() => {
// const handleKeyDown = (event: KeyboardEvent) => {
// if (event.key === "Escape") closeFloatingPanel()
// }
// document.addEventListener("keydown", handleKeyDown)
// return () => document.removeEventListener("keydown", handleKeyDown)
// }, [closeFloatingPanel])
// const variants = {
// hidden: { opacity: 0, scale: 0.9, y: 10 },
// visible: { opacity: 1, scale: 1, y: 0 },
// }
// return (
// <AnimatePresence>
// {isOpen && (
// <>
// <motion.div
// initial={{ backdropFilter: "blur(0px)" }}
// animate={{ backdropFilter: "blur(4px)" }}
// exit={{ backdropFilter: "blur(0px)" }}
// className="fixed inset-0 z-40"
// />
// <motion.div
// ref={contentRef}
// layoutId={`floating-panel-${uniqueId}`}
// className={cn(
// "fixed z-50 overflow-hidden border border-zinc-950/10 bg-white shadow-lg outline-none dark:border-zinc-50/10 dark:bg-zinc-800",
// className
// )}
// style={{
// borderRadius: 12,
// left: triggerRect ? triggerRect.left : "50%",
// top: triggerRect ? triggerRect.bottom + 8 : "50%",
// transformOrigin: "top left",
// }}
// initial="hidden"
// animate="visible"
// exit="hidden"
// variants={variants}
// >
// <FloatingPanelTitle>{title}</FloatingPanelTitle>
// {children}
// </motion.div>
// </>
// )}
// </AnimatePresence>
// )
// }
// interface FloatingPanelTitleProps {
// children: React.ReactNode
// }
// function FloatingPanelTitle({ children }: FloatingPanelTitleProps) {
// const { uniqueId } = useFloatingPanel()
// return (
// <motion.div
// layoutId={`floating-panel-label-${uniqueId}`}
// className="px-4 py-2 font-semibold text-zinc-900 dark:text-zinc-100"
// >
// {children}
// </motion.div>
// )
// }
// interface FloatingPanelFormProps {
// children: React.ReactNode
// onSubmit?: (note: string) => void
// className?: string
// }
// export function FloatingPanelForm({
// children,
// onSubmit,
// className,
// }: FloatingPanelFormProps) {
// const { note, closeFloatingPanel } = useFloatingPanel()
// const handleSubmit = (e: React.FormEvent) => {
// e.preventDefault()
// onSubmit?.(note)
// closeFloatingPanel()
// }
// return (
// <form
// className={cn("flex h-full flex-col", className)}
// onSubmit={handleSubmit}
// >
// {children}
// </form>
// )
// }
// interface FloatingPanelLabelProps {
// children: React.ReactNode
// htmlFor: string
// className?: string
// }
// export function FloatingPanelLabel({
// children,
// htmlFor,
// className,
// }: FloatingPanelLabelProps) {
// const { note } = useFloatingPanel()
// return (
// <motion.label
// htmlFor={htmlFor}
// style={{ opacity: note ? 0 : 1 }}
// className={cn(
// "block mb-2 text-sm font-medium text-zinc-900 dark:text-zinc-100",
// className
// )}
// >
// {children}
// </motion.label>
// )
// }
// interface FloatingPanelTextareaProps {
// className?: string
// id?: string
// }
// export function FloatingPanelTextarea({
// className,
// id,
// }: FloatingPanelTextareaProps) {
// const { note, setNote } = useFloatingPanel()
// return (
// <textarea
// id={id}
// className={cn(
// "h-full w-full resize-none rounded-md bg-transparent px-4 py-3 text-sm outline-none",
// className
// )}
// autoFocus
// value={note}
// onChange={(e) => setNote(e.target.value)}
// />
// )
// }
// interface FloatingPanelHeaderProps {
// children: React.ReactNode
// className?: string
// }
// export function FloatingPanelHeader({
// children,
// className,
// }: FloatingPanelHeaderProps) {
// return (
// <motion.div
// className={cn(
// "px-4 py-2 font-semibold text-zinc-900 dark:text-zinc-100",
// className
// )}
// initial={{ opacity: 0, y: -10 }}
// animate={{ opacity: 1, y: 0 }}
// transition={{ delay: 0.1 }}
// >
// {children}
// </motion.div>
// )
// }
// interface FloatingPanelBodyProps {
// children: React.ReactNode
// className?: string
// }
// export function FloatingPanelBody({
// children,
// className,
// }: FloatingPanelBodyProps) {
// return (
// <motion.div
// className={cn("p-4", className)}
// initial={{ opacity: 0, y: 10 }}
// animate={{ opacity: 1, y: 0 }}
// transition={{ delay: 0.2 }}
// >
// {children}
// </motion.div>
// )
// }
// interface FloatingPanelFooterProps {
// children: React.ReactNode
// className?: string
// }
// export function FloatingPanelFooter({
// children,
// className,
// }: FloatingPanelFooterProps) {
// return (
// <motion.div
// className={cn("flex justify-between px-4 py-3", className)}
// initial={{ opacity: 0, y: 10 }}
// animate={{ opacity: 1, y: 0 }}
// transition={{ delay: 0.3 }}
// >
// {children}
// </motion.div>
// )
// }
// interface FloatingPanelCloseButtonProps {
// className?: string
// }
// export function FloatingPanelCloseButton({
// className,
// }: FloatingPanelCloseButtonProps) {
// const { closeFloatingPanel } = useFloatingPanel()
// return (
// <motion.button
// type="button"
// className={cn("flex items-center", className)}
// onClick={closeFloatingPanel}
// aria-label="Close floating panel"
// whileHover={{ scale: 1.1 }}
// whileTap={{ scale: 0.9 }}
// >
// <ArrowLeftIcon size={16} className="text-zinc-900 dark:text-zinc-100" />
// </motion.button>
// )
// }
// interface FloatingPanelSubmitButtonProps {
// className?: string
// }
// export function FloatingPanelSubmitButton({
// className,
// }: FloatingPanelSubmitButtonProps) {
// return (
// <motion.button
// className={cn(
// "relative ml-1 flex h-8 shrink-0 scale-100 select-none appearance-none items-center justify-center rounded-lg border border-zinc-950/10 bg-transparent px-2 text-sm text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-800 focus-visible:ring-2 active:scale-[0.98] dark:border-zinc-50/10 dark:text-zinc-50 dark:hover:bg-zinc-800",
// className
// )}
// type="submit"
// aria-label="Submit note"
// whileHover={{ scale: 1.05 }}
// whileTap={{ scale: 0.95 }}
// >
// Submit Note
// </motion.button>
// )
// }
// interface FloatingPanelButtonProps {
// children: React.ReactNode
// onClick?: () => void
// className?: string
// }
// export function FloatingPanelButton({
// children,
// onClick,
// className,
// }: FloatingPanelButtonProps) {
// return (
// <motion.button
// className={cn(
// "flex w-full items-center gap-2 rounded-md px-4 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700",
// className
// )}
// onClick={onClick}
// whileHover={{ backgroundColor: "rgba(0, 0, 0, 0.05)" }}
// whileTap={{ scale: 0.98 }}
// >
// {children}
// </motion.button>
// )
// }
// export default {
// Root: FloatingPanelRoot,
// Trigger: FloatingPanelTrigger,
// Content: FloatingPanelContent,
// Form: FloatingPanelForm,
// Label: FloatingPanelLabel,
// Textarea: FloatingPanelTextarea,
// Header: FloatingPanelHeader,
// Body: FloatingPanelBody,
// Footer: FloatingPanelFooter,
// CloseButton: FloatingPanelCloseButton,
// SubmitButton: FloatingPanelSubmitButton,
// Button: FloatingPanelButton,
// }

View File

@ -0,0 +1,206 @@
import { useEffect, useState, useRef } from 'react';
import { motion, HTMLMotionProps } from 'motion/react';
interface DecryptedTextProps extends HTMLMotionProps<'span'> {
text: string;
speed?: number;
maxIterations?: number;
sequential?: boolean;
revealDirection?: 'start' | 'end' | 'center';
useOriginalCharsOnly?: boolean;
characters?: string;
className?: string;
encryptedClassName?: string;
parentClassName?: string;
animateOn?: 'view' | 'hover';
}
export default function DecryptedText({
text,
speed = 50,
maxIterations = 10,
sequential = false,
revealDirection = 'start',
useOriginalCharsOnly = false,
characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+',
className = '',
parentClassName = '',
encryptedClassName = '',
animateOn = 'hover',
...props
}: DecryptedTextProps) {
const [displayText, setDisplayText] = useState<string>(text);
const [isHovering, setIsHovering] = useState<boolean>(false);
const [isScrambling, setIsScrambling] = useState<boolean>(false);
const [revealedIndices, setRevealedIndices] = useState<Set<number>>(new Set());
const [hasAnimated, setHasAnimated] = useState<boolean>(false);
const containerRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
let interval: NodeJS.Timeout;
let currentIteration = 0;
const getNextIndex = (revealedSet: Set<number>): number => {
const textLength = text.length;
switch (revealDirection) {
case 'start':
return revealedSet.size;
case 'end':
return textLength - 1 - revealedSet.size;
case 'center': {
const middle = Math.floor(textLength / 2);
const offset = Math.floor(revealedSet.size / 2);
const nextIndex = revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1;
if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {
return nextIndex;
}
for (let i = 0; i < textLength; i++) {
if (!revealedSet.has(i)) return i;
}
return 0;
}
default:
return revealedSet.size;
}
};
const availableChars = useOriginalCharsOnly
? Array.from(new Set(text.split(''))).filter(char => char !== ' ')
: characters.split('');
const shuffleText = (originalText: string, currentRevealed: Set<number>): string => {
if (useOriginalCharsOnly) {
const positions = originalText.split('').map((char, i) => ({
char,
isSpace: char === ' ',
index: i,
isRevealed: currentRevealed.has(i)
}));
const nonSpaceChars = positions.filter(p => !p.isSpace && !p.isRevealed).map(p => p.char);
for (let i = nonSpaceChars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[nonSpaceChars[i], nonSpaceChars[j]] = [nonSpaceChars[j], nonSpaceChars[i]];
}
let charIndex = 0;
return positions
.map(p => {
if (p.isSpace) return ' ';
if (p.isRevealed) return originalText[p.index];
return nonSpaceChars[charIndex++];
})
.join('');
} else {
return originalText
.split('')
.map((char, i) => {
if (char === ' ') return ' ';
if (currentRevealed.has(i)) return originalText[i];
return availableChars[Math.floor(Math.random() * availableChars.length)];
})
.join('');
}
};
if (isHovering) {
setIsScrambling(true);
interval = setInterval(() => {
setRevealedIndices(prevRevealed => {
if (sequential) {
if (prevRevealed.size < text.length) {
const nextIndex = getNextIndex(prevRevealed);
const newRevealed = new Set(prevRevealed);
newRevealed.add(nextIndex);
setDisplayText(shuffleText(text, newRevealed));
return newRevealed;
} else {
clearInterval(interval);
setIsScrambling(false);
return prevRevealed;
}
} else {
setDisplayText(shuffleText(text, prevRevealed));
currentIteration++;
if (currentIteration >= maxIterations) {
clearInterval(interval);
setIsScrambling(false);
setDisplayText(text);
}
return prevRevealed;
}
});
}, speed);
} else {
setDisplayText(text);
setRevealedIndices(new Set());
setIsScrambling(false);
}
return () => {
if (interval) clearInterval(interval);
};
}, [isHovering, text, speed, maxIterations, sequential, revealDirection, characters, useOriginalCharsOnly]);
useEffect(() => {
if (animateOn !== 'view') return;
const observerCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasAnimated) {
setIsHovering(true);
setHasAnimated(true);
}
});
};
const observerOptions = {
root: null,
rootMargin: '0px',
threshold: 0.1
};
const observer = new IntersectionObserver(observerCallback, observerOptions);
const currentRef = containerRef.current;
if (currentRef) {
observer.observe(currentRef);
}
return () => {
if (currentRef) observer.unobserve(currentRef);
};
}, [animateOn, hasAnimated]);
const hoverProps =
animateOn === 'hover'
? {
onMouseEnter: () => setIsHovering(true),
onMouseLeave: () => setIsHovering(false)
}
: {};
return (
<motion.span
ref={containerRef}
className={`inline-block whitespace-pre-wrap ${parentClassName}`}
{...hoverProps}
{...props}
>
<span className="sr-only">{displayText}</span>
<span aria-hidden="true">
{displayText.split('').map((char, index) => {
const isRevealedOrDone = revealedIndices.has(index) || !isScrambling || !isHovering;
return (
<span key={index} className={isRevealedOrDone ? className : encryptedClassName}>
{char}
</span>
);
})}
</span>
</motion.span>
);
}

View File

@ -0,0 +1,90 @@
'use client';
import * as React from 'react';
import {
motion,
useInView,
type UseInViewOptions,
type Transition,
} from 'motion/react';
const ENTRY_ANIMATION = {
initial: { rotateX: 0 },
animate: { rotateX: 90 },
};
const EXIT_ANIMATION = {
initial: { rotateX: 90 },
animate: { rotateX: 0 },
};
const formatCharacter = (char: string) => (char === ' ' ? '\u00A0' : char);
type RollingTextProps = Omit<React.ComponentProps<'span'>, 'children'> & {
transition?: Transition;
inView?: boolean;
inViewMargin?: UseInViewOptions['margin'];
inViewOnce?: boolean;
text: string;
};
function RollingText({
ref,
transition = { duration: 0.5, delay: 0.1, ease: 'easeOut' },
inView = false,
inViewMargin = '0px',
inViewOnce = true,
text,
...props
}: RollingTextProps) {
const localRef = React.useRef<HTMLSpanElement>(null);
React.useImperativeHandle(ref, () => localRef.current!);
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
const characters = React.useMemo(() => text.split(''), [text]);
return (
<span data-slot="rolling-text" {...props} ref={ref}>
{characters.map((char, idx) => (
<span
key={idx}
className="relative inline-block perspective-[9999999px] transform-3d w-auto"
aria-hidden="true"
>
<motion.span
className="absolute inline-block backface-hidden origin-[50%_25%]"
initial={ENTRY_ANIMATION.initial}
animate={isInView ? ENTRY_ANIMATION.animate : undefined}
transition={{
...transition,
delay: idx * (transition?.delay ?? 0),
}}
>
{formatCharacter(char)}
</motion.span>
<motion.span
className="absolute inline-block backface-hidden origin-[50%_100%]"
initial={EXIT_ANIMATION.initial}
animate={isInView ? EXIT_ANIMATION.animate : undefined}
transition={{
...transition,
delay: idx * (transition?.delay ?? 0) + 0.3,
}}
>
{formatCharacter(char)}
</motion.span>
<span className="invisible">{formatCharacter(char)}</span>
</span>
))}
<span className="sr-only">{text}</span>
</span>
);
}
export { RollingText, type RollingTextProps };

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -15,37 +15,13 @@
"binding": "ASSETS",
"directory": ".open-next/assets"
},
"routes": [
{
"pattern": "minecraft.biohazardvfx.com/*",
"zone_name": "biohazardvfx.com"
}
],
"observability": {
"enabled": true
}
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" }
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" }
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" }
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
}