Q
StrategyQ

CASE STUDY: Xây Dựng Trải Nghiệm Đọc Blog "Visual Zen" Với SvelteKit

CASE STUDY: Xây Dựng Trải Nghiệm Đọc Blog "Visual Zen" Với SvelteKit

 


I. MỞ ĐẦU: VẤN ĐỀ & GIẢI PHÁP

Một trang blog tốt không chỉ cần nội dung hay, mà cần một môi trường đọc (Reading Environment) xuất sắc.

Vấn đề: Các thư viện highlight code phía Client (như PrismJS) gây giật trang (Layout Shift). Các công cụ điều hướng nằm rải rác làm rối mắt.

Mục tiêu: Tối ưu hóa hiệu năng (Performance) và cá nhân hóa trải nghiệm (UX) như các ứng dụng đọc sách chuyên nghiệp (Kindle/Medium).

II. PHẦN CỨNG: SERVER-SIDE RENDERING (SSR)

Chúng tôi chuyển toàn bộ việc xử lý nặng (tô màu code, chỉnh sửa HTML) về phía Server. Client chỉ việc nhận HTML sạch và hiển thị.

1. Xử lý Syntax Highlight & Table Wrapper

Sử dụng cheerio để thao tác DOM ảo trên server và shiki để tô màu code chuẩn VS Code.

File: src/routes/my-blog/[slug]/+page.server.ts

TypeScript
import { getHighlighter } from 'shiki';
import * as cheerio from 'cheerio';

export const load = async ({ params }) => {
    // 1. Lấy nội dung thô từ Database
    const post = await db.collection('blog_posts').doc(params.slug).get();
    let content = post.data()?.content || '';

    // 2. Khởi tạo Highlighter (Theme Github Dark)
    const highlighter = await getHighlighter({ theme: 'github-dark', langs: ['js', 'ts', 'svelte', 'css'] });

    // 3. Load HTML vào Cheerio để xử lý
    const $ = cheerio.load(content);

    // [CORE LOGIC] Tìm thẻ <pre><code> và tô màu
    $('pre code').each((_, el) => {
        const codeText = $(el).text();
        const langClass = $(el).attr('class') || '';
        const lang = langClass.replace('language-', '') || 'text';
        
        // Render ra HTML mới đã có màu (Inline Style)
        const highlightedHtml = highlighter.codeToHtml(codeText, { lang });
        
        // Thay thế thẻ cũ bằng thẻ mới
        $(el).parent().replaceWith(`<div class="gemini-code-wrapper">${highlightedHtml}</div>`);
    });

    // [CORE LOGIC] Bọc bảng (Table) để hỗ trợ cuộn ngang trên mobile
    $('table').each((_, el) => {
        $(el).wrap('<div class="gemini-table-wrapper"></div>');
    });

    return {
        post: { ...post.data(), content: $.html() } // Trả về HTML "sạch"
    };
};


III. GIAO DIỆN: ZEN MODE & STICKY SIDEBAR

Bài toán hóc búa nhất về CSS: Làm sao để thanh Mục lục (TOC) vừa có thể dính (Sticky) khi cuộn, vừa có hiệu ứng ẩn hiện mượt mà (Transition)?

1. Giải quyết mâu thuẫn Sticky vs Overflow

Quy tắc: position: sticky sẽ bị vô hiệu hóa nếu cha nó có overflow: hidden.

Giải pháp: Dùng logic điều kiện (Conditional Class). Khi mở TOC thì bỏ hidden, khi đóng thì thêm hidden.

File: src/routes/my-blog/[slug]/+page.svelte

HTML
<div class="flex flex-col lg:flex-row gap-12 ... relative">
    
    <article class="min-w-0 flex-1 ...">
        </article>

    <div class="hidden lg:block relative transition-all duration-500 ease-in-out
                {showTOC ? 'w-[280px] ml-16 opacity-100' : 'w-0 ml-0 opacity-0 overflow-hidden'}">
        
        <div class="sticky top-12 w-[280px]">
             <TableOfContents ... />
        </div>
    </div>
</div>


IV. TÍNH NĂNG THÔNG MINH (SMART LOGIC)

Nâng cấp trải nghiệm người dùng bằng các tương tác nhỏ (Micro-interactions).

1. Cá nhân hóa & Ghi nhớ (Persistence)

Cho phép người dùng đổi Font/Size và lưu lại vào localStorage. Sử dụng CSS Variables để áp dụng thay đổi tức thì.

File: src/routes/my-blog/[slug]/+page.svelte

TypeScript
// Script: Load/Save Preferences
function loadPreferences() {
    if (typeof localStorage !== 'undefined') {
        const savedFont = localStorage.getItem('reading_fontFamily');
        if (savedFont) currentFontFamily = savedFont;
    }
}

// HTML: Áp dụng biến CSS vào container cha
// <div style="--read-font: {currentFontFamily};"> ... </div>

// Style: Ép buộc mọi phần tử con tuân theo biến CSS
:global(.reader-content *) {
    font-family: var(--read-font) !important;
}


2. Mục lục "Biết suy nghĩ" (Intelligent Scroll)

Mục lục tự động cuộn nội bộ để mục đang đọc (Active Item) luôn nằm trong tầm mắt, nhưng không được làm giật trang web chính.

File: src/lib/components/TableOfContents.svelte

TypeScript
// Khi activeId thay đổi (do người dùng cuộn bài viết)
$: if (activeId && tocContainer) {
    const activeLink = tocContainer.querySelector(`a[href="#${activeId}"]`);
    
    if (activeLink) {
        // Tính toán toán học vị trí tương đối
        const itemTop = activeLink.offsetTop;
        const containerHeight = tocContainer.clientHeight;
        const itemHeight = activeLink.clientHeight;
        
        // Công thức căn giữa: Vị trí đích = (Vị trí Item) - (1/2 Chiều cao khung)
        const targetScroll = itemTop - (containerHeight / 2) + (itemHeight / 2);

        // Chỉ cuộn cái hộp TOC, không đụng vào window
        tocContainer.scrollTo({
            top: targetScroll,
            behavior: 'smooth'
        });
    }
}


3. Action: Kéo bảng bằng chuột (Drag to Scroll)

Cho phép thao tác chuột trên PC giống như vuốt cảm ứng (Touch) khi xem bảng rộng.

File: src/routes/my-blog/[slug]/+page.svelte (Hàm enableTableDrag)

TypeScript
function enableTableDrag(node: HTMLElement) {
    let isDown = false;
    let startX: number;
    let scrollLeft: number;

    const onMouseDown = (e: MouseEvent) => {
        const wrapper = (e.target as HTMLElement).closest('.gemini-table-wrapper');
        if (!wrapper) return;
        isDown = true;
        wrapper.classList.add('cursor-grabbing'); // Đổi con trỏ chuột thành nắm tay
        startX = e.pageX - wrapper.offsetLeft;
        scrollLeft = wrapper.scrollLeft;
    };

    const onMouseMove = (e: MouseEvent) => {
        if (!isDown) return;
        e.preventDefault(); // Ngăn bôi đen chữ
        const wrapper = (e.target as HTMLElement).closest('.gemini-table-wrapper');
        const x = e.pageX - wrapper.offsetLeft;
        const walk = (x - startX) * 2; // Tốc độ kéo x2
        wrapper.scrollLeft = scrollLeft - walk;
    };
    
    // ... (Thêm Event Listeners) ...
}


V. THUẬT TOÁN ĐỀ XUẤT (RECOMMENDATION SYSTEM)

Hệ thống đề xuất bài viết liên quan sử dụng chiến thuật "Phễu 3 lớp" (Hybrid Filtering).

File: src/lib/components/RelatedPosts.svelte

TypeScript
// 1. Ưu tiên 1: Tìm theo TAGS (Độ liên quan cao nhất)
let postsFound = await db.collection('blog_posts')
    .where('tags', 'array-contains-any', currentTags)
    .get();

// 2. Ưu tiên 2: Nếu ít bài quá (< 4 bài), tìm thêm theo CATEGORY (Dự phòng)
if (postsFound.length < 4) {
    const catPosts = await db.collection('blog_posts')
        .where('category', '==', currentCategory)
        .get();
    // Gộp vào và lọc trùng
    postsFound = [...postsFound, ...catPosts];
}

// 3. Cleaning: Loại bỏ bài hiện tại và cắt lấy 3 bài đầu
relatedPosts = postsFound
    .filter(p => p.id !== currentPostId) // Tránh hiện lại bài đang đọc
    .slice(0, 3);


VI. TỔNG KẾT

Dự án này chứng minh rằng việc kết hợp chặt chẽ giữa Tư duy Kiến trúc (Architecture) và Tư duy Sản phẩm (Product) sẽ tạo ra trải nghiệm người dùng vượt trội.

Tốc độ: Nhờ Server-Side Rendering.

Thẩm mỹ: Nhờ thiết kế Zen Mode và Smart Dock.

Tiện dụng: Nhờ các thuật toán xử lý tương tác nhỏ (Micro-interactions).

Thời đại AI đang đến!!!! 🚀

Biến mọi ý tưởng điên rồ nhất thành hiện thực.

Lượt truy cập: ...