vietc/web/src/components/KeycapGallery.tsx
2026-07-04 17:18:22 +07:00

475 lines
22 KiB
TypeScript

import React, { useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Download, Sliders, Palette, Lightbulb, Type, Layers, CheckCircle2, Star, Sparkles, AlertCircle } from 'lucide-react';
import { KeycapCustomization, KeycapModel } from '../types';
import DragonMascot from './DragonMascot';
export default function KeycapGallery() {
const [custom, setCustom] = useState<KeycapCustomization>({
baseColor: '#0E7490', // Cyan 700
stemColor: '#10B981', // Emerald 500
dragonColor: '#3B82F6', // Blue 500
material: 'resin_clear',
ledColor: '#06B6D4', // Cyan 500
ledIntensity: 75,
selectedLetter: 'đ',
showStem: true
});
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState(0);
const [showSuccessToast, setShowSuccessToast] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const lettersList = ['ă', 'â', 'đ', 'ê', 'ô', 'ơ', 'ư', 's (́)', 'f (̀)', 'r (̉)', 'x (̃)', 'j (̣)'];
const colorPresets = [
{ name: 'Rồng Biển Trầm', value: '#0E7490', text: 'text-cyan-400' },
{ name: 'Ngọc Lục Bảo', value: '#047857', text: 'text-emerald-400' },
{ name: 'Hồng Anh Đào', value: '#BE185D', text: 'text-pink-400' },
{ name: 'Hổ Phách Sáng', value: '#B45309', text: 'text-amber-400' },
{ name: 'Thạch Anh Tím', value: '#6D28D9', text: 'text-violet-400' },
{ name: 'Khói Obsidian', value: '#374151', text: 'text-slate-400' }
];
const ledPresets = [
{ name: 'Cyan Neon', value: '#06B6D4' },
{ name: 'Toxic Green', value: '#10B981' },
{ name: 'Sunset Orange', value: '#F97316' },
{ name: 'Sakura Pink', value: '#EC4899' },
{ name: 'Chroma RGB', value: '#8B5CF6' }
];
const keycapModels: KeycapModel[] = [
{
id: 'dragon_keycap_oem',
name: 'Rồng Con OEM Esc Keycap',
letter: 'ESC',
desc: 'Mẫu phím cơ Esc chứa rồng con dễ thương ở trung tâm, đúc khuôn resin thủ công siêu chi tiết.',
rarity: 'Legendary',
stlUrl: 'vietc_dragon_esc_oem.stl'
},
{
id: 'vietnamese_diacritic_caps',
name: 'Bộ Ký Tự Nguyên Âm Tiếng Việt',
letter: 'ă/â/đ/ê/ô/ơ/ư',
desc: 'Trọn bộ keycap các chữ cái đặc trưng và bộ thanh dấu trong tiếng Việt dành cho hàng phím Alpha.',
rarity: 'Epic',
stlUrl: 'vietc_vietnamese_alphas.zip'
},
{
id: 'numlock_dragon_plate',
name: 'Tấm Ốp Phím Numlock 3D',
letter: 'NUM',
desc: 'Bản thiết kế ốp bàn phím số cơ phong cách Rồng Con đan xen các vảy rồng bảo vệ cực chất.',
rarity: 'Rare',
stlUrl: 'vietc_numlock_plate.stl'
},
{
id: 'dragon_spacebar_625u',
name: 'Thanh Spacebar Thủy Cung Rồng Con 6.25u',
letter: 'SPACE',
desc: 'Thanh phím dài uốn lượn phong cách rồng con bay lượn dưới đáy đại dương resin trong suốt.',
rarity: 'Legendary',
stlUrl: 'vietc_spacebar_dragon.stl'
}
];
const startDownload = (model: KeycapModel) => {
if (downloadingId) return;
setDownloadingId(model.id);
setDownloadProgress(0);
const interval = setInterval(() => {
setDownloadProgress(p => {
if (p >= 100) {
clearInterval(interval);
setTimeout(() => {
setDownloadingId(null);
setToastMessage(`Đã tải về thành công tệp thiết kế 3D: ${model.stlUrl}! Sẵn sàng để in 3D FDM/SLA.`);
setShowSuccessToast(true);
setTimeout(() => setShowSuccessToast(false), 4500);
}, 400);
return 100;
}
return p + 5 + Math.floor(Math.random() * 8);
});
}, 120);
};
// Compute CSS styles based on material
const getMaterialStyles = () => {
switch (custom.material) {
case 'resin_frosted':
return {
backdropFilter: 'blur(8px)',
background: `rgba(${hexToRgb(custom.baseColor)}, 0.45)`,
border: '1px solid rgba(255, 255, 255, 0.25)',
boxShadow: `inset 0 0 15px rgba(255, 255, 255, 0.3), 0 0 25px ${custom.ledColor}${Math.floor(custom.ledIntensity / 100 * 255).toString(16)}`
};
case 'glass':
return {
backdropFilter: 'blur(3px)',
background: `rgba(${hexToRgb(custom.baseColor)}, 0.25)`,
border: '1px solid rgba(255, 255, 255, 0.4)',
boxShadow: `inset 0 0 20px rgba(255, 255, 255, 0.4), 0 0 35px ${custom.ledColor}${Math.floor(custom.ledIntensity / 100 * 255).toString(16)}`
};
case 'matte':
return {
background: custom.baseColor,
border: '1px solid rgba(0, 0, 0, 0.3)',
boxShadow: 'inset 0 4px 6px rgba(255, 255, 255, 0.1), inset 0 -4px 6px rgba(0, 0, 0, 0.2)'
};
default: // resin_clear
return {
backdropFilter: 'blur(1px)',
background: `rgba(${hexToRgb(custom.baseColor)}, 0.65)`,
border: '1px solid rgba(255, 255, 255, 0.35)',
boxShadow: `inset 0 0 10px rgba(255, 255, 255, 0.4), 0 0 25px ${custom.ledColor}${Math.floor(custom.ledIntensity / 100 * 255).toString(16)}`
};
}
};
// Helper to convert hex to rgb
function hexToRgb(hex: string): string {
const bigint = parseInt(hex.replace('#', ''), 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return `${r}, ${g}, ${b}`;
}
return (
<div id="keycaps" className="py-16 bg-[#0a0b0d] border-t border-white/10 scroll-mt-20">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
{/* Section Header */}
<div className="text-center max-w-3xl mx-auto mb-12">
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-mono mb-4"
>
<Sparkles size={12} className="text-emerald-400 animate-pulse" />
<span>ARTISAN 3D KEYCAPS</span>
</motion.div>
<h2 className="text-3xl sm:text-4xl font-serif text-white tracking-tight">
Trang Trí Phím <span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-400 italic">VietC Resin 3D</span>
</h2>
<p className="mt-4 text-slate-400 text-sm sm:text-base leading-relaxed">
Như công bố chính thức, VietC không chỉ phần mềm phím, chúng tôi chia sẻ bản vẽ thiết kế 3D hoàn toàn miễn phí của <span className="text-emerald-400 font-semibold">Mascot Rồng Con Resin trong suốt</span> bộ tự dấu tiếng Việt đ bạn tự in 3D nhân hóa bàn phím của mình!
</p>
</div>
{/* WORKSPACE: Customizer on the left, interactive keycap on the right */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-center mb-16">
{/* Controls Panel (6 cols) */}
<div className="lg:col-span-6 bg-white/[0.02] p-5 sm:p-6 rounded-2xl border border-white/10 space-y-6">
<div className="flex items-center gap-2 border-b border-white/5 pb-3">
<Sliders size={18} className="text-emerald-400" />
<h3 className="font-sans font-bold text-sm text-slate-200 tracking-wider uppercase">Bảng Điều Khiển Tùy Biến 3D</h3>
</div>
{/* Vải Màu Resin */}
<div className="space-y-2">
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
<Palette size={14} className="text-emerald-400" />
<span>Màu Sắc Resin Bọc Ngoài</span>
</label>
<div className="grid grid-cols-3 gap-2">
{colorPresets.map((preset) => (
<button
key={preset.value}
onClick={() => setCustom(prev => ({ ...prev, baseColor: preset.value }))}
className={`flex items-center gap-2 p-2 rounded-lg border text-[11px] font-medium transition-all cursor-pointer ${
custom.baseColor === preset.value
? 'bg-white/5 border-emerald-500 text-white'
: 'bg-[#0d0e12] border-white/5 text-slate-400 hover:text-slate-200'
}`}
>
<span className="w-3 h-3 rounded-full border border-slate-700/50" style={{ backgroundColor: preset.value }} />
<span className="truncate">{preset.name}</span>
</button>
))}
</div>
</div>
{/* Chất liệu Resin */}
<div className="space-y-2">
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
<Layers size={14} className="text-emerald-400" />
<span>Chất Liệu Đúc Keycap</span>
</label>
<div className="grid grid-cols-4 gap-2 text-[10px] font-mono">
{[
{ id: 'resin_clear', name: 'Trong Suốt' },
{ id: 'resin_frosted', name: 'Nhám Mờ' },
{ id: 'glass', name: 'Thạch Anh' },
{ id: 'matte', name: 'Nhựa Đục' }
].map((mat) => (
<button
key={mat.id}
onClick={() => setCustom(prev => ({ ...prev, material: mat.id as any }))}
className={`py-1.5 px-1 rounded-lg border text-center font-medium transition-all cursor-pointer ${
custom.material === mat.id
? 'bg-emerald-500/10 border-emerald-500 text-emerald-400'
: 'bg-[#0d0e12] border-white/5 text-slate-400 hover:text-slate-200 hover:bg-white/5'
}`}
>
{mat.name}
</button>
))}
</div>
</div>
{/* Ký Tự Tiếng Việt */}
<div className="space-y-2">
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
<Type size={14} className="text-emerald-400" />
<span> Tự / Dấu Thanh Tiếng Việt Khắc Trên Mặt Phím</span>
</label>
<div className="flex flex-wrap gap-1.5">
{lettersList.map((letItem) => (
<button
key={letItem}
onClick={() => setCustom(prev => ({ ...prev, selectedLetter: letItem }))}
className={`w-10 h-10 rounded-lg border flex items-center justify-center font-sans font-bold text-sm transition-all cursor-pointer ${
custom.selectedLetter === letItem
? 'bg-emerald-600 text-white border-emerald-400 shadow-[0_0_15px_rgba(16,185,129,0.35)]'
: 'bg-[#0d0e12] border-white/5 text-slate-400 hover:text-slate-200'
}`}
>
{letItem}
</button>
))}
</div>
</div>
{/* Đèn LED gầm (Underglow) */}
<div className="space-y-3 pt-2 border-t border-white/5">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
<Lightbulb size={14} className="text-emerald-400" />
<span>Hệ Thống Đèn LED Gầm (PCB Underglow)</span>
</label>
<span className="text-[10px] font-mono text-emerald-400">{custom.ledIntensity}% Đ sáng</span>
</div>
<div className="grid grid-cols-5 gap-2">
{ledPresets.map((preset) => (
<button
key={preset.value}
onClick={() => setCustom(prev => ({ ...prev, ledColor: preset.value }))}
className={`w-full h-7 rounded-md border flex items-center justify-center transition-all cursor-pointer ${
custom.ledColor === preset.value
? 'border-white scale-110 shadow-lg'
: 'border-transparent opacity-65 hover:opacity-100'
}`}
style={{ backgroundColor: preset.value, boxShadow: custom.ledColor === preset.value ? `0 0 10px ${preset.value}` : 'none' }}
title={preset.name}
/>
))}
</div>
<input
type="range"
min="0"
max="100"
value={custom.ledIntensity}
onChange={(e) => setCustom(prev => ({ ...prev, ledIntensity: parseInt(e.target.value) }))}
className="w-full h-1 bg-[#0d0e12] rounded-lg appearance-none cursor-pointer accent-emerald-500"
/>
</div>
</div>
{/* Interactive 3D Render Viewer (6 cols) */}
<div className="lg:col-span-6 flex flex-col items-center justify-center p-6 bg-white/[0.01] rounded-2xl border border-white/5 h-[420px] relative overflow-hidden group">
{/* Grid background effect */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#334155_1px,transparent_1px),linear-gradient(to_bottom,#334155_1px,transparent_1px)] bg-[size:24px_24px] opacity-10" />
{/* Floating glowing indicator */}
<div className="absolute top-4 right-4 bg-[#0d0e12]/80 px-2.5 py-1 rounded-full border border-emerald-500/20 text-[9px] font-mono text-emerald-400 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ backgroundColor: custom.ledColor }} />
<span>3D RENDER PREVIEW</span>
</div>
{/* Rotating 3D Keycap Stage Container */}
<div className="relative w-64 h-64 flex items-center justify-center perspective-[1000px]">
{/* LED Underglow radial halo */}
<div
className="absolute w-48 h-48 rounded-full blur-3xl opacity-40 transition-all duration-300 pointer-events-none"
style={{
backgroundColor: custom.ledColor,
transform: 'translateY(50px) scale(0.8)',
filter: `blur(45px)`,
opacity: (custom.ledIntensity / 100) * 0.5
}}
/>
{/* The Keycap Body Wrapper (3D effect) */}
<motion.div
className="w-40 h-40 relative transform-style-3d cursor-grab active:cursor-grabbing flex items-center justify-center"
animate={{
rotateY: [0, 360],
rotateX: [12, 12]
}}
transition={{
rotateY: { repeat: Infinity, duration: 16, ease: 'linear' }
}}
>
{/* 1. KEYCAP BASE / STEM (Inner structure visible through clear resin) */}
{custom.material !== 'matte' && (
<div className="absolute inset-4 rounded-xl flex items-center justify-center border border-dashed border-slate-700/30 z-10 pointer-events-none">
{/* The mechanical switch cross stem (+) inside */}
<div className="relative w-8 h-8 flex items-center justify-center">
<div className="absolute w-2 h-7 rounded-sm shadow-md" style={{ backgroundColor: custom.stemColor }} />
<div className="absolute w-7 h-2 rounded-sm shadow-md" style={{ backgroundColor: custom.stemColor }} />
</div>
</div>
)}
{/* 2. CUTE MASCOT DRAGON RESTING INSIDE */}
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none scale-65 translate-y-[-5px]">
<DragonMascot size={110} interactive={false} />
</div>
{/* 3. TRANSPARENT RESIN OUTER SHELL (Styled with standard custom glassmorphism) */}
<div
className="absolute inset-0 rounded-2xl transition-all duration-300 z-30 flex flex-col justify-between p-4.5"
style={getMaterialStyles()}
>
{/* Vietnamese Letter Engraving on top facet */}
<div className="text-right w-full">
<span className="font-sans font-extrabold text-2xl tracking-tight text-white drop-shadow-lg opacity-85 select-none">
{custom.selectedLetter.split(' ')[0]}
</span>
</div>
{/* Aesthetic detail: micro-bubbles or text inside */}
<div className="flex justify-between items-end w-full text-[8px] font-mono text-white/55">
<span>VIETC 3D</span>
<span>OEM-R1</span>
</div>
</div>
{/* Bottom Base rim */}
<div className="absolute inset-[-4px] rounded-3xl border-2 border-slate-800/40 translate-y-[8px] scale-95 opacity-50 z-0" />
</motion.div>
</div>
{/* Customizer Instructions */}
<div className="absolute bottom-4 left-4 text-[10px] font-mono text-slate-500 flex items-center gap-1">
<AlertCircle size={10} />
<span>Góc xoay 3D giả lập trực quan 360°</span>
</div>
</div>
</div>
{/* 3D PRINTING FILES DOWNLOAD SECTION */}
<div className="space-y-6">
<h3 className="text-xl font-sans font-bold text-slate-100 flex items-center gap-2 border-b border-white/5 pb-3">
<Download className="text-emerald-400 animate-bounce" size={18} />
<span>Tải Về File Thiết Kế 3D Miễn Phí (STL/OBJ)</span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{keycapModels.map((model) => (
<div
key={model.id}
className="bg-white/[0.02] rounded-2xl border border-white/5 p-5 flex flex-col justify-between hover:border-emerald-500/30 transition-all group"
>
<div>
{/* Rarity and Rating info */}
<div className="flex items-center justify-between mb-3.5">
<span className={`text-[9px] font-mono px-2 py-0.5 rounded-full border ${
model.rarity === 'Legendary'
? 'bg-amber-500/10 border-amber-500/20 text-amber-400'
: model.rarity === 'Epic'
? 'bg-purple-500/10 border-purple-500/20 text-purple-400'
: 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
}`}>
{model.rarity} Design
</span>
<div className="flex items-center gap-0.5 text-amber-400">
<Star size={10} fill="currentColor" />
<Star size={10} fill="currentColor" />
<Star size={10} fill="currentColor" />
<Star size={10} fill="currentColor" />
<Star size={10} fill="currentColor" />
</div>
</div>
<h4 className="text-sm font-sans font-bold text-slate-200 group-hover:text-emerald-400 transition-colors">
{model.name}
</h4>
<p className="text-slate-400 text-xs mt-2.5 leading-relaxed min-h-[48px]">
{model.desc}
</p>
</div>
<div className="mt-5 pt-4 border-t border-white/5">
<div className="flex items-center justify-between text-[11px] text-slate-500 font-mono mb-3">
<span>Đnh dạng: STL / STEP</span>
<span className="text-emerald-400 font-bold">FREE</span>
</div>
{downloadingId === model.id ? (
<div className="space-y-1.5">
<div className="flex justify-between text-[10px] font-mono text-emerald-400">
<span>Đang tải...</span>
<span>{downloadProgress}%</span>
</div>
<div className="w-full bg-[#0d0e12] h-1.5 rounded-full overflow-hidden">
<div className="bg-emerald-500 h-full transition-all duration-100" style={{ width: `${downloadProgress}%` }} />
</div>
</div>
) : (
<button
onClick={() => startDownload(model)}
className="w-full py-2 rounded-lg bg-[#0d0e12] hover:bg-emerald-600 border border-white/10 hover:border-emerald-500 text-slate-300 hover:text-white font-sans font-bold text-xs transition-all flex items-center justify-center gap-1.5 cursor-pointer"
>
<Download size={13} />
Tải File STL
</button>
)}
</div>
</div>
))}
</div>
</div>
{/* Global Action Toast Notification */}
<AnimatePresence>
{showSuccessToast && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="fixed bottom-6 right-6 z-50 max-w-sm bg-[#0d0e12] border border-emerald-500/30 p-4 rounded-xl shadow-2xl flex items-start gap-3"
>
<CheckCircle2 className="text-emerald-400 flex-shrink-0 mt-0.5" size={18} />
<div>
<h4 className="text-xs font-sans font-bold text-slate-200">Bắt đu tải file 3D</h4>
<p className="text-slate-400 text-[11px] mt-1 leading-relaxed">
{toastMessage}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}