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
13
package.json
@ -13,9 +13,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@opennextjs/cloudflare": "^1.3.0",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.552.0",
|
||||
"minecraft-server-util": "^5.4.4",
|
||||
"motion": "^12.23.24",
|
||||
"next": "15.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"react-dom": "19.1.0",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@ -26,6 +36,7 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.4.6",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"wrangler": "^4.45.3"
|
||||
}
|
||||
|
||||
8599
pnpm-lock.yaml
generated
Normal file
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 "tw-animate-css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-sans: Oxanium, sans-serif;
|
||||
--font-mono: Fira Code, monospace;
|
||||
--font-serif: Merriweather, serif;
|
||||
--radius: 0.3rem;
|
||||
--tracking-normal: 0em;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--spacing: var(--spacing);
|
||||
--letter-spacing: var(--letter-spacing);
|
||||
--shadow-offset-y: var(--shadow-offset-y);
|
||||
--shadow-offset-x: var(--shadow-offset-x);
|
||||
--shadow-spread: var(--shadow-spread);
|
||||
--shadow-blur: var(--shadow-blur);
|
||||
--shadow-opacity: var(--shadow-opacity);
|
||||
--color-shadow-color: var(--shadow-color);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
--radius: 0.3rem;
|
||||
--background: oklch(0.9885 0.0057 84.5659);
|
||||
--foreground: oklch(0.3660 0.0251 49.6085);
|
||||
--card: oklch(0.9686 0.0091 78.2818);
|
||||
--card-foreground: oklch(0.3660 0.0251 49.6085);
|
||||
--popover: oklch(0.9686 0.0091 78.2818);
|
||||
--popover-foreground: oklch(0.3660 0.0251 49.6085);
|
||||
--primary: oklch(0.5553 0.1455 48.9975);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.8276 0.0752 74.4400);
|
||||
--secondary-foreground: oklch(0.4444 0.0096 73.6390);
|
||||
--muted: oklch(0.9363 0.0218 83.2637);
|
||||
--muted-foreground: oklch(0.5534 0.0116 58.0708);
|
||||
--accent: oklch(0.9000 0.0500 74.9889);
|
||||
--accent-foreground: oklch(0.4444 0.0096 73.6390);
|
||||
--destructive: oklch(0.4437 0.1613 26.8994);
|
||||
--border: oklch(0.8866 0.0404 89.6994);
|
||||
--input: oklch(0.8866 0.0404 89.6994);
|
||||
--ring: oklch(0.5553 0.1455 48.9975);
|
||||
--chart-1: oklch(0.5553 0.1455 48.9975);
|
||||
--chart-2: oklch(0.5534 0.0116 58.0708);
|
||||
--chart-3: oklch(0.5538 0.1207 66.4416);
|
||||
--chart-4: oklch(0.5534 0.0116 58.0708);
|
||||
--chart-5: oklch(0.6806 0.1423 75.8340);
|
||||
--sidebar: oklch(0.9363 0.0218 83.2637);
|
||||
--sidebar-foreground: oklch(0.3660 0.0251 49.6085);
|
||||
--sidebar-primary: oklch(0.5553 0.1455 48.9975);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.5538 0.1207 66.4416);
|
||||
--sidebar-accent-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-border: oklch(0.8866 0.0404 89.6994);
|
||||
--sidebar-ring: oklch(0.5553 0.1455 48.9975);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--font-sans: Oxanium, sans-serif;
|
||||
--font-serif: Merriweather, serif;
|
||||
--font-mono: Fira Code, monospace;
|
||||
--shadow-color: hsl(28 18% 25%);
|
||||
--shadow-opacity: 0.18;
|
||||
--shadow-blur: 3px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0px;
|
||||
--shadow-offset-y: 2px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0px 2px 3px 0px hsl(28 18% 25% / 0.09);
|
||||
--shadow-xs: 0px 2px 3px 0px hsl(28 18% 25% / 0.09);
|
||||
--shadow-sm: 0px 2px 3px 0px hsl(28 18% 25% / 0.18), 0px 1px 2px -1px hsl(28 18% 25% / 0.18);
|
||||
--shadow: 0px 2px 3px 0px hsl(28 18% 25% / 0.18), 0px 1px 2px -1px hsl(28 18% 25% / 0.18);
|
||||
--shadow-md: 0px 2px 3px 0px hsl(28 18% 25% / 0.18), 0px 2px 4px -1px hsl(28 18% 25% / 0.18);
|
||||
--shadow-lg: 0px 2px 3px 0px hsl(28 18% 25% / 0.18), 0px 4px 6px -1px hsl(28 18% 25% / 0.18);
|
||||
--shadow-xl: 0px 2px 3px 0px hsl(28 18% 25% / 0.18), 0px 8px 10px -1px hsl(28 18% 25% / 0.18);
|
||||
--shadow-2xl: 0px 2px 3px 0px hsl(28 18% 25% / 0.45);
|
||||
--tracking-normal: 0em;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
.dark {
|
||||
--background: oklch(0.2161 0.0061 56.0434);
|
||||
--foreground: oklch(0.9699 0.0013 106.4238);
|
||||
--card: oklch(0.2685 0.0063 34.2976);
|
||||
--card-foreground: oklch(0.9699 0.0013 106.4238);
|
||||
--popover: oklch(0.2685 0.0063 34.2976);
|
||||
--popover-foreground: oklch(0.9699 0.0013 106.4238);
|
||||
--primary: oklch(0.7049 0.1867 47.6044);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.4444 0.0096 73.6390);
|
||||
--secondary-foreground: oklch(0.9232 0.0026 48.7171);
|
||||
--muted: oklch(0.2330 0.0073 67.4563);
|
||||
--muted-foreground: oklch(0.7161 0.0091 56.2590);
|
||||
--accent: oklch(0.3598 0.0497 229.3202);
|
||||
--accent-foreground: oklch(0.9232 0.0026 48.7171);
|
||||
--destructive: oklch(0.5771 0.2152 27.3250);
|
||||
--border: oklch(0.3741 0.0087 67.5582);
|
||||
--input: oklch(0.3741 0.0087 67.5582);
|
||||
--ring: oklch(0.7049 0.1867 47.6044);
|
||||
--chart-1: oklch(0.7049 0.1867 47.6044);
|
||||
--chart-2: oklch(0.6847 0.1479 237.3225);
|
||||
--chart-3: oklch(0.7952 0.1617 86.0468);
|
||||
--chart-4: oklch(0.7161 0.0091 56.2590);
|
||||
--chart-5: oklch(0.5534 0.0116 58.0708);
|
||||
--sidebar: oklch(0.2685 0.0063 34.2976);
|
||||
--sidebar-foreground: oklch(0.9699 0.0013 106.4238);
|
||||
--sidebar-primary: oklch(0.7049 0.1867 47.6044);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.6847 0.1479 237.3225);
|
||||
--sidebar-accent-foreground: oklch(0.2839 0.0734 254.5378);
|
||||
--sidebar-border: oklch(0.3741 0.0087 67.5582);
|
||||
--sidebar-ring: oklch(0.7049 0.1867 47.6044);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--radius: 0.3rem;
|
||||
--font-sans: Oxanium, sans-serif;
|
||||
--font-serif: Merriweather, serif;
|
||||
--font-mono: Fira Code, monospace;
|
||||
--shadow-color: hsl(0 0% 5%);
|
||||
--shadow-opacity: 0.18;
|
||||
--shadow-blur: 3px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0px;
|
||||
--shadow-offset-y: 2px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0px 2px 3px 0px hsl(0 0% 5% / 0.09);
|
||||
--shadow-xs: 0px 2px 3px 0px hsl(0 0% 5% / 0.09);
|
||||
--shadow-sm: 0px 2px 3px 0px hsl(0 0% 5% / 0.18), 0px 1px 2px -1px hsl(0 0% 5% / 0.18);
|
||||
--shadow: 0px 2px 3px 0px hsl(0 0% 5% / 0.18), 0px 1px 2px -1px hsl(0 0% 5% / 0.18);
|
||||
--shadow-md: 0px 2px 3px 0px hsl(0 0% 5% / 0.18), 0px 2px 4px -1px hsl(0 0% 5% / 0.18);
|
||||
--shadow-lg: 0px 2px 3px 0px hsl(0 0% 5% / 0.18), 0px 4px 6px -1px hsl(0 0% 5% / 0.18);
|
||||
--shadow-xl: 0px 2px 3px 0px hsl(0 0% 5% / 0.18), 0px 8px 10px -1px hsl(0 0% 5% / 0.18);
|
||||
--shadow-2xl: 0px 2px 3px 0px hsl(0 0% 5% / 0.45);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Toaster } from 'sonner';
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@ -13,8 +14,55 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: {
|
||||
default: "BiohazardVFX Minecraft Server | Join Our SMP Community",
|
||||
template: "%s | BiohazardVFX Minecraft Server"
|
||||
},
|
||||
description: "Join BiohazardVFX, a 1.21.x Survival Minecraft server for VFX artists and creatives. Experience vanilla-first gameplay with community and whitelist access.",
|
||||
keywords: ["Minecraft", "SMP", "Survival", "VFX", "Creative", "Community", "Server", "Minecraft Server", "Whitelist", "Discord", "1.21.x", "BiohazardVFX"],
|
||||
authors: [{ name: "BiohazardVFX Team" }],
|
||||
creator: "BiohazardVFX Team",
|
||||
publisher: "BiohazardVFX Team",
|
||||
metadataBase: new URL('https://minecraft.biohazardvfx.com'),
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
url: 'https://minecraft.biohazardvfx.com',
|
||||
title: 'BiohazardVFX Minecraft Server | Join Our SMP Community',
|
||||
description: 'Join BiohazardVFX, a 1.21.x Survival Minecraft server for VFX artists and creatives. Experience vanilla-first gameplay with community and whitelist access.',
|
||||
siteName: 'BiohazardVFX Minecraft Server',
|
||||
images: [
|
||||
{
|
||||
url: '/backgroundimg.avif',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'BiohazardVFX Minecraft Server',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'BiohazardVFX Minecraft Server | Join Our SMP Community',
|
||||
description: 'Join BiohazardVFX, a 1.21.x Survival Minecraft server for VFX artists and creatives. Experience vanilla-first gameplay with community and whitelist access.',
|
||||
images: ['/backgroundimg.avif'],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
}
|
||||
},
|
||||
verification: {
|
||||
// Add any verification meta tags here if needed
|
||||
}
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@ -28,6 +76,7 @@ export default function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Toaster position="bottom-right" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
249
src/app/page.tsx
@ -1,103 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Image from "next/image";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Hero } from "@/components/hero";
|
||||
import { ServerStatus } from "@/components/server-status";
|
||||
import { MinecraftServerStructuredData } from "@/components/structured-data";
|
||||
|
||||
interface ServerData {
|
||||
online: boolean;
|
||||
players: {
|
||||
online: number;
|
||||
playerList?: string[];
|
||||
};
|
||||
uptime?: {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
formatted: string;
|
||||
} | null;
|
||||
stats?: {
|
||||
totalPlayers: number;
|
||||
uniquePlayers7d: number;
|
||||
averageTps: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [serverData, setServerData] = useState<ServerData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchServerData = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/server-status');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch server status');
|
||||
}
|
||||
const data = await response.json() as ServerData;
|
||||
setServerData(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching server data for structured data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchServerData();
|
||||
|
||||
// Refresh every 30 seconds to keep structured data updated
|
||||
const interval = setInterval(fetchServerData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const structuredDataProps = serverData ? {
|
||||
playersOnline: serverData.players.online,
|
||||
maxPlayers: 20, // Assuming max players is 20, you can adjust this
|
||||
version: "1.21.x",
|
||||
status: serverData.online ? "Online" : "Offline"
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<>
|
||||
<MinecraftServerStructuredData serverData={structuredDataProps} />
|
||||
<div className="relative min-h-svh overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
src="/backgroundimg.avif"
|
||||
alt="Minecraft background"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-black/52 via-black/28 to-black/10 backdrop-blur-[1.5px]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.32),_transparent_60%)]" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<div className="relative z-10 flex min-h-svh flex-col font-sans">
|
||||
<main className="flex flex-1 items-center justify-center px-6 py-16 sm:px-10 lg:px-14">
|
||||
<div className="w-full max-w-5xl space-y-10">
|
||||
<section className="rounded-3xl border border-white/10 bg-background/96 p-8 shadow-2xl backdrop-blur-xl sm:p-10 lg:p-12">
|
||||
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||
<Hero />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-3xl border border-border/40 bg-background p-6 shadow-lg">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between text-foreground">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-muted-foreground">
|
||||
Live snapshot
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Server Pulse
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-muted-foreground">
|
||||
Auto refresh • 30s
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<ServerStatus className="h-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="rounded-3xl border border-border/50 bg-background/50 p-6 shadow-xl backdrop-blur"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 1.2, ease: [0.33, 1, 0.68, 1] }}
|
||||
style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden", transform: "translateZ(0)" }}
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<motion.div
|
||||
className="flex items-center justify-between"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 1.3 }}
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.15em] text-muted-foreground">
|
||||
Quick Facts
|
||||
</p>
|
||||
<span className="text-[11px] font-medium text-muted-foreground/70">
|
||||
Join-ready snapshot
|
||||
</span>
|
||||
</motion.div>
|
||||
<motion.dl
|
||||
className="mt-6 grid gap-3 text-sm text-muted-foreground sm:grid-cols-2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 1.4, ease: [0.33, 1, 0.68, 1] }}
|
||||
style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden" }}
|
||||
>
|
||||
{[
|
||||
{ label: "Version", value: "1.21.x Survival" },
|
||||
{ label: "Difficulty", value: "Hard + Vanilla Tweaks" },
|
||||
{ label: "Region", value: "United States" },
|
||||
{ label: "Community", value: "Discord & Whitelist" },
|
||||
].map((item, index) => (
|
||||
<motion.div
|
||||
key={item.label}
|
||||
className="group rounded-xl border border-border/50 bg-background/95 p-4 shadow-md transition-all hover:border-primary/30 hover:shadow-lg"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 1.5 + index * 0.1, ease: [0.33, 1, 0.68, 1] }}
|
||||
style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden", transform: "translateZ(0)" }}
|
||||
>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="mt-2 text-base font-bold text-foreground">
|
||||
{item.value}
|
||||
</dd>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.dl>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 1.9, ease: "easeOut" }}
|
||||
style={{ willChange: "opacity, transform" }}
|
||||
>
|
||||
<Button size="lg" className="mt-6 w-full font-bold shadow-lg transition-all hover:shadow-xl">
|
||||
Join the Server
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
166
src/components/hero.tsx
Normal file
@ -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))
|
||||
}
|
||||
@ -15,37 +15,13 @@
|
||||
"binding": "ASSETS",
|
||||
"directory": ".open-next/assets"
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "minecraft.biohazardvfx.com/*",
|
||||
"zone_name": "biohazardvfx.com"
|
||||
}
|
||||
],
|
||||
"observability": {
|
||||
"enabled": true
|
||||
}
|
||||
/**
|
||||
* Smart Placement
|
||||
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
|
||||
*/
|
||||
// "placement": { "mode": "smart" }
|
||||
/**
|
||||
* Bindings
|
||||
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
|
||||
* databases, object storage, AI inference, real-time communication and more.
|
||||
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
|
||||
*/
|
||||
/**
|
||||
* Environment Variables
|
||||
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
|
||||
*/
|
||||
// "vars": { "MY_VARIABLE": "production_value" }
|
||||
/**
|
||||
* Note: Use secrets to store sensitive data.
|
||||
* https://developers.cloudflare.com/workers/configuration/secrets/
|
||||
*/
|
||||
/**
|
||||
* Static Assets
|
||||
* https://developers.cloudflare.com/workers/static-assets/binding/
|
||||
*/
|
||||
// "assets": { "directory": "./public/", "binding": "ASSETS" }
|
||||
/**
|
||||
* Service Bindings (communicate between multiple Workers)
|
||||
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
|
||||
*/
|
||||
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
|
||||
}
|
||||