initial
This commit is contained in:
24
books/.gitignore
vendored
Normal file
24
books/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
132
books/App.tsx
Normal file
132
books/App.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { AppScreen, Book } from './types';
|
||||
import { INITIAL_BOOKS } from './constants';
|
||||
import { Layout } from './components/Layout';
|
||||
import { Library } from './screens/Library';
|
||||
import { Categories } from './screens/Categories';
|
||||
import { BookDetails } from './screens/BookDetails';
|
||||
import { AddBook } from './screens/AddBook';
|
||||
import { Scanner } from './screens/Scanner';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [currentScreen, setCurrentScreen] = useState<AppScreen>(AppScreen.LIBRARY);
|
||||
const [books, setBooks] = useState<Book[]>(INITIAL_BOOKS);
|
||||
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
||||
const [prefilledData, setPrefilledData] = useState<Partial<Book> | null>(null);
|
||||
|
||||
const handleBookClick = (book: Book) => {
|
||||
setSelectedBook(book);
|
||||
setCurrentScreen(AppScreen.DETAILS);
|
||||
};
|
||||
|
||||
const handleAddClick = () => {
|
||||
setPrefilledData(null);
|
||||
setCurrentScreen(AppScreen.ADD_BOOK);
|
||||
};
|
||||
|
||||
const handleSaveBook = (bookData: Partial<Book>) => {
|
||||
if (selectedBook) {
|
||||
// Edit existing
|
||||
setBooks(prev => prev.map(b => b.id === selectedBook.id ? { ...b, ...bookData } as Book : b));
|
||||
} else {
|
||||
// Add new
|
||||
const newBook: Book = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
title: bookData.title || 'Unknown',
|
||||
author: bookData.author || 'Unknown',
|
||||
genre: bookData.genre || 'Unknown',
|
||||
annotation: bookData.annotation || '',
|
||||
coverUrl: bookData.coverUrl || 'https://picsum.photos/seed/new/400/600',
|
||||
status: 'want_to_read',
|
||||
...bookData
|
||||
} as Book;
|
||||
setBooks(prev => [...prev, newBook]);
|
||||
}
|
||||
setCurrentScreen(AppScreen.LIBRARY);
|
||||
setSelectedBook(null);
|
||||
setPrefilledData(null);
|
||||
};
|
||||
|
||||
const handleDeleteBook = (id: string) => {
|
||||
setBooks(prev => prev.filter(b => b.id !== id));
|
||||
setCurrentScreen(AppScreen.LIBRARY);
|
||||
};
|
||||
|
||||
const handleDetected = (data: Partial<Book>) => {
|
||||
setPrefilledData(data);
|
||||
setCurrentScreen(AppScreen.ADD_BOOK);
|
||||
};
|
||||
|
||||
const renderScreen = () => {
|
||||
switch (currentScreen) {
|
||||
case AppScreen.LIBRARY:
|
||||
return (
|
||||
<Library
|
||||
books={books}
|
||||
onBookClick={handleBookClick}
|
||||
onAddClick={handleAddClick}
|
||||
/>
|
||||
);
|
||||
case AppScreen.CATEGORIES:
|
||||
return <Categories />;
|
||||
case AppScreen.DETAILS:
|
||||
return selectedBook ? (
|
||||
<BookDetails
|
||||
book={selectedBook}
|
||||
onBack={() => setCurrentScreen(AppScreen.LIBRARY)}
|
||||
onDelete={handleDeleteBook}
|
||||
onEdit={(b) => {
|
||||
setSelectedBook(b);
|
||||
setCurrentScreen(AppScreen.ADD_BOOK);
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
case AppScreen.ADD_BOOK:
|
||||
return (
|
||||
<AddBook
|
||||
initialData={selectedBook || prefilledData || {}}
|
||||
onSave={handleSaveBook}
|
||||
onCancel={() => {
|
||||
setCurrentScreen(selectedBook ? AppScreen.DETAILS : AppScreen.LIBRARY);
|
||||
setPrefilledData(null);
|
||||
}}
|
||||
onScanClick={() => setCurrentScreen(AppScreen.SCANNER)}
|
||||
/>
|
||||
);
|
||||
case AppScreen.SCANNER:
|
||||
return (
|
||||
<Scanner
|
||||
onCancel={() => setCurrentScreen(AppScreen.ADD_BOOK)}
|
||||
onDetected={handleDetected}
|
||||
/>
|
||||
);
|
||||
case AppScreen.WISHLIST:
|
||||
return (
|
||||
<div className="p-10 text-center">
|
||||
<h2 className="text-xl font-bold">Вишлист</h2>
|
||||
<p className="text-text-muted mt-2">Здесь будут книги, которые вы хотите прочитать.</p>
|
||||
</div>
|
||||
);
|
||||
case AppScreen.SETTINGS:
|
||||
return (
|
||||
<div className="p-10 text-center">
|
||||
<h2 className="text-xl font-bold">Настройки</h2>
|
||||
<p className="text-text-muted mt-2">Персонализируйте ваше приложение.</p>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <Library books={books} onBookClick={handleBookClick} onAddClick={handleAddClick} />;
|
||||
}
|
||||
};
|
||||
|
||||
const hideNav = [AppScreen.SCANNER, AppScreen.DETAILS, AppScreen.ADD_BOOK].includes(currentScreen);
|
||||
|
||||
return (
|
||||
<Layout currentScreen={currentScreen} setScreen={setCurrentScreen} hideNav={hideNav}>
|
||||
{renderScreen()}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
20
books/README.md
Normal file
20
books/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/temp/1
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
41
books/components/BottomNav.tsx
Normal file
41
books/components/BottomNav.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
|
||||
import React from 'react';
|
||||
import { AppScreen } from '../types';
|
||||
|
||||
interface BottomNavProps {
|
||||
currentScreen: AppScreen;
|
||||
setScreen: (screen: AppScreen) => void;
|
||||
}
|
||||
|
||||
export const BottomNav: React.FC<BottomNavProps> = ({ currentScreen, setScreen }) => {
|
||||
const navItems = [
|
||||
{ id: AppScreen.LIBRARY, label: 'Полка', icon: 'library_books' },
|
||||
{ id: AppScreen.CATEGORIES, label: 'Категории', icon: 'category' },
|
||||
{ id: AppScreen.WISHLIST, label: 'Вишлист', icon: 'bookmark' },
|
||||
{ id: AppScreen.SETTINGS, label: 'Настройки', icon: 'settings' },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 z-50 w-full max-w-md glass-nav pb-safe">
|
||||
<div className="flex items-center justify-around h-16 px-2">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setScreen(item.id)}
|
||||
className={`flex flex-col items-center justify-center w-full h-full gap-1 transition-colors ${
|
||||
currentScreen === item.id || (item.id === AppScreen.CATEGORIES && currentScreen === AppScreen.CATEGORIES)
|
||||
? 'text-primary'
|
||||
: 'text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[24px] ${currentScreen === item.id ? 'fill-current' : ''}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-5 w-full"></div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
25
books/components/Layout.tsx
Normal file
25
books/components/Layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
import React from 'react';
|
||||
import { AppScreen } from '../types';
|
||||
import { BottomNav } from './BottomNav';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
currentScreen: AppScreen;
|
||||
setScreen: (screen: AppScreen) => void;
|
||||
hideNav?: boolean;
|
||||
}
|
||||
|
||||
export const Layout: React.FC<LayoutProps> = ({ children, currentScreen, setScreen, hideNav }) => {
|
||||
return (
|
||||
<div className="relative flex flex-col min-h-screen w-full overflow-x-hidden max-w-md mx-auto shadow-2xl bg-background-light dark:bg-background-dark border-x border-gray-200 dark:border-border-dark/30">
|
||||
<main className="flex-1 flex flex-col">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{!hideNav && (
|
||||
<BottomNav currentScreen={currentScreen} setScreen={setScreen} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
70
books/constants.tsx
Normal file
70
books/constants.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
|
||||
import { Category, Book } from './types';
|
||||
|
||||
export const CATEGORIES: Category[] = [
|
||||
{ id: 'fiction', name: 'Фантастика', count: 24, icon: 'rocket_launch', colorClass: 'bg-indigo-500/20 text-indigo-300' },
|
||||
{ id: 'fantasy', name: 'Фэнтези', count: 18, icon: 'auto_fix_high', colorClass: 'bg-purple-500/20 text-purple-300' },
|
||||
{ id: 'nonfiction', name: 'Научпоп', count: 7, icon: 'psychology', colorClass: 'bg-teal-500/20 text-teal-300' },
|
||||
{ id: 'business', name: 'Бизнес', count: 3, icon: 'business_center', colorClass: 'bg-blue-500/20 text-blue-300' },
|
||||
{ id: 'education', name: 'Учебная', count: 0, icon: 'school', colorClass: 'bg-orange-500/20 text-orange-300' },
|
||||
{ id: 'classics', name: 'Классика', count: 15, icon: 'history_edu', colorClass: 'bg-amber-500/20 text-amber-300' },
|
||||
];
|
||||
|
||||
export const INITIAL_BOOKS: Book[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Великий Гэтсби',
|
||||
author: 'Ф. Скотт Фицджеральд',
|
||||
genre: 'Classic',
|
||||
annotation: 'История о несбывшейся любви и трагедии американской мечты на фоне бурных двадцатых годов.',
|
||||
coverUrl: 'https://picsum.photos/seed/gatsby/400/600',
|
||||
pages: 208,
|
||||
language: 'English',
|
||||
publishedYear: 1925,
|
||||
rating: 4.8,
|
||||
status: 'reading',
|
||||
progress: 45,
|
||||
isFavorite: true
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '1984',
|
||||
author: 'Джордж Оруэлл',
|
||||
genre: 'Dystopian',
|
||||
annotation: 'Антиутопия о тоталитарном государстве, где мысли контролируются, а правда переменчива.',
|
||||
coverUrl: 'https://picsum.photos/seed/1984/400/600',
|
||||
pages: 328,
|
||||
language: 'English',
|
||||
publishedYear: 1949,
|
||||
rating: 4.9,
|
||||
status: 'want_to_read',
|
||||
isFavorite: true
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Дюна',
|
||||
author: 'Фрэнк Герберт',
|
||||
genre: 'Sci-Fi',
|
||||
annotation: 'Эпическая сага о борьбе за власть над самой важной планетой во Вселенной.',
|
||||
coverUrl: 'https://picsum.photos/seed/dune/400/600',
|
||||
pages: 896,
|
||||
language: 'English',
|
||||
publishedYear: 1965,
|
||||
rating: 4.7,
|
||||
status: 'reading',
|
||||
progress: 12
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Хоббит',
|
||||
author: 'Дж. Р. Р. Толкин',
|
||||
genre: 'Fantasy',
|
||||
annotation: 'Путешествие Бильбо Бэггинса туда и обратно в поисках сокровищ гномов.',
|
||||
coverUrl: 'https://picsum.photos/seed/hobbit/400/600',
|
||||
pages: 310,
|
||||
language: 'English',
|
||||
publishedYear: 1937,
|
||||
rating: 4.9,
|
||||
status: 'done'
|
||||
}
|
||||
];
|
||||
92
books/index.html
Normal file
92
books/index.html
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Книжная полка</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"primary": "#17cf54",
|
||||
"background-light": "#f6f8f6",
|
||||
"background-dark": "#112116",
|
||||
"surface-dark": "#1a3222",
|
||||
"border-dark": "#346544",
|
||||
"surface-highlight": "#244730",
|
||||
"card-dark": "#1a3023",
|
||||
"text-muted": "#93c8a5",
|
||||
},
|
||||
fontFamily: {
|
||||
"display": ["Inter", "Noto Sans", "sans-serif"]
|
||||
},
|
||||
borderRadius: {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"2xl": "1rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
animation: {
|
||||
'scan': 'scan 2.5s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
scan: {
|
||||
'0%, 100%': { transform: 'translateY(-100%)' },
|
||||
'50%': { transform: 'translateY(100%)' },
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #346544;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.glass-nav {
|
||||
background: rgba(17, 33, 22, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react/": "https://esm.sh/react@^19.2.4/",
|
||||
"react": "https://esm.sh/react@^19.2.4",
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
|
||||
"@google/genai": "https://esm.sh/@google/genai@^1.39.0"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body class="bg-background-dark font-display text-white antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
books/index.tsx
Normal file
16
books/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
8
books/metadata.json
Normal file
8
books/metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
{
|
||||
"name": "Книжная полка (Bookshelf)",
|
||||
"description": "A sophisticated book management application featuring OCR-based book scanning, library management, and genre organization, with a sleek dark-themed aesthetic.",
|
||||
"requestFramePermissions": [
|
||||
"camera"
|
||||
]
|
||||
}
|
||||
22
books/package.json
Normal file
22
books/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "книжная-полка-(bookshelf)",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"@google/genai": "^1.39.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
136
books/screens/AddBook.tsx
Normal file
136
books/screens/AddBook.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Book, AppScreen } from '../types';
|
||||
|
||||
interface AddBookProps {
|
||||
initialData?: Partial<Book>;
|
||||
onSave: (book: Partial<Book>) => void;
|
||||
onCancel: () => void;
|
||||
onScanClick: () => void;
|
||||
}
|
||||
|
||||
export const AddBook: React.FC<AddBookProps> = ({ initialData, onSave, onCancel, onScanClick }) => {
|
||||
const [formData, setFormData] = useState<Partial<Book>>({
|
||||
title: '',
|
||||
author: '',
|
||||
genre: '',
|
||||
annotation: '',
|
||||
status: 'want_to_read',
|
||||
pages: 0,
|
||||
language: 'Russian',
|
||||
publishedYear: new Date().getFullYear(),
|
||||
rating: 5,
|
||||
coverUrl: 'https://picsum.photos/seed/newbook/400/600',
|
||||
...initialData
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { id, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background-dark">
|
||||
<header className="sticky top-0 z-50 flex items-center justify-between px-4 py-3 bg-background-dark/90 backdrop-blur-md border-b border-white/5">
|
||||
<button onClick={onCancel} className="flex items-center justify-center size-10 rounded-full hover:bg-surface-dark transition-colors text-gray-300">
|
||||
<span className="material-symbols-outlined">arrow_back_ios_new</span>
|
||||
</button>
|
||||
<h1 className="text-lg font-bold tracking-tight text-center flex-1 pr-10">Добавить книгу</h1>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex flex-col p-5 gap-6 overflow-y-auto no-scrollbar pb-32">
|
||||
<div className="flex justify-center w-full">
|
||||
<div
|
||||
onClick={onScanClick}
|
||||
className="group relative flex flex-col items-center justify-center w-40 h-60 rounded-lg border-2 border-dashed border-border-dark bg-surface-dark transition-all hover:border-primary cursor-pointer overflow-hidden shadow-sm"
|
||||
>
|
||||
{formData.coverUrl && !formData.coverUrl.includes('picsum') ? (
|
||||
<img src={formData.coverUrl} className="absolute inset-0 w-full h-full object-cover" alt="Preview" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 p-4 text-center z-10 transition-transform group-hover:scale-105">
|
||||
<div className="size-12 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<span className="material-symbols-outlined text-[28px]">add_a_photo</span>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-gray-400 group-hover:text-primary transition-colors">Загрузить или отсканировать</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-gray-200 ml-1" htmlFor="title">Название</label>
|
||||
<input
|
||||
id="title"
|
||||
className="w-full rounded-xl border-border-dark bg-surface-dark px-4 py-3.5 text-base text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary transition-shadow shadow-sm"
|
||||
placeholder="Введите название книги"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-gray-200 ml-1" htmlFor="author">Автор</label>
|
||||
<input
|
||||
id="author"
|
||||
className="w-full rounded-xl border-border-dark bg-surface-dark px-4 py-3.5 text-base text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary transition-shadow shadow-sm"
|
||||
placeholder="Имя автора"
|
||||
value={formData.author}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-gray-200 ml-1" htmlFor="genre">Жанр</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="genre"
|
||||
className="w-full appearance-none rounded-xl border-border-dark bg-surface-dark px-4 py-3.5 text-base text-white focus:border-primary focus:ring-1 focus:ring-primary transition-shadow shadow-sm pr-10 cursor-pointer"
|
||||
value={formData.genre}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option disabled value="">Выберите жанр</option>
|
||||
<option value="fiction">Фантастика</option>
|
||||
<option value="fantasy">Фэнтези</option>
|
||||
<option value="science">Научпоп</option>
|
||||
<option value="biography">Биография</option>
|
||||
<option value="detective">Детектив</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-4 text-gray-400">
|
||||
<span className="material-symbols-outlined">expand_more</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-gray-200 ml-1" htmlFor="annotation">Аннотация</label>
|
||||
<textarea
|
||||
id="annotation"
|
||||
rows={4}
|
||||
className="w-full rounded-xl border-border-dark bg-surface-dark px-4 py-3.5 text-base text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary transition-shadow shadow-sm resize-none"
|
||||
placeholder="О чем эта книга? Краткое описание или заметки..."
|
||||
value={formData.annotation}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<div className="fixed bottom-0 left-0 right-0 max-w-md mx-auto p-4 bg-background-dark/95 backdrop-blur-sm border-t border-white/5 z-40">
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onCancel} className="flex-1 py-3.5 px-4 rounded-xl font-semibold text-gray-300 bg-surface-dark hover:bg-[#25422e] transition-colors">
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSave(formData)}
|
||||
className="flex-[2] py-3.5 px-4 rounded-xl font-bold text-white bg-primary hover:bg-[#14b047] shadow-lg shadow-primary/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">save</span>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
105
books/screens/BookDetails.tsx
Normal file
105
books/screens/BookDetails.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Book, AppScreen } from '../types';
|
||||
|
||||
interface BookDetailsProps {
|
||||
book: Book;
|
||||
onBack: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (book: Book) => void;
|
||||
}
|
||||
|
||||
export const BookDetails: React.FC<BookDetailsProps> = ({ book, onBack, onDelete, onEdit }) => {
|
||||
return (
|
||||
<div className="relative flex flex-col h-full bg-background-dark overflow-y-auto no-scrollbar">
|
||||
<div className="absolute top-0 left-0 right-0 z-50 p-4 pt-6 flex items-center justify-between">
|
||||
<button onClick={onBack} className="size-10 flex items-center justify-center rounded-full bg-black/20 hover:bg-black/40 text-white backdrop-blur-md border border-white/10">
|
||||
<span className="material-symbols-outlined">arrow_back_ios_new</span>
|
||||
</button>
|
||||
<button className="size-10 flex items-center justify-center rounded-full bg-black/20 hover:bg-black/40 text-white backdrop-blur-md border border-white/10">
|
||||
<span className="material-symbols-outlined">more_horiz</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-[460px] flex flex-col items-center justify-end pb-8 overflow-hidden shrink-0">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center blur-2xl opacity-40 scale-125 saturate-150"
|
||||
style={{ backgroundImage: `url(${book.coverUrl})` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background-dark via-background-dark/80 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mb-4 transform transition-transform duration-500 hover:scale-105">
|
||||
<div className="w-44 h-64 rounded-lg shadow-2xl bg-gray-800 relative overflow-hidden shadow-black/50">
|
||||
<img src={book.coverUrl} className="absolute inset-0 w-full h-full object-cover" alt={book.title} />
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-black/40 via-transparent to-white/10 pointer-events-none"></div>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-white/20 blur-[1px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-20 mb-2">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-black/40 border border-white/10 backdrop-blur-md">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
</span>
|
||||
<span className="text-primary text-xs font-bold tracking-wide uppercase">
|
||||
{book.status === 'reading' ? 'Reading Now' : book.status === 'done' ? 'Completed' : 'Wishlist'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-20 px-6 -mt-2 space-y-8 pb-10">
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-white text-[28px] font-bold leading-tight tracking-tight">{book.title}</h1>
|
||||
<p className="text-gray-400 text-lg font-medium">{book.author}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{book.genre.split(',').map((g, i) => (
|
||||
<div key={i} className="px-4 py-2 rounded-lg bg-[#244730] border border-[#386e4b]/50">
|
||||
<p className="text-white text-xs font-semibold uppercase tracking-wider">{g.trim()}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||
<button onClick={() => onEdit(book)} className="group flex items-center justify-center gap-2 h-12 bg-primary hover:bg-[#15bd4d] text-background-dark font-bold rounded-xl transition-all active:scale-95 shadow-[0_4px_20px_-5px_rgba(23,207,84,0.4)]">
|
||||
<span className="material-symbols-outlined text-[20px]">edit_square</span>
|
||||
Edit Details
|
||||
</button>
|
||||
<button onClick={() => onDelete(book.id)} className="group flex items-center justify-center gap-2 h-12 bg-white/5 hover:bg-red-500/10 text-gray-300 hover:text-red-400 border border-white/10 hover:border-red-500/30 font-semibold rounded-xl transition-all active:scale-95">
|
||||
<span className="material-symbols-outlined text-[20px]">delete</span>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<h3 className="text-white text-lg font-bold">About</h3>
|
||||
<div className="relative bg-white/5 rounded-2xl p-4 border border-white/5">
|
||||
<p className="text-gray-300 leading-relaxed text-[15px] font-light">{book.annotation}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 pt-4">
|
||||
{[
|
||||
{ label: 'Pages', val: book.pages || 'N/A', icon: 'menu_book', color: 'text-blue-400', bg: 'bg-blue-500/20' },
|
||||
{ label: 'Language', val: book.language || 'Russian', icon: 'language', color: 'text-purple-400', bg: 'bg-purple-500/20' },
|
||||
{ label: 'Published', val: book.publishedYear || 'N/A', icon: 'calendar_month', color: 'text-orange-400', bg: 'bg-orange-500/20' },
|
||||
{ label: 'Rating', val: `${book.rating || '0'}/5`, icon: 'star', color: 'text-yellow-400', bg: 'bg-yellow-500/20' },
|
||||
].map((item, idx) => (
|
||||
<div key={idx} className="glass-panel p-3.5 rounded-xl border border-white/5 flex flex-col gap-1 items-start bg-white/5">
|
||||
<div className={`p-1.5 rounded-md ${item.bg} ${item.color} mb-1`}>
|
||||
<span className="material-symbols-outlined text-[18px]">{item.icon}</span>
|
||||
</div>
|
||||
<span className="text-gray-500 text-[11px] uppercase font-bold tracking-wider">{item.label}</span>
|
||||
<span className="text-white font-semibold text-base">{item.val}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
52
books/screens/Categories.tsx
Normal file
52
books/screens/Categories.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
|
||||
import React from 'react';
|
||||
import { CATEGORIES } from '../constants';
|
||||
|
||||
export const Categories: React.FC = () => {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background-dark">
|
||||
<div className="h-10 w-full"></div>
|
||||
|
||||
<div className="flex flex-col gap-2 px-4 pb-2 sticky top-0 z-20 bg-background-dark/95 backdrop-blur-md">
|
||||
<div className="flex items-center h-12 justify-between">
|
||||
<span className="text-white/60 text-sm font-medium">Изменить</span>
|
||||
<button className="flex items-center justify-center rounded-full size-10 bg-primary/10 text-primary">
|
||||
<span className="material-symbols-outlined text-[24px]">add</span>
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-white tracking-tight text-[32px] font-bold leading-tight">Категории</h1>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 sticky top-[108px] z-10 bg-background-dark pb-4">
|
||||
<div className="flex w-full items-stretch rounded-lg h-10 bg-card-dark overflow-hidden transition-all duration-200">
|
||||
<div className="text-white/40 flex items-center justify-center pl-3 pr-2">
|
||||
<span className="material-symbols-outlined text-[20px]">search</span>
|
||||
</div>
|
||||
<input className="bg-transparent border-none focus:ring-0 placeholder:text-white/40 px-0 text-base text-white flex-1" placeholder="Поиск жанра..." />
|
||||
<div className="flex items-center pr-3">
|
||||
<span className="material-symbols-outlined text-[20px] text-white/40">mic</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col px-4 pb-24 gap-3 overflow-y-auto no-scrollbar">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button key={cat.id} className="group flex items-center gap-4 bg-card-dark p-4 rounded-xl shadow-sm hover:shadow-md active:scale-[0.98] transition-all border border-white/5">
|
||||
<div className={`flex items-center justify-center rounded-lg ${cat.colorClass} shrink-0 size-12`}>
|
||||
<span className="material-symbols-outlined text-[24px]">{cat.icon}</span>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 items-start">
|
||||
<p className="text-white text-[17px] font-semibold leading-snug">{cat.name}</p>
|
||||
<p className="text-white/50 text-[13px] font-medium leading-normal">
|
||||
{cat.count > 0 ? `${cat.count} книги` : 'Нет книг'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-white/20 group-hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined text-[24px]">chevron_right</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
108
books/screens/Library.tsx
Normal file
108
books/screens/Library.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Book, AppScreen } from '../types';
|
||||
|
||||
interface LibraryProps {
|
||||
books: Book[];
|
||||
onBookClick: (book: Book) => void;
|
||||
onAddClick: () => void;
|
||||
}
|
||||
|
||||
export const Library: React.FC<LibraryProps> = ({ books, onBookClick, onAddClick }) => {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredBooks = books.filter(b =>
|
||||
b.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
b.author.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background-dark">
|
||||
<header className="sticky top-0 z-30 bg-background-dark/95 backdrop-blur-md border-b border-white/5 pb-2 pt-4">
|
||||
<div className="flex items-center px-4 py-3 justify-between">
|
||||
<div className="w-10"></div>
|
||||
<h2 className="text-xl font-bold leading-tight tracking-tight flex-1 text-center">Книжная полка</h2>
|
||||
<div className="w-10 flex justify-end">
|
||||
<button className="text-white hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined">notifications</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-2">
|
||||
<div className="flex w-full items-stretch rounded-xl h-12 bg-surface-highlight shadow-sm ring-1 ring-white/5 focus-within:ring-2 focus-within:ring-primary transition-all">
|
||||
<div className="text-text-muted flex items-center justify-center pl-4 pr-2">
|
||||
<span className="material-symbols-outlined text-[20px]">search</span>
|
||||
</div>
|
||||
<input
|
||||
className="flex w-full min-w-0 flex-1 bg-transparent text-white focus:outline-none placeholder:text-text-muted text-base font-normal"
|
||||
placeholder="Поиск по названию или автору..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="px-4 pt-2 pb-24 flex-1">
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
<div className="flex h-10 w-full items-center justify-center rounded-lg bg-surface-highlight p-1">
|
||||
<button className="flex-1 h-full rounded-md bg-background-dark shadow-sm text-sm font-medium text-white">Все книги</button>
|
||||
<button className="flex-1 h-full text-sm font-medium text-text-muted">Категории</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 overflow-x-auto no-scrollbar pb-1">
|
||||
<button className="flex h-9 items-center justify-center gap-x-2 rounded-full bg-surface-highlight border border-white/5 px-4">
|
||||
<span className="material-symbols-outlined text-[18px]">sort</span>
|
||||
<span className="text-white text-sm font-medium">Сортировка</span>
|
||||
</button>
|
||||
<button className="flex h-9 items-center justify-center gap-x-2 rounded-full bg-surface-highlight border border-white/5 px-4">
|
||||
<span className="material-symbols-outlined text-[18px]">filter_list</span>
|
||||
<span className="text-white text-sm font-medium">Фильтр</span>
|
||||
</button>
|
||||
<button className="flex h-9 items-center justify-center gap-x-2 rounded-full bg-surface-highlight border border-white/5 px-4 whitespace-nowrap">
|
||||
<span className="text-white text-sm font-medium">Непрочитанные</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{filteredBooks.map((book) => (
|
||||
<div key={book.id} className="flex flex-col gap-2 group cursor-pointer" onClick={() => onBookClick(book)}>
|
||||
<div className="relative w-full aspect-[2/3] rounded-lg overflow-hidden shadow-md group-hover:shadow-xl transition-all duration-300 bg-surface-dark">
|
||||
<img src={book.coverUrl || 'https://picsum.photos/seed/placeholder/400/600'} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" alt={book.title} />
|
||||
|
||||
{book.status === 'reading' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/50">
|
||||
<div className="h-full bg-primary" style={{ width: `${book.progress || 0}%` }}></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{book.status === 'done' && (
|
||||
<div className="absolute top-2 left-2 bg-primary/90 text-white text-[10px] font-bold px-2 py-0.5 rounded-full backdrop-blur-sm">DONE</div>
|
||||
)}
|
||||
|
||||
{book.isFavorite && (
|
||||
<div className="absolute top-2 right-2 bg-black/60 backdrop-blur-sm rounded-full p-1">
|
||||
<span className="material-symbols-outlined text-white text-[14px] fill-current">favorite</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white text-base font-semibold leading-tight line-clamp-1">{book.title}</h3>
|
||||
<p className="text-text-muted text-sm font-normal leading-normal line-clamp-1">{book.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onAddClick}
|
||||
className="fixed bottom-24 right-5 z-40 bg-primary text-white w-14 h-14 rounded-full shadow-lg shadow-primary/40 flex items-center justify-center hover:scale-110 active:scale-95 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[28px]">add</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
165
books/screens/Scanner.tsx
Normal file
165
books/screens/Scanner.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { extractBookData } from '../services/geminiService';
|
||||
import { Book } from '../types';
|
||||
|
||||
interface ScannerProps {
|
||||
onCancel: () => void;
|
||||
onDetected: (data: Partial<Book>) => void;
|
||||
}
|
||||
|
||||
export const Scanner: React.FC<ScannerProps> = ({ onCancel, onDetected }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let stream: MediaStream | null = null;
|
||||
|
||||
async function startCamera() {
|
||||
try {
|
||||
// Try preferred environment camera first
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' }
|
||||
});
|
||||
} catch (innerErr) {
|
||||
console.warn("Environment camera not found, falling back to default camera...");
|
||||
// Fallback to any available video source
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: true
|
||||
});
|
||||
}
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Не удалось получить доступ к камере. Убедитесь, что разрешения предоставлены.');
|
||||
console.error("Camera access error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
startCamera();
|
||||
|
||||
return () => {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCapture = async () => {
|
||||
if (!videoRef.current || !canvasRef.current || isProcessing) return;
|
||||
|
||||
// Ensure the video is actually playing and has dimensions
|
||||
if (videoRef.current.readyState < 2 || videoRef.current.videoWidth === 0) {
|
||||
setError('Камера еще не готова. Пожалуйста, подождите.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const video = videoRef.current;
|
||||
|
||||
// Scale down large images to avoid API limits/errors while keeping readability
|
||||
const MAX_WIDTH = 1024;
|
||||
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
|
||||
canvas.width = video.videoWidth * scale;
|
||||
canvas.height = video.videoHeight * scale;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
// Use standard quality for reliable extraction
|
||||
const base64Image = canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
|
||||
|
||||
try {
|
||||
const bookData = await extractBookData(base64Image);
|
||||
onDetected({
|
||||
...bookData,
|
||||
coverUrl: canvas.toDataURL('image/jpeg') // Local preview for the form
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("OCR Error:", err);
|
||||
setError('Не удалось распознать книгу. Попробуйте еще раз с лучшим освещением.');
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-screen w-full overflow-hidden bg-black flex flex-col">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-80"
|
||||
/>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
<div className="absolute inset-0 flex flex-col justify-between z-10">
|
||||
<div className="flex items-center justify-between p-4 pt-12 bg-gradient-to-b from-black/70 to-transparent">
|
||||
<button onClick={onCancel} className="flex items-center justify-center w-10 h-10 rounded-full bg-black/40 text-white backdrop-blur-md">
|
||||
<span className="material-symbols-outlined text-2xl">close</span>
|
||||
</button>
|
||||
<div className="px-4 py-1.5 rounded-full bg-black/40 backdrop-blur-md border border-white/10">
|
||||
<span className="text-white text-xs font-medium tracking-wide">СКАНЕР</span>
|
||||
</div>
|
||||
<div className="w-10"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center relative w-full">
|
||||
<div className="relative w-[75%] aspect-[2/3] rounded-2xl border border-white/20 shadow-2xl">
|
||||
<div className="absolute inset-0 rounded-2xl shadow-[0_0_0_9999px_rgba(0,0,0,0.65)] pointer-events-none"></div>
|
||||
|
||||
<div className="absolute -top-1 -left-1 w-8 h-8 border-t-4 border-l-4 border-primary rounded-tl-xl z-20"></div>
|
||||
<div className="absolute -top-1 -right-1 w-8 h-8 border-t-4 border-r-4 border-primary rounded-tr-xl z-20"></div>
|
||||
<div className="absolute -bottom-1 -left-1 w-8 h-8 border-b-4 border-l-4 border-primary rounded-bl-xl z-20"></div>
|
||||
<div className="absolute -bottom-1 -right-1 w-8 h-8 border-b-4 border-r-4 border-primary rounded-br-xl z-20"></div>
|
||||
|
||||
<div className="absolute inset-0 overflow-hidden rounded-2xl z-10">
|
||||
<div className="w-full h-1/2 bg-gradient-to-b from-transparent via-primary/30 to-transparent opacity-50 absolute top-0 animate-scan"></div>
|
||||
<div className="w-full h-0.5 bg-primary shadow-[0_0_15px_rgba(23,207,84,0.8)] absolute top-1/2 animate-scan"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center px-8">
|
||||
<p className="text-white font-medium drop-shadow-md">
|
||||
Поместите обложку в рамку
|
||||
</p>
|
||||
{isProcessing && (
|
||||
<p className="text-primary text-sm font-medium mt-2 animate-pulse">
|
||||
Распознавание...
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm font-medium mt-2 bg-black/50 p-2 rounded-lg backdrop-blur-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-gradient-to-t from-black/90 via-black/80 to-transparent pb-10 pt-12 px-6">
|
||||
<div className="flex items-center justify-center max-w-md mx-auto">
|
||||
<button
|
||||
disabled={isProcessing}
|
||||
onClick={handleCapture}
|
||||
className="relative group cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className={`w-20 h-20 rounded-full border-4 ${isProcessing ? 'border-primary' : 'border-white/30'} flex items-center justify-center transition-all duration-300`}>
|
||||
<div className={`w-16 h-16 ${isProcessing ? 'bg-primary animate-pulse' : 'bg-white'} rounded-full transition-all duration-200 group-active:scale-90 shadow-[0_0_20px_rgba(255,255,255,0.3)]`}></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
55
books/services/geminiService.ts
Normal file
55
books/services/geminiService.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
|
||||
const BOOK_DATA_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
title: { type: Type.STRING, description: 'Title of the book' },
|
||||
author: { type: Type.STRING, description: 'Author of the book' },
|
||||
genre: { type: Type.STRING, description: 'Main genre of the book' },
|
||||
annotation: { type: Type.STRING, description: 'Brief summary or annotation' },
|
||||
pages: { type: Type.NUMBER, description: 'Approximate number of pages' },
|
||||
language: { type: Type.STRING, description: 'Language of the book' },
|
||||
publishedYear: { type: Type.NUMBER, description: 'Approximate publication year' }
|
||||
},
|
||||
required: ['title', 'author', 'genre']
|
||||
};
|
||||
|
||||
export async function extractBookData(base64Image: string) {
|
||||
// Ensure we use the exact initialization pattern from guidelines
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
|
||||
try {
|
||||
// Use gemini-3-flash-preview for multimodal extraction tasks
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3-flash-preview',
|
||||
contents: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: base64Image.replace(/^data:image\/[a-z]+;base64,/, "")
|
||||
}
|
||||
},
|
||||
{ text: "Identify this book from the cover. Extract the title, author, genre, and a brief annotation in Russian. Return the information in structured JSON format." }
|
||||
]
|
||||
},
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: BOOK_DATA_SCHEMA
|
||||
}
|
||||
});
|
||||
|
||||
const text = response.text;
|
||||
if (!text) throw new Error("No response from model");
|
||||
|
||||
return JSON.parse(text);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to extract book data:", error);
|
||||
// Log specific details if available
|
||||
if (error.message) {
|
||||
console.error("Error Message:", error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
29
books/tsconfig.json
Normal file
29
books/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
34
books/types.ts
Normal file
34
books/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
export enum AppScreen {
|
||||
LIBRARY = 'LIBRARY',
|
||||
CATEGORIES = 'CATEGORIES',
|
||||
WISHLIST = 'WISHLIST',
|
||||
SETTINGS = 'SETTINGS',
|
||||
DETAILS = 'DETAILS',
|
||||
ADD_BOOK = 'ADD_BOOK',
|
||||
SCANNER = 'SCANNER',
|
||||
}
|
||||
|
||||
export interface Book {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
genre: string;
|
||||
annotation: string;
|
||||
coverUrl?: string;
|
||||
pages?: number;
|
||||
language?: string;
|
||||
publishedYear?: number;
|
||||
rating?: number;
|
||||
status: 'reading' | 'done' | 'want_to_read';
|
||||
progress?: number;
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
icon: string;
|
||||
colorClass: string;
|
||||
}
|
||||
23
books/vite.config.ts
Normal file
23
books/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user