轻松在CF搭建一个自己的导航页面,方便的页面定制和卡片式书签管理

来到论坛2个月,学到了很多,也收获(白嫖)了很多,佬友们无私地分享着各种资源,有的佬甚至自掏腰包建公益API站,呆得越久越觉得自己也应做点什么。奈何能力有限,一直以来也没有什么值得拿出来回馈论坛的佬友们。直到最近看到论坛里几篇关于搭建导航页面的帖子,自己刚好有这方面的需求,试了一圈佬友们的推荐觉得都不是太满意,要么部署麻烦需要vps或者docker容器,要么维护麻烦。于是我这个技术小白就靠着佬友们的共享API,跟Gpt和Claude深入的交流了两天,还真就搓出一个 导航页面,自己感觉挺满意的,所以打算分享出来给有需要的佬友们。感谢@RawChat的公益Claude 和 @chenyme 的公益Gpt(虽然@chenyme佬分享的账号好像登不上了),让我实现了AI自由。还得感谢始皇搭建了这个自由共建的平台,让我受益良多。

我将代码放在GitHub上了,项目名是Card-Tab。 导航页面的书签卡片式管理,进入管理模式可以自由移动书签位置,添加和删除书签,支持自定义网站分类,支持切换黑暗色主题。依托CF的workers进行部署,对于纯小白来说难度也不算大,只需要一个Cloudflare的账号和一个域名就行。整体风格比较简约,也可以说是简陋,会代码的佬友可以自行美化界面。这是我GitHub的第一个项目,如果你喜欢烦请点亮一下小星星!

详细的部署步骤,README已经说得很清楚了,可以访问项目地址按照指引操作。演示站点: https://demo.linuxdo.nyc.mn 密码:admin 这里秀一下刚申请的免费二级域名,感谢@PKYF 的分享


2024.09.17 更新:
对手机端进行了适配
2024.09.14 更新:
修复 1、‘删除分类’操作导致的书签显示问题;2、‘删除分类’按钮点击过后隐藏显示的问题; 添加网站图标和操作日志
2024.09.09更新:
1、增加私密书签,登录后可见
2、增加网站分类管理,现在你无需编辑代码,通过页面即可进行网站分类的添加和删除操作
3、增加搜索框和一言接口
之前移动书签的小问题好像也没有了。

注意:如果你已经部署过第一版(20240902),更新workes代码后将无法看到之前保存的书签,需重新添加书签,望知悉!

131 Likes

前排 支持 一下

1 Like

支持支持支持

可以设置书签登陆可见吗?

登录可见的已经有可用的成品了啊,我就是不想登录才弄这么个东西出来的,你可以试试 WeTab \ ITtab之类的插件

2 Likes

我的意思是有公开的也有一部分私有的,已经很强了

4 Likes


这可爱小结节谁加的,这不会把我新申请的域名给寄了吧,别搞我啊

2 Likes

可以考虑增加一个登录之后可见的功能

你和楼上的佬友同样的需求,有空我看看能不能弄出来

1 Like

比我的好看多了,果然审美该提高了

1 Like

https://nav.sk.gs 已搭建支持

1 Like

界面看着不错啊

感谢分享,已点star :partying_face:

1 Like

比如 排版简洁的 0x3.com 导航 我只是比较好奇此类导航站如何实现密钥数据库

1 Like

我也不懂,反正我这个导航页 密码是 存在workers的变量里,书签是存在kv里的,所有的东西都在你的cf账号下,需要备份的时候只要把KV里的东西拷贝出来就行

7 Likes

支持,非常有帮助,老早就想要个属于自己的书签页面

我之前还发了个贴,都没找到答案,今天倒是自己冒出来了:expressionless:

支持下,有空学习下你的代码,cf一直没有找教程学习环境变量,kv得使用

1 Like

找CLAUDE改了一下,可以添加和删除分类

// @ts-nocheck

const adminSessions = new Set();

// 这里定义 generateSessionToken 函数
function generateSessionToken() {
    return crypto.randomUUID();
}

const HTML_CONTENT = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Card Tab</title>
    <link rel="stylesheet" href="/styles.css">
</head>
<body>
    <h1>我的导航</h1>

    <div class="admin-controls">
        <input type="password" id="admin-password" placeholder="输入密码">
        <button id="admin-mode-btn" onclick="toggleAdminMode()">进入管理模式</button>
    </div>

    <div class="add-remove-controls">
        <button class="round-btn" onclick="showAddDialog()">+</button>
        <button class="round-btn" onclick="toggleRemoveMode()">-</button>
        <button class="round-btn" onclick="addCategory()">C</button>
    </div>

    <div id="sections-container">
        <!-- 分类将在这里动态生成 -->
    </div>

    <button id="theme-toggle" onclick="toggleTheme()">&#9681;</button>

    <div id="dialog-overlay">
        <div id="dialog-box">
            <label for="name-input">名称</label>
            <input type="text" id="name-input">
            <label for="url-input">地址</label>
            <input type="text" id="url-input">
            <label for="category-select">选择分类</label>
            <select id="category-select">
                <!-- 分类选项将在这里动态生成 -->
            </select>
            <button onclick="addLink()">确定</button>
            <button onclick="hideAddDialog()">取消</button>
        </div>
    </div>

    <div class="copyright">
        <p>项目地址: <a href="https://github.com/hmhm2022/Card-Tab" target="_blank">GitHub</a> 烦请点个star!</p>
    </div>

    <script src="/script.js"></script>
</body>
</html>
`;

const CSS_CONTENT = `
body {
    font-family: Arial, sans-serif;
    background-color: #d8eac4;
    margin: 0;
    padding: 20px;
    display: flex;
    flex-direction: column;
    align-items: center;
    transition: background-color 0.3s ease;
}

.card-container {
    display: grid;
    grid-template-columns: repeat(6, 1fr);
    gap: 10px;
}

.card {
    display: flex;
    flex-direction: column;
    position: relative;
    background-color: #a0c9e5;
    padding: 10px;
    border-radius: 10px;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
    cursor: grab;
    transition: transform 0.2s ease, box-shadow 0.2s ease;
    width: 200px;
    height: auto;
}

.card-top {
    display: flex;
    align-items: center;
    margin-bottom: 5px;
}

.card-icon {
    width: 24px;
    height: 24px;
    margin-right: 10px;
}

.card-title {
    font-size: 16px;
    font-weight: bold;
}

.card-url {
    color: #555;
    font-size: 12px;
    word-break: break-all;
}

.card.dragging {
    opacity: 0.8;
    transform: scale(1.05);
    cursor: grabbing;
}

.card:hover {
    transform: translateY(-5px);
    box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
}

.delete-btn {
    position: absolute;
    top: -10px;
    right: -10px;
    background-color: red;
    color: white;
    border: none;
    border-radius: 50%;
    width: 20px;
    height: 20px;
    text-align: center;
    font-size: 14px;
    line-height: 20px;
    cursor: pointer;
    display: none;
}

.delete-category-btn {
    background-color: #ff4d4d;
    color: white;
    border: none;
    padding: 5px 10px;
    border-radius: 5px;
    cursor: pointer;
    margin-left: 10px;
}

.delete-category-btn:hover {
    background-color: #ff1a1a;
}


.admin-controls {
    position: fixed;
    top: 10px;
    right: 10px;
    font-size: 60%;
}

.admin-controls input {
    padding: 5px;
    font-size: 60%;
}

.admin-controls button {
    padding: 5px 10px;
    font-size: 60%;
    margin-left: 10px;
}

.add-remove-controls {
    display: none;
    margin-top: 10px;
}

.round-btn {
    background-color: #007bff;
    color: white;
    border: none;
    border-radius: 50%;
    width: 40px;
    height: 40px;
    text-align: center;
    font-size: 24px;
    line-height: 40px;
    cursor: pointer;
    margin: 0 10px;
}

#theme-toggle {
    position: fixed;
    bottom: 10px;
    left: 10px;
    background-color: #007bff;
    color: white;
    border: none;
    border-radius: 50%;
    width: 40px;
    height: 40px;
    text-align: center;
    font-size: 24px;
    line-height: 40px;
    cursor: pointer;
}

#dialog-overlay {
    display: none;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.5);
    justify-content: center;
    align-items: center;
}

#dialog-box {
    background: white;
    padding: 20px;
    border-radius: 10px;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}

#dialog-box label {
    display: block;
    margin-bottom: 5px;
}

#dialog-box input, #dialog-box select {
    width: 100%;
    padding: 5px;
    margin-bottom: 10px;
}

#dialog-box button {
    padding: 5px 10px;
    margin-right: 10px;
}

.section {
    margin-bottom: 20px;
}

.section-title {
    font-size: 24px;
    font-weight: bold;
    color: #333;
    margin-bottom: 10px;
}

@media (max-width: 768px) {
    .card-container {
        grid-template-columns: repeat(2, 1fr);
    }

    .card {
        width: 100%;
    }

    .admin-controls {
        position: static;
        margin-bottom: 20px;
    }

    #theme-toggle {
        position: static;
        margin-top: 20px;
    }
}
`;

const JS_CONTENT = `
let isAdmin = false;
let removeMode = false;
let isDarkTheme = false;
let links = [];
let adminToken = null;
const categories = {
    "常用网站": [],
    "工具导航": [],
    "游戏娱乐": [],
    "影音视听": [],
    "技术论坛": []
};

async function loadLinks() {
    try {
        const response = await fetch('/api/getLinks?userId=testUser');
        if (!response.ok) {
            throw new Error('Failed to load links');
        }
        links = await response.json();

        Object.keys(categories).forEach(key => {
            categories[key] = [];
        });

        links.forEach(link => {
            if (categories[link.category]) {
                categories[link.category].push(link);
            }
        });

        loadSections();
        updateCategorySelect();
    } catch (error) {
        console.error('Error loading links:', error);
        alert('Failed to load links. Please try again later.');
    }
}


function loadSections() {
    const container = document.getElementById('sections-container');
    container.innerHTML = '';

    Object.keys(categories).forEach(category => {
        const section = document.createElement('div');
        section.className = 'section';

        const titleContainer = document.createElement('div');
        titleContainer.className = 'section-title-container';

        const title = document.createElement('div');
        title.className = 'section-title';
        title.textContent = category;

        titleContainer.appendChild(title);

        // 只在管理模式下添加删除分类按钮
        if (isAdmin) {
           const deleteBtn = document.createElement('button');
           deleteBtn.textContent = '删除分类';
           deleteBtn.className = 'delete-category-btn';
           deleteBtn.onclick = () => deleteCategory(category);
           titleContainer.appendChild(deleteBtn);
        }


        const cardContainer = document.createElement('div');
        cardContainer.className = 'card-container';
        cardContainer.id = category;

        section.appendChild(titleContainer);
        section.appendChild(cardContainer);

        categories[category].forEach(link => {
            createCard(link, cardContainer);
        });

        container.appendChild(section);
    });
}


function deleteCategory(category) {
    const confirmMessage = \`确定要删除 "\${category}" 分类吗?这将删除该分类下的所有链接。\`;
    if (confirm(confirmMessage)) {
        delete categories[category];
        updateCategorySelect();
        loadSections();
        saveLinks();
    }
}



function createCard(link, container) {
    const card = document.createElement('div');
    card.className = 'card';
    card.setAttribute('draggable', isAdmin);

    const cardTop = document.createElement('div');
    cardTop.className = 'card-top';

    const icon = document.createElement('img');
    icon.className = 'card-icon';
    icon.src = 'https://favicon.zhusl.com/ico?url=' + link.url;
    icon.alt = 'Website Icon';

    const title = document.createElement('div');
    title.className = 'card-title';
    title.textContent = link.name;

    cardTop.appendChild(icon);
    cardTop.appendChild(title);

    const url = document.createElement('div');
    url.className = 'card-url';
    url.textContent = link.url;

    card.appendChild(cardTop);
    card.appendChild(url);

    if (!isAdmin) {
        card.addEventListener('click', () => {
            window.open(link.url, '_blank');
        });
    }

    const deleteBtn = document.createElement('button');
    deleteBtn.textContent = '–';
    deleteBtn.className = 'delete-btn';
    deleteBtn.onclick = function (event) {
        event.stopPropagation();
        removeCard(card);
    };
    card.appendChild(deleteBtn);

    if (isDarkTheme) {
        card.style.backgroundColor = '#1e1e1e';
        card.style.color = '#ffffff';
        card.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.5)';
    } else {
        card.style.backgroundColor = '#a0c9e5';
        card.style.color = '#333';
        card.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.1)';
    }

    card.addEventListener('dragstart', dragStart);
    card.addEventListener('dragover', dragOver);
    card.addEventListener('dragend', dragEnd);
    card.addEventListener('drop', drop);

    if (isAdmin && removeMode) {
        deleteBtn.style.display = 'block';
    }

    container.appendChild(card);
}


function updateCategorySelect() {
    const categorySelect = document.getElementById('category-select');
    categorySelect.innerHTML = '';

    Object.keys(categories).forEach(category => {
        const option = document.createElement('option');
        option.value = category;
        option.textContent = category;
        categorySelect.appendChild(option);
    });
}

async function saveLinks() {
    let links = [];
    for (const category in categories) {
        links = links.concat(categories[category]);
    }

    try {
        await fetch('/api/saveOrder', {
            method: 'POST',
            headers: { 
                'Content-Type': 'application/json',
                'Authorization': \`Bearer \${adminToken}\`  // 注意这里的转义
            },
            body: JSON.stringify({ userId: 'testUser', links }),
        });
        // 成功保存后的逻辑(如果需要)
    } catch (error) {
        console.error('Error saving links:', error);
        // 错误处理逻辑(如果需要)
    }
}



function addLink() {
    const name = document.getElementById('name-input').value;
    const url = document.getElementById('url-input').value;
    const category = document.getElementById('category-select').value;

    if (name && url && category) {
        const newLink = { name, url, category };

        if (!categories[category]) {
            categories[category] = [];
        }
        categories[category].push(newLink);

        const container = document.getElementById(category);
        createCard(newLink, container);

        saveLinks();

        document.getElementById('name-input').value = '';
        document.getElementById('url-input').value = '';
        hideAddDialog();
    }
}


function removeCard(card) {
    const url = card.querySelector('.card-url').textContent;
    let category;
    for (const key in categories) {
        const index = categories[key].findIndex(link => link.url === url);
        if (index !== -1) {
            categories[key].splice(index, 1);
            category = key;
            break;
        }
    }
    card.remove();

    saveLinks();
}

let draggedCard = null;

function dragStart(event) {
    if (!isAdmin) return;
    draggedCard = event.target;
    draggedCard.classList.add('dragging');
    event.dataTransfer.effectAllowed = "move";
}

function dragOver(event) {
    if (!isAdmin) return;
    event.preventDefault();
    const target = event.target.closest('.card');
    if (target && target !== draggedCard) {
        const container = target.parentElement;
        const mousePositionX = event.clientX;
        const targetRect = target.getBoundingClientRect();

        if (mousePositionX < targetRect.left + targetRect.width / 2) {
            container.insertBefore(draggedCard, target);
        } else {
            container.insertBefore(draggedCard, target.nextSibling);
        }
    }
}

function drop(event) {
    if (!isAdmin) return;
    event.preventDefault();
    draggedCard.classList.remove('dragging');
    draggedCard = null;
    saveCardOrder();
}

function dragEnd(event) {
    if (draggedCard) {
        draggedCard.classList.remove('dragging');
    }
}

async function saveCardOrder() {
    if (!isAdmin) return;
    const containers = document.querySelectorAll('.card-container');
    let newLinks = [];

    containers.forEach(container => {
        const category = container.id;
        categories[category] = [];
        [...container.children].forEach(card => {
            const url = card.querySelector('.card-url').textContent;
            const name = card.querySelector('.card-title').textContent;
            const link = { name, url, category };
            categories[category].push(link);
            newLinks.push(link);
        });
    });

    links = newLinks;

    await saveLinks();
}

async function toggleAdminMode() {
    const passwordInput = document.getElementById('admin-password');
    const adminBtn = document.getElementById('admin-mode-btn');
    const addRemoveControls = document.querySelector('.add-remove-controls');

    if (!isAdmin) {
        try {
            const response = await fetch('/api/login', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ password: passwordInput.value }),
            });

            if (!response.ok) {
                throw new Error('Invalid password');
            }

            const { token } = await response.json();
            adminToken = token;

            isAdmin = true;
            adminBtn.textContent = "退出管理模式";
            alert('已进入管理模式');
            addRemoveControls.style.display = 'block';
            loadSections(); // 重新加载分类以显示删除按钮
        } catch (error) {
            console.error('Login error:', error);
            alert('密码错误');
        }
    } else {
        await logoutAdmin();
        loadSections(); // 重新加载分类以隐藏删除按钮
    }

    passwordInput.value = '';
}


function reloadCardsAsAdmin() {
    document.querySelectorAll('.card-container').forEach(container => {
        container.innerHTML = '';
    });
    loadLinks().then(() => {
        if (isDarkTheme) {
            applyDarkTheme();
        }
    });
}

function applyDarkTheme() {
    document.body.style.backgroundColor = '#121212';
    document.body.style.color = '#ffffff';
    const cards = document.querySelectorAll('.card');
    cards.forEach(card => {
        card.style.backgroundColor = '#1e1e1e';
        card.style.color = '#ffffff';
        card.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.5)';
    });
}

function showAddDialog() {
    document.getElementById('dialog-overlay').style.display = 'flex';
}

function hideAddDialog() {
    document.getElementById('dialog-overlay').style.display = 'none';
}

function toggleRemoveMode() {
    removeMode = !removeMode;
    const deleteButtons = document.querySelectorAll('.delete-btn');
    deleteButtons.forEach(btn => {
        btn.style.display = removeMode ? 'block' : 'none';
    });
}

function toggleTheme() {
    isDarkTheme = !isDarkTheme;
    document.body.style.backgroundColor = isDarkTheme ? '#121212' : '#d8eac4';
    document.body.style.color = isDarkTheme ? '#ffffff' : '#333';

    const cards = document.querySelectorAll('.card');
    cards.forEach(card => {
        card.style.backgroundColor = isDarkTheme ? '#1e1e1e' : '#a0c9e5';
        card.style.color = isDarkTheme ? '#ffffff' : '#333';
        card.style.boxShadow = isDarkTheme
            ? '0 4px 8px rgba(0, 0, 0, 0.5)'
            : '0 4px 8px rgba(0, 0, 0, 0.1)';
    });

    const dialogBox = document.getElementById('dialog-box');
    dialogBox.style.backgroundColor = isDarkTheme ? '#1e1e1e' : '#ffffff';
    dialogBox.style.color = isDarkTheme ? '#ffffff' : '#333';

    const inputs = dialogBox.querySelectorAll('input, select');
    inputs.forEach(input => {
        input.style.backgroundColor = isDarkTheme ? '#333333' : '#ffffff';
        input.style.color = isDarkTheme ? '#ffffff' : '#333';
    });
}

function addCategory() {
    const promptMessage = '请输入新分类名称:';
    const categoryName = prompt(promptMessage);
    if (categoryName && !categories[categoryName]) {
        categories[categoryName] = [];
        updateCategorySelect();
        loadSections();
        saveLinks();
    } else if (categories[categoryName]) {
        const alertMessage = '该分类已存在';
        alert(alertMessage);
    }
}


async function logoutAdmin() {
    if (adminToken) {
        try {
            await fetch('/api/logout', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ token: adminToken }),
            });
        } catch (error) {
            console.error('Logout error:', error);
        }
    }
    adminToken = null;
    isAdmin = false;
    removeMode = false;
    document.getElementById('admin-mode-btn').textContent = "进入管理模式";
    document.querySelector('.add-remove-controls').style.display = 'none';
    const deleteButtons = document.querySelectorAll('.delete-btn');
    deleteButtons.forEach(btn => btn.style.display = 'none');
    loadSections(); // 重新加载分类以隐藏删除按钮
}


loadLinks();
`;


// 主要的 Worker 处理逻辑
export default {
    async fetch(request, env) {
        const url = new URL(request.url);

        if (url.pathname === '/') {
            return new Response(HTML_CONTENT, {
                headers: { 'Content-Type': 'text/html' }
            });
        }

        if (url.pathname === '/styles.css') {
            return new Response(CSS_CONTENT, {
                headers: { 'Content-Type': 'text/css' }
            });
        }

        if (url.pathname === '/script.js') {
            return new Response(JS_CONTENT, {
                headers: { 'Content-Type': 'application/javascript' }
            });
        }

        if (url.pathname === '/api/getLinks') {
            const userId = url.searchParams.get('userId');
            const links = await env.CARD_ORDER.get(userId);
            return new Response(links || JSON.stringify([]), { status: 200 });
        }

        if (url.pathname === '/api/login' && request.method === 'POST') {
            const { password } = await request.json();
            if (password === env.ADMIN_PASSWORD) {
                const sessionToken = generateSessionToken();
                adminSessions.add(sessionToken);
                return new Response(JSON.stringify({ token: sessionToken }), {
                    status: 200,
                    headers: { 'Content-Type': 'application/json' },
                });
            }
            return new Response(JSON.stringify({ error: 'Invalid password' }), {
                status: 403,
                headers: { 'Content-Type': 'application/json' },
            });
        }

        if (url.pathname === '/api/logout' && request.method === 'POST') {
            const { token } = await request.json();
            adminSessions.delete(token);
            return new Response(JSON.stringify({ success: true }), {
                status: 200,
                headers: { 'Content-Type': 'application/json' },
            });
        }

        if (url.pathname === '/api/saveOrder' && request.method === 'POST') {
            if (!(await verifyAdminSession(request))) {
                return new Response('Unauthorized', { status: 401 });
            }
            const { userId, links } = await request.json();
            await env.CARD_ORDER.put(userId, JSON.stringify(links));
            return new Response(JSON.stringify({ success: true }), { status: 200 });
        }

        return new Response('Not Found', { status: 404 });
    },
};

async function verifyAdminSession(request) {
    const authHeader = request.headers.get('Authorization');
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return false;
    }
    const token = authHeader.split(' ')[1];
    return adminSessions.has(token);
}
2 Likes

感谢分享大佬厉害啊

1 Like