Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/feat-test-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,56 @@ jobs:
files: |
${{ steps.prepare_apk.outputs.apk_path }}
token: ${{ secrets.GITHUB_TOKEN }}

build-server-docker:
name: Build & Push Server Docker (feat-test)
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Set lowercase image name
id: image_name
run: |
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
NAME_LOWER=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')
OWNER_LOWER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
DOCKERHUB_USER_LOWER=$(echo "${{ secrets.DOCKERHUB_USERNAME }}" | tr '[:upper:]' '[:lower:]')
echo "ghcr=${REGISTRY}/${REPO_LOWER}" >> $GITHUB_OUTPUT
echo "dockerhub=${DOCKERHUB_USER_LOWER}/${NAME_LOWER}" >> $GITHUB_OUTPUT
env:
REGISTRY: ghcr.io

- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
target: runner
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ steps.image_name.outputs.ghcr }}:feat-test
${{ steps.image_name.outputs.dockerhub }}:feat-test
9 changes: 9 additions & 0 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import BookDetail from "./pages/BookDetail";
import AuthorDetail from "./pages/AuthorDetail";
import Settings from "./pages/Settings";
import Notes from "./pages/Notes";
import Stats from "./pages/Stats";

// Stores
import { useAuthStore, useThemeStore } from "./stores/authStore";
Expand Down Expand Up @@ -533,6 +534,14 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/stats"
element={
<ProtectedRoute>
<Stats />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
Expand Down
126 changes: 126 additions & 0 deletions apps/desktop/src/hooks/useReadingTimer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useEffect, useRef, useCallback } from 'react';
import { getApiClient } from '@bookdock/api-client';

const MIN_REPORT_THRESHOLD = 1; // Minimum seconds to report
const REPORT_INTERVAL = 3; // Report every 3 seconds while reading

export function useReadingTimer(bookId: string | undefined) {
const startTimeRef = useRef<number>(0);
const accumulatedRef = useRef<number>(0);
const isActiveRef = useRef<boolean>(false);
const bookIdRef = useRef<string | undefined>(bookId);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

// Update bookId ref when it changes
useEffect(() => {
bookIdRef.current = bookId;
}, [bookId]);

const reportSession = useCallback(async (durationSecs: number) => {
const currentBookId = bookIdRef.current;
if (!currentBookId || durationSecs < MIN_REPORT_THRESHOLD) return;

try {
const hour = new Date().getHours();
await getApiClient().recordReadingSession(currentBookId, durationSecs, hour);
} catch (err) {
console.warn('Failed to report reading session:', err);
}
}, []);

const startTimer = useCallback(() => {
if (!isActiveRef.current) {
isActiveRef.current = true;
startTimeRef.current = Date.now();
}
}, []);

const pauseTimer = useCallback(() => {
if (isActiveRef.current && startTimeRef.current > 0) {
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000);
accumulatedRef.current += elapsed;
isActiveRef.current = false;
startTimeRef.current = 0;
}
}, []);

const flushTimer = useCallback(async () => {
pauseTimer();
const total = accumulatedRef.current;
if (total >= MIN_REPORT_THRESHOLD) {
await reportSession(total);
}
accumulatedRef.current = 0;
}, [pauseTimer, reportSession]);

// Periodic report while reading
const startPeriodicReport = useCallback(() => {
if (intervalRef.current) return;
intervalRef.current = setInterval(() => {
if (isActiveRef.current && startTimeRef.current > 0) {
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000);
accumulatedRef.current += elapsed;
startTimeRef.current = Date.now();

// Report accumulated time every interval
const total = accumulatedRef.current;
accumulatedRef.current = 0;
if (total >= MIN_REPORT_THRESHOLD) {
reportSession(total);
}
}
}, REPORT_INTERVAL * 1000);
}, [reportSession]);

const stopPeriodicReport = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);

// Handle page visibility changes
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
pauseTimer();
stopPeriodicReport();
} else {
startTimer();
startPeriodicReport();
}
};

document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [startTimer, pauseTimer, startPeriodicReport, stopPeriodicReport]);

// Handle beforeunload
useEffect(() => {
const handleBeforeUnload = () => {
stopPeriodicReport();
flushTimer();
};

window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [flushTimer, stopPeriodicReport]);

// Start timer when bookId is set
useEffect(() => {
if (bookId) {
startTimer();
startPeriodicReport();
}
return () => {
stopPeriodicReport();
flushTimer();
};
}, [bookId, startTimer, flushTimer, startPeriodicReport, stopPeriodicReport]);

return { startTimer, pauseTimer, flushTimer };
}
7 changes: 7 additions & 0 deletions apps/desktop/src/pages/Library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
List,
PenLine,
Search,
ArrowLeft,
} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
Expand Down Expand Up @@ -489,6 +490,12 @@ export default function Library() {
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
{/* Mobile Header */}
<div className="flex md:hidden items-center gap-3">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">我的书库</h1>
</div>

{/* Desktop Header */}
<div className="hidden md:block">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
我的书库
Expand Down
101 changes: 99 additions & 2 deletions apps/desktop/src/pages/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
RefreshCw,
Shield,
LogOut,
BarChart3,
} from "lucide-react";

function formatDate(dateStr: string): string {
Expand Down Expand Up @@ -58,7 +59,7 @@ type TabKey = "collections" | "reading" | "favorites" | "downloads" | "notes";

export default function Profile() {
const navigate = useNavigate();
const { user, logout } = useAuthStore();
const { user, logout, isVip, plusUser } = useAuthStore();
const [activeTab, setActiveTab] = useState<TabKey>("collections");
const [collections, setCollections] = useState<Collection[]>([]);
const [favorites, setFavorites] = useState<Book[]>([]);
Expand All @@ -75,6 +76,29 @@ export default function Profile() {
const [showDropdown, setShowDropdown] = useState(false);
const [syncing, setSyncing] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [readingSummary, setReadingSummary] = useState<{
todaySecs: number;
weekSecs: number;
monthSecs: number;
yearSecs: number;
totalSecs: number;
} | null>(null);

// Fetch reading time summary
useEffect(() => {
const fetchSummary = async () => {
try {
const api = getApiClient();
const res = await api.getReadingTimeSummary();
if (res.success && res.data) {
setReadingSummary(res.data);
}
} catch (err) {
console.error("Failed to fetch reading summary:", err);
}
};
fetchSummary();
}, []);

// Close dropdown on click outside
useEffect(() => {
Expand Down Expand Up @@ -581,11 +605,84 @@ export default function Profile() {
</div>
<div>
<p className="text-lg font-semibold text-gray-900 dark:text-white">{user?.username || "用户"}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{user?.role === "admin" ? "管理员" : "普通用户"}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{(() => {
if (user?.role === "admin") return "管理员";
if (!isVip) return "普通用户";
const level = plusUser?.level;
if (level === "lifetime") return "永久会员";
if (level === "year") return "年会员";
return "会员";
})()}
</p>
</div>
</div>
</div>

{/* Reading Time Card */}
{isVip ? (
readingSummary && (
<Link
to="/stats"
className="block bg-gradient-to-r from-blue-500 to-indigo-600 rounded-xl p-4 text-white hover:from-blue-600 hover:to-indigo-700 transition-all shadow-md"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-white/20 rounded-lg">
<Clock className="w-5 h-5" />
</div>
<div>
<p className="text-sm text-blue-100">阅读时长</p>
<p className="text-lg font-bold">
{(() => {
const format = (secs: number) => {
if (secs < 60) return `${secs}秒`;
if (secs < 3600) return `${Math.floor(secs / 60)}分钟`;
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return m > 0 ? `${h}小时${m}分钟` : `${h}小时`;
};
if (readingSummary.todaySecs > 0) {
return `今日阅读 ${format(readingSummary.todaySecs)}`;
}
if (readingSummary.weekSecs > 0) {
return `本周阅读 ${format(readingSummary.weekSecs)}`;
}
if (readingSummary.monthSecs > 0) {
return `本月阅读 ${format(readingSummary.monthSecs)}`;
}
if (readingSummary.yearSecs > 0) {
return `今年阅读 ${format(readingSummary.yearSecs)}`;
}
return "今日还没有阅读";
})()}
</p>
</div>
</div>
<BarChart3 className="w-5 h-5 text-blue-200" />
</div>
</Link>
)
) : (
<Link
to="/membership"
className="block bg-gradient-to-r from-gray-400 to-gray-500 rounded-xl p-4 text-white hover:from-gray-500 hover:to-gray-600 transition-all shadow-md"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-white/20 rounded-lg">
<Crown className="w-5 h-5" />
</div>
<div>
<p className="text-sm text-gray-100">阅读时长</p>
<p className="text-lg font-bold">开通会员解锁阅读时长统计</p>
</div>
</div>
<BarChart3 className="w-5 h-5 text-gray-200" />
</div>
</Link>
)}

{/* Tabs */}
<div className="bg-white dark:bg-gray-800 rounded-xl p-1 flex">
{tabs.map((tab) => {
Expand Down
Loading
Loading