diff --git a/violet-search/ocr_article.py b/violet-search/ocr_article.py index 74f42bf9d..1d546944d 100644 --- a/violet-search/ocr_article.py +++ b/violet-search/ocr_article.py @@ -214,6 +214,38 @@ def group_into_dialogues( return dialogues +def make_ocr_config( + device="gpu:0", + threshold=0.5, + rec_batch_size=16, + det_model="PP-OCRv5_mobile_det", + rec_model="korean_PP-OCRv5_mobile_rec", +) -> dict: + """OCR 설정 dict 생성""" + return { + "device": device, + "threshold": threshold, + "rec_batch_size": rec_batch_size, + "det_model": det_model, + "rec_model": rec_model, + } + + +def create_ocr_pool(workers: int, config: dict) -> Pool: + """OCR 워커 Pool 생성 (모델 로딩 1회)""" + return Pool(processes=workers, initializer=_worker_init, initargs=(config,)) + + +def save_ocr_result(result: dict, output_dir: str) -> str: + """OCR 결과를 JSON 파일로 저장. 저장 경로 반환.""" + os.makedirs(output_dir, exist_ok=True) + article_id = result["articleId"] + output_path = os.path.join(output_dir, f"{article_id}.json") + with open(output_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + return output_path + + def parse_args(): parser = argparse.ArgumentParser( description="Article OCR - 페이지별 대사 추출 (멀티프로세스)" @@ -338,24 +370,20 @@ def main(): for page_num, target, (w, h) in zip(page_nums, target_paths, dimensions) ] - worker_config = { - "device": args.device, - "threshold": args.threshold, - "rec_batch_size": args.rec_batch_size, - "det_model": args.det_model, - "rec_model": args.rec_model, - } + worker_config = make_ocr_config( + device=args.device, + threshold=args.threshold, + rec_batch_size=args.rec_batch_size, + det_model=args.det_model, + rec_model=args.rec_model, + ) total = len(tasks) print(f"OCR 실행 중 ({total}장, {args.workers} 워커)...") t2 = time.perf_counter() - with Pool( - processes=args.workers, - initializer=_worker_init, - initargs=(worker_config,), - ) as pool: + with create_ocr_pool(args.workers, worker_config) as pool: results_unordered = [] for i, result in enumerate(pool.imap_unordered(_worker_process_image, tasks)): results_unordered.append(result) @@ -387,11 +415,7 @@ def main(): script_dir = os.path.dirname(os.path.abspath(__file__)) output_dir = args.output_dir if args.output_dir else os.path.join(script_dir, "raw") - os.makedirs(output_dir, exist_ok=True) - output_path = os.path.join(output_dir, f"{article_id}.json") - - with open(output_path, "w", encoding="utf-8") as f: - json.dump(output, f, ensure_ascii=False, indent=2) + output_path = save_ocr_result(output, output_dir) # 임시 디렉토리 정리 import shutil diff --git a/violet-search/run.py b/violet-search/run.py index 1fe3940d4..4d531d4d2 100644 --- a/violet-search/run.py +++ b/violet-search/run.py @@ -2,11 +2,15 @@ 전체 파이프라인 실행: OCR → print → summary articles 디렉토리의 각 작품에 대해 결과가 없는 경우만 실행. 단계별로 전체 작품을 순차 처리. + +OCR 단계는 모델 1회 로딩 + 전처리↔OCR 파이프라이닝으로 최적화. """ import os +import shutil import subprocess import sys +import tempfile from concurrent.futures import ThreadPoolExecutor, as_completed SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -19,6 +23,8 @@ python = sys.executable +OCR_WORKERS = 5 + def get_article_ids(): ids = [] @@ -36,6 +42,123 @@ def run(cmd: list[str]) -> bool: return result.returncode == 0 +def run_ocr_pipeline(pending_articles: list[tuple[str, str]], config: dict, workers: int): + """ + pending_articles: [(article_id, article_dir), ...] + + 1) OCR Pool 1회 생성 (GPU 모델 로딩 1회) + 2) 전처리 스레드풀이 이미지 변환 → 완료 즉시 OCR Pool에 submit + 3) OCR 완료 즉시 progress 출력, 결과를 article별로 수집 → JSON 저장 + """ + import threading + from ocr_article import ( + create_ocr_pool, + get_sorted_images, + convert_single, + _worker_process_image, + save_ocr_result, + ) + + pool = create_ocr_pool(workers, config) + tmp_dir = tempfile.mkdtemp() + + try: + preprocess_pool = ThreadPoolExecutor(max_workers=8) + + # article별 AsyncResult 목록 및 메타데이터 + article_async_results: dict[str, list] = {} + article_metadata: dict[str, dict] = {} + + # 전체 이미지 수 미리 집계 (progress 표시용) + articles_with_images = [] + for article_id, article_dir in pending_articles: + images = get_sorted_images(article_dir) + if not images: + print(f" [스킵] {article_id} - 이미지 없음") + continue + articles_with_images.append((article_id, article_dir, images)) + + total_images = sum(len(imgs) for _, _, imgs in articles_with_images) + completed_count = [0] + count_lock = threading.Lock() + + # 동시에 OCR 큐에 올라갈 수 있는 태스크 수 제한 + # workers * 2: 각 워커가 처리 중 + 다음 태스크 1개 대기 → GPU idle 없이 연속 처리 + # mobile 모델 기준 16GB VRAM에서 workers=5이면 ~1GB 미만이므로 여유 충분 + ocr_semaphore = threading.Semaphore(workers * 2) + + def make_ocr_callback(aid): + """OCR 완료 시 즉시 progress를 출력하고 세마포어를 해제하는 콜백 생성""" + def on_ocr_done(result): + ocr_semaphore.release() + with count_lock: + completed_count[0] += 1 + cnt = completed_count[0] + dlg_preview = " | ".join(d["text"][:20] for d in result["dialogues"][:3]) + if len(result["dialogues"]) > 3: + dlg_preview += " ..." + print( + f" [{cnt}/{total_images}] {aid} p{result['page']}: " + f"{len(result['dialogues'])}개 대사" + + (f" - {dlg_preview}" if dlg_preview else ""), + flush=True, + ) + return on_ocr_done + + for article_id, article_dir, images in articles_with_images: + article_async_results[article_id] = [] + article_metadata[article_id] = { + "total_pages": len(images), + } + + for page_num, img_path in images: + future = preprocess_pool.submit(convert_single, (img_path, tmp_dir)) + + def on_preprocess_done(fut, _aid=article_id, _pn=page_num): + ocr_semaphore.acquire() # OCR 큐가 꽉 찼으면 전처리 스레드 대기 + try: + target, width, height = fut.result() + except Exception: + ocr_semaphore.release() + raise + task = { + "target": target, + "page_num": _pn, + "width": width, + "height": height, + } + async_result = pool.apply_async( + _worker_process_image, (task,), + callback=make_ocr_callback(_aid), + ) + article_async_results[_aid].append(async_result) + + future.add_done_callback(on_preprocess_done) + + # 전처리 완료 대기 → 모든 OCR 태스크 제출 완료 + preprocess_pool.shutdown(wait=True) + + # OCR 결과 수집 및 저장 (이미 완료된 것은 즉시 반환) + for article_id, async_results in article_async_results.items(): + pages = [ar.get() for ar in async_results] + pages.sort(key=lambda x: x["page"]) + meta = article_metadata[article_id] + + output = { + "articleId": article_id, + "totalPages": meta["total_pages"], + "threshold": config["threshold"], + "pages": pages, + } + output_path = save_ocr_result(output, RAW_DIR) + print(f" [저장] {article_id} ({len(pages)}페이지) -> {output_path}") + + finally: + pool.close() + pool.join() + shutil.rmtree(tmp_dir, ignore_errors=True) + + def main(): article_ids = get_article_ids() print(f"총 {len(article_ids)}개 작품 발견") @@ -45,14 +168,23 @@ def main(): print("=" * 50) print("1단계: OCR") print("=" * 50) + + from ocr_article import make_ocr_config + + config = make_ocr_config() + + pending_articles = [] for article_id in article_ids: raw_path = os.path.join(RAW_DIR, f"{article_id}.json") if os.path.exists(raw_path): print(f" [건너뜀] {article_id}") continue article_dir = os.path.join(ARTICLES_DIR, article_id) - if not run([python, os.path.join(SCRIPT_DIR, "ocr_article.py"), article_dir]): - print(f" [실패] {article_id}") + pending_articles.append((article_id, article_dir)) + + if pending_articles: + print(f" {len(pending_articles)}개 작품 OCR 실행") + run_ocr_pipeline(pending_articles, config, OCR_WORKERS) print() # ── 2단계: print ── diff --git a/violet-web/packages/backend/src/services/download-service.ts b/violet-web/packages/backend/src/services/download-service.ts index e3e0e4572..5686714a8 100644 --- a/violet-web/packages/backend/src/services/download-service.ts +++ b/violet-web/packages/backend/src/services/download-service.ts @@ -10,7 +10,7 @@ const ARTICLES_DIR = path.resolve(__dirname, '../../data/articles'); const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; -const MAX_RETRIES = 10; +const MAX_RETRIES = 100; const RETRY_DELAY_MS = 500; async function fetchWithRetry( diff --git a/violet-web/packages/frontend/src/api/hot.ts b/violet-web/packages/frontend/src/api/hot.ts new file mode 100644 index 000000000..b9aad3b22 --- /dev/null +++ b/violet-web/packages/frontend/src/api/hot.ts @@ -0,0 +1,40 @@ +export type HotPeriod = 'daily' | 'weekly' | 'monthly' | 'alltime'; + +interface HotViewResponse { + elements: { articleId: number; count: number }[]; +} + +async function computeVValid(salt: string, token: string): Promise { + const input = salt.replace(/\\/g, '') + token; + const encoded = new TextEncoder().encode(input); + const hashBuffer = await crypto.subtle.digest('SHA-512', encoded); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + return hex.slice(0, 7); +} + +export async function fetchHotView( + serverHost: string, + salt: string, + period: HotPeriod, + offset: number, + count: number, +): Promise<{ elements: { articleId: number; count: number }[] }> { + const token = Date.now().toString(); + const valid = await computeVValid(salt, token); + + const url = `${serverHost}/api/v2/view?offset=${offset}&count=${count}&type=${period}`; + const res = await fetch(url, { + headers: { + 'v-token': token, + 'v-valid': valid, + }, + }); + + if (!res.ok) { + throw new Error(`Hot API error: ${res.status}`); + } + + const data: HotViewResponse = await res.json(); + return { elements: data.elements }; +} diff --git a/violet-web/packages/frontend/src/components/common/LazyImage.tsx b/violet-web/packages/frontend/src/components/common/LazyImage.tsx index 68a7fa146..11a6d182f 100644 --- a/violet-web/packages/frontend/src/components/common/LazyImage.tsx +++ b/violet-web/packages/frontend/src/components/common/LazyImage.tsx @@ -6,9 +6,10 @@ interface LazyImageProps { alt?: string; className?: string; onClick?: () => void; + onLoad?: () => void; } -export function LazyImage({ src, alt = '', className, onClick }: LazyImageProps) { +export function LazyImage({ src, alt = '', className, onClick, onLoad }: LazyImageProps) { const [loaded, setLoaded] = useState(false); const [inView, setInView] = useState(false); const [error, setError] = useState(false); @@ -44,7 +45,7 @@ export function LazyImage({ src, alt = '', className, onClick }: LazyImageProps) src={src} alt={alt} className={`${styles.image} ${loaded ? styles.loaded : ''}`} - onLoad={() => setLoaded(true)} + onLoad={() => { setLoaded(true); onLoad?.(); }} onError={() => setError(true)} loading="lazy" /> diff --git a/violet-web/packages/frontend/src/components/layout/BottomNav.tsx b/violet-web/packages/frontend/src/components/layout/BottomNav.tsx index 11da76046..d4f928e34 100644 --- a/violet-web/packages/frontend/src/components/layout/BottomNav.tsx +++ b/violet-web/packages/frontend/src/components/layout/BottomNav.tsx @@ -5,6 +5,7 @@ import styles from './BottomNav.module.css'; const navItems = [ { to: '/', labelKey: 'nav.home' }, + { to: '/hot', labelKey: 'nav.hot' }, { to: '/history', labelKey: 'nav.history' }, { to: '/bookmarks', labelKey: 'nav.bookmarks' }, { to: '/crop-bookmarks', labelKey: 'nav.cropBookmarks' }, diff --git a/violet-web/packages/frontend/src/components/layout/Sidebar.tsx b/violet-web/packages/frontend/src/components/layout/Sidebar.tsx index f6ea3be1b..8efa1bd4a 100644 --- a/violet-web/packages/frontend/src/components/layout/Sidebar.tsx +++ b/violet-web/packages/frontend/src/components/layout/Sidebar.tsx @@ -1,6 +1,6 @@ import { NavLink, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; -import { Home, Bookmark, Crop, History, Download, Settings, ChevronLeft, ChevronRight, Sparkles, Sun, Moon, Monitor } from 'lucide-react'; +import { Home, Bookmark, Crop, History, Download, Settings, ChevronLeft, ChevronRight, Sparkles, Flame, Sun, Moon, Monitor } from 'lucide-react'; import { DiscordIcon } from '../icons/DiscordIcon'; import { GithubIcon } from '../icons/GithubIcon'; import { useAppStore } from '../../stores/app-store'; @@ -8,6 +8,7 @@ import styles from './Sidebar.module.css'; const navItems = [ { to: '/', labelKey: 'nav.home', icon: Home }, + { to: '/hot', labelKey: 'nav.hot', icon: Flame }, { to: '/history', labelKey: 'nav.history', icon: History }, { to: '/bookmarks', labelKey: 'nav.bookmarks', icon: Bookmark }, { to: '/crop-bookmarks', labelKey: 'nav.cropBookmarks', icon: Crop }, diff --git a/violet-web/packages/frontend/src/components/search/ArticleCard.module.css b/violet-web/packages/frontend/src/components/search/ArticleCard.module.css index 36bfb28a4..6aa16c32a 100644 --- a/violet-web/packages/frontend/src/components/search/ArticleCard.module.css +++ b/violet-web/packages/frontend/src/components/search/ArticleCard.module.css @@ -179,6 +179,20 @@ transform: scale(1.05); } +.rankBadge { + position: absolute; + bottom: 6px; + left: 6px; + padding: 2px 7px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + font-size: 0.7rem; + font-weight: 600; + border-radius: 4px; + z-index: 5; + letter-spacing: -0.01em; +} + .progressOverlay { position: absolute; inset: 0; diff --git a/violet-web/packages/frontend/src/components/search/ArticleCard.tsx b/violet-web/packages/frontend/src/components/search/ArticleCard.tsx index 370971c4f..61c378608 100644 --- a/violet-web/packages/frontend/src/components/search/ArticleCard.tsx +++ b/violet-web/packages/frontend/src/components/search/ArticleCard.tsx @@ -5,7 +5,7 @@ import { Download, Trash2, RotateCw } from 'lucide-react'; import type { Article } from '@violet-web/shared'; import { parsePipeTags, parseTagTuples, ticksToDate } from '@violet-web/shared'; import { LazyImage } from '../common/LazyImage'; -import { useThumbnail } from '../../hooks/useThumbnail'; +import { useCachedThumbnail } from '../../hooks/useCachedThumbnail'; import { useIsBookmarked, useToggleBookmark } from '../../hooks/useBookmarks'; import { useStartDownload, useRetryDownload, useDeleteDownload, useIsDownloaded } from '../../hooks/useDownloads'; import { useDownloadProgress, useIsDownloadsPage } from '../../contexts/DownloadProgressContext'; @@ -23,6 +23,8 @@ interface ArticleCardProps { viewMode?: ViewMode; aiScore?: number; aiDescription?: string; + rank?: number; + viewCount?: number; } const TAG_ORDER: Record = { female: 0, male: 1, tag: 2, '': 2 }; @@ -31,10 +33,10 @@ function getTagOrder(ns: string): number { return TAG_ORDER[ns] ?? 3; } -export function ArticleCard({ article, viewMode = 'grid', aiScore, aiDescription }: ArticleCardProps) { +export function ArticleCard({ article, viewMode = 'grid', aiScore, aiDescription, rank, viewCount }: ArticleCardProps) { const { t } = useTranslation(); const navigate = useNavigate(); - const { data: thumbnailUrl } = useThumbnail(article.Id); + const { src: thumbnailSrc, onLoadSuccess: onThumbnailLoad } = useCachedThumbnail(article.Id); const { data: isBookmarked } = useIsBookmarked(String(article.Id)); const toggleBookmark = useToggleBookmark(); const startDownload = useStartDownload(); @@ -123,8 +125,8 @@ export function ArticleCard({ article, viewMode = 'grid', aiScore, aiDescription onClick={() => navigate(`/viewer/${article.Id}`)} >
- {thumbnailUrl ? ( - + {thumbnailSrc ? ( + ) : (
{t('article.noImage')}
)} @@ -163,6 +165,11 @@ export function ArticleCard({ article, viewMode = 'grid', aiScore, aiDescription {article.Files}P )} + {rank != null && ( + + #{rank}{viewCount != null && ` (${viewCount.toLocaleString()})`} + + )} {downloadRecord && downloadRecord.Status === 'downloading' && (() => { const pct = downloadRecord.TotalPages > 0 ? downloadRecord.DownloadedPages / downloadRecord.TotalPages diff --git a/violet-web/packages/frontend/src/components/search/SearchResultGrid.tsx b/violet-web/packages/frontend/src/components/search/SearchResultGrid.tsx index cae10bab2..3b4c6969f 100644 --- a/violet-web/packages/frontend/src/components/search/SearchResultGrid.tsx +++ b/violet-web/packages/frontend/src/components/search/SearchResultGrid.tsx @@ -6,9 +6,10 @@ import styles from './SearchResultGrid.module.css'; interface SearchResultGridProps { articles: Article[]; + rankInfo?: Map; } -export function SearchResultGrid({ articles }: SearchResultGridProps) { +export function SearchResultGrid({ articles, rankInfo }: SearchResultGridProps) { const { t } = useTranslation(); const viewMode = useAppStore((s) => s.viewMode); const cardMinWidth = useAppStore((s) => s.cardMinWidth); @@ -22,9 +23,18 @@ export function SearchResultGrid({ articles }: SearchResultGridProps) { className={styles.grid} style={{ '--card-min-width': `${cardMinWidth}px` } as React.CSSProperties} > - {articles.map((article) => ( - - ))} + {articles.map((article) => { + const ri = rankInfo?.get(article.Id); + return ( + + ); + })}
); } diff --git a/violet-web/packages/frontend/src/hooks/useCachedThumbnail.ts b/violet-web/packages/frontend/src/hooks/useCachedThumbnail.ts new file mode 100644 index 000000000..a150f5b0d --- /dev/null +++ b/violet-web/packages/frontend/src/hooks/useCachedThumbnail.ts @@ -0,0 +1,97 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useAppStore } from '../stores/app-store'; +import { getCachedImage, putCachedImage } from '../services/image-cache'; +import { getThumbnailUrl, getProxyImageUrl } from '../api/proxy'; + +const THUMBNAIL_PAGE = -1; + +const noop = () => {}; + +export function useCachedThumbnail(galleryId: number): { src: string; onLoadSuccess: () => void } { + const imageCacheEnabled = useAppStore((s) => s.imageCacheEnabled); + const imageCacheMaxSizeMB = useAppStore((s) => s.imageCacheMaxSizeMB); + + const { data: proxyUrl } = useQuery({ + queryKey: ['thumbnail', galleryId], + queryFn: async () => { + const url = await getThumbnailUrl(galleryId); + return getProxyImageUrl(url, `https://hitomi.la/reader/${galleryId}.html`); + }, + staleTime: 30 * 60 * 1000, + }); + + const [blobUrl, setBlobUrl] = useState(null); + const [cacheChecked, setCacheChecked] = useState(false); + const blobUrlRef = useRef(null); + const savingRef = useRef(false); + + const enabled = imageCacheEnabled && !!proxyUrl; + + useEffect(() => { + if (!enabled) { + setCacheChecked(true); + return; + } + + let cancelled = false; + setCacheChecked(false); + setBlobUrl(null); + + getCachedImage(galleryId, THUMBNAIL_PAGE) + .then((cached) => { + if (cancelled) return; + if (cached) { + const url = URL.createObjectURL(cached.blob); + blobUrlRef.current = url; + setBlobUrl(url); + } + setCacheChecked(true); + }) + .catch(() => { + if (!cancelled) setCacheChecked(true); + }); + + return () => { + cancelled = true; + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + setBlobUrl(null); + }; + }, [enabled, galleryId]); + + const onLoadSuccess = useCallback(() => { + if (!enabled || blobUrl !== null || savingRef.current || !proxyUrl) return; + savingRef.current = true; + + fetch(proxyUrl) + .then((res) => { + if (!res.ok) throw new Error('fetch failed'); + const contentType = res.headers.get('content-type') || 'image/jpeg'; + return res.blob().then((blob) => ({ blob, contentType })); + }) + .then(({ blob, contentType }) => { + const maxBytes = imageCacheMaxSizeMB * 1024 * 1024; + return putCachedImage(galleryId, THUMBNAIL_PAGE, blob, contentType, maxBytes); + }) + .catch(() => {}) + .finally(() => { + savingRef.current = false; + }); + }, [enabled, blobUrl, proxyUrl, galleryId, imageCacheMaxSizeMB]); + + if (!imageCacheEnabled) { + return { src: proxyUrl ?? '', onLoadSuccess: noop }; + } + + if (!cacheChecked) { + return { src: '', onLoadSuccess: noop }; + } + + return { + src: blobUrl ?? proxyUrl ?? '', + onLoadSuccess: blobUrl ? noop : onLoadSuccess, + }; +} diff --git a/violet-web/packages/frontend/src/hooks/useHot.ts b/violet-web/packages/frontend/src/hooks/useHot.ts new file mode 100644 index 000000000..c8b8e0682 --- /dev/null +++ b/violet-web/packages/frontend/src/hooks/useHot.ts @@ -0,0 +1,70 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchHotView, type HotPeriod } from '../api/hot'; +import { getArticlesBatch } from '../api/content'; +import { useAppStore } from '../stores/app-store'; +import type { Article } from '@violet-web/shared'; + +const PAGE_SIZE = 50; + +export interface RankInfo { + rank: number; + viewCount: number; +} + +export function useHot(period: HotPeriod, page: number) { + const { developerMode, hmacSalt, serverHost } = useAppStore(); + + const enabled = developerMode && hmacSalt.trim().length > 0; + + const { + data: hotData, + isLoading: hotLoading, + error: hotError, + } = useQuery({ + queryKey: ['hot', 'view', serverHost, hmacSalt, period, page], + queryFn: () => + fetchHotView(serverHost, hmacSalt, period, page * PAGE_SIZE, PAGE_SIZE), + enabled, + staleTime: 5 * 60 * 1000, + }); + + const elements = hotData?.elements ?? []; + const articleIds = elements.map((e) => e.articleId); + + const { + data: articles, + isLoading: articlesLoading, + error: articlesError, + } = useQuery({ + queryKey: ['hot', 'articles', articleIds], + queryFn: () => getArticlesBatch(articleIds), + enabled: articleIds.length > 0, + }); + + // Re-sort to preserve view-count ranking order + const orderedArticles: Article[] = []; + const rankInfo = new Map(); + + if (articles && elements.length > 0) { + const articleMap = new Map(articles.map((a) => [a.Id, a])); + elements.forEach((el, i) => { + const article = articleMap.get(el.articleId); + if (article) { + orderedArticles.push(article); + rankInfo.set(el.articleId, { + rank: page * PAGE_SIZE + i + 1, + viewCount: el.count, + }); + } + }); + } + + return { + articles: orderedArticles, + rankInfo, + isLoading: hotLoading || articlesLoading, + error: hotError || articlesError, + enabled, + hasMore: elements.length === PAGE_SIZE, + }; +} diff --git a/violet-web/packages/frontend/src/i18n/locales/en.json b/violet-web/packages/frontend/src/i18n/locales/en.json index 69d45fac0..d8886c70f 100644 --- a/violet-web/packages/frontend/src/i18n/locales/en.json +++ b/violet-web/packages/frontend/src/i18n/locales/en.json @@ -9,7 +9,8 @@ "history": "History", "downloads": "Downloads", "aiSearch": "AI Search", - "settings": "Settings" + "settings": "Settings", + "hot": "Hot" }, "home": { "heading": "Browse", @@ -47,6 +48,16 @@ "cancel": "Cancel", "uncategorized": "Uncategorized" }, + "hot": { + "heading": "Hot", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "alltime": "All Time", + "setupRequired": "Developer mode and HMAC salt required.", + "setupHint": "Enable Developer Mode in Settings and configure HMAC Salt.", + "error": "Failed to load. Check developer settings." + }, "history": { "heading": "History" }, @@ -186,6 +197,14 @@ "clear": "Clear Search History", "empty": "No recent searches" }, + "developer": { + "heading": "Developer", + "enable": "Developer Mode", + "enableDesc": "Show developer settings for Hot tab", + "serverHost": "Server Host URL", + "hmacSalt": "HMAC Salt", + "hmacSaltPlaceholder": "Enter HMAC salt..." + }, "suggestions": { "heading": "Tag Cache", "build": "Build Cache", diff --git a/violet-web/packages/frontend/src/i18n/locales/eo.json b/violet-web/packages/frontend/src/i18n/locales/eo.json index 39feef260..58795ca16 100644 --- a/violet-web/packages/frontend/src/i18n/locales/eo.json +++ b/violet-web/packages/frontend/src/i18n/locales/eo.json @@ -9,7 +9,8 @@ "history": "Historio", "downloads": "Elŝutoj", "aiSearch": "AI-Serĉo", - "settings": "Agordoj" + "settings": "Agordoj", + "hot": "Hot" }, "home": { "heading": "Foliumi", @@ -47,6 +48,16 @@ "cancel": "Nuligi", "uncategorized": "Nekategoriita" }, + "hot": { + "heading": "Hot", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "alltime": "All Time", + "setupRequired": "Developer mode and HMAC salt required.", + "setupHint": "Enable Developer Mode in Settings and configure HMAC Salt.", + "error": "Failed to load. Check developer settings." + }, "history": { "heading": "Historio" }, @@ -163,6 +174,14 @@ "clear": "Forigi serĉhistorion", "empty": "Neniuj lastaj serĉoj" }, + "developer": { + "heading": "Developer", + "enable": "Developer Mode", + "enableDesc": "Show developer settings for Hot tab", + "serverHost": "Server Host URL", + "hmacSalt": "HMAC Salt", + "hmacSaltPlaceholder": "Enter HMAC salt..." + }, "suggestions": { "heading": "Etikeda kaŝmemoro", "build": "Konstrui kaŝmemoron", diff --git a/violet-web/packages/frontend/src/i18n/locales/it.json b/violet-web/packages/frontend/src/i18n/locales/it.json index cae66e487..ffd474109 100644 --- a/violet-web/packages/frontend/src/i18n/locales/it.json +++ b/violet-web/packages/frontend/src/i18n/locales/it.json @@ -9,7 +9,8 @@ "history": "Cronologia", "downloads": "Download", "aiSearch": "Ricerca AI", - "settings": "Impostazioni" + "settings": "Impostazioni", + "hot": "Hot" }, "home": { "heading": "Sfoglia", @@ -47,6 +48,16 @@ "cancel": "Annulla", "uncategorized": "Non categorizzato" }, + "hot": { + "heading": "Hot", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "alltime": "All Time", + "setupRequired": "Developer mode and HMAC salt required.", + "setupHint": "Enable Developer Mode in Settings and configure HMAC Salt.", + "error": "Failed to load. Check developer settings." + }, "history": { "heading": "Cronologia" }, @@ -163,6 +174,14 @@ "clear": "Cancella cronologia ricerche", "empty": "Nessuna ricerca recente" }, + "developer": { + "heading": "Developer", + "enable": "Developer Mode", + "enableDesc": "Show developer settings for Hot tab", + "serverHost": "Server Host URL", + "hmacSalt": "HMAC Salt", + "hmacSaltPlaceholder": "Enter HMAC salt..." + }, "suggestions": { "heading": "Cache tag", "build": "Costruisci cache", diff --git a/violet-web/packages/frontend/src/i18n/locales/ja.json b/violet-web/packages/frontend/src/i18n/locales/ja.json index 8eb3d2d0c..255d004f0 100644 --- a/violet-web/packages/frontend/src/i18n/locales/ja.json +++ b/violet-web/packages/frontend/src/i18n/locales/ja.json @@ -9,7 +9,8 @@ "history": "履歴", "downloads": "ダウンロード", "aiSearch": "AI検索", - "settings": "設定" + "settings": "設定", + "hot": "Hot" }, "home": { "heading": "閲覧", @@ -47,6 +48,16 @@ "cancel": "キャンセル", "uncategorized": "未分類" }, + "hot": { + "heading": "Hot", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "alltime": "All Time", + "setupRequired": "Developer mode and HMAC salt required.", + "setupHint": "Enable Developer Mode in Settings and configure HMAC Salt.", + "error": "Failed to load. Check developer settings." + }, "history": { "heading": "履歴" }, @@ -163,6 +174,14 @@ "clear": "検索履歴をクリア", "empty": "最近の検索はありません" }, + "developer": { + "heading": "Developer", + "enable": "Developer Mode", + "enableDesc": "Show developer settings for Hot tab", + "serverHost": "Server Host URL", + "hmacSalt": "HMAC Salt", + "hmacSaltPlaceholder": "Enter HMAC salt..." + }, "suggestions": { "heading": "タグキャッシュ", "build": "キャッシュ構築", diff --git a/violet-web/packages/frontend/src/i18n/locales/ko.json b/violet-web/packages/frontend/src/i18n/locales/ko.json index 2a098f557..31ddf349f 100644 --- a/violet-web/packages/frontend/src/i18n/locales/ko.json +++ b/violet-web/packages/frontend/src/i18n/locales/ko.json @@ -9,7 +9,8 @@ "history": "기록", "downloads": "다운로드", "aiSearch": "AI 검색", - "settings": "설정" + "settings": "설정", + "hot": "인기" }, "home": { "heading": "둘러보기", @@ -47,6 +48,16 @@ "cancel": "취소", "uncategorized": "미분류" }, + "hot": { + "heading": "인기", + "daily": "일간", + "weekly": "주간", + "monthly": "월간", + "alltime": "전체", + "setupRequired": "개발자 모드와 HMAC salt 설정이 필요합니다.", + "setupHint": "설정에서 개발자 모드를 활성화하고 HMAC Salt를 구성하세요.", + "error": "로드에 실패했습니다. 개발자 설정을 확인하세요." + }, "history": { "heading": "기록" }, @@ -186,6 +197,14 @@ "clear": "검색 기록 지우기", "empty": "최근 검색 없음" }, + "developer": { + "heading": "개발자", + "enable": "개발자 모드", + "enableDesc": "Hot 탭 및 외부 API 개발자 설정 표시", + "serverHost": "서버 호스트 URL", + "hmacSalt": "HMAC Salt", + "hmacSaltPlaceholder": "HMAC salt 입력..." + }, "suggestions": { "heading": "태그 캐시", "build": "캐시 빌드", diff --git a/violet-web/packages/frontend/src/i18n/locales/pt.json b/violet-web/packages/frontend/src/i18n/locales/pt.json index 99c6b22a6..dadfef4c8 100644 --- a/violet-web/packages/frontend/src/i18n/locales/pt.json +++ b/violet-web/packages/frontend/src/i18n/locales/pt.json @@ -9,7 +9,8 @@ "history": "Histórico", "downloads": "Downloads", "aiSearch": "Busca IA", - "settings": "Configurações" + "settings": "Configurações", + "hot": "Hot" }, "home": { "heading": "Explorar", @@ -47,6 +48,16 @@ "cancel": "Cancelar", "uncategorized": "Sem categoria" }, + "hot": { + "heading": "Hot", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "alltime": "All Time", + "setupRequired": "Developer mode and HMAC salt required.", + "setupHint": "Enable Developer Mode in Settings and configure HMAC Salt.", + "error": "Failed to load. Check developer settings." + }, "history": { "heading": "Histórico" }, @@ -163,6 +174,14 @@ "clear": "Limpar histórico de buscas", "empty": "Nenhuma busca recente" }, + "developer": { + "heading": "Developer", + "enable": "Developer Mode", + "enableDesc": "Show developer settings for Hot tab", + "serverHost": "Server Host URL", + "hmacSalt": "HMAC Salt", + "hmacSaltPlaceholder": "Enter HMAC salt..." + }, "suggestions": { "heading": "Cache de tags", "build": "Construir cache", diff --git a/violet-web/packages/frontend/src/i18n/locales/zh.json b/violet-web/packages/frontend/src/i18n/locales/zh.json index 0795df21b..2aa695af6 100644 --- a/violet-web/packages/frontend/src/i18n/locales/zh.json +++ b/violet-web/packages/frontend/src/i18n/locales/zh.json @@ -9,7 +9,8 @@ "history": "历史", "downloads": "下载", "aiSearch": "AI搜索", - "settings": "设置" + "settings": "设置", + "hot": "Hot" }, "home": { "heading": "浏览", @@ -47,6 +48,16 @@ "cancel": "取消", "uncategorized": "未分类" }, + "hot": { + "heading": "Hot", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "alltime": "All Time", + "setupRequired": "Developer mode and HMAC salt required.", + "setupHint": "Enable Developer Mode in Settings and configure HMAC Salt.", + "error": "Failed to load. Check developer settings." + }, "history": { "heading": "历史" }, @@ -163,6 +174,14 @@ "clear": "清除搜索历史", "empty": "无最近搜索" }, + "developer": { + "heading": "Developer", + "enable": "Developer Mode", + "enableDesc": "Show developer settings for Hot tab", + "serverHost": "Server Host URL", + "hmacSalt": "HMAC Salt", + "hmacSaltPlaceholder": "Enter HMAC salt..." + }, "suggestions": { "heading": "标签缓存", "build": "构建缓存", diff --git a/violet-web/packages/frontend/src/pages/HotPage.module.css b/violet-web/packages/frontend/src/pages/HotPage.module.css new file mode 100644 index 000000000..5e575f4cb --- /dev/null +++ b/violet-web/packages/frontend/src/pages/HotPage.module.css @@ -0,0 +1,101 @@ +.page { + min-height: 100%; + display: flex; + flex-direction: column; +} + +.heading { + font-size: 1.4rem; + font-weight: 600; + margin-bottom: var(--spacing-md); +} + +.periodTabs { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-lg); + flex-wrap: wrap; +} + +.periodTab { + padding: 6px 16px; + border: 1px solid var(--color-border); + border-radius: 999px; + background: transparent; + color: var(--color-text-secondary); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s; +} + +.periodTab:hover { + background: var(--color-surface-hover); +} + +.periodTab.active { + background: var(--color-primary); + color: var(--color-on-primary); + border-color: var(--color-primary); +} + +.setupMessage { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-xl) var(--spacing-md); + text-align: center; + color: var(--color-text-secondary); +} + +.setupMessage p:first-child { + font-weight: 500; + color: var(--color-text-primary); +} + +.errorMessage { + text-align: center; + padding: var(--spacing-xl); + color: var(--color-error, #ef4444); +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + padding: 10px 16px; + position: sticky; + bottom: 0px; + width: fit-content; + margin-top: auto; + margin-left: auto; + margin-right: auto; + background: var(--color-pagination-bg); + backdrop-filter: blur(8px); + border-radius: 999px; + z-index: 10; + color: var(--color-pagination-text); + font-size: 0.85rem; +} + +.pagination button { + padding: 6px 16px; + background: var(--color-pagination-btn-bg); + border: none; + border-radius: 999px; + color: var(--color-pagination-text); + font-size: 0.85rem; + cursor: pointer; + transition: background 0.15s; +} + +.pagination button:hover { + background: var(--color-pagination-btn-bg-hover); +} + +.pagination button:disabled { + opacity: 0.3; + cursor: not-allowed; +} diff --git a/violet-web/packages/frontend/src/pages/HotPage.tsx b/violet-web/packages/frontend/src/pages/HotPage.tsx new file mode 100644 index 000000000..9ac9f0d08 --- /dev/null +++ b/violet-web/packages/frontend/src/pages/HotPage.tsx @@ -0,0 +1,156 @@ +import { useCallback } from 'react'; +import { useNavigate, useSearchParams } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { SearchResultGrid } from '../components/search/SearchResultGrid'; +import { LocalSearchSection } from '../components/search/LocalSearchSection'; +import { LoadingSpinner } from '../components/common/LoadingSpinner'; +import { useHot } from '../hooks/useHot'; +import { useArticleTagSummary } from '../hooks/useArticleTagSummary'; +import { useLocalArticleSearch } from '../hooks/useLocalArticleSearch'; +import { useLocalSearchState } from '../hooks/useLocalSearchState'; +import { usePaginationKeyboard } from '../hooks/usePaginationKeyboard'; +import { useIsMobile } from '../hooks/useMediaQuery'; +import { useAppStore } from '../stores/app-store'; +import type { HotPeriod } from '../api/hot'; +import styles from './HotPage.module.css'; + +const PERIODS: HotPeriod[] = ['daily', 'weekly', 'monthly', 'alltime']; +const PERIOD_LABEL_KEYS: Record = { + daily: 'hot.daily', + weekly: 'hot.weekly', + monthly: 'hot.monthly', + alltime: 'hot.alltime', +}; + +const PAGE_SIZE = 50; + +export function HotPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const isMobile = useIsMobile(); + const { scrollMode } = useAppStore(); + const [searchParams, setSearchParams] = useSearchParams(); + + const period = (searchParams.get('period') as HotPeriod) || 'daily'; + const page = parseInt(searchParams.get('p') || '0'); + + const setPeriod = useCallback( + (newPeriod: HotPeriod) => { + const newParams = new URLSearchParams(searchParams); + if (newPeriod === 'daily') { + newParams.delete('period'); + } else { + newParams.set('period', newPeriod); + } + newParams.delete('p'); + newParams.delete('q'); + setSearchParams(newParams); + }, + [searchParams, setSearchParams], + ); + + const setPage = useCallback( + (updater: number | ((prev: number) => number)) => { + const newPage = typeof updater === 'function' ? updater(page) : updater; + const newParams = new URLSearchParams(searchParams); + if (newPage === 0) { + newParams.delete('p'); + } else { + newParams.set('p', String(newPage)); + } + setSearchParams(newParams); + }, + [page, searchParams, setSearchParams], + ); + + const { articles, rankInfo, isLoading, error, enabled, hasMore } = useHot(period, page); + + // Tag summary + local search filtering + const tagSummary = useArticleTagSummary(articles); + const filteredArticles = useLocalArticleSearch(articles); + + // Build filtered rankInfo (preserve only articles that pass the local filter) + const filteredRankInfo = rankInfo; + + const totalPages = hasMore ? page + 2 : page + 1; + usePaginationKeyboard(page, totalPages, setPage, scrollMode === 'pagination'); + + const handleReset = useCallback(() => { + navigate('/hot', { replace: true }); + }, [navigate]); + + const { selectedTags, searchBarRef, getSuggestions, handleTagToggle } = + useLocalSearchState({ + basePath: '/hot', + tagSummary, + onReset: handleReset, + preserveParams: ['period', 'p'], + }); + + if (!enabled) { + return ( +
+

{t('hot.heading')}

+
+

{t('hot.setupRequired')}

+

{t('hot.setupHint')}

+
+
+ ); + } + + const periodSelector = ( +
+ {PERIODS.map((p) => ( + + ))} +
+ ); + + return ( +
+ {!isMobile && ( + + )} + + {isMobile && periodSelector} + + {error &&
{t('hot.error')}
} + + {isLoading && } + + {!isLoading && !error && ( + + )} + + {(hasMore || page > 0) && ( +
+ + {page + 1} + +
+ )} +
+ ); +} diff --git a/violet-web/packages/frontend/src/pages/SettingsPage.tsx b/violet-web/packages/frontend/src/pages/SettingsPage.tsx index 794f86743..6e5cd0e7c 100644 --- a/violet-web/packages/frontend/src/pages/SettingsPage.tsx +++ b/violet-web/packages/frontend/src/pages/SettingsPage.tsx @@ -19,7 +19,7 @@ const themeColors = [ export function SettingsPage() { const { t } = useTranslation(); - const { contentLanguage, uiLanguage, themeColor, scrollMode, tagTranslation, aiSearchEnabled, excludedTags, imageCacheEnabled, imageCacheMaxSizeMB, imageCacheExpireDays, setContentLanguage, setUILanguage, setThemeColor, setScrollMode, setTagTranslation, setAiSearchEnabled, addExcludedTag, removeExcludedTag, setImageCacheEnabled, setImageCacheMaxSizeMB, setImageCacheExpireDays } = useAppStore(); + const { contentLanguage, uiLanguage, themeColor, scrollMode, tagTranslation, aiSearchEnabled, excludedTags, imageCacheEnabled, imageCacheMaxSizeMB, imageCacheExpireDays, developerMode, hmacSalt, serverHost, setContentLanguage, setUILanguage, setThemeColor, setScrollMode, setTagTranslation, setAiSearchEnabled, addExcludedTag, removeExcludedTag, setImageCacheEnabled, setImageCacheMaxSizeMB, setImageCacheExpireDays, setDeveloperMode, setHmacSalt, setServerHost } = useAppStore(); const [showAiSearchHelp, setShowAiSearchHelp] = useState(false); const [showImageCacheHelp, setShowImageCacheHelp] = useState(false); const helpRef = useRef(null); @@ -437,6 +437,8 @@ export function SettingsPage() { + + @@ -597,6 +599,51 @@ export function SettingsPage() { +
+

{t('settings.developer.heading')}

+ +
+
+ {t('settings.developer.enable')} + {t('settings.developer.enableDesc')} +
+ +
+ + {developerMode && ( + <> +
+ + setServerHost(e.target.value)} + placeholder="https://koromo.cc" + /> +
+ +
+ + setHmacSalt(e.target.value)} + placeholder={t('settings.developer.hmacSaltPlaceholder')} + /> +
+ + )} +
+ ); } diff --git a/violet-web/packages/frontend/src/router/index.tsx b/violet-web/packages/frontend/src/router/index.tsx index 0a596c333..231640b5d 100644 --- a/violet-web/packages/frontend/src/router/index.tsx +++ b/violet-web/packages/frontend/src/router/index.tsx @@ -9,6 +9,7 @@ import { HistoryPage } from '../pages/HistoryPage'; import { DownloadsPage } from '../pages/DownloadsPage'; import { SettingsPage } from '../pages/SettingsPage'; import { AiSearchPage } from '../pages/AiSearchPage'; +import { HotPage } from '../pages/HotPage'; export function AppRoutes() { return ( @@ -20,6 +21,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/violet-web/packages/frontend/src/stores/app-store.ts b/violet-web/packages/frontend/src/stores/app-store.ts index fe5c173d8..02307562c 100644 --- a/violet-web/packages/frontend/src/stores/app-store.ts +++ b/violet-web/packages/frontend/src/stores/app-store.ts @@ -30,6 +30,9 @@ interface AppState { imageCacheEnabled: boolean; imageCacheMaxSizeMB: number; imageCacheExpireDays: number; + developerMode: boolean; + hmacSalt: string; + serverHost: string; setContentLanguage: (lang: ContentLanguage) => void; setUILanguage: (lang: UILanguage) => void; @@ -47,6 +50,9 @@ interface AppState { setImageCacheEnabled: (enabled: boolean) => void; setImageCacheMaxSizeMB: (size: number) => void; setImageCacheExpireDays: (days: number) => void; + setDeveloperMode: (enabled: boolean) => void; + setHmacSalt: (salt: string) => void; + setServerHost: (host: string) => void; } // Helper to get system language @@ -91,6 +97,9 @@ export const useAppStore = create()( imageCacheEnabled: true, imageCacheMaxSizeMB: 500, imageCacheExpireDays: 7, + developerMode: false, + hmacSalt: '', + serverHost: 'https://koromo.cc', setContentLanguage: (contentLanguage) => set({ contentLanguage }), setUILanguage: (uiLanguage) => { @@ -120,6 +129,9 @@ export const useAppStore = create()( setImageCacheEnabled: (imageCacheEnabled) => set({ imageCacheEnabled }), setImageCacheMaxSizeMB: (imageCacheMaxSizeMB) => set({ imageCacheMaxSizeMB }), setImageCacheExpireDays: (imageCacheExpireDays) => set({ imageCacheExpireDays }), + setDeveloperMode: (developerMode) => set({ developerMode }), + setHmacSalt: (hmacSalt) => set({ hmacSalt }), + setServerHost: (serverHost) => set({ serverHost }), }), { name: 'violet-app-settings' }, ),