set up the site
95
CLAUDE.md
Normal 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
@ -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
@ -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
15
package.json
@ -13,9 +13,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opennextjs/cloudflare": "^1.3.0",
|
"@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",
|
"next": "15.4.6",
|
||||||
"react": "19.1.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@ -26,7 +36,8 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.4.6",
|
"eslint-config-next": "15.4.6",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"wrangler": "^4.45.3"
|
"wrangler": "^4.45.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8599
pnpm-lock.yaml
generated
Normal file
BIN
public/2023-11-23_21.00.17.png
Executable file
|
After Width: | Height: | Size: 7.3 MiB |
BIN
public/2025-07-13_12.59.13.png
Executable file
|
After Width: | Height: | Size: 10 MiB |
BIN
public/2025-07-13_13.01.28.png
Executable file
|
After Width: | Height: | Size: 10 MiB |
BIN
public/2025-10-22_17.07.42.png
Normal file
|
After Width: | Height: | Size: 15 MiB |
BIN
public/backgroundimg.avif
Normal file
|
After Width: | Height: | Size: 709 KiB |
BIN
public/backgroundimg.png
Normal file
|
After Width: | Height: | Size: 17 MiB |
8
public/robots.txt
Normal 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
@ -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>
|
||||||
BIN
screenshot_20251102_002802.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
82
src/app/api/server-status/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,26 +1,190 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
:root {
|
@custom-variant dark (&:is(.dark *));
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: Oxanium, sans-serif;
|
||||||
--font-mono: var(--font-geist-mono);
|
--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 {
|
||||||
:root {
|
--radius: 0.3rem;
|
||||||
--background: #0a0a0a;
|
--background: oklch(0.9885 0.0057 84.5659);
|
||||||
--foreground: #ededed;
|
--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
body {
|
letter-spacing: var(--tracking-normal);
|
||||||
background: var(--background);
|
}
|
||||||
color: var(--foreground);
|
}
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
@ -1,34 +1,83 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const geistMono = Geist_Mono({
|
||||||
variable: "--font-geist-mono",
|
variable: "--font-geist-mono",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: {
|
||||||
description: "Generated by create next app",
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</body>
|
<Toaster position="bottom-right" />
|
||||||
</html>
|
</body>
|
||||||
);
|
</html>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
269
src/app/page.tsx
@ -1,103 +1,176 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import Image from "next/image";
|
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() {
|
export default function Home() {
|
||||||
return (
|
const [serverData, setServerData] = useState<ServerData | null>(null);
|
||||||
<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">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={180}
|
|
||||||
height={38}
|
|
||||||
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="flex gap-4 items-center flex-col sm:flex-row">
|
useEffect(() => {
|
||||||
<a
|
const fetchServerData = async () => {
|
||||||
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"
|
try {
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
const response = await fetch('/api/server-status');
|
||||||
target="_blank"
|
if (!response.ok) {
|
||||||
rel="noopener noreferrer"
|
throw new Error('Failed to fetch server status');
|
||||||
>
|
}
|
||||||
<Image
|
const data = await response.json() as ServerData;
|
||||||
className="dark:invert"
|
setServerData(data);
|
||||||
src="/vercel.svg"
|
} catch (err) {
|
||||||
alt="Vercel logomark"
|
console.error('Error fetching server data for structured data:', err);
|
||||||
width={20}
|
}
|
||||||
height={20}
|
};
|
||||||
/>
|
|
||||||
Deploy now
|
fetchServerData();
|
||||||
</a>
|
|
||||||
<a
|
// Refresh every 30 seconds to keep structured data updated
|
||||||
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]"
|
const interval = setInterval(fetchServerData, 30000);
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
return () => clearInterval(interval);
|
||||||
target="_blank"
|
}, []);
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
const structuredDataProps = serverData ? {
|
||||||
Read our docs
|
playersOnline: serverData.players.online,
|
||||||
</a>
|
maxPlayers: 20, // Assuming max players is 20, you can adjust this
|
||||||
</div>
|
version: "1.21.x",
|
||||||
</main>
|
status: serverData.online ? "Online" : "Offline"
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
} : undefined;
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
return (
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<>
|
||||||
target="_blank"
|
<MinecraftServerStructuredData serverData={structuredDataProps} />
|
||||||
rel="noopener noreferrer"
|
<div className="relative min-h-svh overflow-hidden">
|
||||||
>
|
<div className="absolute inset-0">
|
||||||
<Image
|
<Image
|
||||||
aria-hidden
|
src="/backgroundimg.avif"
|
||||||
src="/file.svg"
|
alt="Minecraft background"
|
||||||
alt="File icon"
|
fill
|
||||||
width={16}
|
className="object-cover"
|
||||||
height={16}
|
priority
|
||||||
/>
|
/>
|
||||||
Learn
|
<div className="absolute inset-0 bg-gradient-to-br from-black/52 via-black/28 to-black/10 backdrop-blur-[1.5px]" />
|
||||||
</a>
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.32),_transparent_60%)]" />
|
||||||
<a
|
</div>
|
||||||
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"
|
<div className="relative z-10 flex min-h-svh flex-col font-sans">
|
||||||
target="_blank"
|
<main className="flex flex-1 items-center justify-center px-6 py-16 sm:px-10 lg:px-14">
|
||||||
rel="noopener noreferrer"
|
<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">
|
||||||
<Image
|
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||||
aria-hidden
|
<Hero />
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
<div className="flex flex-col gap-6">
|
||||||
width={16}
|
<div className="rounded-3xl border border-border/40 bg-background p-6 shadow-lg">
|
||||||
height={16}
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between text-foreground">
|
||||||
/>
|
<div>
|
||||||
Examples
|
<p className="text-xs uppercase tracking-[0.35em] text-muted-foreground">
|
||||||
</a>
|
Live snapshot
|
||||||
<a
|
</p>
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
Server Pulse
|
||||||
target="_blank"
|
</h2>
|
||||||
rel="noopener noreferrer"
|
</div>
|
||||||
>
|
<span className="text-xs uppercase tracking-[0.3em] text-muted-foreground">
|
||||||
<Image
|
Auto refresh • 30s
|
||||||
aria-hidden
|
</span>
|
||||||
src="/globe.svg"
|
</div>
|
||||||
alt="Globe icon"
|
<div className="mt-6">
|
||||||
width={16}
|
<ServerStatus className="h-full" />
|
||||||
height={16}
|
</div>
|
||||||
/>
|
</div>
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
<motion.div
|
||||||
</footer>
|
className="rounded-3xl border border-border/50 bg-background/50 p-6 shadow-xl backdrop-blur"
|
||||||
</div>
|
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)" }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 1.3 }}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
166
src/components/hero.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/hover-rolling-text.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/components/player-activity-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/components/server-status.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/structured-data.tsx
Normal 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) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/components/ui/button.tsx
Normal 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 }
|
||||||
92
src/components/ui/card.tsx
Normal 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
@ -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,
|
||||||
|
}
|
||||||
950
src/components/ui/floating-panel.tsx
Normal 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,
|
||||||
|
// }
|
||||||
206
src/components/ui/shadcn-io/decrypted-text/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/components/ui/shadcn-io/rolling-text/index.tsx
Normal 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
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@ -3,49 +3,25 @@
|
|||||||
* https://developers.cloudflare.com/workers/wrangler/configuration/
|
* https://developers.cloudflare.com/workers/wrangler/configuration/
|
||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
"name": "minecraft-website",
|
"name": "minecraft-website",
|
||||||
"main": ".open-next/worker.js",
|
"main": ".open-next/worker.js",
|
||||||
"compatibility_date": "2025-03-01",
|
"compatibility_date": "2025-03-01",
|
||||||
"compatibility_flags": [
|
"compatibility_flags": [
|
||||||
"nodejs_compat",
|
"nodejs_compat",
|
||||||
"global_fetch_strictly_public"
|
"global_fetch_strictly_public"
|
||||||
],
|
],
|
||||||
"assets": {
|
"assets": {
|
||||||
"binding": "ASSETS",
|
"binding": "ASSETS",
|
||||||
"directory": ".open-next/assets"
|
"directory": ".open-next/assets"
|
||||||
},
|
},
|
||||||
"observability": {
|
"routes": [
|
||||||
"enabled": true
|
{
|
||||||
}
|
"pattern": "minecraft.biohazardvfx.com/*",
|
||||||
/**
|
"zone_name": "biohazardvfx.com"
|
||||||
* Smart Placement
|
}
|
||||||
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
|
],
|
||||||
*/
|
"observability": {
|
||||||
// "placement": { "mode": "smart" }
|
"enabled": true
|
||||||
/**
|
}
|
||||||
* 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" }]
|
|
||||||
}
|
|
||||||
|
|||||||