Hướng dẫn làm site ảnh Gif xem mãi không hết – Phần 1: API và Giao diện

Xin chào mọi người, hôm nay mình sẽ giới thiệu với mọi người cách mình làm site ảnh Gif NgaSap.ML

Tình huống là mấy anh em đang nói chuyện với nhau về những tên miền thú vị và nhắc đến tên miền ngasap.ml (ngã sấp ML) thì mình nghĩ ngay đến 1 trang web xem những ảnh Gif liên quan đến ngã (falling).

Bước 1: Nguồn ảnh gif

Về ảnh Gif thì có 1 trang web rất nổi tiếng là Giphy, mình vào đó search thử với từ khóa “falling” thì thấy có rất nhiều ảnh vui liên quan đến từ khóa này.

falling gif

Đã có nơi lấy ảnh rồi, giờ tìm cách lấy ảnh về thôi. Giphy có cung cấp API cho phép developer lấy ảnh về.

Truy cập Giphy Developers, tạo Application dạng SDK và lấy API Key

Chọn SDK và bấm Next Step

Sau đó điền thông tin App NameApp Description, [đọc License Agreement và] tích chọn đồng ý sau đó bấm Create App

Điền đầy đủ thông tin và bấm Create App

Sau khi tạo App xong thì ở giao diện Dashboard sẽ hiện App vừa tạo và API Key. Chúng ta sẽ dùng API Key này để lấy ảnh Gif

Thông tin App vừa tạo và API Key

Bước 2: Setup server

Tiếp theo chúng ta sẽ cần viết 1 API lấy Gif từ Giphy và trả về để hiển thị lên trang web. Ở đây mình dùng NodeJS nên sẽ dùng Web SDK của Giphy.

2.1: Khởi tạo project và cài thư viện

Chúng ta sẽ cần cài express (nodejs framework đùng để viết API), @giphy/js-fetch-api (Web SDK của Giphy), dotenv (đọc biến môi trường)

mkdir ngasapml
cd ngasapml
npm init -y
npm i express dotenv @giphy/js-fetch-api

Sau đó tạo file index.js với nội dung như sau:

// File: index.js
//Load và khởi tạo dotenv để đọc biến môi trường để có thể truy cập qua process.env
require("dotenv").config();
//Load express và tạo app
const app = require("express")();
//Khai báo route cho đường dẫn /
app.get("/", (req, res) => {
    res.send("Ok");
});
//lấy PORT từ biến môi trường, nếu không có thì chạy trên port 3413
const PORT = process.env.PORT || 3413;
//start app và log
app.listen(PORT, (err) => {
    if (err) {
        console.error(err);
        process.exit(1);
    }
    console.log(`Running on port ${PORT}`);
});

Lưu file lại và chạy lệnh node index.js, truy cập http://localhost:3413 thấy chữ Ok

2.2: Viết API lấy dữ liệu từ Giphy.

Thêm 1 route với phương thức GET cho đường dẫn /api/get

//File: index.js
//Load Giphy SDK
const { GiphyFetch } = require("@giphy/js-fetch-api");
//Khởi tạo đối tượng GiphyFetch, GIPHY_API_KEY lấy từ biến môi trường
const gf = new GiphyFetch(process.env.GIPHY_API_KEY);
//Khai báo route get /api/get
app.get("/api/get", async (req, res) => {
    try {
        //tìm kiếm ảnh theo từ khóa "falling"
        let results = await gf.search("falling", {
            sort: "relevant", //sắp xếp theo độ liên quan
            offset: 0, //lấy từ ảnh số 0
            limit: 10, //lấy tối đa 10 ảnh
        });
        return res.json({
            data: results.data,
            pagination: results.pagination,
        });
    } catch (err) {
        res.status(500).json({
            error: err.message,
        });
    }
});

Tạo file .env và thêm biến GIPHY_API_KEY với giá trị là API Key lấy từ Dashboard Giphy (thay giá trị bằng giá trị của bạn)

#File: .env
GIPHY_API_KEY=sT9uqnKchungmungnammoi2021

Khởi động tại server và truy cập http://localhost:3413/api/get, bạn sẽ thấy lỗi fetch is not defined như bên dưới:

Lỗi fetch is not defined khi dùng Giphy SDK

Lỗi này là do Giphy SDK viết để dùng với Fetch API trên trình duyệt, muốn dùng với NodeJS thì chúng ta cần cài thư viện node-fetch và set hàm fetch vào object global của NodeJS. Thực hiện như sau:

Cài thư viện node-fetch

npm i node-fetch

Thêm đoạn code vào đầu file index.js

//File: index.js
global.fetch = require("node-fetch");

Khởi động tại server và truy cập http://localhost:3413/api/get sẽ thấy dữ liệu được trả về là 1 object với key data là list những ảnh gif liên quan đến từ khóa “falling”:

Danh sách ảnh gif phù hợp với từ khóa “falling”

Ở mỗi ảnh gif thì ta chỉ quan tâm đến field images. Field images là 1 object với mỗi key là 1 kích thước khác nhau. Sửa lại api /api/get để chỉ trả ra những dữ liệu chúng ta cần để hiển thị trên giao diện:

return res.json({
    data: results.data.map((gif) => gif.images.downsized_medium),
    pagination: results.pagination,
});

Khởi động tại server và thử lại được kết quả như bên dưới

2.3: Tính năng phân trang

Tiếp theo bổ sung tính năng phân trang: sửa lại phần gọi API Giphy như bên dưới

//Khai báo route get /api/get?page=<page_number>
app.get("/api/get", async (req, res) => {
    try {
        let page = Number(req.query.page || 1); //lấy query param "page", nếu không có thì mặc định là 1
        let limit = 10;
        //tìm kiếm ảnh theo từ khóa "falling"
        let results = await gf.search("falling", {
            sort: "relevant", //sắp xếp theo độ liên quan
            offset: (page - 1) * limit, //tính offset dựa trên page và limit
            limit: limit, //giới hạn số ảnh lấy về
        });
        return res.json({
            data: results.data.map((gif) => gif.images.downsized_medium),
            pagination: results.pagination,
        });
    } catch (err) {
        res.status(500).json({
            error: err.message,
        });
    }
});

Bước 3: Làm giao diện.

3.1 Giao diện loading

Giao diện mình sẽ làm tính năng infinite-scroll để người xem có thể thoải mái xem mà không phải bấm nút next page.

Search Google với từ khóa “js infinite scroll” ta có ngay kết quả là thư viện: Infinite Scroll. May mắn thay khi đọc docs thì có link đến phần demo Loading JSON, vanilla JS, vào xem thì đúng thứ mình cần nên mình copy HTML, CSS, JS trên phần demo về rồi sửa lại 1 chút là xong:

Tào file index.html với nội dung như sau:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>NgaSapML</title>
        <style>
            body {
                font-family: sans-serif;
                line-height: 1.4;
                padding: 20px;
                max-width: 640px;
                margin: 0 auto;
            }
            .photo-item {
                margin: 40px 0;
                padding-top: 20px;
                border-top: 1px solid #ddd;
            }
            .photo-item__image {
                display: block;
                width: 100%;
            }
            .page-load-status {
                display: none; /* hidden by default */
                padding-top: 20px;
                border-top: 1px solid #ddd;
                text-align: center;
                color: #777;
            }
            .loader-ellips {
                font-size: 20px; /* change size here */
                position: relative;
                width: 4em;
                height: 1em;
                margin: 10px auto;
            }
            .loader-ellips__dot {
                display: block;
                width: 1em;
                height: 1em;
                border-radius: 0.5em;
                background: #555; /* change color here */
                position: absolute;
                animation-duration: 0.5s;
                animation-timing-function: ease;
                animation-iteration-count: infinite;
            }
            .loader-ellips__dot:nth-child(1),
            .loader-ellips__dot:nth-child(2) {
                left: 0;
            }
            .loader-ellips__dot:nth-child(3) {
                left: 1.5em;
            }
            .loader-ellips__dot:nth-child(4) {
                left: 3em;
            }
            @keyframes reveal {
                from {
                    transform: scale(0.001);
                }
                to {
                    transform: scale(1);
                }
            }
            @keyframes slide {
                to {
                    transform: translateX(1.5em);
                }
            }
            .loader-ellips__dot:nth-child(1) {
                animation-name: reveal;
            }
            .loader-ellips__dot:nth-child(2),
            .loader-ellips__dot:nth-child(3) {
                animation-name: slide;
            }
            .loader-ellips__dot:nth-child(4) {
                animation-name: reveal;
                animation-direction: reverse;
            }
        </style>
    </head>
    <body>
        <h1>NgaSapML - #falling</h1>
        <div class="container"></div>
        <div class="page-load-status">
            <div class="loader-ellips infinite-scroll-request">
                <span class="loader-ellips__dot"></span>
                <span class="loader-ellips__dot"></span>
                <span class="loader-ellips__dot"></span>
                <span class="loader-ellips__dot"></span>
            </div>
            <p class="infinite-scroll-last">End of content</p>
            <p class="infinite-scroll-error">No more pages to load</p>
        </div>
    </body>
</html>

3.2 Tích hợp với API

Thêm phần script sau vào cuối thẻ body:

<script src="https://unpkg.com/infinite-scroll@4/dist/infinite-scroll.pkgd.min.js"></script>
<script>
    let container = document.querySelector(".container");
    let infScroll = new InfiniteScroll(container, {
        path: function () {
            //API vừa tạo
            return `/api/get?page=${this.pageIndex}`;
        },
        // load response as JSON
        responseBody: "json",
        status: ".page-load-status",
        history: false,
    });
    // use element to turn HTML string into elements
    let proxyElem = document.createElement("div");
    infScroll.on("load", function (body) {
        // body có dạng: {data: [...], pagination: {...}}
        var itemsHTML = body.data.map(getItemHTML).join("");
        // convert HTML string into elements
        proxyElem.innerHTML = itemsHTML;
        // append item elements
        container.append(...proxyElem.children);
    });
    // load initial page
    infScroll.loadNextPage();
    //------------------//
    function getItemHTML(image) {
        // image có dạng {url: '...', height: xxx, width: xxx}
        return `<div class="photo-item">
            <img class="photo-item__image" src="${image.url}" />
        </div>`;
    }
</script>

3.3 Render file index.html

Sửa api GET / ở file index.js trả về file index.html vừa tạo

const path = require("path");
//Khai báo route cho đường dẫn /
app.get("/", (req, res) => {
    res.sendFile(path.resolve(__dirname, "index.html"));
});

Khởi động lại server và truy cập http://localhost:3413 sẽ được như hình dưới

Giao diện xem ảnh Gif Infinite Scroll

Hết phần 1. Ở phần sau mình sẽ giới thiệu cách đóng gói và triển khai ứng dụng với Docker.

Source code: dangdungcntt/ngasap.ml