feat: add website as web/ subfolder

This commit is contained in:
VietC 2026-07-04 17:18:22 +07:00
parent 0495c7cbd7
commit 143ba5ca58
24 changed files with 6760 additions and 0 deletions

9
web/.env.example Normal file
View file

@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

8
web/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

20
web/README.md Normal file
View file

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/aa240f3c-abe9-494f-8e47-678406f50ae1
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

14
web/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vietc-favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VietC - Bộ gõ tiếng Việt Native cho Linux Terminal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6
web/metadata.json Normal file
View file

@ -0,0 +1,6 @@
{
"name": "VietC - Native Linux VNI Terminal IME",
"description": "Website giới thiệu bộ gõ tiếng Việt VietC siêu nhẹ, siêu mượt cho Linux Terminal, kèm theo hướng dẫn cài đặt và trang tùy biến bộ Keycap 3D Resin Rồng Con dễ thương.",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}

4278
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
web/package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
}
}

View file

@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none">
<!-- Left ear -->
<path d="M38 72 C 15 45, 12 85, 34 108 Z" fill="#1E3A5F"/>
<path d="M36 70 C 20 52, 18 80, 32 100 Z" fill="#2563EB" opacity="0.6"/>
<!-- Right ear -->
<path d="M162 72 C 185 45, 188 85, 166 108 Z" fill="#1E3A5F"/>
<path d="M164 70 C 180 52, 182 80, 168 100 Z" fill="#2563EB" opacity="0.6"/>
<!-- Main head -->
<ellipse cx="100" cy="100" rx="62" ry="58" fill="#3B82F6"/>
<!-- Head highlight -->
<ellipse cx="100" cy="72" rx="44" ry="22" fill="#60A5FA" opacity="0.3"/>
<!-- Cheeks -->
<ellipse cx="48" cy="112" rx="18" ry="20" fill="#3B82F6"/>
<ellipse cx="152" cy="112" rx="18" ry="20" fill="#3B82F6"/>
<!-- Horns -->
<path d="M76 44 C 65 16, 84 6, 90 30 C 92 38, 88 44, 82 46 Z" fill="#1E3A5F" stroke="#0F2942" stroke-width="1"/>
<path d="M80 40 C 72 22, 80 14, 86 34 Z" fill="#2563EB" opacity="0.5"/>
<path d="M124 44 C 135 16, 116 6, 110 30 C 108 38, 112 44, 118 46 Z" fill="#1E3A5F" stroke="#0F2942" stroke-width="1"/>
<path d="M120 40 C 128 22, 120 14, 114 34 Z" fill="#2563EB" opacity="0.5"/>
<!-- Forehead ridges -->
<path d="M82 52 C 90 45, 110 45, 118 52" stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.5"/>
<path d="M86 60 C 92 55, 108 55, 114 60" stroke="#2563EB" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
<!-- Snout -->
<ellipse cx="100" cy="120" rx="28" ry="20" fill="#60A5FA"/>
<path d="M76 114 C 86 106, 114 106, 124 114 C 118 118, 82 118, 76 114 Z" fill="#93C5FD" opacity="0.3"/>
<!-- Nostrils -->
<ellipse cx="91" cy="117" rx="3" ry="3.5" fill="#2563EB"/>
<ellipse cx="109" cy="117" rx="3" ry="3.5" fill="#2563EB"/>
<!-- Blush -->
<ellipse cx="60" cy="120" rx="12" ry="6" fill="#F87171" opacity="0.35"/>
<ellipse cx="140" cy="120" rx="12" ry="6" fill="#F87171" opacity="0.35"/>
<!-- Left eye -->
<ellipse cx="78" cy="92" rx="16" ry="18" fill="#111827"/>
<ellipse cx="78" cy="92" rx="13" ry="15" fill="#0D9488"/>
<ellipse cx="78" cy="92" rx="8" ry="10" fill="#111827"/>
<circle cx="72" cy="85" r="5" fill="#FFFFFF"/>
<circle cx="85" cy="99" r="2.2" fill="#FFFFFF" opacity="0.7"/>
<circle cx="71" cy="97" r="1.4" fill="#FFFFFF" opacity="0.4"/>
<!-- Right eye -->
<ellipse cx="122" cy="92" rx="16" ry="18" fill="#111827"/>
<ellipse cx="122" cy="92" rx="13" ry="15" fill="#0D9488"/>
<ellipse cx="122" cy="92" rx="8" ry="10" fill="#111827"/>
<circle cx="116" cy="85" r="5" fill="#FFFFFF"/>
<circle cx="129" cy="99" r="2.2" fill="#FFFFFF" opacity="0.7"/>
<circle cx="115" cy="97" r="1.4" fill="#FFFFFF" opacity="0.4"/>
<!-- Smile -->
<path d="M84 124 C 88 130, 98 128, 100 124 C 102 128, 112 130, 116 124" stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M88 123 L 90 125 L 92 123 Z" fill="#FFFFFF" opacity="0.7"/>
<path d="M112 123 L 110 125 L 108 123 Z" fill="#FFFFFF" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

63
web/src/App.tsx Normal file
View file

@ -0,0 +1,63 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import Navbar from './components/Navbar';
import Hero from './components/Hero';
import Features from './components/Features';
import TerminalSimulator from './components/TerminalSimulator';
import SetupGuide from './components/SetupGuide';
import KeycapGallery from './components/KeycapGallery';
import Footer from './components/Footer';
export default function App() {
const [activeView, setActiveView] = useState<'home' | 'keycaps'>('home');
return (
<div className="min-h-screen bg-[#0a0b0d] text-slate-200 flex flex-col font-sans antialiased selection:bg-emerald-500/30 selection:text-white">
{/* Dynamic Navigation bar */}
<Navbar activeView={activeView} setActiveView={setActiveView} />
{/* Main page content layout with view switcher transitions */}
<main className="flex-1">
<AnimatePresence mode="wait">
{activeView === 'home' ? (
<motion.div
key="home-view"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
{/* Hero & Official Announcement Card */}
<Hero setActiveView={setActiveView} />
{/* Core technical pillars section */}
<Features />
{/* Live Interactive Terminal Simulator VNI Engine */}
<TerminalSimulator />
{/* Step-by-step Linux System-Level Setup Guide */}
<SetupGuide />
</motion.div>
) : (
<motion.div
key="keycaps-view"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
{/* Trang phụ: 3D Transparent Resin Keycap Customizer & Gallery */}
<KeycapGallery />
</motion.div>
)}
</AnimatePresence>
</main>
{/* Footer component with social repository links & author credits */}
<Footer />
</div>
);
}

View file

@ -0,0 +1,180 @@
import React, { useState } from 'react';
import { motion } from 'motion/react';
interface DragonMascotProps {
className?: string;
size?: number;
interactive?: boolean;
}
export default function DragonMascot({ className = '', size = 150, interactive = true }: DragonMascotProps) {
const [isClicked, setIsClicked] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const handleClick = () => {
if (!interactive) return;
setIsClicked(true);
setTimeout(() => setIsClicked(false), 800);
};
return (
<div
className={`relative select-none flex flex-col items-center justify-center ${className}`}
style={{ width: size, height: size }}
onClick={handleClick}
onMouseEnter={() => interactive && setIsHovered(true)}
onMouseLeave={() => interactive && setIsHovered(false)}
>
<motion.svg
viewBox="0 0 200 200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-full h-full drop-shadow-xl"
animate={{
y: isHovered ? [0, -6, 0] : [0, -2, 0],
rotate: isClicked ? [0, -8, 8, -4, 4, 0] : 0,
scale: isClicked ? [1, 1.12, 0.96, 1.04, 1] : 1,
}}
transition={{
y: { repeat: Infinity, duration: isHovered ? 1.2 : 3, ease: "easeInOut" },
rotate: { duration: 0.5 },
scale: { duration: 0.5 }
}}
>
{/* Left ear */}
<motion.path
d="M38 72 C 15 45, 12 85, 34 108 Z"
fill="#1E3A5F"
animate={{ rotate: isHovered ? -3 : 0 }}
transition={{ duration: 0.3 }}
/>
<path d="M36 70 C 20 52, 18 80, 32 100 Z" fill="#2563EB" opacity="0.6" />
{/* Right ear */}
<motion.path
d="M162 72 C 185 45, 188 85, 166 108 Z"
fill="#1E3A5F"
animate={{ rotate: isHovered ? 3 : 0 }}
transition={{ duration: 0.3 }}
/>
<path d="M164 70 C 180 52, 182 80, 168 100 Z" fill="#2563EB" opacity="0.6" />
{/* Main head - bigger, fills more of the viewbox */}
<ellipse cx="100" cy="100" rx="62" ry="58" fill="#3B82F6" />
{/* Head highlight */}
<ellipse cx="100" cy="72" rx="44" ry="22" fill="#60A5FA" opacity="0.3" />
{/* Cheeks */}
<ellipse cx="48" cy="112" rx="18" ry="20" fill="#3B82F6" />
<ellipse cx="152" cy="112" rx="18" ry="20" fill="#3B82F6" />
{/* Horns */}
<motion.path
d="M76 44 C 65 16, 84 6, 90 30 C 92 38, 88 44, 82 46 Z"
fill="#1E3A5F"
stroke="#0F2942"
strokeWidth="1"
animate={{ rotate: isHovered ? -3 : 0 }}
/>
<path d="M80 40 C 72 22, 80 14, 86 34 Z" fill="#2563EB" opacity="0.5" />
<motion.path
d="M124 44 C 135 16, 116 6, 110 30 C 108 38, 112 44, 118 46 Z"
fill="#1E3A5F"
stroke="#0F2942"
strokeWidth="1"
animate={{ rotate: isHovered ? 3 : 0 }}
/>
<path d="M120 40 C 128 22, 120 14, 114 34 Z" fill="#2563EB" opacity="0.5" />
{/* Forehead ridges */}
<path d="M82 52 C 90 45, 110 45, 118 52" stroke="#2563EB" strokeWidth="2.5" strokeLinecap="round" fill="none" opacity="0.5" />
<path d="M86 60 C 92 55, 108 55, 114 60" stroke="#2563EB" strokeWidth="2" strokeLinecap="round" fill="none" opacity="0.35" />
{/* Snout */}
<ellipse cx="100" cy="120" rx="28" ry="20" fill="#60A5FA" />
<path d="M76 114 C 86 106, 114 106, 124 114 C 118 118, 82 118, 76 114 Z" fill="#93C5FD" opacity="0.3" />
{/* Nostrils */}
<ellipse cx="91" cy="117" rx="3" ry="3.5" fill="#2563EB" />
<ellipse cx="109" cy="117" rx="3" ry="3.5" fill="#2563EB" />
{/* Blush */}
<ellipse cx="60" cy="120" rx="12" ry="6" fill="#F87171" opacity="0.35" />
<ellipse cx="140" cy="120" rx="12" ry="6" fill="#F87171" opacity="0.35" />
{/* Eyes */}
<g>
<ellipse cx="78" cy="92" rx="16" ry="18" fill="#111827" />
<ellipse cx="78" cy="92" rx="13" ry="15" fill="#0D9488" />
<ellipse cx="78" cy="92" rx="8" ry="10" fill="#111827" />
<motion.circle
cx="72" cy="85" r="5" fill="#FFFFFF"
animate={{ scale: isHovered ? [1, 1.25, 1] : 1 }}
transition={{ repeat: Infinity, duration: 1.8 }}
/>
<circle cx="85" cy="99" r="2.2" fill="#FFFFFF" opacity="0.7" />
<circle cx="71" cy="97" r="1.4" fill="#FFFFFF" opacity="0.4" />
<motion.path
d="M60 72 H 96 V 94 H 60 Z"
fill="#3B82F6"
transformOrigin="78px 72px"
animate={{ scaleY: [0, 0, 1, 0, 0, 0, 1, 0] }}
transition={{ repeat: Infinity, duration: 3.5, times: [0, 0.4, 0.45, 0.5, 0.85, 0.9, 0.95, 1] }}
/>
</g>
<g>
<ellipse cx="122" cy="92" rx="16" ry="18" fill="#111827" />
<ellipse cx="122" cy="92" rx="13" ry="15" fill="#0D9488" />
<ellipse cx="122" cy="92" rx="8" ry="10" fill="#111827" />
<motion.circle
cx="116" cy="85" r="5" fill="#FFFFFF"
animate={{ scale: isHovered ? [1, 1.25, 1] : 1 }}
transition={{ repeat: Infinity, duration: 1.8 }}
/>
<circle cx="129" cy="99" r="2.2" fill="#FFFFFF" opacity="0.7" />
<circle cx="115" cy="97" r="1.4" fill="#FFFFFF" opacity="0.4" />
<motion.path
d="M104 72 H 140 V 94 H 104 Z"
fill="#3B82F6"
transformOrigin="122px 72px"
animate={{ scaleY: [0, 0, 1, 0, 0, 0, 1, 0] }}
transition={{ repeat: Infinity, duration: 3.5, times: [0, 0.4, 0.45, 0.5, 0.85, 0.9, 0.95, 1] }}
/>
</g>
{/* Smile */}
{isHovered || isClicked ? (
<g>
<path d="M82 124 C 82 138, 118 138, 118 124 Z" fill="#991B1B" />
<path d="M86 130 C 90 134, 110 134, 114 130 Z" fill="#FCA5A5" />
<path d="M84 124 L 88 128 L 92 124 Z" fill="#FFFFFF" />
<path d="M116 124 L 112 128 L 108 124 Z" fill="#FFFFFF" />
</g>
) : (
<g>
<path d="M84 124 C 88 130, 98 128, 100 124 C 102 128, 112 130, 116 124" stroke="#2563EB" strokeWidth="2.5" strokeLinecap="round" fill="none" />
<path d="M88 123 L 90 125 L 92 123 Z" fill="#FFFFFF" opacity="0.7" />
<path d="M112 123 L 110 125 L 108 123 Z" fill="#FFFFFF" opacity="0.7" />
</g>
)}
</motion.svg>
{interactive && (
<motion.div
className="absolute -top-6 bg-[#0d0e12] text-emerald-400 text-[10px] font-mono px-2 py-0.5 rounded-full border border-emerald-500/20 shadow-md pointer-events-none"
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: isHovered ? 1 : 0,
scale: isHovered ? 1 : 0.8,
y: isHovered ? -4 : 0,
}}
transition={{ duration: 0.2 }}
>
{isClicked ? "Rawrr! ^_^" : "Click me!"}
</motion.div>
)}
</div>
);
}

View file

@ -0,0 +1,188 @@
import React, { useState } from 'react';
import { motion } from 'motion/react';
import { ShieldCheck, Cpu, GitCompare, HelpCircle, Layers, ArrowRight } from 'lucide-react';
export default function Features() {
const [hoveredState, setHoveredState] = useState<number | null>(null);
const stateDetails = [
{
id: 0,
name: "S0 - Idle (Chờ phím)",
desc: "Trạng thái nghỉ ngơi ban đầu. VietC lắng nghe thụ động thiết bị đầu vào evdev mà không can thiệp, đảm bảo CPU tiêu thụ ~0%."
},
{
id: 1,
name: "S1 - Vowel Buffer (Thu nhận nguyên âm)",
desc: "Kích hoạt khi phát hiện nguyên âm gốc (a, e, o, u, y, i). VietC tạo bộ đệm từ cục bộ để chuẩn bị ghép dấu thanh."
},
{
id: 2,
name: "S2 - Accent Applied (Tạo dấu thanh)",
desc: "Nạp các phím gõ dấu thanh VNI (1-5). Thuật toán tối ưu hóa vị trí đặt dấu theo đúng ngữ pháp Việt ngữ chuẩn."
},
{
id: 3,
name: "S3 - Modifiers (Ký tự đặc biệt)",
desc: "Áp dụng mũ/râu (6-9) để biến đổi thành ă, â, đ, ê, ô, ơ, ư. Kết thúc chu kỳ xử lý và sẵn sàng phát phím uinput mới."
}
];
return (
<div id="features" 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 Title */}
<div className="text-center max-w-3xl mx-auto mb-16">
<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"
>
<Cpu size={12} className="text-emerald-500" />
<span>HOW IT WORKS</span>
</motion.div>
<motion.h2
initial={{ opacity: 0, y: 15 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="text-3xl sm:text-4xl font-serif text-white tracking-tight"
>
Sự Khác Biệt Làm Nên Sức Mạnh <span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-400 italic">VietC</span>
</motion.h2>
<p className="mt-4 text-slate-400 text-sm sm:text-base leading-relaxed">
VietC đưc phát triển dựa trên 3 trụ cột kỹ thuật cốt lõi giúp tối đa hóa tốc đ, đ n đnh tuyệt đi khả năng tương thích 100% với môi trường giả lập Linux Terminal.
</p>
</div>
{/* 3 Pillars Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-16">
{/* Pillar 1: State Machine */}
<div className="bg-white/[0.02] rounded-2xl border border-white/5 p-6 flex flex-col justify-between hover:border-emerald-500/30 transition-all">
<div>
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center mb-5">
<Layers size={20} />
</div>
<h3 className="text-base font-sans font-bold text-white mb-3">
1. State Machine Deterministic
</h3>
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed mb-4">
Sử dụng hình toán học Finite State Machine (FSM) tất đnh đ phân tích tổ hợp phím tiếng Việt. Mọi tự đưc tính toán ràng giúp tránh tình trạng xung đt, mất từ hoặc sai vị trí đt dấu khi nhanh.
</p>
</div>
<div className="text-[11px] font-mono text-emerald-400 mt-2 bg-emerald-950/15 p-2.5 rounded-lg border border-emerald-500/10">
S0 (Chờ) &rarr; S1 () &rarr; S2 (Dấu) &rarr; S3 (Chữ )
</div>
</div>
{/* Pillar 2: Token-Level Diffing */}
<div className="bg-white/[0.02] rounded-2xl border border-white/5 p-6 flex flex-col justify-between hover:border-emerald-500/30 transition-all">
<div>
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center mb-5">
<GitCompare size={20} />
</div>
<h3 className="text-base font-sans font-bold text-white mb-3">
2. Token-Level Diffing
</h3>
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed mb-4">
Thay xóa trắng toàn bộ từ hoặc phát lại một loạt phím Backspace dồn dập gây giật màn hình trong Terminal, VietC tính toán sự khác biệt nhỏ nhất giữa từ đã từ mong muốn đ thay thế cục bộ tức thì.
</p>
</div>
<div className="text-[11px] font-mono text-emerald-400 mt-2 bg-emerald-950/15 p-2.5 rounded-lg border border-emerald-500/10 flex items-center justify-between">
<span>trang thái</span>
<ArrowRight size={10} />
<span className="font-bold">trạng thái [1ms]</span>
</div>
</div>
{/* Pillar 3: Privacy-First Event Sourcing */}
<div className="bg-white/[0.02] rounded-2xl border border-white/5 p-6 flex flex-col justify-between hover:border-emerald-500/30 transition-all">
<div>
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center mb-5">
<ShieldCheck size={20} />
</div>
<h3 className="text-base font-sans font-bold text-white mb-3">
3. Privacy-First Event Sourcing
</h3>
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed mb-4">
Xử sự kiện bàn phím theo luồng đc lập dưới quyền user thông qua uinput cục bộ. VietC nói KHÔNG với kết nối Internet, đm bảo toàn bộ mật khẩu, lệnh Terminal nhạy cảm luôn đưc bảo vệ an toàn tuyệt đi.
</p>
</div>
<div className="text-[11px] font-mono text-emerald-400 mt-2 bg-emerald-950/15 p-2.5 rounded-lg border border-emerald-500/10">
Kiểm soát cục bộ 100% &bull; Không thu thập dữ liệu
</div>
</div>
</div>
{/* Detalized State Machine Explanation Block */}
<div className="bg-white/[0.02] p-6 sm:p-8 rounded-3xl border border-white/10 flex flex-col lg:flex-row gap-8 items-center">
{/* Diagrams Left */}
<div className="w-full lg:w-1/2 space-y-4">
<h3 className="text-lg sm:text-xl font-bold text-white mb-2">
Tìm Hiểu Trạng Thái Finite State Machine
</h3>
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed">
Khi bạn phím, VietC không lưu trữ tự dưới dạng văn bản tĩnh thô . Hệ thống sẽ thay đi các nút liên kết (S0, S1, S2, S3) dựa trên loại phím nhận đưc đ tính toán cách phản hồi phím nhanh nhất. Di chuột vào các nút dưới đây đ xem tả:
</p>
<div className="grid grid-cols-2 gap-3 pt-3">
{stateDetails.map((det) => (
<div
key={det.id}
onMouseEnter={() => setHoveredState(det.id)}
onMouseLeave={() => setHoveredState(null)}
className={`p-3 rounded-xl border transition-all cursor-pointer ${
hoveredState === det.id
? 'bg-emerald-500/10 border-emerald-500 text-emerald-300'
: 'bg-[#0a0b0d] border-white/5 text-slate-400 hover:border-emerald-500/30'
}`}
>
<div className="font-mono text-xs font-bold text-white mb-1">
Trạng thái S{det.id}
</div>
<div className="text-[10px] leading-relaxed">
{det.name.split(' - ')[1]}
</div>
</div>
))}
</div>
</div>
{/* Interactive State explanation box Right */}
<div className="w-full lg:w-1/2 bg-[#0a0b0d] p-6 rounded-2xl border border-white/10 min-h-[220px] flex flex-col justify-center">
{hoveredState !== null ? (
<motion.div
key={`state-det-${hoveredState}`}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
className="space-y-3"
>
<div className="inline-flex px-2 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 font-mono text-[10px] font-bold">
SỰ KIỆN ĐANG HOẠT ĐỘNG: S{hoveredState}
</div>
<h4 className="text-base font-sans font-bold text-white">
{stateDetails[hoveredState].name}
</h4>
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed">
{stateDetails[hoveredState].desc}
</p>
</motion.div>
) : (
<div className="text-center text-slate-500 text-xs sm:text-sm leading-relaxed py-8">
<HelpCircle className="mx-auto text-slate-600 mb-3" size={24} />
Hãy di chuột qua các nút trạng thái bên cạnh đ khám phá cách thiết lập hệ thống logic phím tất đnh của VietC!
</div>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,66 @@
import React from 'react';
import { Github, Heart, MessageSquare } from 'lucide-react';
import DragonMascot from './DragonMascot';
export default function Footer() {
return (
<footer className="bg-[#0a0b0d] border-t border-white/10 py-12 px-4">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
{/* Left branding */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/[0.02] rounded-lg border border-white/10 flex items-center justify-center p-0.5">
<DragonMascot size={34} interactive={false} />
</div>
<div className="text-left">
<span className="font-sans font-black text-slate-100 text-lg tracking-wider block">
VietC Project
</span>
<span className="text-[10px] font-mono text-slate-500 leading-none">
Bàn phím & Bộ tiếng Việt mức thấp cho Linux Terminal
</span>
</div>
</div>
{/* Center Credits */}
<div className="text-center md:text-right text-xs text-slate-500 font-mono space-y-1">
<div>
Phát triển bởi <a href="https://github.com/vndangkhoa" target="_blank" rel="noopener noreferrer" className="text-slate-400 hover:text-emerald-400 underline font-semibold">vndangkhoa</a>
</div>
<div className="flex items-center justify-center md:justify-end gap-1.5 text-[11px] text-slate-600">
<span>Made with</span>
<Heart size={10} className="text-rose-500 animate-pulse fill-rose-500" />
<span>for Vietnamese Linux Community</span>
</div>
</div>
{/* Right External Links */}
<div className="flex items-center gap-4 text-slate-500">
<a
href="https://github.com/vndangkhoa/vietc"
target="_blank"
rel="noopener noreferrer"
className="hover:text-emerald-400 transition-colors"
title="GitHub Repository"
>
<Github size={18} />
</a>
<a
href="https://github.com/vndangkhoa/vietc/issues"
target="_blank"
rel="noopener noreferrer"
className="hover:text-emerald-400 transition-colors"
title="Đóng góp ý kiến"
>
<MessageSquare size={18} />
</a>
</div>
</div>
<div className="max-w-6xl mx-auto mt-8 pt-6 border-t border-white/5 text-center text-[10px] font-mono text-slate-600">
&copy; {new Date().getFullYear()} VietC. Phát hành theo Giấy phép Apache-2.0 / MIT.
</div>
</footer>
);
}

156
web/src/components/Hero.tsx Normal file
View file

@ -0,0 +1,156 @@
import React from 'react';
import { motion } from 'motion/react';
import { Terminal, ArrowRight, Sparkles, Shield, Cpu, Zap, Download } from 'lucide-react';
import DragonMascot from './DragonMascot';
interface HeroProps {
setActiveView: (view: 'home' | 'keycaps') => void;
}
export default function Hero({ setActiveView }: HeroProps) {
const scrollToDemo = () => {
document.getElementById('demo')?.scrollIntoView({ behavior: 'smooth' });
};
const scrollToSetup = () => {
document.getElementById('setup-guide')?.scrollIntoView({ behavior: 'smooth' });
};
return (
<div className="relative pt-10 pb-20 px-4 sm:px-6 overflow-hidden bg-[#0a0b0d]">
{/* Background ambient lighting */}
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] rounded-full bg-emerald-500/5 blur-[120px] pointer-events-none" />
<div className="absolute top-1/3 right-1/4 w-[600px] h-[600px] rounded-full bg-teal-500/5 blur-[150px] pointer-events-none" />
<div className="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-12 items-center relative z-10">
{/* LEFT COLUMN: Main Presentation & CTAs */}
<div className="lg:col-span-6 space-y-6 text-left">
{/* Version badge */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
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 font-semibold"
>
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<span>VietC v1.2.0 - Native Linux Input Mode</span>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-4xl sm:text-5xl lg:text-6xl font-serif text-white leading-tight"
>
Tiếng Việt <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-400 italic">
"Như Bay"
</span> <br />
Mượt Trên Linux!
</motion.h1>
<p className="text-slate-400 text-sm sm:text-base leading-relaxed max-w-lg">
VietC giải pháp nhập liệu nguồn mở hiện đi cho môi trường Linux, tối ưu hóa tốc đ sự đơn giản với linh vật chú rồng con Long-kun. Không qua IBus/Fcitx5 phức tạp, giải quyết triệt đ lỗi nuốt phím lag chữ.
</p>
{/* Quick Metrics */}
<div className="grid grid-cols-3 gap-3 max-w-md pt-2">
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 flex flex-col items-center">
<span className="text-xs text-slate-500 font-mono">Keystroke</span>
<span className="text-sm font-bold text-emerald-400 font-mono">0ms</span>
</div>
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 flex flex-col items-center">
<span className="text-xs text-slate-500 font-mono">IBus/Fcitx5</span>
<span className="text-sm font-bold text-emerald-300 font-mono">Bypass</span>
</div>
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 flex flex-col items-center">
<span className="text-xs text-slate-500 font-mono">Trễ Phím</span>
<span className="text-sm font-bold text-emerald-400 font-mono">Giảm 20x</span>
</div>
</div>
{/* Action buttons */}
<div className="flex flex-col sm:flex-row gap-3 pt-4">
<button
onClick={scrollToSetup}
className="px-6 py-3.5 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-[#0a0b0d] font-sans font-bold text-sm transition-all shadow-[0_0_20px_rgba(16,185,129,0.25)] flex items-center justify-center gap-2 group cursor-pointer"
>
Cài Đt Trên Linux
<ArrowRight size={16} className="group-hover:translate-x-1 transition-transform" />
</button>
<button
onClick={() => setActiveView('keycaps')}
className="px-6 py-3.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 hover:text-white font-sans font-bold text-sm transition-all flex items-center justify-center gap-2 cursor-pointer"
>
<Sparkles size={14} className="text-emerald-400 animate-pulse" />
Artisan Keycaps 3D
</button>
</div>
</div>
{/* RIGHT COLUMN: Official Announcement Card */}
<div className="lg:col-span-6 flex flex-col items-center">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="w-full max-w-md bg-gradient-to-b from-white/[0.04] to-transparent p-6 sm:p-8 rounded-[2rem] border border-white/10 shadow-2xl relative overflow-hidden"
>
{/* Soft inner corner borders */}
<div className="absolute top-2 left-2 w-4 h-4 border-t border-l border-white/20 rounded-tl" />
<div className="absolute top-2 right-2 w-4 h-4 border-t border-r border-white/20 rounded-tr" />
<div className="absolute bottom-2 left-2 w-4 h-4 border-b border-l border-white/20 rounded-bl" />
<div className="absolute bottom-2 right-2 w-4 h-4 border-b border-r border-white/20 rounded-br" />
{/* Mascot on top of announcement */}
<div className="flex flex-col items-center mb-6">
<DragonMascot size={110} />
<h2 className="font-sans font-black text-white text-2xl tracking-widest uppercase mt-1">
VIETC<span className="text-emerald-500">.</span>
</h2>
</div>
{/* Official Announcement body */}
<div className="space-y-6 text-center">
<div className="bg-emerald-950/20 border border-emerald-500/20 py-2.5 px-4 rounded-xl">
<span className="font-sans font-black text-xl sm:text-2xl text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 via-teal-300 to-emerald-400 tracking-wide block uppercase">
TUYÊN BỐ CHÍNH THỨC
</span>
</div>
<p className="text-slate-300 text-xs sm:text-sm leading-relaxed text-justify">
Đ đơn giản hóa tối đa việc VNI trên Terminal bấy lâu nay cùng <span className="text-emerald-400 font-bold">gian khổ</span> cho dân Linux, <span className="text-emerald-400 font-bold">VIETC</span> tự hào công bố đã support native VNI trên Terminal.
</p>
<p className="text-slate-400 text-xs leading-relaxed text-justify">
Quý khách thể tải toàn bộ các bản thiết kế <span className="text-emerald-400 font-bold">3D phím Numlock Keycap</span> trên website VIETC xuống. Sau đy, tận dụng trí tưởng tượng phong phú đ <span className="text-emerald-400 font-bold">lắp ghép</span> trải nghiệm cảm giác <span className="text-teal-300 font-bold"> như bay</span> ngay trên Terminal o của bạn không cần bất kỳ phần cứng vật nào.
</p>
</div>
{/* Try online indicator */}
<div className="mt-6 pt-4 border-t border-white/5 flex justify-center">
<button
onClick={scrollToDemo}
className="text-[11px] font-mono text-emerald-400 hover:text-emerald-300 flex items-center gap-1.5 transition-colors cursor-pointer"
>
<Terminal size={12} className="text-emerald-500" />
<span>Trải nghiệm giả lập Terminal bên dưới &darr;</span>
</button>
</div>
</motion.div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,475 @@
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>
);
}

View file

@ -0,0 +1,122 @@
import React from 'react';
import { motion } from 'motion/react';
import { Github, Key, Terminal, Code, Home, Sparkles } from 'lucide-react';
import DragonMascot from './DragonMascot';
interface NavbarProps {
activeView: 'home' | 'keycaps';
setActiveView: (view: 'home' | 'keycaps') => void;
}
export default function Navbar({ activeView, setActiveView }: NavbarProps) {
const scrollToId = (id: string) => {
// Switch to home first if on keycaps and clicking scroll targets
if (activeView !== 'home') {
setActiveView('home');
setTimeout(() => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
}, 150);
} else {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
}
};
return (
<nav className="sticky top-0 z-50 bg-[#0a0b0d]/90 backdrop-blur-md border-b border-white/10 px-6 h-20 flex items-center">
<div className="w-full max-w-6xl mx-auto flex items-center justify-between">
{/* LOGO AND BRANDING */}
<div
className="flex items-center gap-3 cursor-pointer select-none group"
onClick={() => { setActiveView('home'); window.scrollTo({ top: 0, behavior: 'smooth' }); }}
>
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center shadow-[0_0_20px_rgba(16,185,129,0.35)] transition-transform group-hover:scale-105 duration-300">
<DragonMascot size={32} interactive={false} />
</div>
<div className="flex flex-col">
<span className="font-sans font-black text-2xl text-white tracking-tighter">
VietC<span className="text-emerald-500">.</span>
</span>
<span className="text-[9px] font-mono text-emerald-500 font-bold -mt-1 tracking-widest uppercase">
Native Linux IME
</span>
</div>
</div>
{/* NAVIGATION LINKS */}
<div className="hidden md:flex items-center gap-8 text-xs font-semibold tracking-widest uppercase text-slate-400">
<button
onClick={() => { setActiveView('home'); window.scrollTo({ top: 0, behavior: 'smooth' }); }}
className={`hover:text-emerald-400 cursor-pointer transition-colors pb-1 border-b-2 ${
activeView === 'home' ? 'text-emerald-400 border-emerald-400 font-bold' : 'border-transparent'
}`}
>
Giới Thiệu
</button>
{activeView === 'home' && (
<>
<button
onClick={() => scrollToId('features')}
className="hover:text-emerald-400 cursor-pointer transition-colors pb-1 border-b-2 border-transparent"
>
Tính Năng
</button>
<button
onClick={() => scrollToId('demo')}
className="hover:text-emerald-400 cursor-pointer transition-colors pb-1 border-b-2 border-transparent flex items-center gap-1.5"
>
<Terminal size={12} className="text-emerald-500" />
Giả Lập Demo
</button>
<button
onClick={() => scrollToId('setup-guide')}
className="hover:text-emerald-400 cursor-pointer transition-colors pb-1 border-b-2 border-transparent"
>
Setup Guide
</button>
</>
)}
<button
onClick={() => setActiveView('keycaps')}
className={`hover:text-emerald-400 cursor-pointer transition-all flex items-center gap-1.5 px-3 py-1.5 rounded-full border ${
activeView === 'keycaps'
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 font-bold'
: 'border-white/10 text-slate-400 hover:border-emerald-500/30'
}`}
>
<Sparkles size={11} className={activeView === 'keycaps' ? 'animate-pulse text-emerald-400' : 'text-slate-500'} />
Artisan Keycaps
</button>
</div>
{/* EXTERNAL GITHUB BUTTON */}
<div className="flex items-center gap-2">
{/* Mobile view toggle */}
<button
onClick={() => setActiveView(activeView === 'home' ? 'keycaps' : 'home')}
className="md:hidden text-[10px] font-bold px-3 py-1.5 rounded-md bg-white/5 border border-white/10 text-slate-300"
>
{activeView === 'home' ? 'Keycaps 3D' : 'Bộ Gõ VietC'}
</button>
<a
href="https://github.com/vndangkhoa/vietc"
target="_blank"
rel="noopener noreferrer"
className="px-3.5 py-1.5 rounded-xl bg-white/5 border border-white/10 hover:border-emerald-500/30 text-slate-300 hover:text-emerald-400 transition-all flex items-center gap-1.5 text-xs font-semibold"
>
<Github size={14} />
<span className="hidden sm:inline">GitHub</span>
</a>
</div>
</div>
</nav>
);
}

View file

@ -0,0 +1,379 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Copy, Check, Terminal, Shield, Cpu, RefreshCw, Layers, GitBranch, Hammer } from 'lucide-react';
import { SetupStep } from '../types';
type TabId = 'mint_ubuntu' | 'arch' | 'fedora' | 'dev';
export default function SetupGuide() {
const [activeTab, setActiveTab] = useState<TabId>('mint_ubuntu');
const [copiedText, setCopiedText] = useState<string | null>(null);
const handleCopy = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopiedText(id);
setTimeout(() => setCopiedText(null), 2000);
};
const installSteps: Record<Exclude<TabId, 'dev'>, SetupStep[]> = {
mint_ubuntu: [
{
id: 1,
title: "Cài đặt VietC (Pre-built)",
description: "Chạy lệnh dưới đây để tự động tải về, cài đặt phụ thuộc và biên dịch VietC trên hệ thống của bạn.",
command: `git clone https://github.com/vndangkhoa/vietc.git /tmp/vietc \\
&& cd /tmp/vietc && sudo ./install.sh`,
notes: "Script tự động phát hiện distro, cài đặt dependencies, build và cấu hình udev rules cho uinput."
},
{
id: 2,
title: "Gỡ cài đặt (Uninstall)",
description: "Xoá hoàn toàn VietC khỏi hệ thống, bao gồm binary, service và udev rules.",
command: `curl -sSL https://raw.githubusercontent.com/vndangkhoa/vietc/main/uninstall.sh | sudo bash`,
notes: "Lệnh này sẽ xoá /usr/local/bin/vietc, systemd service và các file cấu hình."
}
],
arch: [
{
id: 1,
title: "Cài đặt VietC (Pre-built)",
description: "Tự động clone, build và cài đặt VietC trên Arch Linux.",
command: `git clone https://github.com/vndangkhoa/vietc.git /tmp/vietc \\
&& cd /tmp/vietc && sudo ./install.sh`,
notes: "Script hỗ trợ pacman, tự động cài đặt base-devel và các thư viện cần thiết."
},
{
id: 2,
title: "Gỡ cài đặt (Uninstall)",
description: "Xoá VietC hoàn toàn khỏi hệ thống Arch.",
command: `curl -sSL https://raw.githubusercontent.com/vndangkhoa/vietc/main/uninstall.sh | sudo bash`,
}
],
fedora: [
{
id: 1,
title: "Cài đặt VietC (Pre-built)",
description: "Tự động clone, build và cài đặt VietC trên Fedora.",
command: `git clone https://github.com/vndangkhoa/vietc.git /tmp/vietc \\
&& cd /tmp/vietc && sudo ./install.sh`,
notes: "Script hỗ trợ dnf, tự động cài đặt Development Tools và thư viện X11."
},
{
id: 2,
title: "Gỡ cài đặt (Uninstall)",
description: "Xoá VietC hoàn toàn khỏi hệ thống Fedora.",
command: `curl -sSL https://raw.githubusercontent.com/vndangkhoa/vietc/main/uninstall.sh | sudo bash`,
}
]
};
const devSteps: SetupStep[] = [
{
id: 1,
title: "Clone mã nguồn",
description: "Nhánh main chứa code mới nhất.",
command: `git clone https://github.com/vndangkhoa/vietc.git
cd vietc`,
},
{
id: 2,
title: "Cài đặt Rust (nếu chưa có)",
description: "Dùng rustup để cài Rust toolchain mới nhất.",
command: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"`,
notes: "Kiểm tra với 'rustc --version' và 'cargo --version'."
},
{
id: 3,
title: "Cài đặt hệ thống phụ thuộc",
description: "Thư viện dev cho X11, evdev và dbus.",
command: `sudo apt install build-essential pkg-config libx11-dev libxtst-dev \\
libevdev-dev libdbus-1-dev libwayland-dev wl-clipboard`,
notes: "Trên Fedora: dnf install; trên Arch: pacman -S. Xem install.sh để biết chi tiết."
},
{
id: 4,
title: "Biên dịch (debug)",
description: "Build nhanh không tối ưu, phù hợp khi phát triển.",
command: `cargo build`,
notes: "Binary ở target/debug/vietc. Chạy thử: ./target/debug/vietc"
},
{
id: 5,
title: "Biên dịch (release - tối ưu)",
description: "Build với tối ưu hóa cho hiệu năng cao nhất.",
command: `cargo build --release`,
notes: "Binary ở target/release/vietc. Chạy thử: ./target/release/vietc"
},
{
id: 6,
title: "Cấp quyền uinput",
description: "VietC cần quyền ghi /dev/uinput. Thêm user vào group input và uinput.",
command: `sudo gpasswd -a $USER input
sudo groupadd -f uinput
sudo gpasswd -a $USER uinput
echo 'KERNEL=="uinput", GROUP="uinput", MODE="0660", OPTIONS+="static_node=uinput"' | sudo tee /etc/udev/rules.d/99-vietc.rules
sudo udevadm control --reload-rules && sudo udevadm trigger`,
notes: "Đăng xuất và đăng nhập lại (hoặc reboot) để group có hiệu lực."
},
{
id: 7,
title: "Chạy thử (không cần cài đặt)",
description: "Chạy trực tiếp từ thư mục build, không cần systemd service.",
command: `./target/release/vietc`,
notes: "Tắt bằng Ctrl+C. Có thể chạy ở chế độ nền với '&' và dùng 'fg' để đưa lên foreground."
}
];
const tabs: { id: TabId; label: string; icon?: React.ReactNode }[] = [
{ id: 'mint_ubuntu', label: 'Mint / Ubuntu' },
{ id: 'arch', label: 'Arch Linux' },
{ id: 'fedora', label: 'Fedora' },
{ id: 'dev', label: 'Dev Build', icon: <Hammer size={12} /> },
];
return (
<div id="setup-guide" 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"
>
<Terminal size={12} className="text-emerald-400" />
<span>NATIVE LINUX INTEGRATION</span>
</motion.div>
<motion.h2
initial={{ opacity: 0, y: 15 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="text-3xl sm:text-4xl font-serif text-white tracking-tight"
>
Hướng Dẫn Cài Đt <span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-400 italic">VietC</span>
</motion.h2>
<p className="mt-4 text-slate-400 text-sm sm:text-base">
VietC bộ mức thấp (System & Application level) không phụ thuộc IBus hay Fcitx5, việc cài đt sẽ tác đng trực tiếp lên driver uinput hệ thống đ đt tốc đ tuyệt đi.
</p>
</div>
{/* Tabs */}
<div className="flex justify-center mb-10">
<div className="bg-white/[0.02] p-1.5 rounded-xl border border-white/10 flex gap-2 w-full max-w-2xl">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 py-2.5 rounded-lg text-xs font-semibold tracking-wide transition-all cursor-pointer flex items-center justify-center gap-1.5 ${
activeTab === tab.id
? 'bg-emerald-500 text-[#0a0b0d] font-bold shadow-[0_0_15px_rgba(16,185,129,0.25)]'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
</div>
{/* Content */}
{activeTab === 'dev' ? (
<div>
<div className="flex items-center gap-2 mb-6">
<GitBranch size={18} className="text-emerald-400" />
<h3 className="text-lg font-semibold text-slate-100">Build từ nguồn (dành cho Developer)</h3>
</div>
<p className="text-slate-400 text-sm mb-8 max-w-3xl">
Các bước dưới đây hướng dẫn bạn tự biên dịch VietC từ source, chạy thử không cần cài đt
system-wide. Phù hợp cho developer muốn đóng góp hoặc tùy chỉnh.
</p>
<div className="space-y-6">
{devSteps.map((step, idx) => (
<motion.div
key={`dev-step-${step.id}`}
initial={{ opacity: 0, x: -15 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: idx * 0.08 }}
className="relative bg-white/[0.02] rounded-2xl border border-white/5 p-5 sm:p-6 lg:p-8 hover:border-emerald-500/30 transition-all group"
>
{idx !== devSteps.length - 1 && (
<div className="absolute left-[33px] sm:left-[37px] top-[75px] bottom-[-35px] w-0.5 bg-white/5 pointer-events-none group-hover:bg-emerald-500/15 transition-all" />
)}
<div className="flex items-start gap-4 sm:gap-6">
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 font-mono font-bold text-sm sm:text-base shadow-inner">
0{step.id}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-sans font-semibold text-slate-100 mb-2">
{step.title}
</h3>
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed mb-4">
{step.description}
</p>
{step.command && (
<div className="relative rounded-xl overflow-hidden bg-[#0d0e12] border border-white/10 shadow-2xl font-mono text-xs text-slate-300 group/term">
<div className="flex items-center justify-between px-4 py-2 bg-[#0a0b0d] border-b border-white/5">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-rose-500/60" />
<div className="w-2.5 h-2.5 rounded-full bg-amber-500/60" />
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500/60" />
<span className="ml-2 text-[10px] text-slate-500 font-mono font-medium">BASH TERMINAL</span>
</div>
<button
onClick={() => handleCopy(step.command!, `dev-${step.id}`)}
className="p-1 rounded hover:bg-white/5 text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
title="Sao chép lệnh"
>
{copiedText === `dev-${step.id}` ? (
<Check size={14} className="text-emerald-400" />
) : (
<Copy size={14} />
)}
</button>
</div>
<div className="p-4 overflow-x-auto whitespace-pre leading-5 selection:bg-emerald-500/30 selection:text-white">
{step.command}
</div>
</div>
)}
{step.notes && (
<div className="mt-3 flex gap-2 p-3.5 rounded-xl bg-emerald-950/15 border border-emerald-500/10 text-xs text-emerald-300/90">
<Shield size={14} className="flex-shrink-0 mt-0.5 text-emerald-400" />
<div className="leading-relaxed">
<span className="font-semibold text-emerald-400">Lưu ý:</span> {step.notes}
</div>
</div>
)}
</div>
</div>
</motion.div>
))}
</div>
</div>
) : (
<div className="space-y-8">
{installSteps[activeTab].map((step, idx) => (
<motion.div
key={`${activeTab}-${step.id}`}
initial={{ opacity: 0, x: -15 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: idx * 0.1 }}
className="relative bg-white/[0.02] rounded-2xl border border-white/5 p-5 sm:p-6 lg:p-8 hover:border-emerald-500/30 transition-all group"
>
{idx !== installSteps[activeTab].length - 1 && (
<div className="absolute left-[33px] sm:left-[37px] top-[75px] bottom-[-45px] w-0.5 bg-white/5 pointer-events-none group-hover:bg-emerald-500/15 transition-all" />
)}
<div className="flex items-start gap-4 sm:gap-6">
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-emerald-400 font-mono font-bold text-sm sm:text-base shadow-inner group-hover:border-emerald-500/30 group-hover:bg-emerald-500/10 transition-all">
0{step.id}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
<h3 className="text-base sm:text-lg font-sans font-semibold text-slate-100 group-hover:text-emerald-300 transition-colors">
{step.title}
</h3>
</div>
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed mb-4">
{step.description}
</p>
{step.command && (
<div className="relative rounded-xl overflow-hidden bg-[#0d0e12] border border-white/10 shadow-2xl font-mono text-xs text-slate-300 group/term">
<div className="flex items-center justify-between px-4 py-2 bg-[#0a0b0d] border-b border-white/5">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-rose-500/60" />
<div className="w-2.5 h-2.5 rounded-full bg-amber-500/60" />
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500/60" />
<span className="ml-2 text-[10px] text-slate-500 font-mono font-medium">BASH TERMINAL</span>
</div>
<button
onClick={() => handleCopy(step.command!, `${activeTab}-${step.id}`)}
className="p-1 rounded hover:bg-white/5 text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
title="Sao chép lệnh"
>
{copiedText === `${activeTab}-${step.id}` ? (
<Check size={14} className="text-emerald-400" />
) : (
<Copy size={14} />
)}
</button>
</div>
<div className="p-4 overflow-x-auto whitespace-pre leading-5 selection:bg-emerald-500/30 selection:text-white">
{step.command}
</div>
</div>
)}
{step.notes && (
<div className="mt-3 flex gap-2 p-3.5 rounded-xl bg-emerald-950/15 border border-emerald-500/10 text-xs text-emerald-300/90">
<Shield size={14} className="flex-shrink-0 mt-0.5 text-emerald-400" />
<div className="leading-relaxed">
<span className="font-semibold text-emerald-400">Lưu ý:</span> {step.notes}
</div>
</div>
)}
</div>
</div>
</motion.div>
))}
</div>
)}
{/* Architecture graphic */}
<div className="mt-16 bg-gradient-to-br from-white/[0.03] to-transparent p-6 sm:p-8 rounded-3xl border border-white/10">
<h3 className="text-lg sm:text-xl font-semibold text-slate-100 flex items-center gap-2 mb-6">
<Layers className="text-emerald-400" size={18} />
<span> Hình Hoạt Đng Khác Biệt của VietC</span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="p-5 rounded-2xl bg-[#0d0e12] border border-white/5">
<div className="w-8 h-8 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 flex items-center justify-center font-bold text-xs mb-3">
OLD
</div>
<h4 className="text-sm font-semibold text-slate-200 mb-2">IBus / Fcitx5</h4>
<p className="text-slate-400 text-xs leading-relaxed">
Hoạt đng Application Layer qua chế giao tiếp DBus phức tạp. Khi trong Terminal o, các lệnh Backspace/Delete giả lập thường bị trễ hoặc nuốt tự gây lỗi nhân đôi hoặc mất chữ.
</p>
</div>
<div className="p-5 rounded-2xl bg-[#0d0e12] border border-white/5">
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center font-bold text-xs mb-3">
NEW
</div>
<h4 className="text-sm font-semibold text-slate-200 mb-2">VietC (uinput + evdev)</h4>
<p className="text-slate-400 text-xs leading-relaxed">
Chặn (grab) sự kiện gốc từ bàn phím vật thông qua driver <code className="text-emerald-400 font-mono">evdev</code>, sau đó tự tính toán bằng State Machine xuất ra bàn phím o mới thông qua <code className="text-emerald-400 font-mono">uinput</code>.
</p>
</div>
<div className="p-5 rounded-2xl bg-emerald-950/15 border border-emerald-500/20">
<div className="w-8 h-8 rounded-lg bg-emerald-500/20 text-emerald-300 flex items-center justify-center font-bold text-xs mb-3">
WIN
</div>
<h4 className="text-sm font-semibold text-emerald-300 mb-2">Trải Nghiệm "Như Bay"</h4>
<p className="text-emerald-400/80 text-xs leading-relaxed">
Đ trễ phản hồi phím <span className="text-white font-semibold">Keystroke: 0ms</span> giải phóng nút <span className="text-white font-semibold">&lt;1ms</span>. tiếng Việt gốc 100% không bị lag, không kén Terminal nào (Alacritty, Kitty, GNOME Terminal, v.v.).
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,347 @@
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Terminal, Send, Play, RefreshCw, Zap, Cpu, Lock, HelpCircle } from 'lucide-react';
import { parseVni } from '../utils/vniParser';
import { TerminalLog } from '../types';
export default function TerminalSimulator() {
const [inputText, setInputText] = useState('');
const [typedOutput, setTypedOutput] = useState('');
const [imeState, setImeState] = useState('S0');
const [terminalLogs, setTerminalLogs] = useState<TerminalLog[]>([]);
const [isTypingDemo, setIsTypingDemo] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
// Suggested pre-recorded typing strings (VNI sequence)
const presets = [
{ label: "Gõ 'Việt Nam'", code: "Vie6t5 Nam" },
{ label: "Gõ 'tiếng việt'", code: "tie61ng vie6t5" },
{ label: "Gõ 'đường sá'", code: "d9uo7ng2 sa1" },
{ label: "Gõ 'rồng con'", code: "ro6ng2 con" },
];
// Process live input
useEffect(() => {
const result = parseVni(inputText);
setTypedOutput(result.text);
setImeState(result.state);
// Convert string logs into TerminalLog structures
const parsedLogs: TerminalLog[] = result.logs.map((logStr, idx) => ({
id: `log-${idx}-${Date.now()}`,
type: logStr.includes('Diffing') ? 'diff' : logStr.includes('uinput') ? 'ime_state' : 'system',
text: logStr,
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}));
setTerminalLogs(parsedLogs);
}, [inputText]);
// Auto-scroll the log container to the bottom when new events arrive.
// Uses scrollTop on the container (never scrollIntoView, which scrolls the page).
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [terminalLogs]);
// Simulate automated character-by-character typing demo
const startDemo = async (vniString: string) => {
if (isTypingDemo) return;
setIsTypingDemo(true);
setInputText('');
let current = '';
for (let i = 0; i < vniString.length; i++) {
await new Promise(resolve => setTimeout(resolve, 200 + Math.random() * 150));
current += vniString[i];
setInputText(current);
}
setIsTypingDemo(false);
};
const handleClear = () => {
setInputText('');
setTypedOutput('');
setImeState('S0');
setTerminalLogs([]);
};
return (
<div id="demo" 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"
>
<Zap size={12} className="text-emerald-400 animate-pulse" />
<span>INTERACTIVE EXPERIMENT</span>
</motion.div>
<h2 className="text-3xl sm:text-4xl font-serif text-white tracking-tight">
Trải Nghiệm <span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-400 italic">VietC Simulator</span>
</h2>
<p className="mt-4 text-slate-400 text-sm sm:text-base">
Hãy tự tay chuỗi phím VNI hoặc chọn các mẫu nhanh dưới đây đ xem cách State Machine của VietC biên dịch gửi tín hiệu trực tiếp lên Linux Terminal o cực mượt.
</p>
</div>
{/* Interactive Workspace Grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-stretch">
{/* LEFT: The Linux Terminal Emulator (7 cols) */}
<div className="lg:col-span-7 flex flex-col h-[480px] bg-[#0d0e12] rounded-2xl border border-white/10 shadow-2xl overflow-hidden relative">
{/* Terminal Window Chrome Title bar */}
<div className="flex items-center justify-between px-4 py-3 bg-[#0a0b0d] border-b border-white/10 flex-shrink-0">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-rose-500/60" />
<div className="w-3 h-3 rounded-full bg-amber-500/60" />
<div className="w-3 h-3 rounded-full bg-emerald-500/60" />
<span className="ml-2 text-xs text-slate-400 font-mono flex items-center gap-1.5 font-semibold">
<Terminal size={12} className="text-emerald-500" />
vietc@linuxmint-terminal: ~
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-400 px-2 py-0.5 rounded border border-emerald-500/20">
VIETC: ON (Double Shift)
</span>
</div>
</div>
{/* Terminal Screen Body */}
<div className="flex-1 p-5 overflow-y-auto font-mono text-sm leading-relaxed space-y-4 select-text">
<div className="text-slate-500 text-xs border-b border-white/5 pb-3 leading-relaxed">
<div>VietC uinput Emulator Engine v1.2.0 (x86_64-linux-mint)</div>
<div>Trạng thái: Hoạt đng trực tiếp driver nhân (kernel space)...</div>
<div> phím số 1-9 đ dấu VNI (vd: 'ro6ngs2' hoặc 'ro6ng2' &rarr; rồng).</div>
</div>
{/* History Console Feed */}
<div className="space-y-2">
<div className="text-slate-500"># tiếng Việt cực nhanh không cần DBus/IBus</div>
<div className="flex items-start gap-1">
<span className="text-emerald-500">user@mint:~$</span>
<span className="text-slate-300">cat vietc_stats.txt</span>
</div>
<div className="text-emerald-400/90 pl-4 text-xs space-y-1 bg-white/[0.01] p-2.5 rounded border border-white/5">
<div>+ Keystroke Latency: 0ms (Mức phần cứng)</div>
<div>+ Press-Release Latency: &lt;1ms (Driver-level)</div>
<div>+ Event Type: evdev grab / virtual uinput raw keypress</div>
<div>+ Memory footprint: ~1.2 MB</div>
</div>
</div>
{/* Interactive Terminal Line */}
<div className="pt-2 border-t border-white/5">
<div className="flex items-start gap-2">
<span className="text-emerald-400 font-mono text-sm whitespace-nowrap shrink-0 pt-0.5">
vietc@linuxmint-terminal:~$
</span>
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
disabled={isTypingDemo}
placeholder="Gõ VNI tại đây (vd: Vie6t1 Nam)..."
className="flex-1 bg-transparent border-none text-slate-100 placeholder-slate-600 focus:outline-none font-mono text-sm"
autoFocus
/>
{inputText && (
<button
onClick={handleClear}
className="p-1 rounded hover:bg-white/5 text-slate-500 hover:text-slate-300 transition-colors cursor-pointer shrink-0"
title="Clear"
>
<RefreshCw size={12} />
</button>
)}
</div>
</div>
{/* Converted Output */}
<div className="flex items-start gap-2 ml-0">
<span className="text-slate-500 font-mono text-sm shrink-0 pt-0.5 select-none">
&gt;
</span>
<span className="text-emerald-300 font-mono text-sm break-all">
{typedOutput || <span className="text-slate-600 italic">Kết quả tiếng Việt sẽ hiện đây...</span>}
</span>
{typedOutput && (
<span className="inline-block w-2 h-4 bg-emerald-400 animate-pulse ml-0.5 shrink-0 mt-0.5" />
)}
</div>
</div>
{/* Quick Demo bar */}
<div className="p-3 bg-[#0a0b0d] border-t border-white/10 flex flex-wrap gap-2 items-center flex-shrink-0">
<span className="text-[10px] text-slate-500 font-mono mr-1">Gợi ý nhanh:</span>
{presets.map((preset, idx) => (
<button
key={idx}
onClick={() => startDemo(preset.code)}
disabled={isTypingDemo}
className="px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 border border-white/10 hover:border-emerald-500/30 text-slate-300 hover:text-white font-mono text-[10px] transition-all flex items-center gap-1 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play size={8} className="text-emerald-400" />
{preset.label}
</button>
))}
</div>
</div>
{/* RIGHT: Real-time Monitor & Event Logs (5 cols) */}
<div className="lg:col-span-5 flex flex-col bg-white/[0.02] rounded-2xl border border-white/10 p-5 lg:p-6 shadow-xl relative">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-white/5 mb-4">
<div className="flex items-center gap-2">
<Cpu size={16} className="text-emerald-400" />
<span className="font-sans font-bold text-sm text-slate-200 tracking-wide uppercase">Màn Hình Kiểm Soát VietC</span>
</div>
<div className="flex items-center gap-1 bg-emerald-500/10 text-emerald-400 px-2 py-0.5 rounded-full text-[10px] font-mono border border-emerald-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-ping" />
<span>Live Monitor</span>
</div>
</div>
{/* State Machine Visualization */}
<div className="bg-[#0d0e12] p-4 rounded-xl border border-white/5 mb-5">
<div className="text-xs text-slate-400 font-semibold mb-3 flex items-center justify-between">
<span>Deterministic State Machine</span>
<span className="text-[10px] font-mono text-slate-500">Sự thay đi trạng thái gốc</span>
</div>
<div className="flex items-center justify-between px-2 py-1.5 relative">
{/* Horizontal progress background bar */}
<div className="absolute left-6 right-6 top-[22px] h-0.5 bg-white/5 z-0" />
{/* S0 */}
<div className="flex flex-col items-center z-10">
<div className={`w-8 h-8 rounded-full border flex items-center justify-center font-mono text-xs font-bold transition-all duration-300 ${
imeState === 'S0'
? 'bg-emerald-600 text-white border-emerald-400 shadow-[0_0_15px_rgba(16,185,129,0.35)] scale-110'
: 'bg-[#0a0b0d] text-slate-500 border-white/5'
}`}>
S0
</div>
<span className="text-[9px] font-mono text-slate-500 mt-1.5">Chờ phím</span>
</div>
{/* S1 */}
<div className="flex flex-col items-center z-10">
<div className={`w-8 h-8 rounded-full border flex items-center justify-center font-mono text-xs font-bold transition-all duration-300 ${
imeState === 'S1'
? 'bg-emerald-600 text-white border-emerald-400 shadow-[0_0_15px_rgba(16,185,129,0.35)] scale-110'
: 'bg-[#0a0b0d] text-slate-500 border-white/5'
}`}>
S1
</div>
<span className="text-[9px] font-mono text-slate-500 mt-1.5">Nguyên âm</span>
</div>
{/* S2 */}
<div className="flex flex-col items-center z-10">
<div className={`w-8 h-8 rounded-full border flex items-center justify-center font-mono text-xs font-bold transition-all duration-300 ${
imeState === 'S2'
? 'bg-emerald-600 text-white border-emerald-400 shadow-[0_0_15px_rgba(16,185,129,0.35)] scale-110'
: 'bg-[#0a0b0d] text-slate-500 border-white/5'
}`}>
S2
</div>
<span className="text-[9px] font-mono text-slate-500 mt-1.5">Dấu thanh</span>
</div>
{/* S3 */}
<div className="flex flex-col items-center z-10">
<div className={`w-8 h-8 rounded-full border flex items-center justify-center font-mono text-xs font-bold transition-all duration-300 ${
imeState === 'S3'
? 'bg-emerald-600 text-white border-emerald-400 shadow-[0_0_15px_rgba(16,185,129,0.35)] scale-110'
: 'bg-[#0a0b0d] text-slate-500 border-white/5'
}`}>
S3
</div>
<span className="text-[9px] font-mono text-slate-500 mt-1.5"> tự phụ</span>
</div>
</div>
</div>
{/* Core Specs metrics */}
<div className="grid grid-cols-3 gap-2.5 mb-5 font-mono text-center">
<div className="bg-[#0d0e12] p-2.5 rounded-lg border border-white/5">
<div className="text-[9px] text-slate-500">Keystroke</div>
<div className="text-sm font-bold text-emerald-400 mt-0.5">0 ms</div>
</div>
<div className="bg-[#0d0e12] p-2.5 rounded-lg border border-white/5">
<div className="text-[9px] text-slate-500">Press-Release</div>
<div className="text-sm font-bold text-emerald-400 mt-0.5">&lt;1 ms</div>
</div>
<div className="bg-[#0d0e12] p-2.5 rounded-lg border border-white/5">
<div className="text-[9px] text-slate-500">Clipboard</div>
<div className="text-sm font-bold text-emerald-400 mt-0.5">1 ms</div>
</div>
</div>
{/* Event Log Stream */}
<div className="text-xs text-slate-400 font-semibold mb-2 flex items-center justify-between">
<span>Sự Kiện Thiết Bị Thấp (uinput Event Logs)</span>
<span className="text-[10px] font-mono text-slate-500">Thời gian thực</span>
</div>
<div ref={logContainerRef} className="flex-1 bg-[#0d0e12] p-4 rounded-xl border border-white/5 overflow-y-auto h-[170px] font-mono text-[11px] space-y-2.5">
<AnimatePresence initial={false}>
{terminalLogs.map((log) => (
<motion.div
key={log.id}
initial={{ opacity: 0, x: 5 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0 }}
className="border-b border-white/5 pb-2 last:border-none"
>
<div className="flex items-center justify-between text-[9px] text-slate-500 mb-0.5">
<span className="flex items-center gap-1">
<span className={`w-1 h-1 rounded-full ${
log.type === 'diff' ? 'bg-emerald-400' : log.type === 'ime_state' ? 'bg-teal-400' : 'bg-slate-400'
}`} />
{log.type.toUpperCase()}
</span>
<span>{log.timestamp}</span>
</div>
<div className={
log.type === 'diff' ? 'text-emerald-300' : log.type === 'ime_state' ? 'text-teal-200' : 'text-slate-300'
}>
{log.text}
</div>
</motion.div>
))}
</AnimatePresence>
{terminalLogs.length === 0 && (
<div className="h-full flex items-center justify-center text-slate-600 italic">
phím hoặc chọn mẫu đ hiển thị logs sự kiện nhân (kernel event logs)
</div>
)}
<div ref={logEndRef} />
</div>
{/* Privacy note */}
<div className="mt-4 flex gap-2 items-center text-[10px] text-slate-500 bg-[#0d0e12] p-2.5 rounded-lg border border-white/10">
<Lock size={12} className="text-emerald-500 flex-shrink-0" />
<span>An toàn & Bảo mật: VietC thu thập sự kiện phím tại local không bao giờ gửi bất kỳ dữ liệu nào qua mạng Internet.</span>
</div>
</div>
</div>
</div>
</div>
);
}

26
web/src/index.css Normal file
View file

@ -0,0 +1,26 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;950&family=JetBrains+Mono:wght@400;500;700&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-serif: "Playfair Display", Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* Hide all scrollbars */
*::-webkit-scrollbar {
display: none;
}
* {
scrollbar-width: none;
-ms-overflow-style: none;
}
/* Custom 3D Perspective Utility class */
.perspective-1000 {
perspective: 1000px;
}
.transform-style-3d {
transform-style: preserve-3d;
}

10
web/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

35
web/src/types.ts Normal file
View file

@ -0,0 +1,35 @@
export interface KeycapCustomization {
baseColor: string;
stemColor: string;
dragonColor: string;
material: 'resin_clear' | 'resin_frosted' | 'matte' | 'glass';
ledColor: string;
ledIntensity: number; // 0 to 100
selectedLetter: string; // e.g. "ă", "â", "đ", "ê", "ô", "ơ", "ư", "Sắc (s)", "Huyền (f)"...
showStem: boolean;
}
export interface TerminalLog {
id: string;
type: 'input' | 'system' | 'ime_state' | 'diff';
text: string;
timestamp: string;
}
export interface SetupStep {
id: number;
title: string;
description: string;
command?: string;
notes?: string;
}
export interface KeycapModel {
id: string;
name: string;
letter: string;
desc: string;
rarity: 'Common' | 'Rare' | 'Epic' | 'Legendary';
price?: string;
stlUrl: string;
}

233
web/src/utils/vniParser.ts Normal file
View file

@ -0,0 +1,233 @@
// Simple VNI Vietnamese Parser & State Machine for the VietC Terminal simulator
// It processes VNI input (numbers 1-9 for diacritics) and returns the converted string
// and logs detailing the State Machine transitions and Token-Level Diffing.
interface VniStateResult {
text: string;
logs: string[];
state: string; // S0, S1, S2, S3
}
// Maps for tone accents on vowels
const ACUTE = '\u0301'; // Sắc (1)
const GRAVE = '\u0300'; // Huyền (2)
const HOOK = '\u0309'; // Hỏi (3)
const TILDE = '\u0303'; // Ngã (4)
const DOT = '\u0323'; // Nặng (5)
const TONES_MAP: Record<string, string> = {
'1': ACUTE,
'2': GRAVE,
'3': HOOK,
'4': TILDE,
'5': DOT,
};
// Maps for letter modifiers
// 6 -> â, ê, ô
// 7 -> ơ, ư
// 8 -> ă
// 9 -> đ
const MOD_6: Record<string, string> = {
'a': 'â', 'A': 'Â',
'e': 'ê', 'E': 'Ê',
'o': 'ô', 'O': 'Ô',
};
const MOD_7: Record<string, string> = {
'o': 'ơ', 'O': 'Ơ',
'u': 'ư', 'U': 'Ư',
};
const MOD_8: Record<string, string> = {
'a': 'ă', 'A': 'Ă',
};
/**
* Normalizes combining diacritics to standard precomposed Vietnamese characters.
*/
function normalizeVietnamese(text: string): string {
return text.normalize('NFC');
}
/**
* Parse a full sentence/text typed in VNI.
* E.g., "tieengs vieetj" -> "tiếng việt"
* VNI: "vietetj" or "viet1" -> "viết"
* Let's process word by word.
*/
export function parseVni(inputText: string): VniStateResult {
const words = inputText.split(' ');
const processedWords: string[] = [];
const logs: string[] = [];
let currentState = 'S0';
for (let w = 0; w < words.length; w++) {
const word = words[w];
if (!word) {
processedWords.push('');
continue;
}
let resultWord = '';
let tone: string | null = null;
let dStroke = false;
// We will build the word character-by-character
for (let i = 0; i < word.length; i++) {
const char = word[i];
// Check for Đ (9)
if (char === '9') {
const lastChar = resultWord[resultWord.length - 1];
if (lastChar === 'd' || lastChar === 'D') {
resultWord = resultWord.slice(0, -1) + (lastChar === 'd' ? 'đ' : 'Đ');
dStroke = true;
currentState = 'S3';
logs.push(`[uinput / S3] Nhận phím '9': Chuyển đổi phụ âm '${lastChar}' -> 'đ' (Độ trễ: 0ms)`);
} else {
resultWord += '9';
logs.push(`[uinput / S0] Nhận phím '9': Không khớp phụ âm d/D, giữ nguyên chữ '9'`);
}
continue;
}
// Check for circumflex â, ê, ô (6)
if (char === '6') {
// Find last matching vowel in resultWord to apply modifier
let applied = false;
for (let j = resultWord.length - 1; j >= 0; j--) {
const c = resultWord[j];
if (MOD_6[c]) {
resultWord = resultWord.substring(0, j) + MOD_6[c] + resultWord.substring(j + 1);
applied = true;
currentState = 'S3';
logs.push(`[uinput / S3] Nhận phím '6': Thêm mũ ô/ê/â cho '${c}' -> '${MOD_6[c]}' (Độ trễ: 0ms)`);
break;
}
}
if (!applied) {
resultWord += '6';
logs.push(`[uinput / S0] Nhận phím '6': Không tìm thấy nguyên âm thích hợp để đội mũ (giữ nguyên '6')`);
}
continue;
}
// Check for horn ơ, ư (7)
if (char === '7') {
let applied = false;
for (let j = resultWord.length - 1; j >= 0; j--) {
const c = resultWord[j];
if (MOD_7[c]) {
resultWord = resultWord.substring(0, j) + MOD_7[c] + resultWord.substring(j + 1);
// If 'o'->'ơ' preceded by 'u', merge to 'ươ' (standard VNI digraph)
if (MOD_7[c] === 'ơ' && j > 0 && (resultWord[j-1] === 'u' || resultWord[j-1] === 'U')) {
const prefix = resultWord.substring(0, j - 1);
const suffix = resultWord.substring(j + 1);
resultWord = prefix + (resultWord[j-1] === 'U' ? 'Ươ' : 'ươ') + suffix;
}
applied = true;
currentState = 'S3';
logs.push(`[uinput / S3] Nhận phím '7': Thêm râu ơ/ư cho '${c}' -> '${MOD_7[c]}' (Độ trễ: 0ms)`);
break;
}
}
if (!applied) {
resultWord += '7';
logs.push(`[uinput / S0] Nhận phím '7': Không tìm thấy nguyên âm o/u để thêm râu`);
}
continue;
}
// Check for breve ă (8)
if (char === '8') {
let applied = false;
for (let j = resultWord.length - 1; j >= 0; j--) {
const c = resultWord[j];
if (MOD_8[c]) {
resultWord = resultWord.substring(0, j) + MOD_8[c] + resultWord.substring(j + 1);
applied = true;
currentState = 'S3';
logs.push(`[uinput / S3] Nhận phím '8': Thêm á cho '${c}' -> '${MOD_8[c]}' (Độ trễ: 0ms)`);
break;
}
}
if (!applied) {
resultWord += '8';
logs.push(`[uinput / S0] Nhận phím '8': Không tìm thấy nguyên âm a để chuyển thành ă`);
}
continue;
}
// Check for tones (1, 2, 3, 4, 5)
if (TONES_MAP[char]) {
tone = TONES_MAP[char];
currentState = 'S2';
const toneNames: Record<string, string> = { '1': 'Sắc', '2': 'Huyền', '3': 'Hỏi', '4': 'Ngã', '5': 'Nặng' };
logs.push(`[uinput / S2] Nhận phím '${char}': Áp dụng dấu thanh [${toneNames[char]}] lên từ đang gõ`);
continue;
}
// Cancel tone (0)
if (char === '0') {
tone = null;
currentState = 'S1';
logs.push(`[uinput / S1] Nhận phím '0': Xóa toàn bộ dấu thanh đang áp dụng`);
continue;
}
// Standard alphabetical letters
resultWord += char;
currentState = 'S1';
}
// Apply the tone accent if any
if (tone) {
// Find the correct vowel to put the tone on (Vietnamese grammar rule)
// Standard rules: usually the last vowel if double vowel, or the middle one.
// E.g., "hoàng" -> tone on "à", "tiếng" -> tone on "ế"
// Let's implement a simple heuristic:
const vowels = ['a', 'e', 'i', 'o', 'u', 'y', 'â', 'ê', 'ô', 'ơ', 'ư', 'ă', 'Ă', 'Â', 'Ê', 'Ô', 'Ơ', 'Ư'];
let vowelPositions: number[] = [];
for (let i = 0; i < resultWord.length; i++) {
if (vowels.includes(resultWord[i].toLowerCase())) {
vowelPositions.push(i);
}
}
if (vowelPositions.length > 0) {
// Decide which vowel receives the tone
let targetIndex = vowelPositions[0];
if (vowelPositions.length === 2) {
// If there is "uy", tone is on "y", else "oa", "oe", "ue", "uy", etc.
const pair = (resultWord[vowelPositions[0]] + resultWord[vowelPositions[1]]).toLowerCase();
if (pair === 'oa' || pair === 'oe' || pair === 'uâ' || pair === 'uy' || pair === 'iê' || pair === 'yê' || pair === 'uô' || pair === 'ươ') {
targetIndex = vowelPositions[1];
} else {
targetIndex = vowelPositions[0];
}
} else if (vowelPositions.length === 3) {
// Three vowels (e.g. "oai", "uay", "ươu"), tone usually on the middle one
targetIndex = vowelPositions[1];
}
const targetChar = resultWord[targetIndex];
resultWord = resultWord.substring(0, targetIndex) + targetChar + tone + resultWord.substring(targetIndex + 1);
}
}
processedWords.push(normalizeVietnamese(resultWord));
}
// Generate a final state change summary for the diff system
const finalOutput = processedWords.join(' ');
if (inputText !== finalOutput && finalOutput !== '') {
logs.push(`[Token-Level Diffing] Đã đồng bộ sự kiện phím ảo: Thay thế chuỗi "${inputText}" thành "${finalOutput}" trong 1ms`);
}
return {
text: finalOutput,
logs: logs.length > 0 ? logs : ["Chờ phím gõ từ terminal..."],
state: currentState,
};
}

26
web/tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

22
web/vite.config.ts Normal file
View file

@ -0,0 +1,22 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});