├── .env ├── hooks ├── useColorScheme.ts ├── book │ ├── useWindowLayout.ts │ ├── useBookData.ts │ ├── useFab.ts │ ├── useColumns.ts │ ├── useFavorites.ts │ ├── useRelatedComments.ts │ └── useDownload.ts ├── usePersistedState.ts ├── useGridConfig.ts ├── useOnlineMe.ts ├── useCharacterCardsForPage.ts ├── useUpdateCheck.ts ├── useFavHistory.ts └── useAuthBridge.ts ├── json.d.ts ├── assets ├── images │ ├── icon.png │ ├── favicon.png │ ├── flags │ │ ├── CN.png │ │ ├── GB.png │ │ └── JP.png │ ├── react-logo.png │ ├── splash-icon.png │ ├── adaptive-icon.png │ ├── react-logo@2x.png │ ├── react-logo@3x.png │ └── partial-react-logo.png └── fonts │ └── SpaceMono-Regular.ttf ├── utils └── book │ ├── sanitize.ts │ ├── useThrottle.ts │ └── timeAgo.ts ├── declarations.d.ts ├── components ├── settings │ ├── keys.ts │ ├── Section.tsx │ ├── Card.tsx │ ├── schema.ts │ ├── SettingsLayout.tsx │ ├── rows │ │ ├── SliderRow.tsx │ │ ├── SwitchRow.tsx │ │ └── LanguageRow.tsx │ └── SettingsBuilder.tsx ├── DrawerContext.tsx ├── buildImageFallbacks.ts ├── SmartImage.tsx ├── buildPageSources.ts ├── tags │ ├── types.ts │ ├── SelectedRow.tsx │ ├── SearchBar.tsx │ ├── useFavs.ts │ ├── Tabs.tsx │ ├── helpers.ts │ ├── TagRow.tsx │ └── CollectionsList.tsx ├── ThemedView.tsx ├── ui │ ├── Section.tsx │ ├── IconBtn.tsx │ └── CardPressable.tsx ├── OverlayPortal.tsx ├── book │ ├── Ring.tsx │ └── PageItem.tsx ├── ThemedText.tsx ├── BookCard │ ├── design │ │ ├── BookCardImage.tsx │ │ └── BookCardClassic.tsx │ └── index.tsx ├── read │ ├── InspectCanvas.tsx │ ├── Buttons.tsx │ ├── Overlays.tsx │ ├── ControlsDesktop.tsx │ └── ControlsMobile.tsx ├── NoResultsPanel.tsx └── SideMenu │ ├── LibraryMenuList.tsx │ └── LoginModal.tsx ├── .vscode └── settings.json ├── types └── routes.ts ├── eslint.config.js ├── config ├── api.ts └── gridConfig.ts ├── tsconfig.json ├── api ├── online │ ├── types.ts │ ├── me.ts │ ├── http.ts │ ├── favorites.ts │ └── scrape.ts └── nhentaiOnline.ts ├── scripts ├── move-apk.js ├── sync-version.js ├── prepare-android.js ├── sync-android-overrides.js └── reset-project.js ├── constants ├── Menu.ts └── Colors.ts ├── .gitignore ├── app ├── +not-found.tsx ├── favoritesOnline.tsx ├── downloaded.tsx ├── favorites.tsx ├── history.tsx └── recommendations.tsx ├── metro.config.js ├── app.json ├── context ├── SortContext.tsx ├── DateRangeContext.tsx ├── AutoImportProvider.tsx └── TagFilterContext.tsx ├── background └── autoImport.task.ts ├── README.md ├── lib ├── ThemeContext.tsx ├── autoImport.ts └── i18n │ └── I18nContext.tsx └── package.json /.env: -------------------------------------------------------------------------------- 1 | EXPO_PUBLIC_API_BASE_URL=https://nhapp-api.onrender.com -------------------------------------------------------------------------------- /hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | export { useColorScheme } from 'react-native'; 2 | -------------------------------------------------------------------------------- /json.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.json" { 2 | const value: any; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e18lab/NHAppAndroid/HEAD/assets/images/icon.png -------------------------------------------------------------------------------- /utils/book/sanitize.ts: -------------------------------------------------------------------------------- 1 | export const sanitize = (s: string) => s.replace(/[^a-z0-9_\-]+/gi, "_"); 2 | -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e18lab/NHAppAndroid/HEAD/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/flags/CN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e18lab/NHAppAndroid/HEAD/assets/images/flags/CN.png -------------------------------------------------------------------------------- /assets/images/flags/GB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e18lab/NHAppAndroid/HEAD/assets/images/flags/GB.png -------------------------------------------------------------------------------- /assets/images/flags/JP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e18lab/NHAppAndroid/HEAD/assets/images/flags/JP.png -------------------------------------------------------------------------------- /assets/images/react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e18lab/NHAppAndroid/HEAD/assets/images/react-logo.png -------------------------------------------------------------------------------- /assets/images/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e18lab/NHAppAndroid/HEAD/assets/images/splash-icon.png -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e18lab/NHAppAndroid/HEAD/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/react-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e18lab/NHAppAndroid/HEAD/assets/images/react-logo@2x.png -------------------------------------------------------------------------------- /assets/images/react-logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e18lab/NHAppAndroid/HEAD/assets/images/react-logo@3x.png -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e18lab/NHAppAndroid/HEAD/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /assets/images/partial-react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e18lab/NHAppAndroid/HEAD/assets/images/partial-react-logo.png -------------------------------------------------------------------------------- /declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.module.scss" { 2 | const classes: Record; 3 | export default classes; 4 | } 5 | -------------------------------------------------------------------------------- /components/settings/keys.ts: -------------------------------------------------------------------------------- 1 | export const STORAGE_KEY_HUE = "themeHue"; 2 | export const FS_KEY = "ui_fullscreen"; 3 | export const RH_KEY = "reader_hide_hints"; -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll": "explicit", 4 | "source.organizeImports": "explicit", 5 | "source.sortMembers": "explicit" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /types/routes.ts: -------------------------------------------------------------------------------- 1 | export type MenuRoute = 2 | | "/downloaded" 3 | | "/favorites" 4 | | "/favoritesOnline" 5 | | "/history" 6 | | "/characters" 7 | | "/recommendations" 8 | | "/tags" 9 | | "/settings"; 10 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // https://docs.expo.dev/guides/using-eslint/ 2 | const { defineConfig } = require('eslint/config'); 3 | const expoConfig = require('eslint-config-expo/flat'); 4 | 5 | module.exports = defineConfig([ 6 | expoConfig, 7 | { 8 | ignores: ['dist/*'], 9 | }, 10 | ]); 11 | -------------------------------------------------------------------------------- /components/DrawerContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | export type DrawerContextType = { 4 | openDrawer: () => void; 5 | }; 6 | 7 | export const DrawerContext = createContext({ 8 | openDrawer: () => {}, 9 | }); 10 | 11 | export const useDrawer = () => useContext(DrawerContext); 12 | -------------------------------------------------------------------------------- /config/api.ts: -------------------------------------------------------------------------------- 1 | export const API_BASE_URL: string = 2 | process.env.EXPO_PUBLIC_API_BASE_URL ?? 3 | (__DEV__ 4 | ? "http://10.0.2.2:3000" // Android-эмулятор ходит на локальный хост через 10.0.2.2 :contentReference[oaicite:2]{index=2} 5 | : `${process.env.EXPO_PUBLIC_API_BASE_URL}`); // здесь подставь свой URL на Render 6 | 7 | export const API_TIMEOUT_MS = 10000; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "esModuleInterop": true, 6 | "strict": true, 7 | "paths": { 8 | "@/*": [ 9 | "./*" 10 | ] 11 | } 12 | }, 13 | "include": [ 14 | "**/*.ts", 15 | "**/*.tsx", 16 | ".expo/types/**/*.ts", 17 | "expo-env.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /components/buildImageFallbacks.ts: -------------------------------------------------------------------------------- 1 | export const buildImageFallbacks = (url: string): string[] => { 2 | const m = url.match(/^(.*)\.(jpg|png|webp|gif)(\.webp)?$/i); 3 | if (!m) return [url]; 4 | const base = m[1]; 5 | 6 | const exts = ["jpg", "png", "webp", "gif"]; 7 | 8 | const orig = m.slice(2).filter(Boolean).join("."); 9 | return [orig, ...exts] 10 | .filter((e, i, self) => self.indexOf(e) === i) 11 | .map((ext) => `${base}.${ext}`); 12 | }; 13 | -------------------------------------------------------------------------------- /utils/book/useThrottle.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | 3 | export const useThrottle = ( 4 | fn: (...args: T) => void, 5 | ms: number 6 | ) => { 7 | const last = useRef(0); 8 | return useCallback( 9 | (...args: T) => { 10 | const now = Date.now(); 11 | if (now - last.current >= ms) { 12 | last.current = now; 13 | fn(...args); 14 | } 15 | }, 16 | [fn, ms] 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /components/settings/Section.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@/lib/ThemeContext"; 2 | import React from "react"; 3 | import { StyleSheet, Text } from "react-native"; 4 | 5 | export default function Section({ title }: { title: string }) { 6 | const { colors } = useTheme(); 7 | return ( 8 | {title} 9 | ); 10 | } 11 | 12 | const styles = StyleSheet.create({ 13 | sectionTitle: { 14 | fontSize: 13, 15 | fontWeight: "700", 16 | opacity: 0.6, 17 | marginBottom: 8, 18 | letterSpacing: 0.3, 19 | }, 20 | }); -------------------------------------------------------------------------------- /components/SmartImage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Image, ImageProps } from "react-native"; 3 | 4 | interface Props extends Omit { 5 | sources: string[]; 6 | } 7 | 8 | export default function SmartImage({ sources, ...rest }: Props) { 9 | const [idx, setIdx] = useState(0); 10 | if (sources.length === 0) return null; 11 | 12 | return ( 13 | { 17 | if (idx + 1 < sources.length) setIdx(idx + 1); 18 | }} 19 | /> 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /api/online/types.ts: -------------------------------------------------------------------------------- 1 | // api/online/types.ts 2 | export interface Me { 3 | id?: number; 4 | username: string; 5 | slug?: string; 6 | avatar_url?: string; 7 | profile_url?: string; 8 | } 9 | 10 | export interface UserComment { 11 | id: number; 12 | gallery_id: number; 13 | body: string; 14 | post_date?: number; 15 | avatar_url?: string; 16 | page_url?: string; 17 | } 18 | 19 | export interface UserOverview { 20 | me: Me; 21 | joinedText?: string; 22 | joinedAt?: number; 23 | favoriteTags?: string[]; 24 | favoriteTagsText?: string; 25 | about?: string; 26 | recentFavoriteIds: number[]; 27 | recentComments: UserComment[]; 28 | } -------------------------------------------------------------------------------- /components/settings/Card.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@/lib/ThemeContext"; 2 | import React from "react"; 3 | import { StyleSheet, View } from "react-native"; 4 | 5 | export default function Card({ children }: { children: React.ReactNode }) { 6 | const { colors } = useTheme(); 7 | return ( 8 | 14 | {children} 15 | 16 | ); 17 | } 18 | 19 | const styles = StyleSheet.create({ 20 | card: { 21 | borderRadius: 14, 22 | paddingVertical: 16, 23 | paddingHorizontal: 14, 24 | marginBottom: 20, 25 | borderWidth: StyleSheet.hairlineWidth, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /scripts/move-apk.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const pkg = require("../package.json"); 4 | 5 | const version = pkg.version || "0.0.0"; 6 | const srcApk = path.join( 7 | __dirname, 8 | "..", 9 | "android", 10 | "app", 11 | "build", 12 | "outputs", 13 | "apk", 14 | "release", 15 | "app-release.apk" 16 | ); 17 | const outDir = path.join(__dirname, "..", "output_android"); 18 | const outApk = path.join(outDir, `NHApp-Android-Setup-${version}.apk`); 19 | 20 | if (!fs.existsSync(srcApk)) { 21 | console.error(`❌ APK not found: ${srcApk}`); 22 | process.exit(1); 23 | } 24 | 25 | fs.mkdirSync(outDir, { recursive: true }); 26 | fs.copyFileSync(srcApk, outApk); 27 | console.log(`✅ Copied → ${outApk}`); 28 | -------------------------------------------------------------------------------- /components/buildPageSources.ts: -------------------------------------------------------------------------------- 1 | import { buildImageFallbacks } from "./buildImageFallbacks"; 2 | 3 | export const buildPageSources = (url: string): string[] => { 4 | const hostMatch = url.match(/^https:\/\/i\d\.nhentai\.net\/(.+)$/); 5 | if (!hostMatch) return buildImageFallbacks(url); 6 | 7 | const path = hostMatch[1]; 8 | const hosts = ["i1", "i2", "i3", "i4"]; 9 | const exts = buildImageFallbacks(url).map((u) => u.split(".").pop()!); 10 | 11 | const uniq = new Set(); 12 | hosts.forEach((h) => 13 | exts.forEach((ext) => { 14 | const full = `https://${h}.nhentai.net/${path.replace(/\.(\w+)(\.webp)?$/, `.${ext}`)}`; 15 | uniq.add(full); 16 | }), 17 | ); 18 | return [...uniq]; 19 | }; 20 | -------------------------------------------------------------------------------- /components/tags/types.ts: -------------------------------------------------------------------------------- 1 | 2 | export type TagKind = "tags" | "artists" | "characters" | "parodies" | "groups"; 3 | export type TagSingular = "tag" | "artist" | "character" | "parody" | "group"; 4 | 5 | export interface TagEntry { 6 | id: number | string; 7 | type: TagKind | TagSingular | string; 8 | name: string; 9 | count: number; 10 | url: string; 11 | } 12 | 13 | export type TagItem = Omit & { 14 | type: TagKind; 15 | enLow: string; 16 | ruLow: string; 17 | }; 18 | 19 | export type TagMode = "include" | "exclude"; 20 | export type DraftItem = { type: TagKind; name: string; mode: TagMode }; 21 | export type Draft = { id: string; name: string; items: DraftItem[] }; 22 | 23 | export type MainTab = "all" | "favs" | "collections"; 24 | 25 | -------------------------------------------------------------------------------- /components/ThemedView.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { View, type ViewProps } from "react-native"; 3 | 4 | import { useColorScheme } from "@/hooks/useColorScheme"; 5 | import { useTheme } from "@/lib/ThemeContext"; 6 | 7 | export type ThemedViewProps = ViewProps & { 8 | lightColor?: string; 9 | darkColor?: string; 10 | }; 11 | 12 | export function ThemedView({ 13 | style, 14 | lightColor, 15 | darkColor, 16 | ...otherProps 17 | }: ThemedViewProps) { 18 | const scheme = useColorScheme() ?? "light"; 19 | const { colors } = useTheme(); 20 | 21 | const backgroundColor = 22 | scheme === "light" 23 | ? lightColor ?? colors.bg 24 | : darkColor ?? colors.bg; 25 | 26 | return ; 27 | } 28 | -------------------------------------------------------------------------------- /constants/Menu.ts: -------------------------------------------------------------------------------- 1 | import type { MenuRoute } from "@/types/routes"; 2 | 3 | export type MenuItem = { 4 | labelKey: string; 5 | icon: string; 6 | route: MenuRoute; 7 | }; 8 | 9 | export const LIBRARY_MENU: MenuItem[] = [ 10 | { labelKey: "menu.downloaded", icon: "download", route: "/downloaded" }, 11 | { labelKey: "menu.favorites", icon: "heart", route: "/favorites" }, 12 | { 13 | labelKey: "menu.favoritesOnline", 14 | icon: "cloud", 15 | route: "/favoritesOnline", 16 | }, 17 | { labelKey: "menu.history", icon: "clock", route: "/history" }, 18 | { labelKey: "menu.characters", icon: "package", route: "/characters" }, 19 | { labelKey: "menu.recommendations", icon: "star", route: "/recommendations" }, 20 | { labelKey: "menu.settings", icon: "settings", route: "/settings" }, 21 | ]; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/* 5 | 6 | api/* 7 | !api/online 8 | !api/nhentai-tags.json 9 | !api/nhentai.ts 10 | !api/nhentaiOnline.ts 11 | !api/auth.ts 12 | !api/RuTags.json 13 | !api/EnTags.json 14 | 15 | # Expo 16 | .expo/ 17 | dist/ 18 | web-build/ 19 | expo-env.d.ts 20 | 21 | # Native 22 | .kotlin/ 23 | *.orig.* 24 | *.jks 25 | *.p8 26 | *.p12 27 | *.key 28 | *.mobileprovision 29 | 30 | # Metro 31 | .metro-health-check* 32 | 33 | # debug 34 | npm-debug.* 35 | yarn-debug.* 36 | yarn-error.* 37 | 38 | # macOS 39 | .DS_Store 40 | *.pem 41 | 42 | # local env files 43 | .env*.local 44 | 45 | # typescript 46 | *.tsbuildinfo 47 | 48 | app-example 49 | android 50 | output_android 51 | package-lock.json -------------------------------------------------------------------------------- /hooks/book/useWindowLayout.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Dimensions } from "react-native"; 3 | 4 | export const useWindowLayout = () => { 5 | const { width: INIT_W, height: INIT_H } = Dimensions.get("window"); 6 | const [win, setWin] = useState({ w: INIT_W, h: INIT_H }); 7 | 8 | useEffect(() => { 9 | const sub = Dimensions.addEventListener("change", ({ window }) => 10 | setWin({ w: window.width, h: window.height }) 11 | ); 12 | return () => sub.remove(); 13 | }, []); 14 | 15 | const shortest = Math.min(win.w, win.h); 16 | const isTablet = shortest >= 600; 17 | const isLandscape = win.w > win.h; 18 | const wide = isTablet || (isLandscape && win.w >= 400); 19 | const innerPadding = wide ? 16 : 12; 20 | 21 | return { win, isTablet, isLandscape, wide, innerPadding }; 22 | }; 23 | -------------------------------------------------------------------------------- /hooks/usePersistedState.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import { useEffect, useState } from "react"; 3 | 4 | export function usePersistedState(key: string, initial: T) { 5 | const [value, setValue] = useState(initial); 6 | 7 | useEffect(() => { 8 | let mounted = true; 9 | (async () => { 10 | try { 11 | const raw = await AsyncStorage.getItem(key); 12 | if (!mounted) return; 13 | if (raw != null) setValue(JSON.parse(raw)); 14 | } catch {} 15 | })(); 16 | return () => { 17 | mounted = false; 18 | }; 19 | }, [key]); 20 | 21 | const update = async (next: T) => { 22 | setValue(next); 23 | try { 24 | await AsyncStorage.setItem(key, JSON.stringify(next)); 25 | } catch {} 26 | }; 27 | 28 | return [value, update] as const; 29 | } 30 | -------------------------------------------------------------------------------- /app/+not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Stack } from 'expo-router'; 2 | import { StyleSheet } from 'react-native'; 3 | 4 | import { ThemedText } from '@/components/ThemedText'; 5 | import { ThemedView } from '@/components/ThemedView'; 6 | 7 | export default function NotFoundScreen() { 8 | return ( 9 | <> 10 | 11 | 12 | This screen does not exist. 13 | 14 | Go to home screen! 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | const styles = StyleSheet.create({ 22 | container: { 23 | flex: 1, 24 | alignItems: 'center', 25 | justifyContent: 'center', 26 | padding: 20, 27 | }, 28 | link: { 29 | marginTop: 15, 30 | paddingVertical: 15, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /components/settings/schema.ts: -------------------------------------------------------------------------------- 1 | import { JSX } from "react"; 2 | 3 | export type SliderItem = { 4 | id: string; 5 | kind: "slider"; 6 | label: string; 7 | value: number; 8 | min: number; 9 | max: number; 10 | step?: number; 11 | onChange?: (v: number) => void; 12 | onCommit: (v: number) => void; 13 | }; 14 | 15 | export type ToggleItem = { 16 | id: string; 17 | kind: "toggle"; 18 | title: string; 19 | description?: string; 20 | value: boolean; 21 | onToggle: (v: boolean) => void; 22 | }; 23 | 24 | export type CustomItem = { 25 | id: string; 26 | kind: "custom"; 27 | render: () => JSX.Element; 28 | }; 29 | 30 | export type SettingsItem = SliderItem | ToggleItem | CustomItem; 31 | 32 | export type SettingsCard = { 33 | id: string; 34 | title?: string; 35 | items: SettingsItem[]; 36 | }; 37 | 38 | export type SettingsSection = { 39 | id: string; 40 | title: string; 41 | cards: SettingsCard[]; 42 | }; -------------------------------------------------------------------------------- /components/ui/Section.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleProp, Text, View, ViewStyle } from "react-native"; 3 | 4 | export const Section = React.memo(function Section({ 5 | title, 6 | color, 7 | dividerColor, 8 | dense = false, 9 | style, 10 | }: { 11 | title?: string; 12 | color: string; 13 | dividerColor?: string; 14 | dense?: boolean; 15 | style?: StyleProp; 16 | }) { 17 | return ( 18 | 19 | {title ? ( 20 | 30 | {title} 31 | 32 | ) : null} 33 | 34 | 35 | ); 36 | }); 37 | -------------------------------------------------------------------------------- /hooks/useGridConfig.ts: -------------------------------------------------------------------------------- 1 | import type { GridConfig } from "@/components/BookList"; 2 | import { 3 | getCurrentGridConfigMapSync, 4 | getGridConfigMap, 5 | subscribeGridConfig, 6 | } from "@/config/gridConfig"; 7 | import { useEffect, useState } from "react"; 8 | import { useWindowDimensions } from "react-native"; 9 | 10 | export function useGridConfig(): GridConfig { 11 | const { width, height } = useWindowDimensions(); 12 | const [map, setMap] = useState(getCurrentGridConfigMapSync()); 13 | 14 | useEffect(() => { 15 | const unsub = subscribeGridConfig(setMap); 16 | getGridConfigMap() 17 | .then(setMap) 18 | .catch(() => {}); 19 | return () => unsub(); 20 | }, []); 21 | 22 | const isLandscape = width > height; 23 | const isTablet = Math.min(width, height) >= 600; 24 | 25 | if (isTablet) return isLandscape ? map.tabletLandscape : map.tabletPortrait; 26 | return isLandscape ? map.phoneLandscape : map.phonePortrait; 27 | } 28 | -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | const { getDefaultConfig } = require('@expo/metro-config'); 2 | 3 | const config = getDefaultConfig(__dirname, { 4 | // Enable CSS support for react-native-web 5 | isCSSEnabled: true, 6 | }); 7 | 8 | config.transformer = { 9 | ...config.transformer, 10 | // Use react-native-css-transformer for CSS files 11 | babelTransformerPath: require.resolve('react-native-css-transformer'), 12 | // Ensure other transformer options are preserved 13 | getTransformOptions: async () => ({ 14 | transform: { 15 | experimentalImportSupport: false, 16 | inlineRequires: false, 17 | }, 18 | }), 19 | }; 20 | 21 | config.resolver = { 22 | ...config.resolver, 23 | // Add CSS to source extensions 24 | sourceExts: [...config.resolver.sourceExts, 'css'], 25 | // Ignore native-only modules for web 26 | extraNodeModules: { 27 | 'react-native/Libraries/Utilities/codegenNativeCommands': null, 28 | }, 29 | }; 30 | 31 | module.exports = config; -------------------------------------------------------------------------------- /components/OverlayPortal.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, ReactNode, useContext, useState } from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | 4 | type Ctx = { show: (node: ReactNode) => void; hide: () => void }; 5 | const OverlayCtx = createContext({ show: () => {}, hide: () => {} }); 6 | 7 | export const OverlayPortalProvider = ({ 8 | children, 9 | }: { 10 | children: ReactNode; 11 | }) => { 12 | const [node, setNode] = useState(null); 13 | return ( 14 | setNode(null) }}> 15 | 16 | {children} 17 | {node ? ( 18 | 19 | {node} 20 | 21 | ) : null} 22 | 23 | 24 | ); 25 | }; 26 | 27 | export const useOverlayPortal = () => useContext(OverlayCtx); 28 | -------------------------------------------------------------------------------- /hooks/book/useBookData.ts: -------------------------------------------------------------------------------- 1 | import { Book, getBook, loadBookFromLocal } from "@/api/nhentai"; 2 | import { useRouter } from "expo-router"; 3 | import { useEffect, useState } from "react"; 4 | import { Platform, ToastAndroid } from "react-native"; 5 | 6 | export const useBookData = (idNum: number) => { 7 | const router = useRouter(); 8 | const [book, setBook] = useState(null); 9 | const [local, setLocal] = useState(false); 10 | 11 | useEffect(() => { 12 | (async () => { 13 | const bLocal = await loadBookFromLocal(idNum); 14 | if (bLocal) { 15 | setBook(bLocal); 16 | setLocal(true); 17 | return; 18 | } 19 | try { 20 | setBook(await getBook(idNum)); 21 | } catch { 22 | if (Platform.OS === "android") 23 | ToastAndroid.show("Unable to load", ToastAndroid.LONG); 24 | router.back(); 25 | } 26 | })(); 27 | }, [idNum, router]); 28 | 29 | return { book, setBook, local, setLocal }; 30 | }; 31 | -------------------------------------------------------------------------------- /scripts/sync-version.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | const root = path.resolve(process.cwd()); 5 | const pkgPath = path.join(root, "package.json"); 6 | const appPath = path.join(root, "app.json"); 7 | 8 | const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); 9 | const app = JSON.parse(fs.readFileSync(appPath, "utf8")); 10 | 11 | const ver = pkg.version || "0.0.1"; 12 | 13 | const parts = ver.split(".").map((x) => x.padStart(2, "0")); 14 | const versionCode = parseInt(parts.join("").padEnd(5, "0"), 10); 15 | 16 | if (app.expo.version === ver && app.expo.android?.versionCode === versionCode) { 17 | console.log(`[sync-version] app.json уже содержит ${ver} (${versionCode})`); 18 | process.exit(0); 19 | } 20 | 21 | app.expo.version = ver; 22 | app.expo.android = { ...app.expo.android, versionCode }; 23 | 24 | fs.writeFileSync(appPath, JSON.stringify(app, null, 2)); 25 | console.log( 26 | `[sync-version] → app.json обновлён до ${ver} (code ${versionCode})` 27 | ); 28 | -------------------------------------------------------------------------------- /components/tags/SelectedRow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { rusOf, toPlural } from "./helpers"; 3 | import { TagRow } from "./TagRow"; 4 | import { TagItem, TagMode } from "./types"; 5 | 6 | export function SelectedRow({ 7 | type, 8 | name, 9 | mode, 10 | isFav, 11 | onToggleMode, 12 | onRemove, 13 | onToggleFav, 14 | resolveTag, 15 | }: { 16 | type: string; 17 | name: string; 18 | mode: TagMode; 19 | isFav: boolean; 20 | onToggleMode: () => void; 21 | onRemove: () => void; 22 | onToggleFav: () => void; 23 | resolveTag?: (typePlural: string, name: string) => TagItem | undefined; 24 | }) { 25 | const found = 26 | (resolveTag && resolveTag(String(type), name)) || 27 | ({ 28 | id: name, 29 | type: toPlural(String(type)), 30 | name, 31 | count: 0, 32 | url: "", 33 | enLow: name.toLowerCase(), 34 | ruLow: rusOf(name).toLowerCase(), 35 | } as TagItem); 36 | 37 | return ( 38 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /components/settings/SettingsLayout.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from "@/lib/i18n/I18nContext"; 2 | import { useTheme } from "@/lib/ThemeContext"; 3 | import Constants from "expo-constants"; 4 | import React from "react"; 5 | import { ScrollView, StyleSheet, Text, View } from "react-native"; 6 | 7 | export default function SettingsLayout({ 8 | children, 9 | }: { 10 | title: string; 11 | children: React.ReactNode; 12 | }) { 13 | const { t } = useI18n(); 14 | const { colors } = useTheme(); 15 | return ( 16 | 17 | 22 | {children} 23 | 24 | v{Constants.expoConfig?.version} {t("app.version.beta")} 25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | const styles = StyleSheet.create({ 32 | page: { flex: 1, paddingHorizontal: 16 }, 33 | caption: { textAlign: "center", opacity: 0.5, marginVertical: 24 }, 34 | }); 35 | -------------------------------------------------------------------------------- /hooks/book/useFab.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | import { Animated, Easing, FlatList } from "react-native"; 3 | 4 | 5 | export const useFab = () => { 6 | const fabScale = useRef(new Animated.Value(0)).current; 7 | const fabVisibleRef = useRef(false); 8 | const listRef = useRef(null); 9 | const scrollY = useRef(0); 10 | 11 | const animateFab = useCallback( 12 | (show: boolean) => { 13 | if (fabVisibleRef.current === show) return; 14 | fabVisibleRef.current = show; 15 | Animated.timing(fabScale, { 16 | toValue: show ? 1 : 0, 17 | duration: 200, 18 | easing: Easing.out(Easing.quad), 19 | useNativeDriver: true, 20 | }).start(); 21 | }, 22 | [fabScale] 23 | ); 24 | 25 | const onScroll = (e: any) => { 26 | const y = e.nativeEvent.contentOffset.y; 27 | const dy = y - scrollY.current; 28 | scrollY.current = y; 29 | if (dy > 10 && y > 160) animateFab(true); 30 | if (dy < -10 && y < 160) animateFab(false); 31 | }; 32 | 33 | const scrollTop = () => listRef.current?.scrollToOffset({ offset: 0, animated: true }); 34 | 35 | return { fabScale, onScroll, scrollTop, listRef }; 36 | }; 37 | 38 | -------------------------------------------------------------------------------- /components/book/Ring.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@/lib/ThemeContext"; 2 | import React from "react"; 3 | import Svg, { Circle as SvgCircle } from "react-native-svg"; 4 | 5 | export const Ring = ({ 6 | progress, 7 | size = 22, 8 | stroke = 3, 9 | }: { progress: number; size?: number; stroke?: number }) => { 10 | const r = (size - stroke) / 2; 11 | const c = 2 * Math.PI * r; 12 | const off = c * (1 - progress); 13 | const { colors } = useTheme(); 14 | 15 | return ( 16 | 17 | 26 | 39 | 40 | ); 41 | }; 42 | export default Ring; 43 | -------------------------------------------------------------------------------- /hooks/book/useColumns.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import { useCallback, useEffect, useRef, useState } from "react"; 3 | import { FlatList } from "react-native"; 4 | 5 | const COLS_KEY = "galleryColumns"; 6 | 7 | export const useColumns = (wide: boolean) => { 8 | const [cols, setCols] = useState(1); 9 | const listRef = useRef(null); 10 | const scrollY = useRef(0); 11 | 12 | useEffect(() => { 13 | AsyncStorage.getItem(COLS_KEY).then((s) => { 14 | const saved = Math.min(Math.max(parseInt(s ?? "0") || 0, 1), 4); 15 | if (saved) setCols(saved); 16 | else setCols(wide ? 3 : 1); 17 | }); 18 | }, [wide]); 19 | 20 | const cycleCols = useCallback(() => { 21 | const keep = scrollY.current; 22 | setCols((c) => { 23 | const max = wide ? 4 : 3; 24 | const n = c >= max ? 1 : c + 1; 25 | AsyncStorage.setItem(COLS_KEY, String(n)); 26 | return n; 27 | }); 28 | setTimeout( 29 | () => listRef.current?.scrollToOffset({ offset: keep, animated: false }), 30 | 0 31 | ); 32 | }, [wide]); 33 | 34 | const setScrollY = (y: number) => (scrollY.current = y); 35 | 36 | return { cols, setCols, cycleCols, listRef, setScrollY }; 37 | }; 38 | -------------------------------------------------------------------------------- /hooks/useOnlineMe.ts: -------------------------------------------------------------------------------- 1 | import { getMe } from "@/api/online/me"; 2 | import type { Me } from "@/api/online/types"; 3 | import { useEffect, useState } from "react"; 4 | 5 | const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL; 6 | 7 | export function useOnlineMe(): Me | null { 8 | const [me, setMe] = useState(null); 9 | 10 | useEffect(() => { 11 | let cancelled = false; 12 | 13 | getMe() 14 | .then((res) => { 15 | if (!cancelled) { 16 | setMe(res); 17 | } 18 | }) 19 | .catch((err) => { 20 | console.error("Failed to load me:", err); 21 | }); 22 | 23 | return () => { 24 | cancelled = true; 25 | }; 26 | }, []); 27 | 28 | useEffect(() => { 29 | if (!me?.id || !me.username) return; 30 | 31 | const controller = new AbortController(); 32 | 33 | fetch(`${API_BASE_URL}/api/users/sync`, { 34 | method: "POST", 35 | headers: { 36 | "Content-Type": "application/json", 37 | }, 38 | body: JSON.stringify({ 39 | id: me.id, 40 | username: me.username, 41 | }), 42 | signal: controller.signal, 43 | }).catch(() => {}); 44 | 45 | return () => controller.abort(); 46 | }, [me?.id, me?.username]); 47 | 48 | return me; 49 | } 50 | -------------------------------------------------------------------------------- /api/online/me.ts: -------------------------------------------------------------------------------- 1 | import { NH_HOST } from "@/api/auth"; 2 | import { getHtmlWithCookies, isBrowser } from "./http"; 3 | import { 4 | normalizeNhUrl, 5 | tryParseUserFromAppScript, 6 | tryParseUserFromRightMenu, 7 | } from "./scrape"; 8 | import type { Me } from "./types"; 9 | 10 | export async function getMe(): Promise { 11 | if (isBrowser) return null; 12 | try { 13 | const html = await getHtmlWithCookies(NH_HOST + "/"); 14 | 15 | const fromApp = tryParseUserFromAppScript(html); 16 | const fromMenu = tryParseUserFromRightMenu(html); 17 | 18 | if (!fromApp && !fromMenu) return null; 19 | 20 | const id = fromApp?.id ?? fromMenu?.id; 21 | const username = fromApp?.username ?? fromMenu?.username; 22 | const slug = fromApp?.slug ?? fromMenu?.slug; 23 | const avatar_url = normalizeNhUrl( 24 | fromApp?.avatar_url || fromMenu?.avatar_url 25 | ); 26 | const profile_url = 27 | fromApp?.profile_url || 28 | fromMenu?.profile_url || 29 | (id && username 30 | ? `${NH_HOST}/users/${id}/${encodeURIComponent(slug || username)}/` 31 | : undefined); 32 | 33 | if (!username) return null; 34 | 35 | return { id, username, slug, avatar_url, profile_url }; 36 | } catch { 37 | return null; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /components/settings/rows/SliderRow.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@/lib/ThemeContext"; 2 | import Slider from "@react-native-community/slider"; 3 | import React from "react"; 4 | import { StyleSheet, Text, View } from "react-native"; 5 | 6 | interface Props { 7 | label: string; 8 | value: number; 9 | min: number; 10 | max: number; 11 | step?: number; 12 | onChange?: (v: number) => void; 13 | onCommit: (v: number) => void; 14 | } 15 | 16 | export default function SliderRow({ 17 | label, 18 | value, 19 | min, 20 | max, 21 | step = 1, 22 | onChange, 23 | onCommit, 24 | }: Props) { 25 | const { colors } = useTheme(); 26 | return ( 27 | 28 | 29 | {label}: {Math.round(value)} 30 | 31 | 42 | 43 | ); 44 | } 45 | 46 | const styles = StyleSheet.create({ 47 | wrap: { marginTop: 8 }, 48 | label: { fontSize: 14 }, 49 | slider: { marginTop: 6 }, 50 | }); 51 | -------------------------------------------------------------------------------- /components/ui/IconBtn.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { View } from "react-native"; 3 | import { CardPressable } from "./CardPressable"; 4 | 5 | export type IconBtnProps = { 6 | onPress?: () => void; 7 | onLongPress?: () => void; 8 | children: React.ReactNode; 9 | ripple: string; 10 | overlayColor?: string; 11 | size?: number; 12 | radius?: number; 13 | hitSlop?: number; 14 | accessibilityLabel?: string; 15 | shape?: "circle" | "rounded" | "square"; 16 | }; 17 | 18 | export const IconBtn = React.memo(function IconBtn({ 19 | onPress, 20 | onLongPress, 21 | children, 22 | ripple, 23 | overlayColor, 24 | size = 36, 25 | radius = 10, 26 | hitSlop = 6, 27 | accessibilityLabel, 28 | shape = "rounded", 29 | }: IconBtnProps) { 30 | const r = shape === "circle" ? size / 2 : shape === "square" ? 0 : radius; 31 | 32 | return ( 33 | 44 | 45 | {children} 46 | 47 | 48 | ); 49 | }); 50 | -------------------------------------------------------------------------------- /components/settings/rows/SwitchRow.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@/lib/ThemeContext"; 2 | import React from "react"; 3 | import { StyleSheet, Switch, Text, View } from "react-native"; 4 | 5 | interface Props { 6 | title: string; 7 | description?: string; 8 | value: boolean; 9 | onChange: (v: boolean) => void; 10 | } 11 | 12 | export default function SwitchRow({ 13 | title, 14 | description, 15 | value, 16 | onChange, 17 | }: Props) { 18 | const { colors } = useTheme(); 19 | return ( 20 | 21 | 22 | {title} 23 | {description ? ( 24 | 25 | {description} 26 | 27 | ) : null} 28 | 29 | 35 | 36 | ); 37 | } 38 | 39 | const styles = StyleSheet.create({ 40 | rowBetween: { 41 | flexDirection: "row", 42 | alignItems: "center", 43 | justifyContent: "space-between", 44 | gap: 12, 45 | marginTop: 8, 46 | }, 47 | cardTitle: { fontSize: 16, fontWeight: "700" }, 48 | desc: { fontSize: 12, marginTop: 4, lineHeight: 16 }, 49 | }); 50 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "nhappandroid", 4 | "slug": "nhappandroid", 5 | "version": "1.2.0", 6 | "orientation": "default", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "nhappandroid", 9 | "userInterfaceStyle": "automatic", 10 | "ios": { 11 | "supportsTablet": true 12 | }, 13 | "androidNavigationBar": { 14 | "barStyle": "light-content", 15 | "backgroundColor": "#000000" 16 | }, 17 | "android": { 18 | "adaptiveIcon": { 19 | "foregroundImage": "./assets/images/adaptive-icon.png", 20 | "backgroundColor": "#ffffff" 21 | }, 22 | "edgeToEdgeEnabled": true, 23 | "package": "com.nhapp.android", 24 | "versionCode": 10200 25 | }, 26 | "web": { 27 | "bundler": "metro", 28 | "output": "static", 29 | "favicon": "./assets/images/favicon.png" 30 | }, 31 | "plugins": [ 32 | "expo-router", 33 | [ 34 | "expo-splash-screen", 35 | { 36 | "image": "./assets/images/splash-icon.png", 37 | "imageWidth": 200, 38 | "resizeMode": "contain", 39 | "backgroundColor": "#ffffff" 40 | } 41 | ], 42 | [ 43 | "expo-document-picker", 44 | { 45 | "iCloudContainerEnvironment": "Production" 46 | } 47 | ], 48 | "expo-video", 49 | "expo-background-task" 50 | ], 51 | "experiments": { 52 | "typedRoutes": true 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /components/ThemedText.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@/lib/ThemeContext"; 2 | import { StyleSheet, Text, type TextProps } from 'react-native'; 3 | 4 | 5 | export type ThemedTextProps = TextProps & { 6 | lightColor?: string; 7 | darkColor?: string; 8 | type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; 9 | }; 10 | 11 | export function ThemedText({ 12 | style, 13 | lightColor, 14 | darkColor, 15 | type = 'default', 16 | ...rest 17 | }: ThemedTextProps) { 18 | const { colors } = useTheme(); 19 | 20 | return ( 21 | 33 | ); 34 | } 35 | 36 | const styles = StyleSheet.create({ 37 | default: { 38 | fontSize: 16, 39 | lineHeight: 24, 40 | }, 41 | defaultSemiBold: { 42 | fontSize: 16, 43 | lineHeight: 24, 44 | fontWeight: '600', 45 | }, 46 | title: { 47 | fontSize: 32, 48 | fontWeight: 'bold', 49 | lineHeight: 32, 50 | }, 51 | subtitle: { 52 | fontSize: 20, 53 | fontWeight: 'bold', 54 | }, 55 | link: { 56 | lineHeight: 30, 57 | fontSize: 16, 58 | color: '#0a7ea4', 59 | }, 60 | }); -------------------------------------------------------------------------------- /hooks/book/useFavorites.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | 4 | const FAVORITES = "bookFavorites"; 5 | 6 | export const useFavorites = (currentId: number) => { 7 | const [favorites, setFav] = useState>(new Set()); 8 | const [liked, setLiked] = useState(false); 9 | 10 | useEffect(() => { 11 | AsyncStorage.getItem(FAVORITES).then((j) => { 12 | const arr: number[] = j ? JSON.parse(j) : []; 13 | setFav(new Set(arr)); 14 | setLiked(arr.includes(currentId)); 15 | }); 16 | }, [currentId]); 17 | 18 | const toggleFav = useCallback((bid: number, next: boolean) => { 19 | setFav((prev) => { 20 | const cp = new Set(prev); 21 | next ? cp.add(bid) : cp.delete(bid); 22 | AsyncStorage.setItem(FAVORITES, JSON.stringify([...cp])); 23 | if (bid === currentId) setLiked(next); 24 | return cp; 25 | }); 26 | }, [currentId]); 27 | 28 | const toggleLike = useCallback(async () => { 29 | const j = await AsyncStorage.getItem(FAVORITES); 30 | const arr: number[] = j ? JSON.parse(j) : []; 31 | const nextArr = arr.includes(currentId) 32 | ? arr.filter((x) => x !== currentId) 33 | : [...arr, currentId]; 34 | setLiked(!arr.includes(currentId)); 35 | setFav(new Set(nextArr)); 36 | await AsyncStorage.setItem(FAVORITES, JSON.stringify(nextArr)); 37 | }, [currentId]); 38 | 39 | return { favorites, toggleFav, liked, toggleLike }; 40 | }; 41 | -------------------------------------------------------------------------------- /context/SortContext.tsx: -------------------------------------------------------------------------------- 1 | 2 | import AsyncStorage from "@react-native-async-storage/async-storage" 3 | import React, { 4 | createContext, 5 | useCallback, 6 | useContext, 7 | useEffect, 8 | useMemo, 9 | useState, 10 | } from "react" 11 | 12 | export type SortKey = 13 | | "popular" 14 | | "popular-week" 15 | | "popular-today" 16 | | "popular-month" 17 | | "date" 18 | 19 | const STORAGE_KEY = "searchSortPref" 20 | 21 | 22 | interface ISortCtx { 23 | sort: SortKey 24 | setSort: (s: SortKey) => void 25 | } 26 | const SortContext = createContext(undefined) 27 | 28 | 29 | export const SortProvider = ({ children }: { children: React.ReactNode }) => { 30 | const [sort, setSortState] = useState("date") 31 | const [ready, setReady] = useState(false) 32 | 33 | 34 | useEffect(() => { 35 | AsyncStorage.getItem(STORAGE_KEY).then((s) => { 36 | if (s) setSortState(s as SortKey) 37 | setReady(true) 38 | }) 39 | }, []) 40 | 41 | 42 | const setSort = useCallback((s: SortKey) => { 43 | setSortState(s) 44 | AsyncStorage.setItem(STORAGE_KEY, s).catch(() => {}) 45 | }, []) 46 | 47 | const value = useMemo(() => ({ sort, setSort }), [sort, setSort]) 48 | 49 | 50 | if (!ready) return null 51 | 52 | return {children} 53 | } 54 | 55 | 56 | export const useSort = () => { 57 | const ctx = useContext(SortContext) 58 | if (!ctx) throw new Error("useSort must be used inside SortProvider") 59 | return ctx 60 | } 61 | 62 | -------------------------------------------------------------------------------- /components/BookCard/design/BookCardImage.tsx: -------------------------------------------------------------------------------- 1 | import { Book } from "@/api/nhentai"; 2 | import SmartImage from "@/components/SmartImage"; 3 | import { buildImageFallbacks } from "@/components/buildImageFallbacks"; 4 | import { useTheme } from "@/lib/ThemeContext"; 5 | import React, { useMemo } from "react"; 6 | import { Pressable, View } from "react-native"; 7 | import { makeCardStyles } from "../BookCard.styles"; 8 | 9 | export interface BookCardImageProps { 10 | book: Book; 11 | cardWidth?: number; 12 | contentScale?: number; 13 | isFavorite?: boolean; 14 | onPress?: (id: number) => void; 15 | background?: string; 16 | } 17 | 18 | export default function BookCardImage({ 19 | book, 20 | cardWidth = 160, 21 | contentScale = 1, 22 | onPress, 23 | background, 24 | }: BookCardImageProps) { 25 | const { colors } = useTheme(); 26 | const styles = useMemo( 27 | () => makeCardStyles(colors, cardWidth, contentScale), 28 | [colors, cardWidth, contentScale] 29 | ); 30 | 31 | const variants = buildImageFallbacks(book.cover || book.cover); 32 | 33 | return ( 34 | onPress?.(book.id)} 36 | style={[ 37 | { 38 | width: cardWidth, 39 | borderRadius: styles.card.borderRadius as number, 40 | overflow: "hidden", 41 | }, 42 | background ? { backgroundColor: background } : null, 43 | ]} 44 | > 45 | 46 | 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /background/autoImport.task.ts: -------------------------------------------------------------------------------- 1 | // background/autoImport.task.ts 2 | import { autoImportSyncOnce } from "@/lib/autoImport"; 3 | import * as BackgroundTask from "expo-background-task"; 4 | import * as TaskManager from "expo-task-manager"; 5 | 6 | // Имя фоновой задачи 7 | export const AUTO_IMPORT_TASK = "auto-import-background-task"; 8 | 9 | // defineTask ДОЛЖЕН быть в глобальной области — не внутри React-компонента! 10 | // Иначе iOS/Android не смогут поднять JS при фоне. :contentReference[oaicite:5]{index=5} 11 | TaskManager.defineTask(AUTO_IMPORT_TASK, async () => { 12 | try { 13 | const { discovered, sent } = await autoImportSyncOnce(); 14 | // Можно писать в логи/аналитику при желании 15 | // console.log(`[AUTO-IMPORT] discovered=${discovered} sent=${sent}`); 16 | return BackgroundTask.BackgroundTaskResult.Success; 17 | } catch (e) { 18 | // console.warn("[AUTO-IMPORT] failed", e); 19 | return BackgroundTask.BackgroundTaskResult.Failed; 20 | } 21 | }); 22 | 23 | /** 24 | * Регистрирует/пере-регистрирует фоновую задачу. 25 | * minimumInterval — минимальный интервал (в минутах). ОС может запускать реже; на iOS особенно. Минимум ~15 минут. :contentReference[oaicite:6]{index=6} 26 | */ 27 | export async function registerAutoImportTask(minimumInterval = 15) { 28 | // Последняя зарегистрированная задача определяет интервал. :contentReference[oaicite:7]{index=7} 29 | await BackgroundTask.registerTaskAsync(AUTO_IMPORT_TASK, { 30 | minimumInterval, 31 | }); 32 | } 33 | 34 | export async function unregisterAutoImportTask() { 35 | await BackgroundTask.unregisterTaskAsync(AUTO_IMPORT_TASK); 36 | } 37 | -------------------------------------------------------------------------------- /api/online/http.ts: -------------------------------------------------------------------------------- 1 | import { NH_HOST, cookieHeaderString, hasNativeCookieJar } from "@/api/auth"; 2 | import axios from "axios"; 3 | import { Platform } from "react-native"; 4 | 5 | export const isBrowser = Platform.OS === "web"; 6 | 7 | function baseHeaders(): Record { 8 | return { 9 | Referer: NH_HOST + "/", 10 | "User-Agent": "nh-client", 11 | Accept: "text/html,application/xhtml+xml", 12 | "Cache-Control": "no-cache", 13 | }; 14 | } 15 | 16 | /** Универсальный HTML-GET с куками (нативный jar или руками через Header) */ 17 | export async function fetchHtml(url: string): Promise<{ 18 | html: string; 19 | finalUrl: string; 20 | status: number; 21 | }> { 22 | if (isBrowser) return { html: "", finalUrl: url, status: 0 }; 23 | 24 | const useNativeJar = hasNativeCookieJar(); 25 | const headers = baseHeaders(); 26 | 27 | if (!useNativeJar) { 28 | const cookie = await cookieHeaderString({ preferNative: false }); 29 | if (cookie) headers.Cookie = cookie; 30 | } 31 | 32 | const res = await axios.get(url, { 33 | transformResponse: (r) => r, 34 | validateStatus: (s) => s >= 200 && s < 500, 35 | withCredentials: true, 36 | headers, 37 | }); 38 | 39 | const finalUrl = 40 | String((res as any)?.request?.responseURL || res.headers?.location || url) || url; 41 | 42 | return { 43 | html: String(res.data || ""), 44 | finalUrl, 45 | status: Number(res.status || 0), 46 | }; 47 | } 48 | 49 | /** Простой помощник: только HTML (когда редирект не важен) */ 50 | export async function getHtmlWithCookies(url: string): Promise { 51 | const { html } = await fetchHtml(url); 52 | return html; 53 | } 54 | -------------------------------------------------------------------------------- /scripts/prepare-android.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const os = require("os"); 3 | const path = require("path"); 4 | 5 | const rootDir = path.resolve(__dirname, ".."); // project root 6 | const androidDir = path.join(rootDir, "android"); 7 | const propFile = path.join(androidDir, "local.properties"); 8 | 9 | function detectSdkDir() { 10 | const env = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; 11 | 12 | if (env && fs.existsSync(env)) return env; 13 | 14 | const home = os.homedir(); 15 | const candidates = 16 | process.platform === "win32" 17 | ? [ 18 | path.join(process.env.LOCALAPPDATA || "", "Android", "Sdk"), 19 | path.join(home, "AppData", "Local", "Android", "Sdk"), 20 | ] 21 | : process.platform === "darwin" 22 | ? [path.join(home, "Library", "Android", "sdk")] 23 | : /* linux */ [path.join(home, "Android", "Sdk"), "/usr/lib/android-sdk"]; 24 | 25 | return candidates.find(fs.existsSync); 26 | } 27 | 28 | function writeLocalProps(sdkDir) { 29 | const content = `sdk.dir=${sdkDir.replace(/\\/g, "\\\\")}\n`; 30 | fs.writeFileSync(propFile, content); 31 | console.log(`✅ android/local.properties created → ${sdkDir}`); 32 | } 33 | 34 | if (!fs.existsSync(androidDir)) { 35 | console.error("⚠️ No android/ directory. Run `npx expo prebuild` first."); 36 | process.exit(1); 37 | } 38 | 39 | const sdkDir = detectSdkDir(); 40 | if (!sdkDir) { 41 | console.error( 42 | "❌ Android SDK not found.\n" + 43 | " • Install Android Studio or standalone platform-tools\n" + 44 | " • Or set ANDROID_HOME / ANDROID_SDK_ROOT environment variable" 45 | ); 46 | process.exit(1); 47 | } 48 | 49 | writeLocalProps(sdkDir); 50 | -------------------------------------------------------------------------------- /components/tags/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@/lib/ThemeContext"; 2 | import { useI18n } from "@/lib/i18n/I18nContext"; 3 | import { Feather } from "@expo/vector-icons"; 4 | import React from "react"; 5 | import { Pressable, StyleSheet, TextInput, View } from "react-native"; 6 | 7 | export function SearchBar({ 8 | value, 9 | onChangeText, 10 | placeholder, 11 | onClear, 12 | }: { 13 | value: string; 14 | onChangeText: (v: string) => void; 15 | placeholder?: string; 16 | onClear: () => void; 17 | }) { 18 | const { colors } = useTheme(); 19 | const { t } = useI18n(); 20 | const ph = placeholder ?? t("tags.searchPlaceholder"); 21 | 22 | return ( 23 | 29 | 30 | 40 | {!!value && ( 41 | 42 | 43 | 44 | )} 45 | 46 | ); 47 | } 48 | 49 | const styles = StyleSheet.create({ 50 | searchBar: { 51 | height: 44, 52 | borderRadius: 12, 53 | paddingHorizontal: 10, 54 | flexDirection: "row", 55 | alignItems: "center", 56 | gap: 8, 57 | }, 58 | input: { flex: 1, fontSize: 14 }, 59 | }); 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | NHAppAndroid icon 4 |

5 | 6 |

NHAppAndroid Beta

7 |

8 | A modern, lightweight Android client for nhentai built with React Native (Expo). 9 |

10 | 11 |

12 | 13 | Join Discord 19 | 20 |

21 | 22 | 23 | --- 24 | 25 | ## Disclaimer 26 | NHAppAndroid is **unofficial** and not affiliated with nhentai.net. 27 | The application displays adult content. By compiling, distributing or using this software you confirm you are of legal age in your jurisdiction and allowed to view such material. 28 | 29 | --- 30 | 31 | ## Screenshots 32 | 33 | > ⚠️ **Work in Progress:** UI and visual assets are subject to change. 34 | 35 | image 36 | 37 | 38 | 39 | ## Installation 40 | 41 | ```bash 42 | # 1 Clone repo & install deps 43 | git clone https://github.com/Maks1mio/NHAppAndroid.git 44 | cd NHAppAndroid 45 | npm install 46 | 47 | # 2 Run on device / emulator 48 | npm run start 49 | ``` 50 | 51 | ### Android build 52 | ```bash 53 | npm run android-build 54 | npm run move-apk 55 | ``` 56 | 57 | ## Contributing 58 | Pull requests are welcome! Please open an issue first to discuss major changes. 59 | -------------------------------------------------------------------------------- /components/settings/SettingsBuilder.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Card from "./Card"; 3 | import Section from "./Section"; 4 | import SliderRow from "./rows/SliderRow"; 5 | import SwitchRow from "./rows/SwitchRow"; 6 | import type { SettingsItem, SettingsSection } from "./schema"; 7 | 8 | export default function SettingsBuilder({ sections }: { sections: SettingsSection[] }) { 9 | return ( 10 | <> 11 | {sections.map((sec) => ( 12 | 13 |
14 | {sec.cards.map((card) => ( 15 | 16 | {card.title ? {card.title} : null} 17 | {card.items.map((item) => ( 18 | 19 | ))} 20 | 21 | ))} 22 | 23 | ))} 24 | 25 | ); 26 | } 27 | 28 | function SectionTitleInline({ children }: { children: React.ReactNode }) { 29 | return <>; 30 | } 31 | 32 | function ItemRenderer({ item }: { item: SettingsItem }) { 33 | if (item.kind === "slider") { 34 | return ( 35 | 44 | ); 45 | } 46 | if (item.kind === "toggle") { 47 | return ( 48 | 54 | ); 55 | } 56 | return <>{item.render()}; 57 | } -------------------------------------------------------------------------------- /components/tags/useFavs.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import { FAV_KEY, FAV_KEY_LEGACY, favKeyOf, normalizeFavMap } from "./helpers"; 4 | 5 | export function useFavs() { 6 | const [favs, setFavs] = useState>({}); 7 | 8 | useEffect(() => { 9 | (async () => { 10 | try { 11 | const pairs = await AsyncStorage.multiGet([FAV_KEY, FAV_KEY_LEGACY]); 12 | const curr = pairs.find(([k]) => k === FAV_KEY)?.[1]; 13 | const legacy = pairs.find(([k]) => k === FAV_KEY_LEGACY)?.[1]; 14 | 15 | let map: Record = {}; 16 | if (curr) map = { ...map, ...normalizeFavMap(JSON.parse(curr)) }; 17 | if (legacy) map = { ...map, ...normalizeFavMap(JSON.parse(legacy)) }; 18 | 19 | setFavs(map); 20 | await AsyncStorage.setItem(FAV_KEY, JSON.stringify(map)); 21 | } catch {} 22 | })(); 23 | }, []); 24 | 25 | const writeFavs = useCallback((next: Record) => { 26 | setFavs(next); 27 | AsyncStorage.setItem(FAV_KEY, JSON.stringify(next)).catch(() => {}); 28 | }, []); 29 | 30 | const isFav = useCallback( 31 | (t: { type: string; name: string }) => !!favs[favKeyOf(t)], 32 | [favs] 33 | ); 34 | 35 | const toggleFav = useCallback( 36 | (t: { type: string; name: string }) => { 37 | const k = favKeyOf(t); 38 | const next = { ...favs }; 39 | if (next[k]) delete next[k]; 40 | else next[k] = true as const; 41 | writeFavs(next); 42 | }, 43 | [favs, writeFavs] 44 | ); 45 | 46 | const favsHash = Object.keys(favs).sort().join(","); 47 | 48 | return { isFav, toggleFav, favsHash }; 49 | } 50 | -------------------------------------------------------------------------------- /api/online/favorites.ts: -------------------------------------------------------------------------------- 1 | // api/online/favorites.ts 2 | import { NH_HOST } from "@/api/auth"; 3 | import type { Book, Paged } from "@/api/nhentai"; 4 | import { getBook } from "@/api/nhentai"; 5 | import { fetchHtml } from "./http"; 6 | import { extractGalleryIdsFromHtml, extractTotalPagesFromHtml } from "./scrape"; 7 | 8 | /** HTML /favorites → ids → API */ 9 | export async function getFavoritesOnline( 10 | p: { page?: number } = {} 11 | ): Promise> { 12 | const page = p.page ?? 1; 13 | 14 | const url = `${NH_HOST}/favorites/?page=${page}`; 15 | 16 | try { 17 | const { html, finalUrl } = await fetchHtml(url); 18 | 19 | // Не авторизованы → редирект/форма логина. 20 | const looksLikeLogin = 21 | finalUrl.includes("/login/") || 22 | /]+action=["']\/login\/?["']/i.test(html) || 23 | /name=["']username_or_email["']/i.test(html); 24 | 25 | if (!html || looksLikeLogin) { 26 | return { items: [], books: [], totalPages: 1, currentPage: page, totalItems: 0 }; 27 | } 28 | 29 | const ids = extractGalleryIdsFromHtml(html).slice(0, 32); 30 | if (ids.length === 0) { 31 | return { items: [], books: [], totalPages: 1, currentPage: page, totalItems: 0 }; 32 | } 33 | 34 | const books = (await Promise.all(ids.map((id) => getBook(id)))).filter(Boolean) as Book[]; 35 | 36 | // сохранить порядок, как на странице 37 | const orderMap = new Map(ids.map((id, i) => [id, i])); 38 | books.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)); 39 | 40 | const totalPages = extractTotalPagesFromHtml(html); 41 | 42 | return { items: books, books, totalPages, currentPage: page, totalItems: books.length }; 43 | } catch { 44 | return { items: [], books: [], totalPages: 1, currentPage: page, totalItems: 0 }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /components/book/PageItem.tsx: -------------------------------------------------------------------------------- 1 | import { Image as ExpoImage } from "expo-image"; 2 | import React, { memo } from "react"; 3 | import { Pressable, Text } from "react-native"; 4 | 5 | export const GAP = 10; 6 | 7 | export const PageItem = memo( 8 | function PageItem({ 9 | page, 10 | itemW, 11 | cols, 12 | metaColor, 13 | onPress, 14 | }: { 15 | page: { page: number; url: string; width: number; height: number }; 16 | itemW: number; 17 | cols: number; 18 | metaColor: string; 19 | onPress: () => void; 20 | }) { 21 | const isGrid = cols > 1; 22 | 23 | return ( 24 | 32 | 46 | 54 | {page.page} 55 | 56 | 57 | ); 58 | }, 59 | (a, b) => 60 | a.page.url === b.page.url && 61 | a.page.page === b.page.page && 62 | a.itemW === b.itemW && 63 | a.cols === b.cols && 64 | a.metaColor === b.metaColor 65 | ); 66 | 67 | export default PageItem; 68 | -------------------------------------------------------------------------------- /components/tags/Tabs.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { useTheme } from "@/lib/ThemeContext"; 3 | import { useI18n } from "@/lib/i18n/I18nContext"; 4 | import React from "react"; 5 | import { Pressable, StyleSheet, Text, View } from "react-native"; 6 | import { MainTab } from "./types"; 7 | 8 | export function Tabs({ tab, setTab }: { tab: MainTab; setTab: (t: MainTab) => void }) { 9 | const { colors } = useTheme(); 10 | const { t } = useI18n(); 11 | 12 | return ( 13 | 14 | {(["all", "favs", "collections"] as MainTab[]).map((tKey) => { 15 | const active = tab === tKey; 16 | const label = 17 | tKey === "all" 18 | ? t("tags.all") 19 | : tKey === "favs" 20 | ? t("tags.favs") 21 | : t("tags.collectionsTab"); 22 | 23 | return ( 24 | setTab(tKey)} 27 | style={[ 28 | styles.tabBtn, 29 | { 30 | borderColor: active ? colors.accent : "transparent", 31 | backgroundColor: active ? colors.incBg : colors.tagBg, 32 | }, 33 | ]} 34 | > 35 | 41 | {label} 42 | 43 | 44 | ); 45 | })} 46 | 47 | ); 48 | } 49 | 50 | const styles = StyleSheet.create({ 51 | tabs: { flexDirection: "row", gap: 8, flexWrap: "wrap" }, 52 | tabBtn: { 53 | paddingHorizontal: 12, 54 | paddingVertical: 8, 55 | borderRadius: 999, 56 | borderWidth: 2, 57 | }, 58 | }); 59 | 60 | -------------------------------------------------------------------------------- /components/settings/rows/LanguageRow.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@/lib/ThemeContext"; 2 | import { AppLocale, useI18n } from "@/lib/i18n/I18nContext"; 3 | import React from "react"; 4 | import { Pressable, Text, View } from "react-native"; 5 | 6 | export default function LanguageRow() { 7 | const { colors } = useTheme(); 8 | const { t, available, locale, setLocale } = useI18n(); 9 | 10 | return ( 11 | 12 | 13 | {t("settings.language.choose")} 14 | 15 | 16 | 17 | {available.map((opt) => ( 18 | setLocale(opt.code as AppLocale)} 23 | colors={colors} 24 | /> 25 | ))} 26 | 27 | 28 | 29 | {t("settings.language.note")} 30 | 31 | 32 | ); 33 | } 34 | 35 | function Chip({ 36 | active, 37 | label, 38 | onPress, 39 | colors, 40 | }: { 41 | active: boolean; 42 | label: string; 43 | onPress: () => void; 44 | colors: any; 45 | }) { 46 | return ( 47 | 58 | {label} 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /hooks/book/useRelatedComments.ts: -------------------------------------------------------------------------------- 1 | import type { Book } from "@/api/nhentai"; 2 | import { GalleryComment, getComments, getRelatedBooks } from "@/api/nhentai"; 3 | import { useCallback, useEffect, useState } from "react"; 4 | import { InteractionManager } from "react-native"; 5 | 6 | export const useRelatedComments = (book: Book | null) => { 7 | const [related, setRelated] = useState([]); 8 | const [relLoading, setRelLoading] = useState(false); 9 | 10 | const [allComments, setAllComments] = useState([]); 11 | const [visibleCount, setVisibleCount] = useState(20); 12 | const [cmtLoading, setCmtLoading] = useState(false); 13 | 14 | const refetchRelated = useCallback(async () => { 15 | if (!book) return; 16 | try { 17 | setRelLoading(true); 18 | const r = await getRelatedBooks(book.id); 19 | setRelated(r.books.slice(0, 5)); 20 | } catch { 21 | setRelated([]); 22 | } finally { 23 | setRelLoading(false); 24 | } 25 | }, [book?.id]); 26 | 27 | const refetchComments = useCallback(async () => { 28 | if (!book) return; 29 | try { 30 | setCmtLoading(true); 31 | const cs = await getComments(book.id); 32 | setAllComments(cs); 33 | setVisibleCount(20); 34 | } catch { 35 | setAllComments([]); 36 | setVisibleCount(0); 37 | } finally { 38 | setCmtLoading(false); 39 | } 40 | }, [book?.id]); 41 | 42 | useEffect(() => { 43 | if (!book) return; 44 | const task = InteractionManager.runAfterInteractions(() => { 45 | refetchRelated(); 46 | refetchComments(); 47 | }); 48 | return () => task.cancel(); 49 | }, [book?.id, refetchRelated, refetchComments]); 50 | 51 | return { 52 | related, 53 | relLoading, 54 | refetchRelated, 55 | allComments, 56 | visibleCount, 57 | setVisibleCount, 58 | cmtLoading, 59 | refetchComments, 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /components/read/InspectCanvas.tsx: -------------------------------------------------------------------------------- 1 | import { Image as ExpoImage } from "expo-image"; 2 | import React from "react"; 3 | import { StyleSheet } from "react-native"; 4 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; 5 | import Animated, { 6 | useAnimatedStyle, 7 | useSharedValue, 8 | } from "react-native-reanimated"; 9 | 10 | export function InspectCanvas({ 11 | uri, 12 | width, 13 | height, 14 | }: { 15 | uri: string; 16 | width: number; 17 | height: number; 18 | }) { 19 | const scale = useSharedValue(1); 20 | const rotation = useSharedValue(0); 21 | const tx = useSharedValue(0); 22 | const ty = useSharedValue(0); 23 | 24 | const pan = Gesture.Pan().onChange((e) => { 25 | tx.value += e.changeX; 26 | ty.value += e.changeY; 27 | }); 28 | const pinch = Gesture.Pinch().onChange((e) => { 29 | const next = scale.value * e.scaleChange; 30 | scale.value = Math.max(1, next); 31 | }); 32 | const rotate = Gesture.Rotation().onChange((e) => { 33 | rotation.value += e.rotationChange; 34 | }); 35 | 36 | const composed = Gesture.Simultaneous(pan, pinch, rotate); 37 | 38 | const style = useAnimatedStyle(() => ({ 39 | transform: [ 40 | { translateX: tx.value }, 41 | { translateY: ty.value }, 42 | { scale: scale.value }, 43 | { rotate: `${rotation.value}rad` }, 44 | ], 45 | })); 46 | 47 | return ( 48 | 49 | 55 | 56 | 62 | 63 | 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /hooks/useCharacterCardsForPage.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { 3 | CharacterCardForPage, 4 | getCharacterCardsForPage, 5 | getCharactersWithCards, 6 | } from "../api/characterCards"; 7 | 8 | export interface UseCharacterCardsForPageParams { 9 | bookExternalId: number; 10 | pageIndex: number; 11 | allCharacterNames: string[]; 12 | } 13 | 14 | export interface UseCharacterCardsForPageResult { 15 | cards: CharacterCardForPage[]; 16 | charactersWithCards: string[]; 17 | canAddMoreCards: boolean; 18 | loading: boolean; 19 | error: string | null; 20 | reload: () => Promise; 21 | } 22 | 23 | export function useCharacterCardsForPage( 24 | params: UseCharacterCardsForPageParams 25 | ): UseCharacterCardsForPageResult { 26 | const { bookExternalId, pageIndex, allCharacterNames } = params; 27 | 28 | const [cards, setCards] = useState([]); 29 | const [charactersWithCards, setCharactersWithCards] = useState([]); 30 | const [loading, setLoading] = useState(false); 31 | const [error, setError] = useState(null); 32 | 33 | const load = useCallback(async () => { 34 | if (!bookExternalId && bookExternalId !== 0) { 35 | return; 36 | } 37 | 38 | setLoading(true); 39 | setError(null); 40 | 41 | try { 42 | const [pageCards, characters] = await Promise.all([ 43 | getCharacterCardsForPage(bookExternalId, pageIndex), 44 | getCharactersWithCards(bookExternalId), 45 | ]); 46 | 47 | setCards(pageCards); 48 | setCharactersWithCards(characters); 49 | } catch (err: any) { 50 | setError(err?.message ?? "Не удалось загрузить карточки персонажей"); 51 | } finally { 52 | setLoading(false); 53 | } 54 | }, [bookExternalId, pageIndex]); 55 | 56 | useEffect(() => { 57 | load(); 58 | }, [load]); 59 | 60 | const canAddMoreCards = allCharacterNames.some( 61 | (name) => !charactersWithCards.includes(name) 62 | ); 63 | 64 | return { 65 | cards, 66 | charactersWithCards, 67 | canAddMoreCards, 68 | loading, 69 | error, 70 | reload: load, 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /config/gridConfig.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | 3 | export type GridProfile = "phonePortrait" | "phoneLandscape" | "tabletPortrait" | "tabletLandscape"; 4 | 5 | export type GridConfig = { 6 | numColumns: number; 7 | paddingHorizontal: number; 8 | columnGap: number; 9 | minColumnWidth?: number; 10 | cardDesign?: "classic" | "stable" | "image"; 11 | }; 12 | 13 | export type GridConfigMap = Record; 14 | 15 | const STORAGE_KEY = "grid_config_map_v2"; 16 | 17 | export const defaultGridConfigMap: GridConfigMap = { 18 | phonePortrait: { numColumns: 2, paddingHorizontal: 10, columnGap: 5, minColumnWidth: 80, cardDesign: "classic" }, 19 | phoneLandscape: { numColumns: 4, paddingHorizontal: 10, columnGap: 5, minColumnWidth: 80, cardDesign: "classic" }, 20 | tabletPortrait: { numColumns: 3, paddingHorizontal: 10, columnGap: 5, minColumnWidth: 80, cardDesign: "classic" }, 21 | tabletLandscape: { numColumns: 5, paddingHorizontal: 10, columnGap: 5, minColumnWidth: 80, cardDesign: "classic" }, 22 | }; 23 | 24 | let currentMap: GridConfigMap = { ...defaultGridConfigMap }; 25 | 26 | type Listener = (map: GridConfigMap) => void; 27 | const listeners = new Set(); 28 | 29 | function notify() { for (const l of listeners) l(currentMap); } 30 | 31 | export function getCurrentGridConfigMapSync(): GridConfigMap { return currentMap; } 32 | 33 | export async function getGridConfigMap(): Promise { 34 | try { 35 | const raw = await AsyncStorage.getItem(STORAGE_KEY); 36 | if (raw) { 37 | const parsed = JSON.parse(raw) as Partial; 38 | currentMap = { ...defaultGridConfigMap, ...parsed }; 39 | } 40 | } catch {} 41 | return currentMap; 42 | } 43 | 44 | export async function setGridConfigMap(partial: Partial): Promise { 45 | currentMap = { ...currentMap, ...partial }; 46 | try { await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(currentMap)); } catch {} 47 | notify(); 48 | } 49 | 50 | export async function resetGridConfigMap(): Promise { 51 | currentMap = { ...defaultGridConfigMap }; 52 | try { await AsyncStorage.removeItem(STORAGE_KEY); } catch {} 53 | notify(); 54 | } 55 | 56 | export function subscribeGridConfig(cb: Listener): () => void { 57 | listeners.add(cb); 58 | cb(currentMap); 59 | return () => listeners.delete(cb); 60 | } 61 | -------------------------------------------------------------------------------- /constants/Colors.ts: -------------------------------------------------------------------------------- 1 | let baseHue = Math.round((260 / 360) * 65535); 2 | 3 | export const getBaseHue = () => (baseHue / 65535) * 360; 4 | 5 | export const setBaseHue = (deg: number) => { 6 | const norm = ((deg % 360) + 360) % 360; 7 | baseHue = Math.round((norm / 360) * 65535); 8 | }; 9 | 10 | const toHex = (c: number) => 11 | ("0" + Math.round(c).toString(16)).slice(-2).toUpperCase(); 12 | 13 | export const hsbToHex = ({ 14 | saturation, 15 | brightness, 16 | }: { 17 | saturation: number; 18 | brightness: number; 19 | }) => { 20 | const h = (baseHue / 65535) * 360; 21 | const s = saturation / 254; 22 | const v = brightness / 254; 23 | 24 | const c = v * s; 25 | const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); 26 | const m = v - c; 27 | 28 | let [r, g, b] = [0, 0, 0]; 29 | if (h < 60) [r, g, b] = [c, x, 0]; 30 | else if (h < 120)[r, g, b] = [x, c, 0]; 31 | else if (h < 180)[r, g, b] = [0, c, x]; 32 | else if (h < 240)[r, g, b] = [0, x, c]; 33 | else if (h < 300)[r, g, b] = [x, 0, c]; 34 | else [r, g, b] = [c, 0, x]; 35 | 36 | r = Math.round((r + m) * 255); 37 | g = Math.round((g + m) * 255); 38 | b = Math.round((b + m) * 255); 39 | 40 | return `#${toHex(r)}${toHex(g)}${toHex(b)}`; 41 | }; 42 | 43 | export const Colors = { 44 | light: { 45 | text: { 46 | hex: hsbToHex({ saturation: 76, brightness: 200 }), 47 | }, 48 | background: { 49 | hex: hsbToHex({ saturation: 76, brightness: 35 }), 50 | }, 51 | tint: hsbToHex({ saturation: 150, brightness: 200 }), 52 | icon: { 53 | hex: hsbToHex({ saturation: 50, brightness: 100 }), 54 | }, 55 | tabIconDefault: { 56 | hex: hsbToHex({ saturation: 50, brightness: 100 }), 57 | }, 58 | tabIconSelected: { 59 | hex: hsbToHex({ saturation: 150, brightness: 200 }), 60 | }, 61 | }, 62 | dark: { 63 | text: { 64 | hex: hsbToHex({ saturation: 50, brightness: 220 }), 65 | }, 66 | background: { 67 | hex: hsbToHex({ saturation: 76, brightness: 25 }), 68 | }, 69 | tint: hsbToHex({ saturation: 150, brightness: 150 }), 70 | icon: { 71 | hex: hsbToHex({ saturation: 40, brightness: 120 }), 72 | }, 73 | tabIconDefault: { 74 | hex: hsbToHex({ saturation: 40, brightness: 120 }), 75 | }, 76 | tabIconSelected: { 77 | hex: hsbToHex({ saturation: 150, brightness: 150 }), 78 | }, 79 | }, 80 | }; 81 | -------------------------------------------------------------------------------- /components/tags/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Feather } from "@expo/vector-icons"; 2 | import { TagItem, TagKind, TagSingular } from "./types"; 3 | 4 | export const sanitize = (s: string) => s.replace(/[^a-z]/gi, "").toUpperCase(); 5 | 6 | export const rusOf = (name: string) => name; 7 | 8 | export const LABEL_OF: Record = { 9 | tags: "тег", 10 | artists: "художник", 11 | characters: "персонаж", 12 | parodies: "пародия", 13 | groups: "группа", 14 | }; 15 | 16 | export const toPlural = (s: string): TagKind => 17 | s === "tag" 18 | ? "tags" 19 | : s === "artist" 20 | ? "artists" 21 | : s === "character" 22 | ? "characters" 23 | : s === "parody" 24 | ? "parodies" 25 | : s === "group" 26 | ? "groups" 27 | : (s as TagKind); 28 | 29 | export const toSingular = (s: string): TagSingular => 30 | s === "tags" 31 | ? "tag" 32 | : s === "artists" 33 | ? "artist" 34 | : s === "characters" 35 | ? "character" 36 | : s === "parodies" 37 | ? "parody" 38 | : s === "groups" 39 | ? "group" 40 | : (s as TagSingular); 41 | 42 | export const typeIcon = (t: TagKind): keyof typeof Feather.glyphMap => 43 | t === "tags" 44 | ? "tag" 45 | : t === "artists" 46 | ? "pen-tool" 47 | : t === "characters" 48 | ? "user" 49 | : t === "parodies" 50 | ? "film" 51 | : "users"; 52 | 53 | export const scoreByQuery = (t: TagItem, needle: string) => { 54 | if (!needle) return 0; 55 | const prefix = 56 | t.enLow.startsWith(needle) || t.ruLow.startsWith(needle) ? 3 : 0; 57 | const byWord = 58 | t.enLow.includes(` ${needle}`) || t.ruLow.includes(` ${needle}`) ? 2 : 0; 59 | const substr = t.enLow.includes(needle) || t.ruLow.includes(needle) ? 1 : 0; 60 | return prefix * 1_000_000 + byWord * 100_000 + substr * 1_000 + t.count; 61 | }; 62 | 63 | export const FAV_KEY = "tag.favs.v1"; 64 | export const FAV_KEY_LEGACY = "tag.favs"; 65 | 66 | export const favKeyOf = (t: { type: string; name: string }) => 67 | `${toPlural(String(t.type))}:${t.name}`; 68 | 69 | export const normalizeFavMap = (obj: any): Record => { 70 | const out: Record = {}; 71 | if (!obj || typeof obj !== "object") return out; 72 | for (const k of Object.keys(obj)) { 73 | const [rawType, ...rest] = k.split(":"); 74 | const name = rest.join(":"); 75 | out[`${toPlural(String(rawType))}:${name}`] = true as const; 76 | } 77 | return out; 78 | }; 79 | -------------------------------------------------------------------------------- /components/BookCard/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { Book, Tag } from "@/api/nhentai"; 3 | import React from "react"; 4 | 5 | import BookCardClassic, { BookCardClassicProps } from "./design/BookCardClassic"; 6 | import BookCardImage, { BookCardImageProps } from "./design/BookCardImage"; 7 | import BookCardStable, { BookCardStableProps } from "./design/BookCardStable"; 8 | 9 | export type BookCardProps = { 10 | book: Book; 11 | cardWidth?: number; 12 | design?: "stable" | "classic" | "image"; 13 | isSingleCol?: boolean; 14 | contentScale?: number; 15 | isFavorite?: boolean; 16 | selectedTags?: Tag[]; 17 | onToggleFavorite?: (id: number, next: boolean) => void; 18 | onPress?: (id: number) => void; 19 | score?: number; 20 | background?: string; 21 | vertical?: boolean | "true" | "false"; 22 | showProgressOnCard?: boolean; 23 | favoritesSet?: Set; 24 | historyMap?: Record; 25 | hydrateFromStorage?: boolean; 26 | }; 27 | 28 | export default function BookCard(props: BookCardProps) { 29 | const { design = "classic", ...rest } = props; 30 | 31 | if (design === "image") { 32 | const imageProps: BookCardImageProps = { 33 | book: rest.book, 34 | cardWidth: rest.cardWidth, 35 | contentScale: rest.contentScale, 36 | onPress: rest.onPress, 37 | background: rest.background, 38 | }; 39 | return ; 40 | } 41 | 42 | if (design === "classic") { 43 | const classicProps: BookCardClassicProps = { 44 | book: rest.book, 45 | cardWidth: rest.cardWidth, 46 | contentScale: rest.contentScale, 47 | isFavorite: rest.isFavorite, 48 | onPress: rest.onPress, 49 | background: rest.background, 50 | }; 51 | return ; 52 | } 53 | 54 | const stableProps: BookCardStableProps = { 55 | book: rest.book, 56 | cardWidth: rest.cardWidth, 57 | isSingleCol: rest.isSingleCol, 58 | contentScale: rest.contentScale, 59 | isFavorite: rest.isFavorite, 60 | selectedTags: rest.selectedTags, 61 | onToggleFavorite: rest.onToggleFavorite, 62 | onPress: rest.onPress, 63 | score: rest.score, 64 | background: rest.background, 65 | vertical: rest.vertical, 66 | showProgressOnCard: rest.showProgressOnCard, 67 | favoritesSet: rest.favoritesSet, 68 | historyMap: rest.historyMap, 69 | hydrateFromStorage: rest.hydrateFromStorage, 70 | }; 71 | 72 | return ; 73 | } 74 | 75 | export { default as BookCardClassic } from "./design/BookCardClassic"; 76 | export { default as BookCardImage } from "./design/BookCardImage"; 77 | export { default as BookCardStable } from "./design/BookCardStable"; 78 | 79 | 80 | -------------------------------------------------------------------------------- /components/NoResultsPanel.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@/lib/ThemeContext"; 2 | import { Feather } from "@expo/vector-icons"; 3 | import React from "react"; 4 | import { Pressable, StyleSheet, Text, View } from "react-native"; 5 | 6 | export type NoResultsAction = { 7 | label: string; 8 | onPress: () => void; 9 | }; 10 | 11 | export default function NoResultsPanel({ 12 | title, 13 | subtitle, 14 | iconName = "info", 15 | actions = [], 16 | }: { 17 | title: string; 18 | subtitle?: string; 19 | iconName?: React.ComponentProps["name"]; 20 | actions?: NoResultsAction[]; 21 | }) { 22 | const { colors } = useTheme(); 23 | 24 | return ( 25 | 34 | 35 | 36 | 37 | {title} 38 | 39 | 40 | {!!subtitle && ( 41 | 42 | {subtitle} 43 | 44 | )} 45 | 46 | {actions.length > 0 && ( 47 | 48 | {actions.map((a, i) => ( 49 | 57 | 58 | {a.label} 59 | 60 | 61 | ))} 62 | 63 | )} 64 | 65 | 66 | ); 67 | } 68 | 69 | const styles = StyleSheet.create({ 70 | wrap: { 71 | borderBottomWidth: StyleSheet.hairlineWidth, 72 | }, 73 | inner: { 74 | paddingHorizontal: 12, 75 | paddingVertical: 12, 76 | }, 77 | titleRow: { 78 | flexDirection: "row", 79 | alignItems: "center", 80 | gap: 8, 81 | }, 82 | title: { 83 | fontWeight: "800", 84 | }, 85 | subtitle: { 86 | marginTop: 6, 87 | lineHeight: 18, 88 | }, 89 | actionsRow: { 90 | flexDirection: "row", 91 | flexWrap: "wrap", 92 | marginTop: 8, 93 | }, 94 | chip: { 95 | paddingHorizontal: 12, 96 | paddingVertical: 8, 97 | borderRadius: 999, 98 | marginRight: 8, 99 | marginTop: 8, 100 | }, 101 | }); 102 | -------------------------------------------------------------------------------- /components/SideMenu/LibraryMenuList.tsx: -------------------------------------------------------------------------------- 1 | import { CardPressable } from "@/components/ui/CardPressable"; 2 | import type { MenuItem } from "@/constants/Menu"; 3 | import type { MenuRoute } from "@/types/routes"; 4 | import { Feather } from "@expo/vector-icons"; 5 | import React from "react"; 6 | import { StyleSheet, Text, View } from "react-native"; 7 | 8 | export const LibraryMenuList = React.memo(function LibraryMenuList({ 9 | items, 10 | pathname, 11 | loggedIn, 12 | colors, 13 | rippleItem, 14 | overlaySoft, 15 | goTo, 16 | }: { 17 | items: MenuItem[]; 18 | pathname?: string | null; 19 | loggedIn: boolean; 20 | colors: any; 21 | rippleItem: string; 22 | overlaySoft: string; 23 | goTo: (route: MenuRoute) => void; 24 | }) { 25 | return ( 26 | 27 | {items.map((item) => { 28 | const active = pathname?.startsWith(item.route); 29 | const disabled = !loggedIn && item.route === "/favoritesOnline"; 30 | const tint = disabled 31 | ? colors.sub 32 | : active 33 | ? colors.accent 34 | : colors.menuTxt; 35 | const bg = active ? colors.accent + "14" : colors.tagBg; 36 | 37 | return ( 38 | { 44 | if (!disabled) goTo(item.route); 45 | }} 46 | disabled={disabled} 47 | accessibilityLabel={item.labelKey} 48 | > 49 | 58 | {active && ( 59 | 60 | )} 61 | 67 | 71 | 72 | 73 | ); 74 | })} 75 | 76 | ); 77 | }); 78 | 79 | const styles = StyleSheet.create({ 80 | row: { 81 | flexDirection: "row", 82 | alignItems: "center", 83 | minHeight: 46, 84 | paddingVertical: 10, 85 | paddingHorizontal: 12, 86 | borderWidth: StyleSheet.hairlineWidth, 87 | borderRadius: 14, 88 | }, 89 | activeBar: { width: 3, height: "70%", borderRadius: 2, marginRight: 8 }, 90 | itemTxt: { fontSize: 13, fontWeight: "900", letterSpacing: 0.2 }, 91 | }); 92 | -------------------------------------------------------------------------------- /components/tags/TagRow.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { useTheme } from "@/lib/ThemeContext"; 3 | import { Feather } from "@expo/vector-icons"; 4 | import React from "react"; 5 | import { GestureResponderEvent, Pressable, StyleSheet, Text, View } from "react-native"; 6 | import { LABEL_OF, typeIcon } from "./helpers"; 7 | import { TagItem } from "./types"; 8 | 9 | type TagMode = "include" | "exclude"; 10 | 11 | export function TagRow({ 12 | item, 13 | mode, 14 | isFav, 15 | onTap, 16 | onToggleFav, 17 | onRemove, 18 | }: { 19 | item: TagItem; 20 | mode?: TagMode; 21 | isFav: boolean; 22 | onTap: () => void; 23 | onToggleFav: (e: GestureResponderEvent) => void; 24 | onRemove?: () => void; 25 | }) { 26 | const { colors } = useTheme(); 27 | const bg = 28 | mode === "include" ? colors.incBg : 29 | mode === "exclude" ? colors.excBg : 30 | "transparent"; 31 | const fg = 32 | mode === "include" ? colors.incTxt : 33 | mode === "exclude" ? colors.excTxt : 34 | colors.txt; 35 | 36 | return ( 37 | 46 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {item.name} 58 | 59 | 60 | {LABEL_OF[item.type]} • {item.count.toLocaleString("en-US")} 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | {!!mode && onRemove && ( 69 | 70 | 71 | 72 | )} 73 | 74 | 75 | ); 76 | } 77 | 78 | const styles = StyleSheet.create({ 79 | rowWrapper: { 80 | borderRadius: 12, 81 | overflow: "hidden", 82 | marginHorizontal: 2, 83 | borderWidth: StyleSheet.hairlineWidth, 84 | }, 85 | rowContent: { 86 | flexDirection: "row", 87 | alignItems: "center", 88 | paddingVertical: 12, 89 | paddingHorizontal: 12, 90 | gap: 10, 91 | }, 92 | typeIcon: { width: 22, alignItems: "center" }, 93 | rowTitle: { fontSize: 14, fontWeight: "800" }, 94 | iconBtn: { padding: 8 }, 95 | }); 96 | 97 | -------------------------------------------------------------------------------- /hooks/useUpdateCheck.ts: -------------------------------------------------------------------------------- 1 | import * as Application from "expo-application"; 2 | import Constants from "expo-constants"; 3 | import * as FileSystem from "expo-file-system/legacy"; 4 | import * as IntentLauncher from "expo-intent-launcher"; 5 | import { useCallback, useEffect, useState } from "react"; 6 | import { Linking, Platform, ToastAndroid } from "react-native"; 7 | 8 | type UpdateInfo = { 9 | versionName: string; 10 | notes: string; 11 | apkUrl: string; 12 | }; 13 | 14 | export function useUpdateCheck() { 15 | const [update, setUpdate] = useState(null); 16 | const [progress, setProgress] = useState(null); 17 | 18 | const checkUpdate = useCallback(async () => { 19 | try { 20 | const res = await fetch( 21 | "https://api.github.com/repos/Maks1mio/NHAppAndroid/releases/latest" 22 | ); 23 | const j = await res.json(); 24 | const tag = j.tag_name as string; 25 | const current = 26 | Constants.expoConfig?.version ?? Application.nativeBuildVersion; 27 | 28 | if (tag && tag !== current && j.assets?.length) { 29 | setUpdate({ 30 | versionName: tag, 31 | notes: j.body ?? "", 32 | apkUrl: j.assets[0].browser_download_url, 33 | }); 34 | } else { 35 | setUpdate(null); 36 | } 37 | } catch (e) { 38 | console.warn("[update-check]", e); 39 | } 40 | }, []); 41 | 42 | useEffect(() => { checkUpdate(); }, [checkUpdate]); 43 | 44 | const launchInstaller = async (file: string) => { 45 | const uri = await FileSystem.getContentUriAsync(file); 46 | await IntentLauncher.startActivityAsync("android.intent.action.VIEW", { 47 | data : uri, 48 | type : "application/vnd.android.package-archive", 49 | flags: 1 | 0x10000000, 50 | }); 51 | }; 52 | 53 | const downloadAndInstall = useCallback(async () => { 54 | if (!update || progress !== null) return; 55 | 56 | if (Platform.OS === "android" && (Platform.Version as number) >= 26) { 57 | ToastAndroid.show("Скачивание через браузер…", ToastAndroid.SHORT); 58 | Linking.openURL(update.apkUrl); 59 | return; 60 | } 61 | 62 | try { 63 | const dest = `${FileSystem.documentDirectory}NHApp_update.apk`; 64 | setProgress(0); 65 | 66 | await FileSystem.createDownloadResumable( 67 | update.apkUrl, 68 | dest, 69 | { headers: { Accept: "application/octet-stream" } }, 70 | ({ totalBytesWritten, totalBytesExpectedToWrite }) => 71 | setProgress(totalBytesWritten / totalBytesExpectedToWrite) 72 | ).downloadAsync(); 73 | 74 | setProgress(null); 75 | ToastAndroid.show("APK загружен, открываю установщик…", ToastAndroid.SHORT); 76 | await launchInstaller(dest); 77 | } catch (e) { 78 | console.error("[update-dl]", e); 79 | ToastAndroid.show("Ошибка загрузки", ToastAndroid.LONG); 80 | setProgress(null); 81 | } 82 | }, [update, progress]); 83 | 84 | return { update, progress, downloadAndInstall, checkUpdate }; 85 | } 86 | -------------------------------------------------------------------------------- /components/ui/CardPressable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { 3 | Animated, 4 | Insets, 5 | Pressable, 6 | StyleProp, 7 | StyleSheet, 8 | Vibration, 9 | View, 10 | ViewStyle, 11 | } from "react-native"; 12 | 13 | export type CardPressableProps = { 14 | children: React.ReactNode; 15 | radius?: number; 16 | onPress?: () => void; 17 | onLongPress?: () => void; 18 | delayLongPress?: number; 19 | style?: StyleProp; 20 | disabled?: boolean; 21 | ripple?: string; 22 | overlayColor?: string; 23 | hitSlop?: number | Insets; 24 | accessibilityLabel?: string; 25 | pressedScale?: number; 26 | animationDuration?: number; 27 | onFeedback?: boolean; 28 | }; 29 | 30 | export const CardPressable = React.memo(function CardPressable({ 31 | children, 32 | radius = 14, 33 | onPress, 34 | onLongPress, 35 | delayLongPress, 36 | style, 37 | disabled, 38 | ripple = "rgba(0, 0, 0, 0.2)", 39 | overlayColor, 40 | hitSlop, 41 | accessibilityLabel, 42 | pressedScale = 0.97, 43 | animationDuration = 150, 44 | onFeedback = true, 45 | }: CardPressableProps) { 46 | const overlay = overlayColor ?? "rgba(255,255,255,0.10)"; 47 | const animatedScale = useRef(new Animated.Value(1)).current; 48 | 49 | const animateScale = (toValue: number) => { 50 | Animated.timing(animatedScale, { 51 | toValue, 52 | duration: animationDuration, 53 | useNativeDriver: true, 54 | }).start(); 55 | }; 56 | 57 | const handlePressIn = () => { 58 | if (onFeedback && !disabled) { 59 | Vibration.vibrate(10); 60 | } 61 | animateScale(pressedScale); 62 | }; 63 | 64 | const handlePressOut = () => { 65 | animateScale(1); 66 | }; 67 | 68 | const handlePress = () => { 69 | onPress?.(); 70 | }; 71 | 72 | const scaleStyle = { 73 | transform: [{ scale: animatedScale }], 74 | }; 75 | 76 | return ( 77 | 78 | [{ borderRadius: radius }]} 92 | > 93 | {({ pressed }) => ( 94 | 95 | {children} 96 | 106 | 107 | )} 108 | 109 | 110 | ); 111 | }); 112 | -------------------------------------------------------------------------------- /hooks/useFavHistory.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 3 | import { InteractionManager } from "react-native"; 4 | 5 | export type ReadHistoryEntry = [number, number, number, number]; 6 | export type HistoryMap = Record; 7 | 8 | const FAV_KEY = "bookFavorites"; 9 | const READ_HISTORY_KEY = "readHistory"; 10 | 11 | export function useFavHistory() { 12 | const loaded = useRef(false); 13 | const [favoritesSet, setFavoritesSet] = useState>(new Set()); 14 | const [historyMap, setHistoryMap] = useState({}); 15 | const [ready, setReady] = useState(false); 16 | 17 | useEffect(() => { 18 | let cancelled = false; 19 | const task = InteractionManager.runAfterInteractions(async () => { 20 | try { 21 | if (cancelled) return; 22 | const [favRaw, histRaw] = await Promise.all([ 23 | AsyncStorage.getItem(FAV_KEY), 24 | AsyncStorage.getItem(READ_HISTORY_KEY), 25 | ]); 26 | 27 | if (cancelled) return; 28 | const favArr: number[] = favRaw ? JSON.parse(favRaw) : []; 29 | setFavoritesSet(new Set(favArr)); 30 | 31 | if (histRaw) { 32 | try { 33 | const parsed = JSON.parse(histRaw) as ReadHistoryEntry[]; 34 | const map: HistoryMap = {}; 35 | for (const e of parsed) { 36 | const id = Number(e?.[0]); 37 | const current = Math.max(0, Math.floor(Number(e?.[1]) || 0)); 38 | const total = Math.max(1, Math.floor(Number(e?.[2]) || 1)); 39 | const ts = Math.floor(Number(e?.[3]) || 0); 40 | if (id) map[id] = { current: Math.min(current, total - 1), total, ts }; 41 | } 42 | setHistoryMap(map); 43 | } catch { 44 | setHistoryMap({}); 45 | } 46 | } else { 47 | setHistoryMap({}); 48 | } 49 | } finally { 50 | if (!cancelled) { 51 | loaded.current = true; 52 | setReady(true); 53 | } 54 | } 55 | }); 56 | 57 | return () => { 58 | cancelled = true; 59 | task.cancel?.(); 60 | }; 61 | }, []); 62 | 63 | const toggleFavorite = useCallback(async (id: number, next?: boolean) => { 64 | setFavoritesSet(prev => { 65 | const has = prev.has(id); 66 | const shouldAdd = typeof next === "boolean" ? next : !has; 67 | const copy = new Set(prev); 68 | if (shouldAdd) copy.add(id); 69 | else copy.delete(id); 70 | AsyncStorage.getItem(FAV_KEY) 71 | .then(raw => { 72 | const arr: number[] = raw ? JSON.parse(raw) : []; 73 | const s = new Set(arr); 74 | if (shouldAdd) s.add(id); 75 | else s.delete(id); 76 | return AsyncStorage.setItem(FAV_KEY, JSON.stringify(Array.from(s))); 77 | }) 78 | .catch(() => {}); 79 | return copy; 80 | }); 81 | }, []); 82 | 83 | const value = useMemo( 84 | () => ({ favoritesSet, historyMap, ready, toggleFavorite }), 85 | [favoritesSet, historyMap, ready, toggleFavorite] 86 | ); 87 | 88 | return value; 89 | } 90 | -------------------------------------------------------------------------------- /context/DateRangeContext.tsx: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import React, { 3 | createContext, 4 | PropsWithChildren, 5 | useCallback, 6 | useContext, 7 | useEffect, 8 | useMemo, 9 | useState, 10 | } from "react"; 11 | 12 | type NullableDate = Date | null; 13 | 14 | type Ctx = { 15 | from: NullableDate; 16 | to: NullableDate; 17 | isHydrated: boolean; 18 | setRange: (from: NullableDate, to: NullableDate) => void; 19 | setFrom: (d: NullableDate) => void; 20 | setTo: (d: NullableDate) => void; 21 | clearRange: () => void; 22 | }; 23 | 24 | const DateRangeContext = createContext({ 25 | from: null, 26 | to: null, 27 | isHydrated: false, 28 | setRange: () => {}, 29 | setFrom: () => {}, 30 | setTo: () => {}, 31 | clearRange: () => {}, 32 | }); 33 | 34 | const STORAGE_KEY = "dateRange:v2"; 35 | 36 | 37 | const toISODate = (d: NullableDate) => 38 | d ? new Date(d).toISOString().slice(0, 10) : null; 39 | const fromISODate = (s: string | null | undefined): NullableDate => 40 | s ? new Date(s + "T00:00:00.000Z") : null; 41 | 42 | export function DateRangeProvider({ children }: PropsWithChildren) { 43 | const [from, setFromState] = useState(null); 44 | const [to, setToState] = useState(null); 45 | const [isHydrated, setHydrated] = useState(false); 46 | 47 | const persist = useCallback(async (f: NullableDate, t: NullableDate) => { 48 | try { 49 | const payload = JSON.stringify({ from: toISODate(f), to: toISODate(t) }); 50 | await AsyncStorage.setItem(STORAGE_KEY, payload); 51 | } catch { 52 | } 53 | }, []); 54 | 55 | useEffect(() => { 56 | (async () => { 57 | try { 58 | const raw = await AsyncStorage.getItem(STORAGE_KEY); 59 | if (raw) { 60 | const parsed = JSON.parse(raw) as { from?: string | null; to?: string | null }; 61 | const f = fromISODate(parsed?.from || null); 62 | const t = fromISODate(parsed?.to || null); 63 | setFromState(f); 64 | setToState(t); 65 | } 66 | } finally { 67 | setHydrated(true); 68 | } 69 | })(); 70 | }, []); 71 | 72 | const setRange = useCallback( 73 | (f: NullableDate, t: NullableDate) => { 74 | setFromState(f); 75 | setToState(t); 76 | void persist(f, t); 77 | }, 78 | [persist] 79 | ); 80 | 81 | const setFrom = useCallback( 82 | (d: NullableDate) => { 83 | setFromState(d); 84 | void persist(d, to); 85 | }, 86 | [to, persist] 87 | ); 88 | 89 | const setTo = useCallback( 90 | (d: NullableDate) => { 91 | setToState(d); 92 | void persist(from, d); 93 | }, 94 | [from, persist] 95 | ); 96 | 97 | const clearRange = useCallback(() => setRange(null, null), [setRange]); 98 | 99 | const value = useMemo( 100 | () => ({ from, to, isHydrated, setRange, setFrom, setTo, clearRange }), 101 | [from, to, isHydrated, setRange, setFrom, setTo, clearRange] 102 | ); 103 | 104 | return ( 105 | {children} 106 | ); 107 | } 108 | 109 | export const useDateRange = () => useContext(DateRangeContext); 110 | 111 | -------------------------------------------------------------------------------- /app/favoritesOnline.tsx: -------------------------------------------------------------------------------- 1 | import { useFocusEffect, useRouter } from "expo-router"; 2 | import React, { useCallback, useEffect, useState } from "react"; 3 | import { StyleSheet, View } from "react-native"; 4 | 5 | import { Book } from "@/api/nhentai"; 6 | import { getFavoritesOnline, getMe } from "@/api/nhentaiOnline"; 7 | import BookListOnline from "@/components/BookListOnline"; 8 | import { useGridConfig } from "@/hooks/useGridConfig"; 9 | import { useTheme } from "@/lib/ThemeContext"; 10 | 11 | export default function FavoritesOnlineScreen() { 12 | const { colors } = useTheme(); 13 | const router = useRouter(); 14 | const gridConfig = useGridConfig(); 15 | 16 | const [books, setBooks] = useState([]); 17 | const [page, setPage] = useState(1); 18 | const [totalPages, setTotalPages] = useState(1); 19 | const [refreshing, setRefreshing] = useState(false); 20 | 21 | const [hasAuth, setHasAuth] = useState(false); 22 | const [authChecked, setAuthChecked] = useState(false); 23 | 24 | const checkAuth = useCallback(async () => { 25 | try { 26 | const me = await getMe(); 27 | setHasAuth(!!me); 28 | } finally { 29 | setAuthChecked(true); 30 | } 31 | }, []); 32 | 33 | useEffect(() => { 34 | checkAuth(); 35 | }, [checkAuth]); 36 | 37 | useFocusEffect( 38 | useCallback(() => { 39 | setAuthChecked(false); 40 | checkAuth(); 41 | }, [checkAuth]) 42 | ); 43 | 44 | const loadPage = useCallback( 45 | async (pageNum: number) => { 46 | if (!hasAuth) { 47 | setBooks([]); 48 | setPage(1); 49 | setTotalPages(1); 50 | return; 51 | } 52 | const { books: fetched, totalPages: tp } = await getFavoritesOnline({ 53 | page: pageNum, 54 | }); 55 | setBooks((prev) => (pageNum === 1 ? fetched : [...prev, ...fetched])); 56 | setTotalPages(tp); 57 | setPage(pageNum); 58 | }, 59 | [hasAuth] 60 | ); 61 | 62 | useEffect(() => { 63 | loadPage(1); 64 | }, [hasAuth, loadPage]); 65 | 66 | const onEnd = () => { 67 | if (page < totalPages) loadPage(page + 1); 68 | }; 69 | 70 | const onRefresh = useCallback(async () => { 71 | setRefreshing(true); 72 | await loadPage(1); 73 | setRefreshing(false); 74 | }, [loadPage]); 75 | 76 | const onAfterUnfavorite = useCallback((removedIds: number[]) => { 77 | if (!removedIds?.length) return; 78 | setBooks((prev) => prev.filter((b) => !removedIds.includes(b.id))); 79 | }, []); 80 | 81 | return ( 82 | 83 | 90 | router.push({ 91 | pathname: "/book/[id]", 92 | params: { 93 | id: String(id), 94 | title: books.find((b) => b.id === id)?.title.pretty, 95 | }, 96 | }) 97 | } 98 | gridConfig={{ default: gridConfig }} 99 | onAfterUnfavorite={onAfterUnfavorite} 100 | /> 101 | 102 | ); 103 | } 104 | 105 | const styles = StyleSheet.create({ flex: { flex: 1 } }); 106 | -------------------------------------------------------------------------------- /api/online/scrape.ts: -------------------------------------------------------------------------------- 1 | // api/online/scrape.ts 2 | import { NH_HOST } from "@/api/auth"; 3 | import type { Me } from "./types"; 4 | 5 | /* ---------- общие парсеры ---------- */ 6 | 7 | export function extractGalleryIdsFromHtml(html: string): number[] { 8 | const ids = new Set(); 9 | const re = /\/g\/(\d+)\//g; 10 | let m: RegExpExecArray | null; 11 | while ((m = re.exec(html))) ids.add(Number(m[1])); 12 | return [...ids]; 13 | } 14 | 15 | export function extractTotalPagesFromHtml(html: string): number { 16 | const nums = Array.from(html.matchAll(/[?&]page=(\d+)/g)).map((m) => Number(m[1])); 17 | const max = nums.length ? Math.max(...nums) : 1; 18 | return Math.max(1, max); 19 | } 20 | 21 | export function normalizeNhUrl(u?: string): string { 22 | if (!u) return ""; 23 | const s = u.trim(); 24 | if (!s) return ""; 25 | 26 | if (s.startsWith("//")) return "https:" + s; 27 | 28 | if (s.startsWith("/avatars/") || s.startsWith("avatars/")) { 29 | const path = s.startsWith("/") ? s.slice(1) : s; 30 | return "https://i.nhentai.net/" + path; 31 | } 32 | 33 | if (/^i\d\.nhentai\.net\/avatars\//i.test(s)) { 34 | return "https://" + s; 35 | } 36 | 37 | if (s.startsWith("/")) return NH_HOST + s; 38 | 39 | return s; 40 | } 41 | 42 | /* ---------- парс пользователя ---------- */ 43 | 44 | export function tryParseUserFromAppScript(html: string): Partial | null { 45 | const m = html.match(/user\s*:\s*JSON\.parse\((["'])(.*?)\1\)/i); 46 | if (!m) return null; 47 | try { 48 | const jsonStr = m[2]; 49 | const user = JSON.parse(jsonStr); 50 | const id = Number(user?.id) || undefined; 51 | const username = String(user?.username || "").trim(); 52 | const slug = String(user?.slug || "").trim() || undefined; 53 | const avatar_url = user?.avatar_url ? normalizeNhUrl(String(user.avatar_url)) : undefined; 54 | const profile_url = 55 | id && (slug || username) 56 | ? `${NH_HOST}/users/${id}/${encodeURIComponent(slug || username)}/` 57 | : undefined; 58 | if (!username) return null; 59 | return { id, username, slug, avatar_url, profile_url }; 60 | } catch { 61 | return null; 62 | } 63 | } 64 | 65 | export function tryParseUserFromRightMenu(html: string): Partial | null { 66 | const mMenu = html.match( 67 | /]*class=["'][^"']*\bmenu\b[^"']*\bright\b[^"']*["'][^>]*>([\s\S]*?)<\/ul>/i 68 | ); 69 | const menuHtml = mMenu ? mMenu[1] : html; 70 | 71 | const mUserLink = menuHtml.match( 72 | /]*>([\s\S]*?)<\/a>/i 73 | ); 74 | if (!mUserLink) return null; 75 | 76 | const profile_url = normalizeNhUrl( 77 | mUserLink[1].endsWith("/") ? mUserLink[1] : mUserLink[1] + "/" 78 | ); 79 | const id = Number(mUserLink[2]) || undefined; 80 | const slug = decodeURIComponent(mUserLink[3] || "") || undefined; 81 | const inner = mUserLink[4] || ""; 82 | 83 | const mUserName = inner.match( 84 | /]*class=["'][^"']*\busername\b[^"']*["'][^>]*>([^<]+)<\/span>/i 85 | ); 86 | let username = mUserName ? mUserName[1].trim() : ""; 87 | if (!username && slug) username = slug; 88 | if (!username) return null; 89 | 90 | const mImg = inner.match(/]+(?:data-src|src)=["']([^"']*avatars[^"']+)["'][^>]*>/i); 91 | const avatar_url = mImg ? normalizeNhUrl(mImg[1]) : undefined; 92 | 93 | return { id, username, slug, avatar_url, profile_url }; 94 | } 95 | -------------------------------------------------------------------------------- /lib/ThemeContext.tsx: -------------------------------------------------------------------------------- 1 | import { getBaseHue, hsbToHex, setBaseHue } from "@/constants/Colors"; 2 | import AsyncStorage from "@react-native-async-storage/async-storage"; 3 | import React, { 4 | createContext, 5 | ReactNode, 6 | useContext, 7 | useEffect, 8 | useMemo, 9 | useState, 10 | } from "react"; 11 | 12 | const STORAGE_KEY = "themeHue"; 13 | 14 | export interface ThemeColors { 15 | bg: string; 16 | page: string; 17 | shadow: string; 18 | accent: string; 19 | 20 | txt: string; 21 | sub: string; 22 | title: string; 23 | metaText: string; 24 | 25 | tagBg: string; 26 | tagText: string; 27 | newBadgeBg: string; 28 | 29 | incBg: string; 30 | incTxt: string; 31 | excBg: string; 32 | excTxt: string; 33 | 34 | searchBg: string; 35 | searchTxt: string; 36 | menuBg: string; 37 | menuTxt: string; 38 | 39 | related: string; 40 | 41 | surfaceElevated: string; 42 | iconOnSurface: string; 43 | } 44 | 45 | interface ThemeContextValue { 46 | hue: number; 47 | setHue: (deg: number) => void; 48 | colors: ThemeColors; 49 | } 50 | 51 | const ThemeContext = createContext(null); 52 | 53 | export const ThemeProvider = ({ children }: { children: ReactNode }) => { 54 | const [hue, _setHue] = useState(getBaseHue()); 55 | 56 | useEffect(() => { 57 | AsyncStorage.getItem(STORAGE_KEY).then((v) => { 58 | const deg = Number(v); 59 | if (!Number.isNaN(deg)) { 60 | setBaseHue(deg); 61 | _setHue(deg); 62 | } 63 | }); 64 | }, []); 65 | 66 | const setHue = (deg: number) => { 67 | setBaseHue(deg); 68 | _setHue(deg); 69 | AsyncStorage.setItem(STORAGE_KEY, String(deg)).catch(console.warn); 70 | }; 71 | 72 | const colors = useMemo( 73 | () => ({ 74 | bg: hsbToHex({ saturation: 6, brightness: 36 }), 75 | page: hsbToHex({ saturation: 6, brightness: 28 }), 76 | shadow: "#000", 77 | accent: hsbToHex({ saturation: 78, brightness: 210 }), 78 | 79 | txt: hsbToHex({ saturation: 6, brightness: 235 }), 80 | sub: hsbToHex({ saturation: 0, brightness: 150 }), 81 | title: hsbToHex({ saturation: 16, brightness: 225 }), 82 | metaText: hsbToHex({ saturation: 8, brightness: 200 }), 83 | 84 | tagBg: hsbToHex({ saturation: 10, brightness: 48 }), 85 | tagText: hsbToHex({ saturation: 8, brightness: 225 }), 86 | newBadgeBg: "#ff4757", 87 | 88 | incBg: hsbToHex({ saturation: 52, brightness: 54 }), 89 | incTxt: hsbToHex({ saturation: 20, brightness: 225 }), 90 | excBg: hsbToHex({ saturation: 0, brightness: 42 }), 91 | excTxt: hsbToHex({ saturation: 0, brightness: 210 }), 92 | 93 | searchBg: hsbToHex({ saturation: 6, brightness: 34 }), 94 | searchTxt: hsbToHex({ saturation: 6, brightness: 235 }), 95 | menuBg: hsbToHex({ saturation: 6, brightness: 32 }), 96 | menuTxt: hsbToHex({ saturation: 6, brightness: 235 }), 97 | 98 | related: hsbToHex({ saturation: 6, brightness: 28 }), 99 | 100 | surfaceElevated: hsbToHex({ saturation: 6, brightness: 34 }), 101 | iconOnSurface: hsbToHex({ saturation: 8, brightness: 210 }), 102 | }), 103 | [hue] 104 | ); 105 | 106 | return ( 107 | 108 | {children} 109 | 110 | ); 111 | }; 112 | 113 | export const useTheme = () => { 114 | const ctx = useContext(ThemeContext); 115 | if (!ctx) throw new Error("useTheme must be used inside ThemeProvider"); 116 | return ctx; 117 | }; 118 | -------------------------------------------------------------------------------- /api/nhentaiOnline.ts: -------------------------------------------------------------------------------- 1 | // api/nhentaiOnline.ts 2 | 3 | export { getFavoritesOnline } from "./online/favorites"; 4 | export { getMe } from "./online/me"; 5 | export { getUserOverview, getUserProfile } from "./online/profile"; 6 | export { normalizeNhUrl } from "./online/scrape"; 7 | export type { Me, UserComment, UserOverview } from "./online/types"; 8 | 9 | import { NH_HOST, nhFetch } from "@/api/auth"; 10 | 11 | /** Ответ API избранного */ 12 | type FavoriteResponse = { 13 | favorited: boolean; 14 | num_favorites?: number[]; 15 | }; 16 | 17 | /** Единая точка POST к эндпоинтам избранного через nhFetch */ 18 | async function postFavoriteEndpoint(path: string): Promise { 19 | const res = await nhFetch(path, { 20 | method: "POST", 21 | csrf: true, // проставит X-CSRFToken + Referer 22 | withAuth: true, // подставит Cookie (или оставит native jar) 23 | noCache: true, 24 | headers: { 25 | Accept: "application/json", 26 | "X-Requested-With": "XMLHttpRequest", 27 | Referer: `${NH_HOST}/favorites/`, 28 | }, 29 | }); 30 | 31 | if (!res.ok) { 32 | const txt = await res.text().catch(() => ""); 33 | throw new Error(`HTTP ${res.status}: ${txt || "request failed"}`); 34 | } 35 | return res.json(); 36 | } 37 | 38 | /** Добавить в онлайн-избранное */ 39 | export async function onlineFavorite(id: number) { 40 | return postFavoriteEndpoint(`/api/gallery/${id}/favorite`); 41 | } 42 | 43 | /** Удалить из онлайн-избранного */ 44 | export async function onlineUnfavorite(id: number) { 45 | return postFavoriteEndpoint(`/api/gallery/${id}/unfavorite`); 46 | } 47 | 48 | /** Небольшая пауза между запросами, чтобы не упираться в rate limit */ 49 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 50 | 51 | /** Массовое удаление из онлайн-избранного с прогрессом и ограничением параллелизма */ 52 | export async function onlineBulkUnfavorite( 53 | ids: number[], 54 | onProgress?: (done: number, total: number) => void 55 | ): Promise<{ failed: number[] }> { 56 | const total = ids.length; 57 | let done = 0; 58 | const failed: number[] = []; 59 | 60 | const CONCURRENCY = 4; 61 | const queue = [...ids]; 62 | 63 | async function worker() { 64 | while (queue.length) { 65 | const id = queue.shift()!; 66 | try { 67 | await onlineUnfavorite(id); 68 | } catch { 69 | failed.push(id); 70 | } finally { 71 | done += 1; 72 | onProgress?.(done, total); 73 | await sleep(120); 74 | } 75 | } 76 | } 77 | 78 | await Promise.all( 79 | Array.from({ length: Math.min(CONCURRENCY, total) }, worker) 80 | ); 81 | return { failed }; 82 | } 83 | 84 | /** Массовое добавление в онлайн-избранное с прогрессом и ограничением параллелизма */ 85 | export async function onlineBulkFavorite( 86 | ids: number[], 87 | onProgress?: (done: number, total: number) => void 88 | ): Promise<{ failed: number[] }> { 89 | const total = ids.length; 90 | let done = 0; 91 | const failed: number[] = []; 92 | 93 | const CONCURRENCY = 4; 94 | const queue = [...ids]; 95 | 96 | async function worker() { 97 | while (queue.length) { 98 | const id = queue.shift()!; 99 | try { 100 | await onlineFavorite(id); 101 | } catch { 102 | failed.push(id); 103 | } finally { 104 | done += 1; 105 | onProgress?.(done, total); 106 | await sleep(120); 107 | } 108 | } 109 | } 110 | 111 | await Promise.all( 112 | Array.from({ length: Math.min(CONCURRENCY, total) }, worker) 113 | ); 114 | return { failed }; 115 | } 116 | -------------------------------------------------------------------------------- /components/read/Buttons.tsx: -------------------------------------------------------------------------------- 1 | import { Feather } from "@expo/vector-icons"; 2 | import React from "react"; 3 | import { Pressable, StyleSheet, Text, View } from "react-native"; 4 | 5 | export function IconBtn({ 6 | onPress, 7 | name, 8 | color, 9 | }: { 10 | onPress: () => void; 11 | name: keyof typeof Feather.glyphMap; 12 | color: string; 13 | }) { 14 | return ( 15 | 16 | 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | export function ToggleBtn({ 28 | active, 29 | onToggle, 30 | name, 31 | activeColor, 32 | color, 33 | }: { 34 | active: boolean; 35 | onToggle: () => void; 36 | name: keyof typeof Feather.glyphMap; 37 | activeColor: string; 38 | color: string; 39 | }) { 40 | return ( 41 | 42 | 50 | 51 | 52 | 53 | ); 54 | } 55 | 56 | export function RowBtn({ 57 | onPress, 58 | icon, 59 | label, 60 | color, 61 | }: { 62 | onPress: () => void; 63 | icon: keyof typeof Feather.glyphMap; 64 | label: string; 65 | color: string; 66 | }) { 67 | return ( 68 | 69 | 74 | 75 | {label} 76 | 77 | 78 | ); 79 | } 80 | 81 | export function RowToggle({ 82 | active, 83 | onToggle, 84 | icon, 85 | label, 86 | color, 87 | activeColor, 88 | }: { 89 | active: boolean; 90 | onToggle: () => void; 91 | icon: keyof typeof Feather.glyphMap; 92 | label: string; 93 | color: string; 94 | activeColor: string; 95 | }) { 96 | return ( 97 | 98 | 106 | 107 | 113 | {label} 114 | 115 | 116 | 117 | ); 118 | } 119 | 120 | const styles = StyleSheet.create({ 121 | iconBtn: { 122 | padding: 8, 123 | borderRadius: 10, 124 | overflow: "hidden", 125 | }, 126 | rowBtn: { 127 | height: 40, 128 | minWidth: 40, 129 | paddingHorizontal: 8, 130 | borderRadius: 10, 131 | alignItems: "center", 132 | justifyContent: "center", 133 | gap: 2, 134 | overflow: "hidden", 135 | }, 136 | rowLabel: { fontSize: 10, fontWeight: "600" }, 137 | }); 138 | -------------------------------------------------------------------------------- /scripts/sync-android-overrides.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable no-console */ 3 | const fs = require("fs"); 4 | const fsp = fs.promises; 5 | const path = require("path"); 6 | const crypto = require("crypto"); 7 | 8 | const ROOT = process.cwd(); 9 | const ANDROID_DIR = path.join(ROOT, "android"); 10 | 11 | // 1-й аргумент — папка-источник с твоими файлами (по умолчанию overrides/android) 12 | const SRC_DIR = path.resolve(process.argv[2] || path.join(ROOT, "overrides", "android")); 13 | const BACKUP_DIR = path.join(ANDROID_DIR, "_backup", timestamp()); 14 | 15 | // Сопоставления: источник → место назначения в android/ 16 | const MAP = [ 17 | { src: "gradle.properties", dest: "gradle.properties" }, // android/gradle.properties 18 | { src: "build.gradle", dest: path.join("app", "build.gradle") }, // android/app/build.gradle 19 | { src: "proguard-rules.pro", dest: path.join("app", "proguard-rules.pro") } // android/app/proguard-rules.pro 20 | ]; 21 | 22 | (async () => { 23 | try { 24 | // Проверка входной папки 25 | if (!fs.existsSync(SRC_DIR)) { 26 | console.error(`[sync-android-files] Нет исходной папки: ${rel(SRC_DIR)}`); 27 | process.exit(2); 28 | } 29 | if (!fs.existsSync(ANDROID_DIR)) { 30 | console.error(`[sync-android-files] Нет папки android/. Запусти expo prebuild перед синхронизацией.`); 31 | process.exit(3); 32 | } 33 | 34 | let changed = 0; 35 | 36 | for (const item of MAP) { 37 | const src = path.join(SRC_DIR, item.src); 38 | const dest = path.join(ANDROID_DIR, item.dest); 39 | const destDir = path.dirname(dest); 40 | 41 | if (!fs.existsSync(src)) { 42 | console.log(gray(`[skip] нет файла ${rel(src)}`)); 43 | continue; 44 | } 45 | await mkdirp(destDir); 46 | 47 | const needCopy = await isDifferent(src, dest); 48 | if (!needCopy) { 49 | console.log(`= ${rel(item.dest)} — без изменений`); 50 | continue; 51 | } 52 | 53 | // Бэкап, если есть что затирать 54 | if (fs.existsSync(dest)) { 55 | const backupPath = path.join(BACKUP_DIR, item.dest); 56 | await mkdirp(path.dirname(backupPath)); 57 | await fsp.copyFile(dest, backupPath); 58 | console.log(gray(`↺ backup → ${rel(backupPath)}`)); 59 | } 60 | 61 | // Копируем (fs.copyFile перезаписывает по умолчанию) :contentReference[oaicite:5]{index=5} 62 | await fsp.copyFile(src, dest); 63 | console.log(`→ ${rel(item.dest)} — обновлён`); 64 | changed++; 65 | } 66 | 67 | console.log(changed ? `[sync-android-files] Готово. Обновлено: ${changed}` : `[sync-android-files] Всё актуально.`); 68 | if (changed) console.log(gray(`[sync-android-files] Бэкапы: ${rel(BACKUP_DIR)}`)); 69 | } catch (err) { 70 | console.error(`[sync-android-files] Ошибка: ${err.stack || err.message}`); 71 | process.exit(1); 72 | } 73 | })(); 74 | 75 | /* ---------------- helpers ---------------- */ 76 | async function isDifferent(a, b) { 77 | if (!fs.existsSync(b)) return true; 78 | const [ha, hb] = await Promise.all([hash(a), hash(b)]); 79 | return ha !== hb; 80 | } 81 | async function hash(p) { 82 | const buf = await fsp.readFile(p); 83 | return crypto.createHash("sha256").update(buf).digest("hex"); 84 | } 85 | async function mkdirp(dir) { 86 | await fsp.mkdir(dir, { recursive: true }); 87 | } 88 | function timestamp() { 89 | const d = new Date(); 90 | const z = (n) => String(n).padStart(2, "0"); 91 | return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}-${z(d.getHours())}${z(d.getMinutes())}${z(d.getSeconds())}`; 92 | } 93 | function rel(p) { 94 | return path.relative(ROOT, p).replace(/\\/g, "/"); 95 | } 96 | function gray(s) { 97 | return `\x1b[90m${s}\x1b[0m`; 98 | } 99 | -------------------------------------------------------------------------------- /scripts/reset-project.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * This script is used to reset the project to a blank state. 5 | * It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file. 6 | * You can remove the `reset-project` script from package.json and safely delete this file after running it. 7 | */ 8 | 9 | const fs = require("fs"); 10 | const path = require("path"); 11 | const readline = require("readline"); 12 | 13 | const root = process.cwd(); 14 | const oldDirs = ["app", "components", "hooks", "constants", "scripts"]; 15 | const exampleDir = "app-example"; 16 | const newAppDir = "app"; 17 | const exampleDirPath = path.join(root, exampleDir); 18 | 19 | const indexContent = `import { Text, View } from "react-native"; 20 | 21 | export default function Index() { 22 | return ( 23 | 30 | Edit app/index.tsx to edit this screen. 31 | 32 | ); 33 | } 34 | `; 35 | 36 | const layoutContent = `import { Stack } from "expo-router"; 37 | 38 | export default function RootLayout() { 39 | return ; 40 | } 41 | `; 42 | 43 | const rl = readline.createInterface({ 44 | input: process.stdin, 45 | output: process.stdout, 46 | }); 47 | 48 | const moveDirectories = async (userInput) => { 49 | try { 50 | if (userInput === "y") { 51 | await fs.promises.mkdir(exampleDirPath, { recursive: true }); 52 | console.log(`📁 /${exampleDir} directory created.`); 53 | } 54 | 55 | for (const dir of oldDirs) { 56 | const oldDirPath = path.join(root, dir); 57 | if (fs.existsSync(oldDirPath)) { 58 | if (userInput === "y") { 59 | const newDirPath = path.join(root, exampleDir, dir); 60 | await fs.promises.rename(oldDirPath, newDirPath); 61 | console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`); 62 | } else { 63 | await fs.promises.rm(oldDirPath, { recursive: true, force: true }); 64 | console.log(`❌ /${dir} deleted.`); 65 | } 66 | } else { 67 | console.log(`➡️ /${dir} does not exist, skipping.`); 68 | } 69 | } 70 | 71 | const newAppDirPath = path.join(root, newAppDir); 72 | await fs.promises.mkdir(newAppDirPath, { recursive: true }); 73 | console.log("\n📁 New /app directory created."); 74 | 75 | const indexPath = path.join(newAppDirPath, "index.tsx"); 76 | await fs.promises.writeFile(indexPath, indexContent); 77 | console.log("📄 app/index.tsx created."); 78 | 79 | const layoutPath = path.join(newAppDirPath, "_layout.tsx"); 80 | await fs.promises.writeFile(layoutPath, layoutContent); 81 | console.log("📄 app/_layout.tsx created."); 82 | 83 | console.log("\n✅ Project reset complete. Next steps:"); 84 | console.log( 85 | `1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${ 86 | userInput === "y" 87 | ? `\n3. Delete the /${exampleDir} directory when you're done referencing it.` 88 | : "" 89 | }` 90 | ); 91 | } catch (error) { 92 | console.error(`❌ Error during script execution: ${error.message}`); 93 | } 94 | }; 95 | 96 | rl.question( 97 | "Do you want to move existing files to /app-example instead of deleting them? (Y/n): ", 98 | (answer) => { 99 | const userInput = answer.trim().toLowerCase() || "y"; 100 | if (userInput === "y" || userInput === "n") { 101 | moveDirectories(userInput).finally(() => rl.close()); 102 | } else { 103 | console.log("❌ Invalid input. Please enter 'Y' or 'N'."); 104 | rl.close(); 105 | } 106 | } 107 | ); 108 | -------------------------------------------------------------------------------- /utils/book/timeAgo.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | import type { Locale as DFLocale } from "date-fns"; 5 | 6 | export type UiLocale = "en" | "ru" | "zh" | "ja"; 7 | 8 | type UnitKey = "year" | "month" | "day" | "hour" | "minute" | "second"; 9 | 10 | const translations: Record< 11 | UiLocale, 12 | { 13 | units: Record; 14 | justNow: string; 15 | ago: string; 16 | } 17 | > = { 18 | en: { 19 | units: { 20 | year: ["year", "years"], 21 | month: ["month", "months"], 22 | day: ["day", "days"], 23 | hour: ["hour", "hours"], 24 | minute: ["minute", "minutes"], 25 | second: ["second", "seconds"], 26 | }, 27 | justNow: "just now", 28 | ago: "ago", 29 | }, 30 | ru: { 31 | units: { 32 | year: ["год", "года", "лет"], 33 | month: ["месяц", "месяца", "месяцев"], 34 | day: ["день", "дня", "дней"], 35 | hour: ["час", "часа", "часов"], 36 | minute: ["минута", "минуты", "минут"], 37 | second: ["секунда", "секунды", "секунд"], 38 | }, 39 | justNow: "только что", 40 | ago: "назад", 41 | }, 42 | zh: { 43 | units: { 44 | year: ["年", "年"], 45 | month: ["个月", "个月"], 46 | day: ["天", "天"], 47 | hour: ["小时", "小时"], 48 | minute: ["分钟", "分钟"], 49 | second: ["秒", "秒"], 50 | }, 51 | justNow: "刚刚", 52 | ago: "前", 53 | }, 54 | ja: { 55 | units: { 56 | year: ["年", "年"], 57 | month: ["ヶ月", "ヶ月"], 58 | day: ["日", "日"], 59 | hour: ["時間", "時間"], 60 | minute: ["分", "分"], 61 | second: ["秒", "秒"], 62 | }, 63 | justNow: "たった今", 64 | ago: "前", 65 | }, 66 | }; 67 | 68 | function pluralRu(n: number, forms: [string, string, string]) { 69 | return forms[ 70 | n % 10 === 1 && n % 100 !== 11 71 | ? 0 72 | : [2, 3, 4].includes(n % 10) && ![12, 13, 14].includes(n % 100) 73 | ? 1 74 | : 2 75 | ]; 76 | } 77 | 78 | 79 | function normalizeLocale(loc?: UiLocale | DFLocale): UiLocale { 80 | if (!loc) return "en"; 81 | if (typeof loc === "string") { 82 | 83 | if (loc === "zh") return "zh"; 84 | return (["en", "ru", "zh", "ja"].includes(loc) ? loc : "en") as UiLocale; 85 | } 86 | 87 | const code = (loc as any)?.code as string | undefined; 88 | if (code) { 89 | const lower = code.toLowerCase(); 90 | if (lower.startsWith("ru")) return "ru"; 91 | if (lower.startsWith("ja")) return "ja"; 92 | if (lower.startsWith("zh")) return "zh"; 93 | return "en"; 94 | } 95 | return "en"; 96 | } 97 | 98 | function toDate(input: string | number | Date): Date { 99 | if (input instanceof Date) return input; 100 | if (typeof input === "string") { 101 | const t = Date.parse(input); 102 | return Number.isFinite(t) ? new Date(t) : new Date(); 103 | } 104 | 105 | return new Date(input < 1e12 ? input * 1000 : input); 106 | } 107 | 108 | export function timeAgo( 109 | d: string | number | Date, 110 | locale?: UiLocale | DFLocale 111 | ): string { 112 | const date = toDate(d); 113 | const s = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000)); 114 | const l = normalizeLocale(locale); 115 | const tr = translations[l]; 116 | 117 | const table: [UnitKey, number][] = [ 118 | ["year", 31536000], 119 | ["month", 2592000], 120 | ["day", 86400], 121 | ["hour", 3600], 122 | ["minute", 60], 123 | ["second", 1], 124 | ]; 125 | 126 | for (const [unit, secs] of table) { 127 | if (s >= secs) { 128 | const v = Math.floor(s / secs); 129 | if (l === "ru") { 130 | return `${v} ${pluralRu(v, tr.units[unit] as [string, string, string])} ${tr.ago}`; 131 | } 132 | if (l === "zh" || l === "ja") { 133 | return `${v}${tr.units[unit][0]}${tr.ago}`; 134 | } 135 | 136 | return `${v} ${tr.units[unit][v === 1 ? 0 : 1]} ${tr.ago}`; 137 | } 138 | } 139 | return tr.justNow; 140 | } 141 | 142 | -------------------------------------------------------------------------------- /lib/autoImport.ts: -------------------------------------------------------------------------------- 1 | import { onlineBulkFavorite } from "@/api/nhentaiOnline"; 2 | import AsyncStorage from "@react-native-async-storage/async-storage"; 3 | 4 | const K_LOCAL_FAV = "bookFavorites"; 5 | const K_IMPORTED_CACHE = "@online.imported.cache"; 6 | const K_PENDING_QUEUE = "@online.pendingFavorites.queue"; 7 | 8 | const BATCH_SIZE = 50; 9 | 10 | let syncLock = false; 11 | 12 | async function readJson(key: string, fallback: T): Promise { 13 | try { 14 | const raw = await AsyncStorage.getItem(key); 15 | return raw ? (JSON.parse(raw) as T) : fallback; 16 | } catch { 17 | return fallback; 18 | } 19 | } 20 | 21 | async function writeJson(key: string, value: T): Promise { 22 | try { 23 | await AsyncStorage.setItem(key, JSON.stringify(value)); 24 | } catch {} 25 | } 26 | 27 | async function getLocalFavoriteIds(): Promise { 28 | const arr = await readJson(K_LOCAL_FAV, []); 29 | return Array.from(new Set(arr.filter((x) => Number.isFinite(x)))); 30 | } 31 | 32 | async function getImportedCache(): Promise> { 33 | const arr = await readJson(K_IMPORTED_CACHE, []); 34 | return new Set(arr); 35 | } 36 | 37 | async function setImportedCache(cacheSet: Set): Promise { 38 | await writeJson(K_IMPORTED_CACHE, Array.from(cacheSet)); 39 | } 40 | 41 | async function getPending(): Promise> { 42 | const arr = await readJson(K_PENDING_QUEUE, []); 43 | return new Set(arr); 44 | } 45 | 46 | async function setPending(setIds: Set): Promise { 47 | await writeJson(K_PENDING_QUEUE, Array.from(setIds)); 48 | } 49 | 50 | async function enqueueNewFavorites(newIds: number[]) { 51 | if (!newIds.length) return; 52 | const pending = await getPending(); 53 | let changed = false; 54 | for (const id of newIds) { 55 | if (!pending.has(id)) { 56 | pending.add(id); 57 | changed = true; 58 | } 59 | } 60 | if (changed) await setPending(pending); 61 | } 62 | 63 | async function flushPendingBatches(): Promise<{ sent: number }> { 64 | const pending = await getPending(); 65 | if (!pending.size) return { sent: 0 }; 66 | 67 | const cache = await getImportedCache(); 68 | 69 | const ids = Array.from(pending); 70 | let sent = 0; 71 | 72 | for (let i = 0; i < ids.length; i += BATCH_SIZE) { 73 | const chunk = ids.slice(i, i + BATCH_SIZE); 74 | try { 75 | await onlineBulkFavorite(chunk); 76 | chunk.forEach((id) => cache.add(id)); 77 | sent += chunk.length; 78 | chunk.forEach((id) => pending.delete(id)); 79 | await setImportedCache(cache); 80 | await setPending(pending); 81 | } catch { 82 | break; 83 | } 84 | } 85 | return { sent }; 86 | } 87 | 88 | export async function autoImportSyncOnce(): Promise<{ 89 | discovered: number; 90 | sent: number; 91 | }> { 92 | if (syncLock) return { discovered: 0, sent: 0 }; 93 | syncLock = true; 94 | try { 95 | const [local, cache, pending] = await Promise.all([ 96 | getLocalFavoriteIds(), 97 | getImportedCache(), 98 | getPending(), 99 | ]); 100 | 101 | const additions = local.filter((id) => !cache.has(id) && !pending.has(id)); 102 | if (additions.length) { 103 | await enqueueNewFavorites(additions); 104 | } 105 | 106 | const { sent } = await flushPendingBatches(); 107 | return { discovered: additions.length, sent }; 108 | } finally { 109 | syncLock = false; 110 | } 111 | } 112 | 113 | export function startForegroundPolling(pollMs = 1000) { 114 | let stopped = false; 115 | let timer: ReturnType | null = null; 116 | 117 | async function tick() { 118 | if (stopped) return; 119 | try { 120 | await autoImportSyncOnce(); 121 | } catch {} 122 | } 123 | 124 | timer = setInterval(tick, Math.max(500, pollMs)); 125 | tick(); 126 | 127 | return () => { 128 | stopped = true; 129 | if (timer) clearInterval(timer); 130 | }; 131 | } 132 | -------------------------------------------------------------------------------- /context/AutoImportProvider.tsx: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import React from "react"; 3 | import { AppState, AppStateStatus } from "react-native"; 4 | 5 | import { 6 | registerAutoImportTask, 7 | unregisterAutoImportTask, 8 | } from "@/background/autoImport.task"; 9 | import { autoImportSyncOnce, startForegroundPolling } from "@/lib/autoImport"; 10 | 11 | type Ctx = { 12 | enabled: boolean; 13 | setEnabled: (v: boolean) => void; 14 | isRunning: boolean; 15 | }; 16 | 17 | const AutoImportContext = React.createContext(undefined); 18 | export const useAutoImport = () => { 19 | const ctx = React.useContext(AutoImportContext); 20 | if (!ctx) { 21 | throw new Error("useAutoImport must be used within AutoImportProvider"); 22 | } 23 | return ctx; 24 | }; 25 | 26 | const K_AUTO_IMPORT_ENABLED = "@autoImport.enabled"; 27 | 28 | export default function AutoImportProvider({ 29 | children, 30 | }: { 31 | children: React.ReactNode; 32 | }) { 33 | const [enabled, setEnabledState] = React.useState(false); 34 | const [loaded, setLoaded] = React.useState(false); 35 | const [isRunning, setIsRunning] = React.useState(false); 36 | 37 | const appState = React.useRef( 38 | AppState.currentState as AppStateStatus | null 39 | ); 40 | const stopPollingRef = React.useRef void)>(null); 41 | 42 | React.useEffect(() => { 43 | (async () => { 44 | try { 45 | const v = await AsyncStorage.getItem(K_AUTO_IMPORT_ENABLED); 46 | setEnabledState(v === "1"); 47 | } finally { 48 | setLoaded(true); 49 | } 50 | })(); 51 | }, []); 52 | 53 | const setEnabled = React.useCallback(async (next: boolean) => { 54 | setEnabledState(next); 55 | await AsyncStorage.setItem(K_AUTO_IMPORT_ENABLED, next ? "1" : "0"); 56 | 57 | if (next) { 58 | registerAutoImportTask(15).catch(() => {}); 59 | if (appState.current === "active" && !stopPollingRef.current) { 60 | stopPollingRef.current = startForegroundPolling(1000); 61 | setIsRunning(true); 62 | } 63 | autoImportSyncOnce().catch(() => {}); 64 | } else { 65 | if (stopPollingRef.current) { 66 | stopPollingRef.current(); 67 | stopPollingRef.current = null; 68 | } 69 | setIsRunning(false); 70 | unregisterAutoImportTask().catch(() => {}); 71 | } 72 | }, []); 73 | 74 | React.useEffect(() => { 75 | if (!loaded) return; 76 | 77 | if (enabled) { 78 | registerAutoImportTask(15).catch(() => {}); 79 | if (appState.current === "active" && !stopPollingRef.current) { 80 | stopPollingRef.current = startForegroundPolling(1000); 81 | setIsRunning(true); 82 | } 83 | autoImportSyncOnce().catch(() => {}); 84 | } 85 | 86 | const sub = AppState.addEventListener("change", (next: AppStateStatus) => { 87 | const prev = appState.current; 88 | appState.current = next; 89 | 90 | if (!enabled) return; 91 | 92 | if (next === "active" && !stopPollingRef.current) { 93 | stopPollingRef.current = startForegroundPolling(1000); 94 | setIsRunning(true); 95 | } else if (prev === "active" && next !== "active") { 96 | if (stopPollingRef.current) { 97 | stopPollingRef.current(); 98 | stopPollingRef.current = null; 99 | } 100 | setIsRunning(false); 101 | } 102 | }); 103 | 104 | return () => { 105 | sub.remove(); 106 | if (stopPollingRef.current) { 107 | stopPollingRef.current(); 108 | stopPollingRef.current = null; 109 | } 110 | setIsRunning(false); 111 | }; 112 | }, [enabled, loaded]); 113 | 114 | const ctx = React.useMemo( 115 | () => ({ enabled, setEnabled, isRunning }), 116 | [enabled, setEnabled, isRunning] 117 | ); 118 | 119 | return ( 120 | 121 | {children} 122 | 123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /lib/i18n/I18nContext.tsx: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import type { Locale } from "date-fns"; 3 | import { enUS, ja, ru, zhCN } from "date-fns/locale"; 4 | import * as Localization from "expo-localization"; 5 | import React, { 6 | createContext, 7 | useContext, 8 | useEffect, 9 | useMemo, 10 | useState, 11 | } from "react"; 12 | 13 | const dictionaries: Record = { 14 | en: require("@/assets/i18n/en.json"), 15 | ru: require("@/assets/i18n/ru.json"), 16 | ja: require("@/assets/i18n/ja.json"), 17 | zh: require("@/assets/i18n/zh.json"), 18 | }; 19 | 20 | export type AppLocale = "system" | "en" | "ru" | "zh" | "ja"; 21 | const LANG_KEY = "app_language"; 22 | 23 | function normalizeDeviceLocale(): "en" | "ru" | "zh" | "ja" { 24 | const tag = ( 25 | Localization.getLocales?.()[0]?.languageCode || "en" 26 | ).toLowerCase(); 27 | if (tag.startsWith("ru") || tag === "uk" || tag === "be") return "ru"; 28 | if (tag.startsWith("zh")) return "zh"; 29 | if (tag.startsWith("ja")) return "ja"; 30 | return "en"; 31 | } 32 | 33 | type I18nValue = { 34 | locale: AppLocale; 35 | resolved: "en" | "ru" | "zh" | "ja"; 36 | resolvedDateFns: Locale; 37 | setLocale: (l: AppLocale) => void; 38 | t: (key: string, params?: Record) => string; 39 | available: { code: AppLocale; label: string }[]; 40 | }; 41 | 42 | const I18nCtx = createContext(null); 43 | 44 | export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ 45 | children, 46 | }) => { 47 | const [locale, setLocale] = useState("system"); 48 | 49 | const resolved = useMemo( 50 | () => (locale === "system" ? normalizeDeviceLocale() : locale), 51 | [locale] 52 | ); 53 | 54 | const localeMap: Record<"en" | "ru" | "ja" | "zh", Locale> = { 55 | en: enUS, 56 | ru: ru, 57 | ja: ja, 58 | zh: zhCN, 59 | }; 60 | 61 | useEffect(() => { 62 | (async () => { 63 | try { 64 | const saved = await AsyncStorage.getItem(LANG_KEY); 65 | if ( 66 | saved === "en" || 67 | saved === "ru" || 68 | saved === "zh" || 69 | saved === "ja" || 70 | saved === "system" 71 | ) { 72 | setLocale(saved); 73 | } 74 | } catch {} 75 | })(); 76 | }, []); 77 | 78 | const dict = dictionaries[resolved] || dictionaries.en; 79 | const fallback = dictionaries.en; 80 | 81 | const t = useMemo( 82 | () => (key: string, params?: Record) => { 83 | const direct = (o: any, k: string) => 84 | o && Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined; 85 | 86 | const raw = 87 | direct(dict, key) ?? 88 | get(dict, key) ?? 89 | direct(fallback, key) ?? 90 | get(fallback, key) ?? 91 | key; 92 | 93 | if (!params) return String(raw); 94 | return String(raw).replace(/\{(\w+)\}/g, (_, name) => 95 | String(params[name] ?? "") 96 | ); 97 | }, 98 | [dict, fallback] 99 | ); 100 | 101 | const value = useMemo( 102 | () => ({ 103 | locale, 104 | resolved, 105 | resolvedDateFns: localeMap[resolved], 106 | setLocale: (l) => { 107 | setLocale(l); 108 | AsyncStorage.setItem(LANG_KEY, l).catch(() => {}); 109 | }, 110 | t, 111 | available: [ 112 | { code: "system", label: t("settings.language.system") }, 113 | { code: "en", label: t("settings.language.english") }, 114 | { code: "ru", label: t("settings.language.russian") }, 115 | { code: "zh", label: t("settings.language.chinese") }, 116 | { code: "ja", label: t("settings.language.japanese") }, 117 | ], 118 | }), 119 | [locale, resolved, t] 120 | ); 121 | 122 | return {children}; 123 | }; 124 | 125 | export function useI18n() { 126 | const ctx = useContext(I18nCtx); 127 | if (!ctx) throw new Error("useI18n must be used inside "); 128 | return ctx; 129 | } 130 | 131 | function get(obj: any, path: string): any { 132 | return path 133 | .split(".") 134 | .reduce((o, k) => (o && typeof o === "object" ? o[k] : undefined), obj); 135 | } 136 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nhappandroid", 3 | "main": "expo-router/entry", 4 | "version": "1.2.0", 5 | "scripts": { 6 | "sync-version": "node scripts/sync-version.js", 7 | "android-studio": "npm run sync-version && node ./scripts/sync-android-files.js overrides/android && npx expo prebuild && node ./scripts/prepare-android", 8 | "android-build": "npm run sync-version && npm run android-studio && npx react-native run-android --mode release", 9 | "android-sync": "node ./scripts/sync-android-files.js overrides/android", 10 | "move-apk": "node ./scripts/move-apk.js", 11 | "android-release:clean": "cd android && gradlew.bat clean", 12 | "android-release:assemble": "cd android && gradlew.bat assembleRelease", 13 | "android-release": "npm run android-release:clean && npm run android-release:assemble", 14 | "start": "expo start", 15 | "reset-project": "node ./scripts/reset-project.js", 16 | "androidStart": "expo start --android", 17 | "iosStart": "expo start --ios", 18 | "web": "expo start --web", 19 | "lint": "expo lint", 20 | "android": "expo run:android", 21 | "ios": "expo run:ios" 22 | }, 23 | "dependencies": { 24 | "@expo/vector-icons": "^15.0.2", 25 | "@likashefqet/react-native-image-zoom": "^4.3.0", 26 | "@react-native-async-storage/async-storage": "2.2.0", 27 | "@react-native-community/slider": "5.0.1", 28 | "@react-native-cookies/cookies": "^6.2.1", 29 | "@react-native-picker/picker": "2.11.1", 30 | "@react-navigation/bottom-tabs": "^7.3.10", 31 | "@react-native-community/netinfo": "^11.3.1", 32 | "@react-navigation/elements": "^2.3.8", 33 | "@react-navigation/native": "^7.1.6", 34 | "@shopify/flash-list": "2.0.2", 35 | "axios": "^1.10.0", 36 | "date-fns": "^4.1.0", 37 | "expo": "^54.0.2", 38 | "expo-application": "~7.0.7", 39 | "expo-blur": "~15.0.7", 40 | "expo-clipboard": "~8.0.7", 41 | "expo-constants": "~18.0.8", 42 | "expo-crypto": "~15.0.7", 43 | "expo-dev-client": "~6.0.12", 44 | "expo-document-picker": "~14.0.7", 45 | "expo-file-system": "~19.0.12", 46 | "expo-font": "~14.0.8", 47 | "expo-haptics": "~15.0.7", 48 | "expo-image": "~3.0.8", 49 | "expo-image-manipulator": "~14.0.7", 50 | "expo-intent-launcher": "~13.0.7", 51 | "expo-linear-gradient": "~15.0.7", 52 | "expo-linking": "~8.0.8", 53 | "expo-localization": "~17.0.7", 54 | "expo-navigation-bar": "~5.0.8", 55 | "expo-notifications": "~0.32.11", 56 | "expo-router": "~6.0.1", 57 | "expo-sharing": "~14.0.7", 58 | "expo-splash-screen": "~31.0.9", 59 | "expo-status-bar": "~3.0.8", 60 | "expo-symbols": "~1.0.7", 61 | "expo-system-ui": "~6.0.7", 62 | "expo-video": "~3.0.11", 63 | "expo-web-browser": "~15.0.7", 64 | "franc-min": "^6.2.0", 65 | "lucide-react-native": "^0.526.0", 66 | "nhentai-api": "^3.4.3", 67 | "react": "19.1.0", 68 | "react-dom": "19.1.0", 69 | "react-native": "0.81.4", 70 | "react-native-drawer-layout": "^4.1.12", 71 | "react-native-gesture-handler": "~2.28.0", 72 | "react-native-image-pan-zoom": "^2.1.12", 73 | "react-native-markdown-display": "^7.0.2", 74 | "react-native-pager-view": "6.9.1", 75 | "react-native-progress": "^5.0.1", 76 | "react-native-reanimated": "~4.1.0", 77 | "react-native-safe-area-context": "~5.6.0", 78 | "react-native-screens": "~4.16.0", 79 | "react-native-svg": "15.12.1", 80 | "react-native-system-setting": "^1.7.6", 81 | "react-native-tab-view": "^4.1.2", 82 | "react-native-turnstile": "^1.0.9", 83 | "react-native-web": "^0.21.0", 84 | "react-native-webview": "13.15.0", 85 | "expo-background-task": "~1.0.8", 86 | "expo-task-manager": "~14.0.8" 87 | }, 88 | "devDependencies": { 89 | "@babel/core": "^7.25.2", 90 | "@react-native-community/cli": "^19.1.0", 91 | "@react-native-community/cli-platform-android": "^19.1.0", 92 | "@svgr/cli": "^8.1.0", 93 | "@types/expo-localization": "^1.0.1", 94 | "@types/react": "~19.1.10", 95 | "@types/react-native": "^0.72.8", 96 | "eslint": "^9.25.0", 97 | "eslint-config-expo": "~10.0.0", 98 | "react-native-css-transformer": "^2.0.0", 99 | "react-native-svg-transformer": "^1.5.1", 100 | "typescript": "~5.9.2" 101 | }, 102 | "private": true 103 | } 104 | -------------------------------------------------------------------------------- /components/read/Overlays.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from "@/lib/i18n/I18nContext"; 2 | import React from "react"; 3 | import { StyleSheet, Text, View } from "react-native"; 4 | import Animated from "react-native-reanimated"; 5 | 6 | export function HintsOverlay({ 7 | visible, 8 | isPhone, 9 | uiVisible, 10 | phoneBottomInset, 11 | colors, 12 | hints, 13 | handSwap, 14 | }: { 15 | visible: boolean; 16 | isPhone: boolean; 17 | uiVisible: boolean; 18 | phoneBottomInset: number; 19 | colors: any; 20 | hints: { left: boolean; center: boolean; right: boolean }; 21 | handSwap: boolean; 22 | }) { 23 | const { t } = useI18n(); 24 | 25 | if (!visible || !(hints.left || hints.center || hints.right)) return null; 26 | 27 | return ( 28 | 32 | {hints.left && ( 33 | 46 | 47 | {!handSwap 48 | ? `${t("common.tapHere")} — ${t("common.back")}` 49 | : `${t("common.tapHere")} — ${t("common.nextPage")}`} 50 | 51 | 52 | )} 53 | {hints.center && ( 54 | 68 | 69 | {`${t("common.tapHere")} — ${t("reader.menuHint")}`} 70 | 71 | 72 | )} 73 | {hints.right && ( 74 | 87 | 88 | {!handSwap 89 | ? `${t("common.tapHere")} — ${t("common.nextPage")}` 90 | : `${t("common.tapHere")} — ${t("common.back")}`} 91 | 92 | 93 | )} 94 | 95 | ); 96 | } 97 | 98 | export function Banner({ 99 | banner, 100 | colors, 101 | animatedStyle, 102 | }: { 103 | banner: string | null; 104 | colors: any; 105 | animatedStyle: any; 106 | }) { 107 | if (!banner) return null; 108 | return ( 109 | 117 | 120 | {banner} 121 | 122 | 123 | ); 124 | } 125 | 126 | const styles = StyleSheet.create({ 127 | hintBox: { 128 | position: "absolute", 129 | top: 0, 130 | bottom: 0, 131 | borderWidth: StyleSheet.hairlineWidth, 132 | justifyContent: "center", 133 | alignItems: "center", 134 | paddingBottom: 10, 135 | }, 136 | hintText: { fontSize: 11, fontWeight: "800" }, 137 | banner: { 138 | position: "absolute", 139 | top: 12, 140 | left: 16, 141 | right: 16, 142 | alignSelf: "center", 143 | paddingVertical: 6, 144 | paddingHorizontal: 12, 145 | borderRadius: 999, 146 | borderWidth: StyleSheet.hairlineWidth, 147 | zIndex: 30, 148 | alignItems: "center", 149 | }, 150 | }); 151 | -------------------------------------------------------------------------------- /app/downloaded.tsx: -------------------------------------------------------------------------------- 1 | import * as FileSystem from "expo-file-system/legacy"; 2 | import { useFocusEffect, useRouter } from "expo-router"; 3 | import React, { useCallback, useState } from "react"; 4 | import { Text } from "react-native"; 5 | 6 | import { Book, BookPage } from "@/api/nhentai"; 7 | import BookList from "@/components/BookList"; 8 | import { useGridConfig } from "@/hooks/useGridConfig"; 9 | import { useI18n } from "@/lib/i18n/I18nContext"; 10 | 11 | export default function DownloadedScreen() { 12 | const [downloadedBooks, setDownloadedBooks] = useState([]); 13 | const [pending, setPending] = useState(true); 14 | const [refreshing, setRefreshing] = useState(false); 15 | const router = useRouter(); 16 | const gridConfig = useGridConfig(); 17 | const { t } = useI18n(); 18 | 19 | const fetchDownloadedBooks = useCallback(async () => { 20 | setPending(true); 21 | try { 22 | const nhDir = `${FileSystem.documentDirectory}NHAppAndroid/`; 23 | const exists = (await FileSystem.getInfoAsync(nhDir)).exists; 24 | if (!exists) { 25 | setDownloadedBooks([]); 26 | return; 27 | } 28 | 29 | const titles = await FileSystem.readDirectoryAsync(nhDir); 30 | const books: Book[] = []; 31 | 32 | for (const title of titles) { 33 | const titleDir = `${nhDir}${title}/`; 34 | const idMatch = title.match(/^(\d+)_/); 35 | const titleId = idMatch ? Number(idMatch[1]) : null; 36 | const langs = await FileSystem.readDirectoryAsync(titleDir); 37 | 38 | for (const lang of langs) { 39 | const langDir = `${titleDir}${lang}/`; 40 | const metaUri = `${langDir}metadata.json`; 41 | if ((await FileSystem.getInfoAsync(metaUri)).exists) { 42 | const raw = await FileSystem.readAsStringAsync(metaUri); 43 | const book: Book = JSON.parse(raw); 44 | if (titleId && book.id !== titleId) continue; 45 | 46 | const files = await FileSystem.readDirectoryAsync(langDir); 47 | const pages = files 48 | .filter((f) => f.startsWith("Image")) 49 | .map( 50 | (img, i): BookPage => ({ 51 | url: `${langDir}${img}`, 52 | urlThumb: `${langDir}${img}`, 53 | width: book.pages[i]?.width || 100, 54 | height: book.pages[i]?.height || 100, 55 | page: i + 1, 56 | }) 57 | ); 58 | books.push({ 59 | ...book, 60 | cover: pages[0]?.url || book.cover, 61 | thumbnail: pages[0]?.urlThumb || book.thumbnail, 62 | pages, 63 | }); 64 | } 65 | } 66 | } 67 | 68 | books.sort((a, b) => b.id - a.id); 69 | const unique = Array.from( 70 | books 71 | .reduce( 72 | (map, b) => (map.has(b.id) ? map : map.set(b.id, b)), 73 | new Map() 74 | ) 75 | .values() 76 | ); 77 | setDownloadedBooks(unique); 78 | } catch (e) { 79 | console.error("Error reading downloads:", e); 80 | setDownloadedBooks([]); 81 | } finally { 82 | setPending(false); 83 | } 84 | }, []); 85 | 86 | useFocusEffect( 87 | useCallback(() => { 88 | fetchDownloadedBooks(); 89 | }, [fetchDownloadedBooks]) 90 | ); 91 | 92 | const onRefresh = useCallback(async () => { 93 | setRefreshing(true); 94 | await fetchDownloadedBooks(); 95 | setRefreshing(false); 96 | }, [fetchDownloadedBooks]); 97 | 98 | return ( 99 | 105 | router.push({ 106 | pathname: "/book/[id]", 107 | params: { 108 | id: String(id), 109 | title: downloadedBooks.find((b) => b.id === id)?.title.pretty, 110 | }, 111 | }) 112 | } 113 | ListEmptyComponent={ 114 | !pending && downloadedBooks.length === 0 ? ( 115 | 116 | {t("downloaded.noHaveADownloadBook")} 117 | 118 | ) : null 119 | } 120 | gridConfig={{ default: gridConfig }} 121 | /> 122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /app/favorites.tsx: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import { useFocusEffect, useRouter } from "expo-router"; 3 | import React, { useCallback, useEffect, useState } from "react"; 4 | import { StyleSheet, Text, View } from "react-native"; 5 | 6 | import { Book, getFavorites } from "@/api/nhentai"; 7 | import BookList from "@/components/BookList"; 8 | import { useGridConfig } from "@/hooks/useGridConfig"; 9 | import { useTheme } from "@/lib/ThemeContext"; 10 | 11 | export default function FavoritesScreen() { 12 | const { colors } = useTheme(); 13 | const [books, setBooks] = useState([]); 14 | const [ids, setIds] = useState([]); 15 | const [favorites, setFavorites] = useState>(new Set()); 16 | const [page, setPage] = useState(1); 17 | const [totalPages, setTotalPages] = useState(1); 18 | const [refreshing, setRefreshing] = useState(false); 19 | const router = useRouter(); 20 | const gridConfig = useGridConfig(); 21 | 22 | const loadFavoriteIds = useCallback(() => { 23 | AsyncStorage.getItem("bookFavorites").then((j) => { 24 | const list = j ? (JSON.parse(j) as number[]) : []; 25 | setIds(list); 26 | setFavorites(new Set(list)); 27 | }); 28 | }, []); 29 | 30 | useEffect(loadFavoriteIds, [loadFavoriteIds]); 31 | useFocusEffect(loadFavoriteIds); 32 | 33 | const loadBooks = useCallback( 34 | async (pageNum: number, perPage: number = 200) => { 35 | if (ids.length === 0) { 36 | setBooks([]); 37 | setTotalPages(1); 38 | return; 39 | } 40 | const start = (pageNum - 1) * perPage; 41 | const pageIds = ids.slice(start, start + perPage); 42 | if (pageIds.length === 0) return; 43 | 44 | try { 45 | const { books: fetched, totalPages: tp } = await getFavorites({ 46 | ids: pageIds, 47 | perPage, 48 | }); 49 | const ordered = pageIds 50 | .slice() 51 | .reverse() 52 | .map((id) => fetched.find((b: { id: number; }) => b.id === id)) 53 | .filter((b): b is Book => !!b); 54 | setBooks((prev) => (pageNum === 1 ? ordered : [...prev, ...ordered])); 55 | setTotalPages(tp); 56 | setPage(pageNum); 57 | } catch (e) { 58 | console.error("Failed loading favorites:", e); 59 | } 60 | }, 61 | [ids] 62 | ); 63 | 64 | useEffect(() => { 65 | loadBooks(1); 66 | }, [ids, loadBooks]); 67 | 68 | const handleLoadMore = () => { 69 | if (page < totalPages) { 70 | loadBooks(page + 1); 71 | } 72 | }; 73 | 74 | const onRefresh = useCallback(async () => { 75 | setRefreshing(true); 76 | await loadBooks(1); 77 | setRefreshing(false); 78 | }, [loadBooks]); 79 | 80 | const toggleFavorite = useCallback((id: number, next: boolean) => { 81 | setFavorites((prev) => { 82 | const copy = new Set(prev); 83 | if (next) { 84 | copy.add(id); 85 | const newList = [...copy]; 86 | setIds(newList); 87 | AsyncStorage.setItem("bookFavorites", JSON.stringify(newList)); 88 | } else { 89 | copy.delete(id); 90 | setBooks((prevBooks) => prevBooks.filter((b) => b.id !== id)); 91 | const newList = [...copy]; 92 | setIds(newList); 93 | AsyncStorage.setItem("bookFavorites", JSON.stringify(newList)); 94 | } 95 | return copy; 96 | }); 97 | }, []); 98 | 99 | return ( 100 | 101 | 0 && books.length === 0} 104 | refreshing={refreshing} 105 | onRefresh={onRefresh} 106 | onEndReached={handleLoadMore} 107 | isFavorite={(id) => favorites.has(id)} 108 | onToggleFavorite={toggleFavorite} 109 | onPress={(id) => 110 | router.push({ pathname: "/book/[id]", params: { id: String(id), title: books.find(b => b.id === id)?.title.pretty } }) 111 | } 112 | ListEmptyComponent={ 113 | ids.length === 0 ? ( 114 | 117 | Ещё нет избранного 118 | 119 | ) : null 120 | } 121 | gridConfig={{ default: gridConfig }} 122 | /> 123 | 124 | ); 125 | } 126 | 127 | const styles = StyleSheet.create({ flex: { flex: 1 } }); 128 | -------------------------------------------------------------------------------- /context/TagFilterContext.tsx: -------------------------------------------------------------------------------- 1 | import { Tag } from "@/api/nhentai"; 2 | import AsyncStorage from "@react-native-async-storage/async-storage"; 3 | import React, { 4 | createContext, 5 | useCallback, 6 | useContext, 7 | useEffect, 8 | useMemo, 9 | useRef, 10 | useState, 11 | } from "react"; 12 | 13 | const KEY = "globalTagFilter.v3"; 14 | 15 | export type TagMode = "include" | "exclude"; 16 | export interface FilterItem { 17 | type: Tag["type"]; 18 | name: string; 19 | mode: TagMode; 20 | } 21 | type ModeMap = Record; 22 | 23 | interface Ctx { 24 | filters: FilterItem[]; 25 | cycle: (t: { type: string; name: string }) => void; 26 | clear: () => void; 27 | includes: FilterItem[]; 28 | excludes: FilterItem[]; 29 | filtersReady: boolean; 30 | lastChangedKey: string | null; 31 | epoch: number; 32 | modeOf: (type: string, name: string) => TagMode | undefined; 33 | } 34 | 35 | const TagCtx = createContext({ 36 | filters: [], 37 | cycle: () => {}, 38 | clear: () => {}, 39 | includes: [], 40 | excludes: [], 41 | filtersReady: false, 42 | lastChangedKey: null, 43 | epoch: 0, 44 | modeOf: () => undefined, 45 | }); 46 | 47 | export function useFilterTags() { 48 | return useContext(TagCtx); 49 | } 50 | 51 | const keyOf = (t: { type: string; name: string }) => `${t.type}:${t.name}`; 52 | 53 | export function TagProvider({ children }: { children: React.ReactNode }) { 54 | const [filters, setFilters] = useState([]); 55 | const [filtersReady, setFiltersReady] = useState(false); 56 | const [lastChangedKey, setLastChangedKey] = useState(null); 57 | const [epoch, setEpoch] = useState(0); 58 | 59 | const [modeMap, setModeMap] = useState({}); 60 | const modeMapRef = useRef(modeMap); 61 | useEffect(() => { 62 | modeMapRef.current = modeMap; 63 | }, [modeMap]); 64 | 65 | const includes = useMemo( 66 | () => filters.filter((f) => f.mode === "include"), 67 | [filters] 68 | ); 69 | const excludes = useMemo( 70 | () => filters.filter((f) => f.mode === "exclude"), 71 | [filters] 72 | ); 73 | 74 | useEffect(() => { 75 | AsyncStorage.getItem(KEY) 76 | .then((j) => { 77 | if (!j) return; 78 | const arr = JSON.parse(j) as FilterItem[]; 79 | setFilters(arr); 80 | const mm: ModeMap = {}; 81 | for (const f of arr) mm[keyOf(f)] = f.mode; 82 | setModeMap(mm); 83 | }) 84 | .finally(() => setFiltersReady(true)); 85 | }, []); 86 | 87 | const saveTimer = useRef | null>(null); 88 | useEffect(() => { 89 | if (!filtersReady) return; 90 | if (saveTimer.current) clearTimeout(saveTimer.current); 91 | saveTimer.current = global.setTimeout(() => { 92 | AsyncStorage.setItem(KEY, JSON.stringify(filters)).catch(() => {}); 93 | }, 150); 94 | return () => { 95 | if (saveTimer.current) clearTimeout(saveTimer.current); 96 | }; 97 | }, [filters, filtersReady]); 98 | 99 | const modeOf = useCallback( 100 | (type: string, name: string) => modeMapRef.current[`${type}:${name}`], 101 | [] 102 | ); 103 | 104 | const cycle = useCallback((t: { type: string; name: string }) => { 105 | const k = keyOf(t); 106 | 107 | setEpoch((e) => e + 1); 108 | setLastChangedKey(`${k}:${Date.now()}`); 109 | 110 | setFilters((prev) => { 111 | const idx = prev.findIndex((x) => x.type === t.type && x.name === t.name); 112 | if (idx === -1) { 113 | setModeMap((m) => ({ ...m, [k]: "include" })); 114 | return [...prev, { ...t, mode: "include" }]; 115 | } 116 | const cur = prev[idx]; 117 | if (cur.mode === "include") { 118 | setModeMap((m) => ({ ...m, [k]: "exclude" })); 119 | const next = prev.slice(); 120 | next[idx] = { ...cur, mode: "exclude" }; 121 | return next; 122 | } 123 | setModeMap((m) => { 124 | const n = { ...m }; 125 | delete n[k]; 126 | return n; 127 | }); 128 | const cp = prev.slice(); 129 | cp.splice(idx, 1); 130 | return cp; 131 | }); 132 | }, []); 133 | 134 | const clear = useCallback(() => { 135 | setFilters([]); 136 | setModeMap({}); 137 | setEpoch((e) => e + 1); 138 | setLastChangedKey(null); 139 | }, []); 140 | 141 | return ( 142 | 155 | {children} 156 | 157 | ); 158 | } 159 | -------------------------------------------------------------------------------- /components/read/ControlsDesktop.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | import { IconBtn, ToggleBtn } from "./Buttons"; 4 | 5 | export function ControlsDesktop({ 6 | colors, 7 | canDual, 8 | settings, 9 | setOrientation, 10 | toggleDual, 11 | toggleFit, 12 | tapFlipEnabled, 13 | toggleTapFlip, 14 | handSwap, 15 | toggleHandSwap, 16 | inspect, 17 | toggleInspect, 18 | jumpPrev, 19 | jumpNext, 20 | onBack, 21 | isSingleFrame, 22 | continuous, 23 | toggleContinuous, 24 | }: { 25 | colors: any; 26 | canDual: boolean; 27 | settings: { 28 | orientation: "vertical" | "horizontal"; 29 | dualInLandscape: boolean; 30 | fit: "contain" | "cover"; 31 | }; 32 | setOrientation: (o: "vertical" | "horizontal") => void; 33 | toggleDual: () => void; 34 | toggleFit: () => void; 35 | tapFlipEnabled: boolean; 36 | toggleTapFlip: () => void; 37 | handSwap: boolean; 38 | toggleHandSwap: () => void; 39 | inspect: boolean; 40 | toggleInspect: () => void; 41 | jumpPrev: () => void; 42 | jumpNext: () => void; 43 | onBack: () => void; 44 | isSingleFrame: boolean; 45 | continuous: boolean; 46 | toggleContinuous: () => void; 47 | }) { 48 | if (continuous) { 49 | return ( 50 | 56 | 61 | 68 | 69 | ); 70 | } 71 | 72 | return ( 73 | 79 | 84 | 89 | 94 | 95 | 102 | 109 | 111 | setOrientation( 112 | settings.orientation === "vertical" ? "horizontal" : "vertical" 113 | ) 114 | } 115 | name={ 116 | settings.orientation === "vertical" ? "arrow-down" : "arrow-right" 117 | } 118 | color={colors.searchTxt} 119 | /> 120 | {canDual && ( 121 | 128 | )} 129 | 134 | {isSingleFrame && ( 135 | 142 | )} 143 | 150 | 151 | ); 152 | } 153 | 154 | const styles = StyleSheet.create({ 155 | topLeftBar: { 156 | position: "absolute", 157 | top: 8, 158 | left: 8, 159 | borderRadius: 14, 160 | borderWidth: StyleSheet.hairlineWidth, 161 | paddingHorizontal: 8, 162 | paddingVertical: 6, 163 | flexDirection: "row", 164 | alignItems: "center", 165 | gap: 6, 166 | zIndex: 1000, 167 | elevation: 12, 168 | }, 169 | divider: { width: 1, height: 18, opacity: 0.5, marginHorizontal: 2 }, 170 | }); 171 | -------------------------------------------------------------------------------- /app/history.tsx: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import { useFocusEffect, useRouter } from "expo-router"; 3 | import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; 4 | import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; 5 | 6 | import { Book, getFavorites } from "@/api/nhentai"; 7 | import BookListHistory, { READ_HISTORY_KEY, ReadHistoryEntry } from "@/components/BookListHistory"; 8 | import { useGridConfig } from "@/hooks/useGridConfig"; 9 | import { useTheme } from "@/lib/ThemeContext"; 10 | 11 | const PER_PAGE = 2000; 12 | 13 | export default function HistoryScreen() { 14 | const { colors } = useTheme(); 15 | const router = useRouter(); 16 | const gridConfig = useGridConfig(); 17 | 18 | const [books, setBooks] = useState([]); 19 | const [ids, setIds] = useState([]); 20 | const [histIndex, setHistIndex] = useState>({}); 21 | const [page, setPage] = useState(1); 22 | const [refreshing, setRefreshing] = useState(false); 23 | const [isLoadingMore, setIsLoadingMore] = useState(false); 24 | 25 | const totalPages = useMemo(() => Math.max(1, Math.ceil(ids.length / PER_PAGE)), [ids.length]); 26 | 27 | const loadHistoryIndex = useCallback(async () => { 28 | const raw = await AsyncStorage.getItem(READ_HISTORY_KEY); 29 | if (!raw) { 30 | setIds([]); 31 | setHistIndex({}); 32 | return; 33 | } 34 | let parsed: unknown; 35 | try { 36 | parsed = JSON.parse(raw); 37 | } catch { 38 | setIds([]); 39 | setHistIndex({}); 40 | return; 41 | } 42 | const arr = Array.isArray(parsed) ? (parsed as ReadHistoryEntry[]) : []; 43 | const byId = new Map(); 44 | for (const e of arr) { 45 | if (!e || !Array.isArray(e)) continue; 46 | const [id, curr, total, ts] = e; 47 | const prev = byId.get(id); 48 | if (!prev || (prev[3] || 0) < (ts || 0)) byId.set(id, [id, curr, total, ts]); 49 | } 50 | const sortedIds = [...byId.values()].sort((a, b) => (b[3] || 0) - (a[3] || 0)).map((e) => e[0]); 51 | const indexObj: Record = {}; 52 | for (const [id, entry] of byId) indexObj[id] = entry; 53 | setIds(sortedIds); 54 | setHistIndex(indexObj); 55 | }, []); 56 | 57 | useEffect(() => { 58 | loadHistoryIndex(); 59 | }, [loadHistoryIndex]); 60 | 61 | useFocusEffect( 62 | useCallback(() => { 63 | loadHistoryIndex(); 64 | }, [loadHistoryIndex]) 65 | ); 66 | 67 | const reqIdRef = useRef(0); 68 | 69 | const loadBooks = useCallback( 70 | async (pageNum: number) => { 71 | if (ids.length === 0) { 72 | setBooks([]); 73 | setPage(1); 74 | return; 75 | } 76 | const start = (pageNum - 1) * PER_PAGE; 77 | const pageIds = ids.slice(start, start + PER_PAGE); 78 | if (pageIds.length === 0) return; 79 | 80 | const myReq = ++reqIdRef.current; 81 | if (pageNum === 1) setBooks([]); 82 | if (pageNum > 1) setIsLoadingMore(true); 83 | 84 | try { 85 | const { books: fetched } = await getFavorites({ ids: pageIds, perPage: PER_PAGE }); 86 | if (reqIdRef.current !== myReq) return; 87 | const ordered = pageIds.map((id) => fetched.find((b) => b.id === id)).filter((b): b is Book => !!b); 88 | setBooks((prev) => (pageNum === 1 ? ordered : [...prev, ...ordered])); 89 | setPage(pageNum); 90 | } catch { 91 | } finally { 92 | if (pageNum > 1) setIsLoadingMore(false); 93 | } 94 | }, 95 | [ids] 96 | ); 97 | 98 | useEffect(() => { 99 | loadBooks(1); 100 | }, [ids, loadBooks]); 101 | 102 | const handleLoadMore = useCallback(() => { 103 | if (isLoadingMore) return; 104 | if (page >= totalPages) return; 105 | loadBooks(page + 1); 106 | }, [isLoadingMore, page, totalPages, loadBooks]); 107 | 108 | const onRefresh = useCallback(async () => { 109 | setRefreshing(true); 110 | await loadHistoryIndex(); 111 | await loadBooks(1); 112 | setRefreshing(false); 113 | }, [loadHistoryIndex, loadBooks]); 114 | 115 | const footer = useMemo(() => (isLoadingMore ? : null), [isLoadingMore]); 116 | 117 | const initialLoading = ids.length > 0 && books.length === 0 && !refreshing; 118 | 119 | return ( 120 | 121 | 129 | router.push({ 130 | pathname: "/book/[id]", 131 | params: { id: String(id), title: books.find((b) => b.id === id)?.title.pretty }, 132 | }) 133 | } 134 | ListEmptyComponent={ids.length === 0 ? История пуста : null} 135 | ListFooterComponent={footer} 136 | gridConfig={{ default: gridConfig }} 137 | /> 138 | 139 | ); 140 | } 141 | 142 | const styles = StyleSheet.create({ flex: { flex: 1 } }); 143 | -------------------------------------------------------------------------------- /hooks/useAuthBridge.ts: -------------------------------------------------------------------------------- 1 | import { 2 | hasNativeCookieJar, 3 | loadTokens, 4 | logout, 5 | setManualTokens, 6 | syncNativeCookiesFromJar, 7 | } from "@/api/auth"; 8 | import { getMe, type Me } from "@/api/nhentaiOnline"; 9 | import * as Clipboard from "expo-clipboard"; 10 | import Constants from "expo-constants"; 11 | import React from "react"; 12 | import { WebViewMessageEvent, WebViewNavigation } from "react-native-webview"; 13 | 14 | export type TokensState = { csrftoken?: string; sessionid?: string }; 15 | 16 | export function useAuthBridge(t: (k: string, p?: any) => string) { 17 | const [tokens, setTokens] = React.useState({}); 18 | const [me, setMe] = React.useState(null); 19 | const [status, setStatus] = React.useState(""); 20 | 21 | const [csrfInput, setCsrfInput] = React.useState(""); 22 | const [sessInput, setSessInput] = React.useState(""); 23 | 24 | const [wvBusy, setWvBusy] = React.useState(false); 25 | 26 | const canUseNativeJar = hasNativeCookieJar(); 27 | const isExpoGo = Constants.appOwnership === "expo"; 28 | 29 | React.useEffect(() => { 30 | (async () => { 31 | const tks = await loadTokens(); 32 | setTokens(tks); 33 | setCsrfInput(tks.csrftoken ?? ""); 34 | setSessInput(tks.sessionid ?? ""); 35 | try { 36 | const m = await getMe(); 37 | if (m) setMe(m); 38 | } catch {} 39 | })(); 40 | }, []); 41 | 42 | const fetchMeAndMaybeClose = React.useCallback( 43 | async (why: string) => { 44 | try { 45 | const m = await getMe(); 46 | if (m) { 47 | setMe(m); 48 | setStatus(t("login.status.signedAs", { user: m.username, why })); 49 | } else { 50 | setStatus(t("login.status.notSigned", { why })); 51 | } 52 | } catch { 53 | setStatus(t("login.status.notSigned", { why })); 54 | } 55 | }, 56 | [t] 57 | ); 58 | 59 | const refreshTokensFromJar = React.useCallback( 60 | async (reason: string) => { 61 | if (!canUseNativeJar) return; 62 | try { 63 | const synced = await syncNativeCookiesFromJar(); 64 | setTokens(synced); 65 | if (synced.csrftoken) setCsrfInput(synced.csrftoken); 66 | if (synced.sessionid) setSessInput(synced.sessionid); 67 | setStatus(t("login.status.cookiesSynced", { reason })); 68 | await fetchMeAndMaybeClose("cookies"); 69 | } catch (e) { 70 | setStatus(t("login.status.cookiesFailed", { reason })); 71 | console.log("[auth] sync error:", e); 72 | } 73 | }, 74 | [canUseNativeJar, fetchMeAndMaybeClose, t] 75 | ); 76 | 77 | const applyManual = React.useCallback( 78 | async (nextCsrf: string, nextSess: string) => { 79 | await setManualTokens( 80 | nextCsrf?.trim() || undefined, 81 | nextSess?.trim() || undefined 82 | ); 83 | const curr = await loadTokens(); 84 | setTokens(curr); 85 | setStatus(t("login.status.tokensSaved")); 86 | await fetchMeAndMaybeClose("manual"); 87 | }, 88 | [fetchMeAndMaybeClose, t] 89 | ); 90 | 91 | const doLogout = React.useCallback(async () => { 92 | await logout(); 93 | const curr = await loadTokens(); 94 | setTokens(curr); 95 | setMe(null); 96 | setCsrfInput(""); 97 | setSessInput(""); 98 | setStatus(t("login.status.loggedOut")); 99 | }, [setTokens, setMe, t]); 100 | 101 | const onWvMessage = React.useCallback( 102 | async (ev: WebViewMessageEvent) => { 103 | try { 104 | const data = JSON.parse((ev as any)?.nativeEvent?.data); 105 | if (data?.type === "cookies") { 106 | const cookies = data.cookies || {}; 107 | const csrf: string | undefined = 108 | typeof cookies.csrftoken === "string" 109 | ? cookies.csrftoken 110 | : undefined; 111 | if (csrf) { 112 | await setManualTokens(csrf, undefined); 113 | const now = await loadTokens(); 114 | setTokens(now); 115 | setCsrfInput(now.csrftoken ?? ""); 116 | } 117 | if (canUseNativeJar) { 118 | await refreshTokensFromJar("wv-msg"); 119 | } else { 120 | await fetchMeAndMaybeClose("webview"); 121 | } 122 | } 123 | } catch {} 124 | }, 125 | [canUseNativeJar, refreshTokensFromJar, fetchMeAndMaybeClose] 126 | ); 127 | 128 | const handleNavChange = React.useCallback( 129 | (_navState: WebViewNavigation) => { 130 | setStatus(t("login.status.navigating")); 131 | if (canUseNativeJar) refreshTokensFromJar("nav"); 132 | }, 133 | [canUseNativeJar, refreshTokensFromJar, t] 134 | ); 135 | 136 | const copy = React.useCallback(async (text: string) => { 137 | try { 138 | await Clipboard.setStringAsync(text); 139 | } catch {} 140 | }, []); 141 | 142 | return { 143 | tokens, 144 | me, 145 | status, 146 | csrfInput, 147 | setCsrfInput, 148 | sessInput, 149 | setSessInput, 150 | wvBusy, 151 | setWvBusy, 152 | canUseNativeJar, 153 | isExpoGo, 154 | fetchMeAndMaybeClose, 155 | refreshTokensFromJar, 156 | applyManual, 157 | doLogout, 158 | onWvMessage, 159 | handleNavChange, 160 | copy, 161 | }; 162 | } 163 | -------------------------------------------------------------------------------- /components/read/ControlsMobile.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from "@/lib/i18n/I18nContext"; 2 | import React from "react"; 3 | import { StyleSheet, View } from "react-native"; 4 | import { RowBtn, RowToggle } from "./Buttons"; 5 | 6 | export function ControlsMobile({ 7 | colors, 8 | canDual, 9 | settings, 10 | setOrientation, 11 | toggleDual, 12 | toggleFit, 13 | tapFlipEnabled, 14 | toggleTapFlip, 15 | handSwap, 16 | toggleHandSwap, 17 | inspect, 18 | toggleInspect, 19 | onBack, 20 | isSingleFrame, 21 | continuous, 22 | toggleContinuous, 23 | }: { 24 | colors: any; 25 | canDual: boolean; 26 | settings: { 27 | orientation: "vertical" | "horizontal"; 28 | dualInLandscape: boolean; 29 | fit: "contain" | "cover"; 30 | }; 31 | setOrientation: (o: "vertical" | "horizontal") => void; 32 | toggleDual: () => void; 33 | toggleFit: () => void; 34 | tapFlipEnabled: boolean; 35 | toggleTapFlip: () => void; 36 | handSwap: boolean; 37 | toggleHandSwap: () => void; 38 | inspect: boolean; 39 | toggleInspect: () => void; 40 | onBack: () => void; 41 | isSingleFrame: boolean; 42 | continuous: boolean; 43 | toggleContinuous: () => void; 44 | }) { 45 | const { t } = useI18n(); 46 | const inspectDisabled = !isSingleFrame; 47 | 48 | if (continuous) { 49 | return ( 50 | 56 | 57 | 63 | 64 | 65 | 66 | 74 | 75 | 76 | ); 77 | } 78 | 79 | return ( 80 | 86 | 87 | 93 | 94 | 95 | 96 | 104 | 105 | 106 | 107 | 115 | 116 | 117 | 118 | 120 | setOrientation( 121 | settings.orientation === "vertical" ? "horizontal" : "vertical" 122 | ) 123 | } 124 | icon={ 125 | settings.orientation === "vertical" ? "arrow-down" : "arrow-right" 126 | } 127 | label={t("reader.controls.orientation")} 128 | color={colors.searchTxt} 129 | /> 130 | 131 | 132 | 133 | 143 | 144 | 145 | 146 | { 149 | if (!inspectDisabled) toggleInspect(); 150 | }} 151 | icon="search" 152 | label={t("reader.controls.inspect")} 153 | color={colors.searchTxt} 154 | activeColor={colors.accent} 155 | /> 156 | 157 | 158 | 159 | 167 | 168 | 169 | ); 170 | } 171 | 172 | const styles = StyleSheet.create({ 173 | bottomBar: { 174 | position: "absolute", 175 | left: 8, 176 | right: 8, 177 | bottom: 8 + 28 + 8, 178 | borderRadius: 14, 179 | borderWidth: StyleSheet.hairlineWidth, 180 | paddingHorizontal: 6, 181 | paddingVertical: 6, 182 | flexDirection: "row", 183 | alignItems: "center", 184 | justifyContent: "space-between", 185 | gap: 0, 186 | zIndex: 19, 187 | }, 188 | slot: { 189 | alignItems: "center", 190 | minWidth: 0, 191 | marginHorizontal: 0, 192 | }, 193 | }); 194 | -------------------------------------------------------------------------------- /components/SideMenu/LoginModal.tsx: -------------------------------------------------------------------------------- 1 | import { LOGIN_URL } from "@/api/auth"; 2 | import { IconBtn } from "@/components/ui/IconBtn"; 3 | import { Feather } from "@expo/vector-icons"; 4 | import React from "react"; 5 | import { ActivityIndicator, Modal, Text, View } from "react-native"; 6 | import { WebView } from "react-native-webview"; 7 | 8 | const injected = ` 9 | (function () { 10 | function getCookieMap() { 11 | var out = {}; 12 | try { 13 | (document.cookie || "").split(";").forEach(function (p) { 14 | var kv = p.split("="); 15 | if (!kv[0]) return; 16 | var k = kv[0].trim(); 17 | var v = (kv[1] || "").trim(); 18 | if (k === "csrftoken" || k === "sessionid") out[k] = v; 19 | }); 20 | } catch (e) {} 21 | return out; 22 | } 23 | var last = ""; 24 | function tick() { 25 | try { 26 | var raw = document.cookie || ""; 27 | if (raw !== last) { 28 | last = raw; 29 | var m = getCookieMap(); 30 | if (m.csrftoken || m.sessionid) { 31 | window.ReactNativeWebView && 32 | window.ReactNativeWebView.postMessage( 33 | JSON.stringify({ type: "cookies", cookies: m, href: location.href }) 34 | ); 35 | } 36 | } 37 | } catch (e) {} 38 | setTimeout(tick, 700); 39 | } 40 | tick(); 41 | })(); 42 | true; 43 | `; 44 | 45 | export type LoginModalProps = { 46 | visible: boolean; 47 | onRequestClose: () => void; 48 | colors: any; 49 | t: (k: string, p?: any) => string; 50 | 51 | canUseNativeJar: boolean; 52 | isExpoGo: boolean; 53 | wvBusy: boolean; 54 | setWvBusy: (b: boolean) => void; 55 | 56 | csrfInput: string; 57 | setCsrfInput: (s: string) => void; 58 | sessInput: string; 59 | setSessInput: (s: string) => void; 60 | 61 | applyManual: (csrf: string, sess: string) => Promise; 62 | refreshTokensFromJar: (reason: string) => Promise; 63 | fetchMeAndMaybeClose: (why: string) => Promise; 64 | handleNavChange: any; 65 | onWvMessage: any; 66 | }; 67 | 68 | export function LoginModal(props: LoginModalProps) { 69 | const { 70 | visible, 71 | onRequestClose, 72 | colors, 73 | t, 74 | canUseNativeJar, 75 | isExpoGo, 76 | wvBusy, 77 | setWvBusy, 78 | csrfInput, 79 | setCsrfInput, 80 | sessInput, 81 | setSessInput, 82 | applyManual, 83 | refreshTokensFromJar, 84 | fetchMeAndMaybeClose, 85 | handleNavChange, 86 | onWvMessage, 87 | } = props; 88 | 89 | const showManualInputs = !canUseNativeJar || isExpoGo; 90 | 91 | return ( 92 | 98 | 99 | 107 | 110 | {t("menu.login")} 111 | 112 | 113 | {canUseNativeJar && ( 114 | refreshTokensFromJar("manual")} 118 | size={36} 119 | accessibilityLabel={t("login.sync")} 120 | > 121 | 122 | 123 | )} 124 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | setWvBusy(true)} 141 | onLoadEnd={async () => { 142 | setWvBusy(false); 143 | if (canUseNativeJar) await refreshTokensFromJar("loadEnd"); 144 | await fetchMeAndMaybeClose("loadEnd"); 145 | }} 146 | onLoadProgress={(e) => { 147 | if (canUseNativeJar && e?.nativeEvent?.progress >= 0.6) { 148 | refreshTokensFromJar("progress"); 149 | } 150 | }} 151 | onNavigationStateChange={handleNavChange} 152 | onMessage={onWvMessage} 153 | injectedJavaScript={injected} 154 | sharedCookiesEnabled 155 | thirdPartyCookiesEnabled 156 | startInLoadingState 157 | renderLoading={() => ( 158 | 159 | 160 | 161 | )} 162 | allowsBackForwardNavigationGestures 163 | style={{ flex: 1 }} 164 | /> 165 | 166 | 167 | 170 | {wvBusy ? t("login.loading") : t("login.ready")} 171 | 172 | 173 | 174 | 175 | ); 176 | } 177 | -------------------------------------------------------------------------------- /hooks/book/useDownload.ts: -------------------------------------------------------------------------------- 1 | import type { Book } from "@/api/nhentai"; 2 | import { sanitize } from "@/utils/book/sanitize"; 3 | import { useThrottle } from "@/utils/book/useThrottle"; 4 | import * as FileSystem from "expo-file-system/legacy"; 5 | import { useRouter } from "expo-router"; 6 | import { useCallback, useRef, useState } from "react"; 7 | import { Platform, ToastAndroid } from "react-native"; 8 | 9 | export const useDownload = ( 10 | book: Book | null, 11 | local: boolean, 12 | setLocal: (v: boolean) => void, 13 | setBook: (updater: any) => void 14 | ) => { 15 | const router = useRouter(); 16 | const [dl, setDL] = useState(false); 17 | const [pr, setPr] = useState(0); 18 | const setPrThrottled = useThrottle((v: number) => setPr(v), 120); 19 | 20 | const currentDL = useRef(null); 21 | const cancelReq = useRef(false); 22 | 23 | const cancel = useCallback(async () => { 24 | if (!dl) return; 25 | cancelReq.current = true; 26 | try { 27 | await currentDL.current?.pauseAsync().catch(() => {}); 28 | } finally { 29 | } 30 | }, [dl]); 31 | 32 | const handleDownloadOrDelete = useCallback(async () => { 33 | if (!book || dl) return; 34 | 35 | const lang = book.languages?.[0]?.name ?? "Unknown"; 36 | const title = sanitize(book.title.pretty); 37 | const dir = `${FileSystem.documentDirectory}NHAppAndroid/${ 38 | book.id 39 | }_${title}/${sanitize(lang)}/`; 40 | 41 | setDL(true); 42 | setPr(0); 43 | cancelReq.current = false; 44 | 45 | try { 46 | if (local) { 47 | const nhDir = `${FileSystem.documentDirectory}NHAppAndroid/`; 48 | const titles = await FileSystem.readDirectoryAsync(nhDir); 49 | 50 | for (const t of titles) { 51 | const titleDir = `${nhDir}${t}/`; 52 | const langs = await FileSystem.readDirectoryAsync(titleDir); 53 | for (const l of langs) { 54 | const langDir = `${titleDir}${l}/`; 55 | const metaUri = `${langDir}metadata.json`; 56 | const info = await FileSystem.getInfoAsync(metaUri); 57 | if (!info.exists) continue; 58 | try { 59 | const raw = await FileSystem.readAsStringAsync(metaUri); 60 | const meta = JSON.parse(raw); 61 | if (meta.id !== book.id) continue; 62 | await FileSystem.deleteAsync(titleDir, { idempotent: true }); 63 | if (Platform.OS === "android") 64 | ToastAndroid.show("Deleted", ToastAndroid.SHORT); 65 | setLocal(false); 66 | setBook(null); 67 | router.back(); 68 | return; 69 | } catch {} 70 | } 71 | } 72 | if (Platform.OS === "android") 73 | ToastAndroid.show("Book not found locally", ToastAndroid.SHORT); 74 | return; 75 | } 76 | 77 | await FileSystem.makeDirectoryAsync(dir, { intermediates: true }); 78 | const total = book.pages.length; 79 | const pagesCopy = [...book.pages]; 80 | 81 | for (let i = 0; i < total; i++) { 82 | if (cancelReq.current) throw new Error("__CANCELLED__"); 83 | 84 | const p = pagesCopy[i]; 85 | const num = (i + 1).toString().padStart(3, "0"); 86 | const ext = p.url.split(".").pop()!.split("?")[0]; 87 | const uri = `${dir}Image${num}.${ext}`; 88 | 89 | const exists = (await FileSystem.getInfoAsync(uri)).exists; 90 | if (!exists) { 91 | const dlObj = FileSystem.createDownloadResumable( 92 | p.url, 93 | uri, 94 | {}, 95 | ({ totalBytesWritten, totalBytesExpectedToWrite }) => { 96 | if (totalBytesExpectedToWrite > 0) { 97 | } 98 | } 99 | ); 100 | currentDL.current = dlObj; 101 | try { 102 | await dlObj.downloadAsync(); 103 | } catch (e: any) { 104 | const info = await FileSystem.getInfoAsync(uri); 105 | if (info.exists) { 106 | try { 107 | await FileSystem.deleteAsync(uri, { idempotent: true }); 108 | } catch {} 109 | } 110 | if (cancelReq.current) throw new Error("__CANCELLED__"); 111 | throw e; 112 | } finally { 113 | currentDL.current = null; 114 | } 115 | } 116 | 117 | pagesCopy[i] = { ...p, url: uri, urlThumb: uri }; 118 | if ((i & 3) === 3) setPrThrottled((i + 1) / total); 119 | if (cancelReq.current) throw new Error("__CANCELLED__"); 120 | } 121 | 122 | await FileSystem.writeAsStringAsync( 123 | `${dir}metadata.json`, 124 | JSON.stringify({ ...book, pages: pagesCopy }), 125 | { encoding: "utf8" } 126 | ); 127 | 128 | setBook((prev: any) => (prev ? { ...prev, pages: pagesCopy } : prev)); 129 | setPr(1); 130 | if (Platform.OS === "android") 131 | ToastAndroid.show("Saved", ToastAndroid.SHORT); 132 | setLocal(true); 133 | } catch (e: any) { 134 | if (e?.message === "__CANCELLED__") { 135 | if (Platform.OS === "android") 136 | ToastAndroid.show("Canceled", ToastAndroid.SHORT); 137 | } else { 138 | console.error(e); 139 | if (Platform.OS === "android") 140 | ToastAndroid.show("Error", ToastAndroid.LONG); 141 | } 142 | } finally { 143 | setDL(false); 144 | cancelReq.current = false; 145 | currentDL.current = null; 146 | setTimeout(() => setPr(0), 150); 147 | } 148 | }, [book, dl, local, router, setLocal, setBook, setPrThrottled]); 149 | 150 | return { dl, pr, handleDownloadOrDelete, cancel }; 151 | }; 152 | -------------------------------------------------------------------------------- /components/BookCard/design/BookCardClassic.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useRef, useState } from "react"; 2 | import { Animated, Image, Pressable, Text, View } from "react-native"; 3 | 4 | import { Book } from "@/api/nhentai"; 5 | import SmartImage from "@/components/SmartImage"; 6 | import { buildImageFallbacks } from "@/components/buildImageFallbacks"; 7 | import { useTheme } from "@/lib/ThemeContext"; 8 | import { makeCardStyles } from "../BookCard.styles"; 9 | 10 | const CN_FLAG = require("@/assets/images/flags/CN.png"); 11 | const GB_FLAG = require("@/assets/images/flags/GB.png"); 12 | const JP_FLAG = require("@/assets/images/flags/JP.png"); 13 | const FLAG_MAP: Record = { 14 | chinese: CN_FLAG, 15 | english: GB_FLAG, 16 | japanese: JP_FLAG, 17 | }; 18 | 19 | export interface BookCardClassicProps { 20 | book: Book; 21 | cardWidth?: number; 22 | contentScale?: number; 23 | isFavorite?: boolean; 24 | onPress?: (id: number) => void; 25 | background?: string; 26 | } 27 | 28 | export default function BookCardClassic({ 29 | book, 30 | cardWidth = 160, 31 | contentScale = 1, 32 | onPress, 33 | background, 34 | }: BookCardClassicProps) { 35 | const { colors } = useTheme(); 36 | const styles = useMemo( 37 | () => makeCardStyles(colors, cardWidth, contentScale), 38 | [colors, cardWidth, contentScale] 39 | ); 40 | 41 | const S = contentScale; 42 | const pillPadX = Math.max(10, Math.round(cardWidth * 0.06 * S)); 43 | const pillPadY = Math.max(5, Math.round(cardWidth * 0.035 * S)); 44 | const padX = pillPadX; 45 | const padY = Math.max(8, Math.round(pillPadY * 0.9)); 46 | 47 | const titleFontSize = Math.max(12, Math.round(cardWidth * 0.08 * S)); 48 | const lineH = Math.max(14, Math.round(cardWidth * 0.09 * S)); 49 | const textWidth = Math.max(0, cardWidth - padX * 2); 50 | 51 | const [measured, setMeasured] = useState(false); 52 | const [lines, setLines] = useState(1); 53 | const [canExpand, setCanExpand] = useState(false); 54 | const [expanded, setExpanded] = useState(false); 55 | 56 | const oneLinePad = Math.round(padY * 0.6); 57 | 58 | const collapsedH0 = oneLinePad * 2 + lineH; 59 | const [collapsedH, setCollapsedH] = useState(collapsedH0); 60 | const [expandedH, setExpandedH] = useState(collapsedH0); 61 | 62 | const hAnim = useRef(new Animated.Value(collapsedH0)).current; 63 | const measuredOnce = useRef(false); 64 | 65 | const fullTitle = book.title?.pretty ?? ""; 66 | 67 | useEffect(() => { 68 | measuredOnce.current = false; 69 | setMeasured(false); 70 | setLines(1); 71 | setCanExpand(false); 72 | setExpanded(false); 73 | setExpandedH(collapsedH0); 74 | setCollapsedH(collapsedH0); 75 | hAnim.setValue(collapsedH0); 76 | }, [book.id, cardWidth, contentScale, lineH, padY, titleFontSize, textWidth]); 77 | 78 | const langName = (() => { 79 | const arr = book.languages || []; 80 | if (!arr?.length) return undefined; 81 | const p = arr[0]?.name?.toLowerCase(); 82 | const s = arr[1]?.name?.toLowerCase(); 83 | return p === "translated" && s ? s : p; 84 | })(); 85 | const flagSrc = langName ? FLAG_MAP[langName] : undefined; 86 | 87 | const sources = buildImageFallbacks(book.cover); 88 | 89 | const handlePressCard = () => onPress?.(book.id); 90 | 91 | const animateTo = (to: number) => { 92 | Animated.timing(hAnim, { 93 | toValue: to, 94 | duration: 180, 95 | useNativeDriver: false, 96 | }).start(); 97 | }; 98 | 99 | const toggleExpand = () => { 100 | if (!canExpand) return; 101 | const to = expanded ? collapsedH : expandedH; 102 | setExpanded(!expanded); 103 | animateTo(to); 104 | }; 105 | 106 | const applyMeasurement = (ln: number) => { 107 | const L = Math.max(1, ln); 108 | const cH = collapsedH0; 109 | const eH = padY * 2 + L * lineH; 110 | setLines(L); 111 | setCanExpand(L > 1); 112 | setCollapsedH(cH); 113 | setExpandedH(eH); 114 | setMeasured(true); 115 | hAnim.setValue(cH); 116 | }; 117 | 118 | return ( 119 | 126 | 127 | 131 | 132 | {flagSrc && ( 133 | 134 | 135 | 136 | )} 137 | 138 | 145 | { 147 | e?.stopPropagation?.(); 148 | toggleExpand(); 149 | }} 150 | style={[ 151 | styles.classicInner, 152 | { 153 | paddingHorizontal: padX, 154 | paddingVertical: expanded ? padY : oneLinePad, 155 | }, 156 | ]} 157 | > 158 | 166 | {fullTitle} 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | {!measured && textWidth > 0 && ( 175 | { 188 | if (measuredOnce.current) return; 189 | measuredOnce.current = true; 190 | const ln = e?.nativeEvent?.lines?.length ?? 1; 191 | applyMeasurement(ln); 192 | }} 193 | > 194 | {fullTitle} 195 | 196 | )} 197 | 198 | ); 199 | } -------------------------------------------------------------------------------- /components/tags/CollectionsList.tsx: -------------------------------------------------------------------------------- 1 | import { useFilterTags } from "@/context/TagFilterContext"; 2 | import { useTheme } from "@/lib/ThemeContext"; 3 | import { useI18n } from "@/lib/i18n/I18nContext"; 4 | import { Feather } from "@expo/vector-icons"; 5 | import React, { useMemo, useRef } from "react"; 6 | import { Animated, Pressable, StyleSheet, Text, View } from "react-native"; 7 | 8 | type Collection = { 9 | id: string; 10 | name: string; 11 | items: Array<{ type: string; name: string; mode: "include" | "exclude" }>; 12 | }; 13 | 14 | const PLURAL_TO_SINGULAR: Record = { 15 | tags: "tag", 16 | artists: "artist", 17 | characters: "character", 18 | parodies: "parody", 19 | groups: "group", 20 | }; 21 | const canon = (type: string, name: string, mode: "include" | "exclude") => 22 | `${(PLURAL_TO_SINGULAR[type] || type).toLowerCase()}:${String( 23 | name 24 | ).toLowerCase()}:${mode}`; 25 | 26 | export function CollectionsList({ 27 | collections, 28 | onReplace, 29 | onEdit, 30 | onDelete, 31 | }: { 32 | collections: Array; 33 | onReplace: (id: string) => void; 34 | onEdit: (id: string) => void; 35 | onDelete: (id: string) => void; 36 | }) { 37 | const { colors } = useTheme(); 38 | const { t } = useI18n(); 39 | 40 | if (collections.length === 0) { 41 | return ( 42 | 43 | {t("common.nothingFound")} 44 | 45 | ); 46 | } 47 | 48 | return ( 49 | <> 50 | {collections.map((c) => ( 51 | 58 | ))} 59 | 60 | ); 61 | } 62 | 63 | function CollectionRow({ 64 | c, 65 | onReplace, 66 | onEdit, 67 | onDelete, 68 | }: { 69 | c: Collection; 70 | onReplace: (id: string) => void; 71 | onEdit: (id: string) => void; 72 | onDelete: (id: string) => void; 73 | }) { 74 | const { colors } = useTheme(); 75 | const { t } = useI18n(); 76 | const { filters } = useFilterTags(); 77 | 78 | const isActive = useMemo(() => { 79 | if (!filters || !Array.isArray(filters)) return false; 80 | if ((filters?.length ?? 0) !== (c.items?.length ?? 0)) return false; 81 | const have = new Set( 82 | filters.map((f: any) => canon(String(f.type), String(f.name), f.mode)) 83 | ); 84 | return c.items.every((it) => 85 | have.has(canon(String(it.type), String(it.name), it.mode)) 86 | ); 87 | }, [filters, c.items]); 88 | 89 | const pressAnim = useRef(new Animated.Value(0)).current; 90 | const onDown = () => { 91 | Animated.spring(pressAnim, { 92 | toValue: 1, 93 | useNativeDriver: false, 94 | speed: 40, 95 | bounciness: 0, 96 | }).start(); 97 | }; 98 | const onUp = () => { 99 | Animated.timing(pressAnim, { 100 | toValue: 0, 101 | duration: 150, 102 | useNativeDriver: false, 103 | }).start(); 104 | }; 105 | 106 | const bgInterp = pressAnim.interpolate({ 107 | inputRange: [0, 1], 108 | outputRange: [ 109 | isActive ? colors.accent + "10" : colors.tagBg, 110 | colors.accent + "20", 111 | ], 112 | }); 113 | const scaleInterp = pressAnim.interpolate({ 114 | inputRange: [0, 1], 115 | outputRange: [1, 0.985], 116 | }); 117 | const ripple = colors.accent + "24"; 118 | 119 | return ( 120 | 130 | {isActive && ( 131 | 132 | )} 133 | 134 | onReplace(c.id)} 136 | onPressIn={onDown} 137 | onPressOut={onUp} 138 | android_ripple={{ color: ripple, borderless: false, foreground: true }} 139 | style={styles.leftArea} 140 | accessibilityRole="button" 141 | accessibilityState={{ selected: isActive }} 142 | > 143 | 147 | {c.name || t("collections.untitled")} 148 | 149 | 150 | {t("collections.itemsCount", { count: c.items.length })} 151 | 152 | 153 | 154 | onEdit(c.id)} 156 | android_ripple={{ color: ripple, borderless: false, foreground: true }} 157 | style={({ pressed }) => [ 158 | styles.iconBtn, 159 | pressed && { 160 | backgroundColor: colors.accent + "12", 161 | borderRadius: 10, 162 | }, 163 | ]} 164 | hitSlop={8} 165 | accessibilityRole="button" 166 | accessibilityLabel={t("collections.list.edit")} 167 | > 168 | 169 | 170 | 171 | onDelete(c.id)} 173 | android_ripple={{ color: ripple, borderless: false, foreground: true }} 174 | style={({ pressed }) => [ 175 | styles.iconBtn, 176 | pressed && { 177 | backgroundColor: colors.accent + "12", 178 | borderRadius: 10, 179 | }, 180 | ]} 181 | hitSlop={8} 182 | accessibilityRole="button" 183 | accessibilityLabel={t("collections.list.delete")} 184 | > 185 | 186 | 187 | 188 | ); 189 | } 190 | 191 | const styles = StyleSheet.create({ 192 | collectionCard: { 193 | flexDirection: "row", 194 | alignItems: "center", 195 | gap: 8, 196 | paddingVertical: 8, 197 | paddingHorizontal: 8, 198 | borderWidth: StyleSheet.hairlineWidth, 199 | borderRadius: 12, 200 | marginBottom: 8, 201 | }, 202 | activeBar: { 203 | position: "absolute", 204 | left: 0, 205 | top: 6, 206 | bottom: 6, 207 | width: 3, 208 | borderRadius: 2, 209 | }, 210 | leftArea: { 211 | flex: 1, 212 | paddingVertical: 8, 213 | paddingHorizontal: 8, 214 | overflow: "hidden", 215 | borderRadius: 10, 216 | }, 217 | collectionTitle: { fontSize: 14, fontWeight: "800" }, 218 | iconBtn: { padding: 8, overflow: "hidden", borderRadius: 10 }, 219 | }); 220 | -------------------------------------------------------------------------------- /app/recommendations.tsx: -------------------------------------------------------------------------------- 1 | import { CandidateBook, getRecommendations } from "@/api/nhentai"; 2 | import BookList from "@/components/BookList"; 3 | import NoResultsPanel from "@/components/NoResultsPanel"; 4 | import { useFilterTags } from "@/context/TagFilterContext"; 5 | import { useGridConfig } from "@/hooks/useGridConfig"; 6 | import { useI18n } from "@/lib/i18n/I18nContext"; 7 | import { useTheme } from "@/lib/ThemeContext"; 8 | import AsyncStorage from "@react-native-async-storage/async-storage"; 9 | import { useFocusEffect, useRouter } from "expo-router"; 10 | import React, { useCallback, useEffect, useMemo, useState } from "react"; 11 | import { ActivityIndicator, StyleSheet, View } from "react-native"; 12 | 13 | type RecBook = CandidateBook & { explain: string[]; score: number }; 14 | 15 | export default function RecommendationsScreen() { 16 | const { colors } = useTheme(); 17 | const { includes, excludes } = useFilterTags(); 18 | const router = useRouter(); 19 | const { t } = useI18n(); 20 | 21 | const [books, setBooks] = useState([]); 22 | const [favIds, setFavIds] = useState([]); 23 | const [favorites, setFav] = useState>(new Set()); 24 | const [loading, setLoading] = useState(true); 25 | const [refreshing, setRefreshing] = useState(false); 26 | const [page, setPage] = useState(1); 27 | const [hasMore, setHasMore] = useState(true); 28 | const gridConfig = useGridConfig(); 29 | 30 | const perPage = 50; 31 | 32 | useEffect(() => { 33 | AsyncStorage.getItem("bookFavorites").then((j) => { 34 | const arr = j ? (JSON.parse(j) as number[]) : []; 35 | setFavIds(arr); 36 | setFav(new Set(arr)); 37 | }); 38 | }, []); 39 | 40 | useEffect(() => { 41 | if (favIds.length === 0) { 42 | setBooks([]); 43 | setLoading(false); 44 | setHasMore(false); 45 | return; 46 | } 47 | fetchRecs(); 48 | }, [favIds]); 49 | 50 | const fetchRecs = useCallback(async () => { 51 | setLoading(true); 52 | setPage(1); 53 | setHasMore(true); 54 | try { 55 | const { books: recs } = await getRecommendations({ 56 | ids: favIds, 57 | includeTags: includes, 58 | excludeTags: excludes, 59 | page: 1, 60 | perPage, 61 | }); 62 | setBooks(recs); 63 | setHasMore(recs.length === perPage); 64 | } catch (e) { 65 | setBooks([]); 66 | setHasMore(false); 67 | console.error("Failed to fetch recommendations:", e); 68 | } finally { 69 | setLoading(false); 70 | } 71 | }, [favIds, includes, excludes]); 72 | 73 | const loadMoreRecommendations = useCallback(async () => { 74 | if (loading || !hasMore) return; 75 | setLoading(true); 76 | try { 77 | const nextPage = page + 1; 78 | const { books: recs } = await getRecommendations({ 79 | ids: favIds, 80 | includeTags: includes, 81 | excludeTags: excludes, 82 | page: nextPage, 83 | perPage, 84 | }); 85 | setBooks((prev) => [...prev, ...recs]); 86 | setPage(nextPage); 87 | setHasMore(recs.length === perPage); 88 | } catch (e) { 89 | setHasMore(false); 90 | console.error("Failed to load more recommendations:", e); 91 | } finally { 92 | setLoading(false); 93 | } 94 | }, [favIds, includes, excludes, page, loading, hasMore]); 95 | 96 | const onRefresh = useCallback(async () => { 97 | setRefreshing(true); 98 | await fetchRecs(); 99 | setRefreshing(false); 100 | }, [fetchRecs]); 101 | 102 | const toggleFav = useCallback((id: number, next: boolean) => { 103 | setFav((prev) => { 104 | const cp = new Set(prev); 105 | next ? cp.add(id) : cp.delete(id); 106 | AsyncStorage.setItem("bookFavorites", JSON.stringify([...cp])); 107 | return cp; 108 | }); 109 | }, []); 110 | 111 | useFocusEffect( 112 | useCallback(() => { 113 | AsyncStorage.getItem("bookFavorites").then( 114 | (j) => j && setFav(new Set(JSON.parse(j))) 115 | ); 116 | }, []) 117 | ); 118 | 119 | const maxScore = 120 | books.length > 0 ? Math.max(...books.map((b) => b.score)) : 1; 121 | 122 | const emptyTitle = 123 | favIds.length === 0 124 | ? t("recommendations.emptyTitle.noFav") || 125 | "Нет рекомендаций — добавь книги в избранное" 126 | : t("recommendations.emptyTitle.default") || "Рекомендаций пока нет"; 127 | 128 | const emptySubtitle = 129 | favIds.length === 0 130 | ? t("recommendations.emptySubtitle.noFav") || 131 | "Поставь несколько лайков — я подберу похожее." 132 | : t("recommendations.emptySubtitle.default") || 133 | "Попробуй изменить фильтры или обновить список."; 134 | 135 | const emptyActions = useMemo( 136 | () => [ 137 | { 138 | label: 139 | t("recommendations.actions.openFavorites") || "Открыть избранное", 140 | onPress: () => router.push("/favorites"), 141 | }, 142 | { 143 | label: t("recommendations.actions.openFilters") || "Открыть фильтры", 144 | onPress: () => router.push("/tags"), 145 | }, 146 | ], 147 | [router, t] 148 | ); 149 | 150 | return ( 151 | 152 | {loading && books.length === 0 ? ( 153 | 154 | ) : ( 155 | <> 156 | 164 | typeof b.score === "number" 165 | ? Math.round((b.score / maxScore) * 100) 166 | : undefined 167 | } 168 | onPress={(id) => 169 | router.push({ 170 | pathname: "/book/[id]", 171 | params: { 172 | id: String(id), 173 | title: books.find((b) => b.id === id)?.title.pretty, 174 | }, 175 | }) 176 | } 177 | ListEmptyComponent={ 178 | !loading && books.length === 0 ? ( 179 | 185 | ) : null 186 | } 187 | gridConfig={{ default: gridConfig }} 188 | /> 189 | 190 | )} 191 | 192 | ); 193 | } 194 | 195 | const styles = StyleSheet.create({ 196 | container: { flex: 1 }, 197 | }); 198 | --------------------------------------------------------------------------------