Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc68102bcd | ||
|
|
9b0d21a0ea | ||
|
|
e9e5809d61 | ||
|
|
43aaef9d43 | ||
|
|
51c2b2a49a | ||
|
|
63cae4765a | ||
|
|
48cd360e37 | ||
|
|
aa8a0624fc | ||
|
|
5f0f059139 | ||
|
|
6756340cb0 | ||
|
|
ed23d6bc35 | ||
|
|
a13c192d65 | ||
|
|
b06035c216 | ||
|
|
143ba5ca58 |
35 changed files with 6982 additions and 92 deletions
29
.github/workflows/release.yml
vendored
Normal file
29
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y build-essential pkg-config libx11-dev libxtst-dev \
|
||||
libdbus-1-dev libevdev-dev libwayland-dev
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Build binaries
|
||||
run: |
|
||||
cargo build --release
|
||||
(cd ui && cargo build --release)
|
||||
gcc -O2 -o target/release/vietc-xrecord packaging/deb/vietc-xrecord.c -lX11 -lXtst
|
||||
- name: Package tarball
|
||||
run: bash packaging/build-tarball.sh
|
||||
- name: Upload tarball to release
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" target/dist/*.tar.gz
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
### Documentation
|
||||
|
||||
- **Roadmap section** added to README (v0.1.19: Wayland IM protocol, event-based AT-SPI2; v0.1.20: CI, Flatpak).
|
||||
- **Roadmap section** added to README (v0.1.22: Wayland IM protocol, event-based AT-SPI2; v0.1.23: CI, Flatpak).
|
||||
- **RELEASE_CHECKLIST.md** removed (process now documented in the release commit messages).
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
### Tài liệu hướng dẫn (Documentation)
|
||||
|
||||
- Thêm mục **Roadmap** vào README (v0.1.19: Giao thức Wayland IM, AT-SPI2 hướng sự kiện; v0.1.20: CI, Flatpak).
|
||||
- Thêm mục **Roadmap** vào README (v0.1.22: Giao thức Wayland IM, AT-SPI2 hướng sự kiện; v0.1.23: CI, Flatpak).
|
||||
- Loại bỏ **RELEASE_CHECKLIST.md** (quy trình phát hành hiện được ghi nhận trong nội dung các commit phát hành).
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<img src="https://img.shields.io/badge/Platform-Linux-blue?style=for-the-badge" alt="Platform">
|
||||
<img src="https://img.shields.io/badge/Language-Rust-orange?style=for-the-badge" alt="Rust">
|
||||
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License">
|
||||
<img src="https://img.shields.io/badge/Version-0.1.18-purple?style=for-the-badge" alt="Version">
|
||||
<img src="https://img.shields.io/badge/Version-0.1.21-purple?style=for-the-badge" alt="Version">
|
||||
<img src="https://img.shields.io/badge/Tests-108_passing-brightgreen?style=for-the-badge" alt="Tests">
|
||||
<img src="https://img.shields.io/badge/Event_Sourcing-✓-blueviolet?style=for-the-badge" alt="Event Sourcing">
|
||||
</p>
|
||||
|
|
@ -266,11 +266,11 @@ vietc/
|
|||
|
||||
## Roadmap
|
||||
|
||||
### v0.1.19
|
||||
### v0.1.22
|
||||
- [ ] Wayland input method protocol (`zwp_input_method_v2`) — eliminates clipboard + backspace race, fixes missing spaces permanently
|
||||
- [ ] Event-based AT-SPI2 focus monitoring (subscribe to a11y focus events, no polling)
|
||||
|
||||
### v0.1.20
|
||||
### v0.1.23
|
||||
- [ ] GitHub Actions CI for automated .deb builds
|
||||
- [ ] Flatpak re-add for immutable distros
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<img src="https://img.shields.io/badge/Nền_tảng-Linux-blue?style=for-the-badge" alt="Platform">
|
||||
<img src="https://img.shields.io/badge/Ngôn_ngữ-Rust-orange?style=for-the-badge" alt="Rust">
|
||||
<img src="https://img.shields.io/badge/Giấy_phép-MIT-green?style=for-the-badge" alt="License">
|
||||
<img src="https://img.shields.io/badge/Phiên_bản-0.1.18-purple?style=for-the-badge" alt="Version">
|
||||
<img src="https://img.shields.io/badge/Phiên_bản-0.1.21-purple?style=for-the-badge" alt="Version">
|
||||
<img src="https://img.shields.io/badge/Kiểm_thử-108_đạt-brightgreen?style=for-the-badge" alt="Tests">
|
||||
<img src="https://img.shields.io/badge/Event_Sourcing-✓-blueviolet?style=for-the-badge" alt="Event Sourcing">
|
||||
</p>
|
||||
|
|
@ -266,11 +266,11 @@ vietc/
|
|||
|
||||
## Lộ trình phát triển
|
||||
|
||||
### Phiên bản v0.1.19
|
||||
### Phiên bản v0.1.22
|
||||
- [ ] Hỗ trợ giao thức nhập liệu Wayland (`zwp_input_method_v2`) — loại bỏ việc dùng clipboard và tranh chấp Backspace, khắc phục triệt để lỗi mất khoảng trắng.
|
||||
- [ ] Cơ chế giám sát tiêu điểm AT-SPI2 hướng sự kiện (đăng ký nhận sự kiện tiêu điểm từ a11y thay vì liên tục truy vấn).
|
||||
|
||||
### Phiên bản v0.1.20
|
||||
### Phiên bản v0.1.23
|
||||
- [ ] Tự động hóa việc đóng gói tệp tin `.deb` bằng GitHub Actions CI.
|
||||
- [ ] Khôi phục hỗ trợ Flatpak cho các hệ điều hành bất biến (immutable distros).
|
||||
|
||||
|
|
|
|||
|
|
@ -495,6 +495,8 @@ enum OutputCommand {
|
|||
Backspace(usize),
|
||||
}
|
||||
|
||||
const KEY_MAX: u32 = 0x1ff;
|
||||
|
||||
/// Characters that flush the current word and start a new one.
|
||||
fn is_flush_char(ch: char) -> bool {
|
||||
matches!(ch, ' ' | '.' | ',' | '!' | '?' | ';' | ':' | '\t' | '\n')
|
||||
|
|
@ -1280,6 +1282,17 @@ fn run_with_x11_keymap(
|
|||
commands.push(OutputCommand::Type(buf_after));
|
||||
}
|
||||
}
|
||||
// X11 capture: the VNI/Telex control key character reached
|
||||
// the app directly. Add 1 extra backspace to remove it.
|
||||
if !commands.is_empty()
|
||||
&& is_vn_control_key(daemon.app_state.effective_method(), ch)
|
||||
{
|
||||
for cmd in &mut commands {
|
||||
if let OutputCommand::Backspace(ref mut n) = cmd {
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
execute_commands(&*injector, &commands, false);
|
||||
}
|
||||
}
|
||||
|
|
@ -1296,7 +1309,7 @@ fn run_with_evdev(
|
|||
_engine_enabled: Arc<AtomicBool>,
|
||||
display: display::DisplayServer,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let injector = create_injector(display)?;
|
||||
let mut injector = create_injector(display)?;
|
||||
|
||||
// Use the first device for grab (only one device can be grabbed at a time)
|
||||
let primary_idx = 0usize;
|
||||
|
|
@ -1323,6 +1336,17 @@ fn run_with_evdev(
|
|||
false
|
||||
};
|
||||
|
||||
// Non-grabbed on X11: use XTest injection for fast, synchronous correction
|
||||
if !grabbed {
|
||||
#[cfg(feature = "x11")]
|
||||
if display != display::DisplayServer::Wayland {
|
||||
if let Ok(x11_inj) = vietc_protocol::x11_inject::X11Injector::new() {
|
||||
injector = Box::new(x11_inj);
|
||||
log_info("[vietc] Non-grabbed: using X11 injection (faster than uinput)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut consumed_keys: HashSet<u16> = HashSet::new();
|
||||
let mut last_active_window = String::new();
|
||||
let mut last_window_class = String::new();
|
||||
|
|
@ -1361,6 +1385,14 @@ fn run_with_evdev(
|
|||
let _ = devices[primary_idx].0.ungrab();
|
||||
grabbed = false;
|
||||
log_info("[vietc] Non-grabbed mode: polling all evdev devices for keystrokes");
|
||||
// Switch to XTest injection for fast synchronous non-grabbed correction
|
||||
#[cfg(feature = "x11")]
|
||||
if display != display::DisplayServer::Wayland {
|
||||
if let Ok(x11_inj) = vietc_protocol::x11_inject::X11Injector::new() {
|
||||
injector = Box::new(x11_inj);
|
||||
log_info("[vietc] Non-grabbed: using X11 injection (faster than uinput)");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -1399,13 +1431,6 @@ fn run_with_evdev(
|
|||
}
|
||||
idle_polls = 0;
|
||||
|
||||
if !grabbed {
|
||||
log_info(&format!(
|
||||
"[vietc] evdev: {} device(s) have events after ungrab",
|
||||
pfds.iter().filter(|p| (p.revents & libc::POLLIN) != 0).count()
|
||||
));
|
||||
}
|
||||
|
||||
// Check for status changes instantly
|
||||
if status_changed.load(Ordering::SeqCst) {
|
||||
daemon.sync_status_file();
|
||||
|
|
@ -1428,8 +1453,8 @@ fn run_with_evdev(
|
|||
let caps = device_states[i].1;
|
||||
let mut key_state = std::mem::take(&mut device_states[i].0);
|
||||
|
||||
let events = match device.fetch_events() {
|
||||
Ok(events) => events,
|
||||
let event_list = match device.fetch_events() {
|
||||
Ok(events) => events.collect::<Vec<_>>(),
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
|
|
@ -1443,12 +1468,13 @@ fn run_with_evdev(
|
|||
};
|
||||
last_event_time = std::time::Instant::now();
|
||||
|
||||
let mut non_key_logged = 0u32;
|
||||
for event in events {
|
||||
match event.kind() {
|
||||
evdev::InputEventKind::Key(key) => {
|
||||
let value = event.value();
|
||||
let keycode = key.0;
|
||||
for event in event_list {
|
||||
if event.event_type() != evdev::EventType::KEY {
|
||||
continue;
|
||||
}
|
||||
let keycode = event.code();
|
||||
let value = event.value();
|
||||
let key = evdev::Key(keycode);
|
||||
|
||||
// Update key state dynamically
|
||||
if value == 1 {
|
||||
|
|
@ -1501,6 +1527,12 @@ fn run_with_evdev(
|
|||
}
|
||||
}
|
||||
|
||||
// In non-grabbed mode, only process engine from primary device (i==0)
|
||||
// to avoid double-processing when multiple keyboard devices report same keys
|
||||
if !grabbed && i != 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !grabbed {
|
||||
if value != 1 {
|
||||
continue;
|
||||
|
|
@ -1694,19 +1726,6 @@ fn run_with_evdev(
|
|||
}
|
||||
injector.send_key_event(keycode, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if non_key_logged < 5 {
|
||||
log_info(&format!(
|
||||
"[vietc] evdev: non-key event type={:?} code={} value={}",
|
||||
event.event_type(),
|
||||
event.code(),
|
||||
event.value()
|
||||
));
|
||||
non_key_logged += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "vietc-engine"
|
||||
version = "0.1.7"
|
||||
version = "0.1.19"
|
||||
edition = "2021"
|
||||
description = "Viet+ Vietnamese IME Core Engine"
|
||||
|
||||
|
|
|
|||
175
install.sh
175
install.sh
|
|
@ -9,32 +9,34 @@ RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m'
|
|||
|
||||
echo -e "${GREEN}=== Viet+ Installer ===${NC}"
|
||||
|
||||
# Detect distro
|
||||
# Architecture
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64) ARCH="arm64" ;;
|
||||
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Distro
|
||||
[ -f /etc/os-release ] && . /etc/os-release
|
||||
DISTRO="${ID:-unknown}"
|
||||
echo "Detected: $DISTRO"
|
||||
echo "Detected: $DISTRO ($ARCH)"
|
||||
|
||||
# Install dependencies
|
||||
install_deps() {
|
||||
install_runtime_deps() {
|
||||
echo "Installing runtime dependencies..."
|
||||
case "$DISTRO" in
|
||||
ubuntu|debian|linuxmint|mint|pop|neon|zorin|elementary)
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -y
|
||||
apt-get install -y build-essential pkg-config libx11-dev libxtst-dev \
|
||||
libdbus-1-dev libevdev-dev libwayland-dev curl git
|
||||
apt-get install -y libevdev2 libdbus-1-3 libx11-6 libxtst6 \
|
||||
libwayland-client0 xclip wl-clipboard
|
||||
libwayland-client0 xclip wl-clipboard curl
|
||||
;;
|
||||
fedora|rhel|centos)
|
||||
dnf install -y gcc pkgconfig libX11-devel libXtst-devel dbus-devel \
|
||||
libevdev-devel libwayland-devel curl git
|
||||
dnf install -y libevdev libX11 libXtst dbus-libs libwayland-client xclip wl-clipboard
|
||||
dnf install -y libevdev libX11 libXtst dbus-libs libwayland-client xclip wl-clipboard curl
|
||||
;;
|
||||
arch|manjaro)
|
||||
pacman -Sy --needed --noconfirm base-devel pkgconf libx11 libxtst dbus \
|
||||
libevdev wayland curl git
|
||||
arch|manjaro|cachyos|endeavouros|garuda|artix)
|
||||
pacman -Sy --needed --noconfirm libevdev libx11 libxtst dbus \
|
||||
libwayland xclip wl-clipboard
|
||||
libwayland xclip wl-clipboard curl
|
||||
;;
|
||||
*)
|
||||
echo -e "${YELLOW}Unsupported: $DISTRO. Install deps manually.${NC}"
|
||||
|
|
@ -42,13 +44,49 @@ install_deps() {
|
|||
esac
|
||||
}
|
||||
|
||||
install_deps
|
||||
install_runtime_deps
|
||||
|
||||
# Install Rust if missing
|
||||
if ! command -v cargo &>/dev/null; then
|
||||
echo "Installing Rust..."
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
echo "Fetching latest release..."
|
||||
RELEASE_JSON=$(curl -sSfL "https://api.github.com/repos/vndangkhoa/vietc/releases/latest" 2>/dev/null || echo "")
|
||||
TAG=$(echo "$RELEASE_JSON" | grep '"tag_name"' | sed 's/.*"v\(.*\)",/\1/')
|
||||
if [ -z "$TAG" ]; then
|
||||
echo -e "${RED}Failed to fetch latest release info.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Latest version: v$TAG"
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
cleanup() { rm -rf "$TMPDIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Try tarball first, then .deb
|
||||
TARBALL="vietc_${TAG}_linux_${ARCH}.tar.gz"
|
||||
TARBALL_URL="https://github.com/vndangkhoa/vietc/releases/download/v${TAG}/${TARBALL}"
|
||||
DEB="vietc_${TAG}-1_amd64.deb"
|
||||
DEB_URL="https://github.com/vndangkhoa/vietc/releases/download/v${TAG}/${DEB}"
|
||||
INSTALL_DIR="$TMPDIR/install"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
if curl -sSfL -o "$TMPDIR/$TARBALL" "$TARBALL_URL" 2>/dev/null; then
|
||||
echo "Downloading tarball..."
|
||||
tar -xzf "$TMPDIR/$TARBALL" -C "$INSTALL_DIR"
|
||||
BIN_DIR="$INSTALL_DIR/vietc_${TAG}_linux_${ARCH}/bin"
|
||||
PKG_DIR="$INSTALL_DIR/vietc_${TAG}_linux_${ARCH}"
|
||||
elif curl -sSfL -o "$TMPDIR/$DEB" "$DEB_URL" 2>/dev/null; then
|
||||
echo "Downloading .deb package..."
|
||||
if command -v dpkg-deb &>/dev/null; then
|
||||
dpkg-deb -x "$TMPDIR/$DEB" "$INSTALL_DIR"
|
||||
else
|
||||
ar x "$TMPDIR/$DEB" --output="$TMPDIR/deb" 2>/dev/null
|
||||
tar -xzf "$TMPDIR/deb/data.tar.gz" -C "$INSTALL_DIR" 2>/dev/null || \
|
||||
tar -xJf "$TMPDIR/deb/data.tar.xz" -C "$INSTALL_DIR" 2>/dev/null || true
|
||||
fi
|
||||
BIN_DIR="$INSTALL_DIR/usr/bin"
|
||||
PKG_DIR="$INSTALL_DIR"
|
||||
else
|
||||
echo -e "${RED}No prebuilt binary found for v$TAG ($ARCH).${NC}"
|
||||
echo -e "${YELLOW}Visit https://github.com/vndangkhoa/vietc/releases${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Kill old processes
|
||||
|
|
@ -56,64 +94,103 @@ pkill -x vietc-tray 2>/dev/null || true
|
|||
pkill -x vietc-daemon 2>/dev/null || true
|
||||
pkill -x vietc 2>/dev/null || true
|
||||
|
||||
# Build
|
||||
echo "Building..."
|
||||
cargo build --release
|
||||
(cd ui && cargo build --release)
|
||||
if command -v gcc &>/dev/null && [ -f packaging/deb/vietc-xrecord.c ]; then
|
||||
gcc -O2 -o target/release/vietc-xrecord packaging/deb/vietc-xrecord.c -lX11 -lXtst 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Install binaries
|
||||
echo "Installing to /usr/bin/..."
|
||||
cp target/release/vietc /usr/bin/vietc-daemon
|
||||
cp target/release/vietc-cli /usr/bin/
|
||||
cp target/release/vietc-uinputd /usr/bin/
|
||||
cp ui/target/release/vietc-tray /usr/bin/
|
||||
[ -f target/release/vietc-xrecord ] && cp target/release/vietc-xrecord /usr/bin/
|
||||
cp "$BIN_DIR/vietc-daemon" /usr/bin/vietc-daemon
|
||||
cp "$BIN_DIR/vietc-cli" /usr/bin/vietc-cli
|
||||
cp "$BIN_DIR/vietc-uinputd" /usr/bin/vietc-uinputd
|
||||
cp "$BIN_DIR/vietc-tray" /usr/bin/vietc-tray
|
||||
[ -f "$BIN_DIR/vietc-xrecord" ] && cp "$BIN_DIR/vietc-xrecord" /usr/bin/vietc-xrecord
|
||||
chmod 755 /usr/bin/vietc-daemon /usr/bin/vietc-cli /usr/bin/vietc-uinputd /usr/bin/vietc-tray 2>/dev/null || true
|
||||
|
||||
# Clean old /usr/local/bin/ binaries
|
||||
rm -f /usr/local/bin/vietc /usr/local/bin/vietc-daemon /usr/local/bin/vietc-cli \
|
||||
/usr/local/bin/vietc-uinputd /usr/local/bin/vietc-tray /usr/local/bin/vietc-xrecord 2>/dev/null || true
|
||||
|
||||
# Udev rules for uinput
|
||||
# Udev rules
|
||||
echo 'KERNEL=="uinput", GROUP="input", MODE="0660"' > /etc/udev/rules.d/99-vietc.rules
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
udevadm trigger 2>/dev/null || true
|
||||
|
||||
# Icons
|
||||
if [ -d "$PKG_DIR/icons" ]; then
|
||||
mkdir -p /usr/share/icons/hicolor/256x256/apps
|
||||
cp "$PKG_DIR/icons"/*.svg /usr/share/icons/hicolor/256x256/apps/ 2>/dev/null || true
|
||||
elif [ -d "$INSTALL_DIR/usr/share/icons" ]; then
|
||||
cp -r "$INSTALL_DIR/usr/share/icons/"* /usr/share/icons/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Desktop file
|
||||
if [ -f "$PKG_DIR/desktop/vietc.desktop" ]; then
|
||||
mkdir -p /usr/share/applications
|
||||
cp "$PKG_DIR/desktop/vietc.desktop" /usr/share/applications/
|
||||
elif [ -f "$INSTALL_DIR/usr/share/applications/vietc.desktop" ]; then
|
||||
cp "$INSTALL_DIR/usr/share/applications/vietc.desktop" /usr/share/applications/
|
||||
fi
|
||||
|
||||
# XDG autostart
|
||||
mkdir -p /etc/xdg/autostart
|
||||
cat > /etc/xdg/autostart/vietc-tray.desktop << 'EOF'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Viet+ Tray
|
||||
Comment=Vietnamese Input Method Tray
|
||||
Exec=vietc-tray
|
||||
Icon=vietc
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
StartupNotify=false
|
||||
NoDisplay=true
|
||||
EOF
|
||||
|
||||
# Systemd user service
|
||||
mkdir -p /usr/lib/systemd/user
|
||||
cat > /usr/lib/systemd/user/vietc.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Viet+ Vietnamese IME Tray
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/vietc-tray
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
# User setup
|
||||
INSTALLING_USER="${SUDO_USER:-$USER}"
|
||||
if [ -n "$INSTALLING_USER" ] && [ "$INSTALLING_USER" != "root" ]; then
|
||||
adduser "$INSTALLING_USER" input 2>/dev/null || true
|
||||
if command -v usermod &>/dev/null; then
|
||||
usermod -aG input "$INSTALLING_USER" 2>/dev/null || true
|
||||
elif command -v adduser &>/dev/null; then
|
||||
adduser "$INSTALLING_USER" input 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$(getent passwd "$INSTALLING_USER" | cut -d: -f6)/.config/vietc/config.toml" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create default config
|
||||
# Config
|
||||
mkdir -p /etc/vietc
|
||||
cat > /etc/vietc/config.toml << 'EOF'
|
||||
if [ -f "$PKG_DIR/config/config.toml" ]; then
|
||||
cp "$PKG_DIR/config/config.toml" /etc/vietc/config.toml
|
||||
elif [ -f "$INSTALL_DIR/etc/vietc/config.toml" ]; then
|
||||
cp "$INSTALL_DIR/etc/vietc/config.toml" /etc/vietc/config.toml
|
||||
fi
|
||||
if [ ! -f /etc/vietc/config.toml ]; then
|
||||
cat > /etc/vietc/config.toml << 'EOF'
|
||||
input_method = "vni"
|
||||
toggle_key = "space"
|
||||
toggle_method_key = "shift"
|
||||
start_enabled = true
|
||||
grab = true
|
||||
|
||||
[password_detection]
|
||||
enabled = true
|
||||
check_atspi2 = true
|
||||
check_window_title = true
|
||||
title_keywords = ["password", "passphrase", "secret", "mật khẩu", "sudo"]
|
||||
password_apps = ["pinentry", "pinentry-gtk-2", "pinentry-qt", "kwallet"]
|
||||
|
||||
[app_state]
|
||||
enabled = true
|
||||
english_apps = ["code", "vim"]
|
||||
vietnamese_apps = ["telegram", "discord", "firefox"]
|
||||
bypass_apps = ["steam"]
|
||||
terminal_apps = ["kitty", "alacritty", "gnome-terminal", "konsole", "foot",
|
||||
"wezterm", "st", "urxvt", "xterm"]
|
||||
terminal_input_method = "vni"
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}=== Done! ===${NC}"
|
||||
echo -e "${YELLOW}Log out and log back in, then run: vietc-tray${NC}"
|
||||
|
|
|
|||
|
|
@ -417,10 +417,10 @@ impl X11Injector {
|
|||
// (unlikely at this point, but be safe)
|
||||
self.handle_pending_events();
|
||||
|
||||
// Send backspaces via XTest (X11 keycode 22 = backspace)
|
||||
// Send backspaces via XTest (evdev 14 = KEY_BACKSPACE → X11 22)
|
||||
if backspaces > 0 {
|
||||
for _ in 0..backspaces {
|
||||
self.send_keycode(22, false);
|
||||
self.send_keycode(14, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -523,7 +523,7 @@ impl KeyInjector for X11Injector {
|
|||
}
|
||||
|
||||
fn send_backspace(&self) -> InjectResult {
|
||||
self.send_keycode(22, false); // X11 keycode 22 = backspace
|
||||
self.send_keycode(14, false); // evdev 14 = KEY_BACKSPACE → X11 22
|
||||
InjectResult::Success
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "vietc-tray"
|
||||
version = "0.1.7"
|
||||
version = "0.1.19"
|
||||
edition = "2021"
|
||||
description = "Viet+ system tray icon"
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,12 @@ rm -f /usr/share/icons/hicolor/256x256/apps/vietc*.svg
|
|||
rm -f /usr/share/applications/vietc.desktop
|
||||
rm -f /etc/xdg/autostart/vietc-tray.desktop
|
||||
|
||||
# Reload
|
||||
# Reload udev
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
|
||||
# Reload systemd user daemon
|
||||
if command -v systemctl &>/dev/null; then
|
||||
systemctl --global daemon-reload 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}=== Viet+ removed ===${NC}"
|
||||
|
|
|
|||
9
web/.env.example
Normal file
9
web/.env.example
Normal 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
8
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
BIN
web/737046771_27588542340832646_2624568238884019344_n.jpg
Normal file
BIN
web/737046771_27588542340832646_2624568238884019344_n.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
20
web/README.md
Normal file
20
web/README.md
Normal 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
14
web/index.html
Normal 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
6
web/metadata.json
Normal 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
4278
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
web/package.json
Normal file
35
web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
62
web/public/vietc-favicon.svg
Normal file
62
web/public/vietc-favicon.svg
Normal 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
63
web/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
web/src/components/DragonMascot.tsx
Normal file
180
web/src/components/DragonMascot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
web/src/components/Features.tsx
Normal file
188
web/src/components/Features.tsx
Normal 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 và 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 mô hình toán học Finite State Machine (FSM) tất định để phân tích tổ hợp phím gõ tiếng Việt. Mọi ký tự được tính toán rõ ràng giúp tránh tình trạng xung đột, mất từ hoặc sai vị trí đặt dấu khi gõ 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ờ) → S1 (Gõ) → S2 (Dấu) → S3 (Chữ mũ)
|
||||
</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 vì 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ừ đã gõ và 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ử lý 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% • 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 gõ phím, VietC không lưu trữ ký tự dưới dạng văn bản tĩnh thô sơ. 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 mô 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 gõ phím tất định của VietC!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
web/src/components/Footer.tsx
Normal file
66
web/src/components/Footer.tsx
Normal 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 cơ & Bộ gõ 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">
|
||||
© {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
156
web/src/components/Hero.tsx
Normal 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"
|
||||
>
|
||||
Gõ 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 Mà Trên Linux!
|
||||
</motion.h1>
|
||||
|
||||
<p className="text-slate-400 text-sm sm:text-base leading-relaxed max-w-lg">
|
||||
VietC là giải pháp nhập liệu mã nguồn mở hiện đại cho môi trường Linux, tối ưu hóa tốc độ và 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 và 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 gõ VNI trên Terminal bấy lâu nay vô 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 gõ VNI trên Terminal.
|
||||
</p>
|
||||
|
||||
<p className="text-slate-400 text-xs leading-relaxed text-justify">
|
||||
Quý khách có thể tải toàn bộ các bản thiết kế <span className="text-emerald-400 font-bold">3D phím Numlock và 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> và trải nghiệm cảm giác <span className="text-teal-300 font-bold">‘gõ như bay’</span> ngay trên Terminal ảo của bạn mà không cần bất kỳ phần cứng vật lý 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 ↓</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
475
web/src/components/KeycapGallery.tsx
Normal file
475
web/src/components/KeycapGallery.tsx
Normal 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 Cơ <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ỉ là phần mềm gõ 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> và bộ ký tự dấu mũ tiếng Việt để bạn tự in 3D cá nhân hóa bàn phím cơ 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>Ký 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>
|
||||
);
|
||||
}
|
||||
122
web/src/components/Navbar.tsx
Normal file
122
web/src/components/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
379
web/src/components/SetupGuide.tsx
Normal file
379
web/src/components/SetupGuide.tsx
Normal 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">
|
||||
Vì VietC là bộ gõ 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 độ gõ 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ừ mã 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ử mà 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>Mô 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 cơ chế giao tiếp DBus phức tạp. Khi gõ trong Terminal ảo, các lệnh Backspace/Delete giả lập thường bị trễ hoặc nuốt ký 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 lý thông qua driver <code className="text-emerald-400 font-mono">evdev</code>, sau đó tự tính toán bằng State Machine và 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> và giải phóng nút <span className="text-white font-semibold"><1ms</span>. Gõ 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>
|
||||
);
|
||||
}
|
||||
347
web/src/components/TerminalSimulator.tsx
Normal file
347
web/src/components/TerminalSimulator.tsx
Normal 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 gõ chuỗi phím VNI hoặc chọn các mẫu gõ nhanh dưới đây để xem cách State Machine của VietC biên dịch và 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>Gõ phím số 1-9 để gõ dấu VNI (vd: 'ro6ngs2' hoặc 'ro6ng2' → rồng).</div>
|
||||
</div>
|
||||
|
||||
{/* History Console Feed */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-slate-500"># Gõ 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: <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">
|
||||
>
|
||||
</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 ý gõ 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">Ký 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"><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">
|
||||
Gõ 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 và 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
26
web/src/index.css
Normal 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
10
web/src/main.tsx
Normal 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
35
web/src/types.ts
Normal 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
233
web/src/utils/vniParser.ts
Normal 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
26
web/tsconfig.json
Normal 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
22
web/vite.config.ts
Normal 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 : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
Loading…
Reference in a new issue