commit 63925333872cecabb8c58a030f441e8d4eb53fe8 Author: sbilketay Date: Mon Nov 10 00:14:49 2025 +0300 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..23fd975 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +NODE_ENV=development +PORT=8080 +GEMINI_API_KEY=your_gemini_key +ALLOWED_ORIGINS=* +REQUEST_LOGGING=true +REDIS_URL=redis://redis:6379 +ISBN_CACHE_TTL_SECONDS=21600 +JWT_SECRET=please_change_me +JWT_EXPIRES_IN=1h +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=bookibra +POSTGRES_USER=bookibra +POSTGRES_PASSWORD=bookibra diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8e5b3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Node.js +node_modules/ +.svelte-kit/ +.serena/ +.claude/ +.vscode +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +package-lock.json +.pnpm-debug.log + +# Build output +/build +/.svelte-kit +/dist +/public/build +/.output +/cache + +# Environment files +.env +.env.* +!.env.example + +# IDE / Editor +.vscode/ +.idea/ +*.swp +*.swo +*.sublime-project +*.sublime-workspace + +# OS generated files +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +pnpm-debug.log* + +# Misc +coverage/ +.cache/ +.sass-cache/ +.eslintcache +.stylelintcache + +# SvelteKit specific +.vercel +.netlify + +# Database files +*.db +db/*.db +yakit_takip.db \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ecfd841 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --legacy-peer-deps +COPY . . +EXPOSE 5173 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] diff --git a/doc/api.md b/doc/api.md new file mode 100644 index 0000000..a3c3746 --- /dev/null +++ b/doc/api.md @@ -0,0 +1,173 @@ +# Bookibra API Dokumani + +## Genel Bakis +Bookibra, Amazon kaynakli kitap verilerini ISBN, kitap adi ve yayin tarihi filtreleri ile sunan bir backend servisidir. Servis, gemini tabanli aciklama uretimi, Socket.IO altyapisi ve Redis/PostgreSQL baglanti noktalarini Docker uzerinden hazir halde getirir. ISBN tabanli tum sorgular otomatik olarak Redis uzerinde cache'lenir ve tekrar eden istekler Amazon'a gitmeden hizlica cevaplanir. + +## Calistirma +1. `.env` dosyanizi olusturun veya mevcut dosyada gerekli alanlari doldurun. Ornek icin `.env.example` goz atabilirsiniz. `ISBN_CACHE_TTL_SECONDS` degiskeni ile Redis cache omrunu saniye cinsinden ayarlayabilirsiniz (varsayilan 21600 sn / 6 saat). JWT tabanli oturum icin `JWT_SECRET` ve `JWT_EXPIRES_IN` alanlarini doldurun. +2. Docker ortamini acmak icin proje kok dizininde `docker compose up --build` komutunu calistirin. +3. API varsayilan olarak `http://localhost:8080`, React frontend ise `http://localhost:5173` adresinde yayin yapar. + +> Not: Docker ortamina eklenen Redis (6379) ve PostgreSQL (5432) servisleri su anda veri saklama icin zorunlu degildir, ancak ileride kullanima hazir sekilde ayaga kalkar. Socket.IO, backend konteyneri icinde hazirlanmistir ve istemci baglantilarini kabul eder. + +## Ortak Parametreler +Her endpoint icin asagidaki sorgu parametreleri desteklenir: + +| Parametre | Tip | Varsayilan | Aciklama | +|-----------|-----|------------|----------| +| `locales` | string | `en,tr` | `en`, `tr` veya `en,tr` seklinde virgulle ayrilmis diller. Her dil icin ayri Amazon kaynagi kullanilir. | +| `withGemini` | boolean | `false` | `true` oldugunda GEMINI_API_KEY kullanilarak kitap aciklamasi Gemini ile yeniden uretilir. | +| `limit` | integer | `3` | Liste bazli aramalarda donulecek maksimum kayit sayisi. ISBN aramasinda dikkate alinmaz. | + +`withGemini=true` parametresi kullanilirsa `.env` icinde `GEMINI_API_KEY` tanimli olmalidir, aksi halde API 400 hatasi dondurur. + +### Standart Yanıt Alanları +- `cacheHit`: (boolean) Yalnızca ISBN endpointinde görünür. Yanıt Redis'ten geldiyse `true` olur. +- `normalizedIsbn`: (string) Arama yaparken Amazon'a gönderilen normalize edilmis ISBN (ör: `605842285X` -> `9786058422854`). Kullanıcı girişi `isbn` alanında korunur. +- `cachedThumbnail`: (string) Thumbnail görselinin proje kokundaki `cache/` dizininde saklanan göreli yolu. Frontend bu yolu kullanarak yereldeki görseli isteyebilir. +- `cachedAt`: (ISO tarih) Cache'e kaydedildiği zamanı gösterir. Cache'ten dönülen yanıtta da aynı değer gönderilir. + +## Kimlik Dogrulama Endpointleri + +### 0. Kullanici Kaydi +- **URL:** `POST /api/auth/register` +- **Body:** `{ "email": "user@example.com", "password": "enAz6Karakter" }` +- **Donus:** `{ "token": "", "user": { "id": "...", "email": "..." } }` + +### 0.1 Giris +- **URL:** `POST /api/auth/login` +- **Body:** Kayit endpointi ile ayni. +- **Donus:** Basarili olursa JWT ve temel kullanici bilgileri. + +### 0.2 Profil +- **URL:** `GET /api/auth/profile` +- **Header:** `Authorization: Bearer ` +- **Donus:** `{ "user": { "id": "...", "email": "..." } }` + +> Not: Tum JWT'ler `.env` icindeki `JWT_SECRET` ile uretilir. Varsayilan sure `JWT_EXPIRES_IN` degiskeniyle (or. `1h`, `2d`) belirlenir. + +## Endpointler + +### 1. ISBN ile Arama +- **URL:** `GET /api/books/isbn/:isbn` +- **Aciklama:** Verilen ISBN numarasi icin ingilizce (`amazon.com`) ve turkce (`amazon.com.tr`) kayitlarindan detaya ulasir. Ilk sorgu Amazon'dan alinip Redis'e kaydedilir; takip eden ayni parametreli sorgular cache'ten servis edilir. +- **Ornek Istek:** + ```http + GET /api/books/isbn/9789750719383?locales=en,tr&withGemini=true HTTP/1.1 + Host: localhost:8080 + ``` +- **Ornek Donus:** + ```json + { + "isbn": "9789750719383", + "locales": ["en", "tr"], + "includeGemini": true, + "cacheHit": false, + "cachedAt": "2024-01-10T12:34:56.789Z", + "data": { + "en": { + "title": "Book Title", + "authorName": "Author", + "description": "Gemini tarafindan iyilestirilmis aciklama", + "date": "September 5, 2023", + "publisher": "Publisher", + "rate": "4.7", + "thumbImage": "https://..." + }, + "tr": { + "title": "Kitap Basligi", + "description": "Gemini aciklamasi", + "date": "5 Eylul 2023", + "publisher": "Yayinevi" + } + } + } + ``` +- **Hata Durumlari:** + - 400: ISBN formati hatali veya Gemini anahtari eksik. + - 404: Amazon tarafinda kayit bulunamazsa ilgili dil icin `error` alaninda bilgi gelir. + +### 2. Kitap Adi ile Arama +- **URL:** `GET /api/books/title` +- **Gerekli Parametre:** `title` +- **Aciklama:** Amazon arama sayfalarindan belirlenen `limit` kadar kaydi bulur, her biri icin detay sayfasina giderek bilgileri toplar. Bu endpointte cache mekanizması devrede değildir. +- **Ornek Istek:** + ```http + GET /api/books/title?title=atomic%20habits&limit=2&locales=en HTTP/1.1 + Host: localhost:8080 + ``` +- **Ornek Donus:** + ```json + { + "title": "atomic habits", + "locales": ["en"], + "limit": 2, + "includeGemini": false, + "data": [ + { + "locale": "en", + "items": [ + { + "asin": "0593189647", + "locale": "en", + "title": "Atomic Habits", + "authorName": "James Clear", + "date": "October 16, 2018", + "publisher": "Avery", + "rate": "4.8", + "summary": { + "asin": "0593189647", + "title": "Atomic Habits", + "price": "$16.20", + "image": "https://...", + "author": "James Clear" + } + } + ] + } + ] + } + ``` + +### 3. Kitap Adi + Tarih Filtreli Arama +- **URL:** `GET /api/books/filter` +- **Gerekli Parametreler:** `title`, `published` +- **Aciklama:** Ilgili kitabi basliktan arar, donen kayitlari `published` degerini icerenler ile sinirlar. `published` alanina yil (`2020`) veya tam tarih (`2020-05-01`, `May 2020`, `Eylul 2020`) verilebilir. Bu endpointte de cache mekanizması devre dışıdır. +- **Ornek Istek:** + ```http + GET /api/books/filter?title=lord%20of%20the%20rings&published=1954&locales=en,tr HTTP/1.1 + Host: localhost:8080 + ``` +- **Ornek Donus:** `title` endpointi ile ayni yapida olur ancak `items` listesi sadece `published` bilgisini iceren kayitlari tutar. + +## Health Check +- **URL:** `GET /health` +- **Aciklama:** Servisin ayakta oldugunu dogrulamak icin kullanilir. + +## Socket.IO +- Sunucu baglantilarini kabul eder ve `connection:ack` etkinligi ile basit bir karsilama mesaji gonderir. +- Ileride frontend istemcileri bu kanal uzerinden bildirim alabilir. + +## Redis & Thumbnail Cache ve Loglama +- ISBN sorgulari `book:isbn:{ISBN}:loc={lokaller}:gem={flag}` formundaki anahtarlarla varsayilan olarak 6 saat (21600 sn) boyunca Redis'te saklanir. Sure `.env` icindeki `ISBN_CACHE_TTL_SECONDS` degiskeniyle degistirilebilir. Yeni bir sorgu cache olusturdukten sonra tum parametre uyumlu talepler direk Redis'ten 3xx/4xx/5xx durumlarina bakilmaksizin cevaplanir. +- Amazon'dan donen her `thumbImage` url'si indirilip proje kokundaki `cache/` klasorune `{isbn}_{locale}_thumbnail.jpg/png` formatinda kaydedilir. Aynı istek tekrarlandiginda yerel dosya kullanilir ve API yanitina `cachedThumbnail` alanı olarak eklenir. Frontend bu yolu kullanarak dosyayi servis edebilir. +- Docker konsolunda `🚀`, `📦` gibi emojili ve chalk tabanli renkli loglar gorunur. Her istegin suresi parantez icinde gosterilir (ornegin `(152.3ms)`), cache'den gelen yanitlarda `[cache-hit 📦]`, Amazon'a gidilenlerde `[cache-miss 🆕]` etiketi yer alir. +- Redis veya thumbnail indirme surecinde olusan uyari/hata mesajlari da renklendirilmis bicimde loglanir; boylece sistem sagligi anlik olarak izlenebilir. + +## Frontend (React) +- `frontend/` dizininde Vite + React 18 tabanli bir login ekranı bulunur. Google/Apple sosyal butonlari ve Booklibra baykus logosu ile uyumlu pastel renk paleti kullanir. +- Varsayilan olarak tarayicinin bulundugu hostname uzerinden `:8080` portuna istek atar. Farkli bir domain kullanacaksaniz `VITE_API_BASE_URL` degiskenini `http(s)://...` formatinda build aninda girin. +- Gelistirme icin `cd frontend && npm run dev -- --host` komutu ile uygulamayi baslatabilirsiniz. + +## Altyapi +- **Redis:** `redis://redis:6379` adresinde, `docker-compose` ile otomatik calisir. +- **PostgreSQL:** Varsayilan `bookibra` veritabani ve kullanicisi ile ayaga kalkar. +- **Gemini:** `.env` dosyasina `GEMINI_API_KEY` eklendiginde aciklama zenginlestirme ozelligi aktive olur. + +## Hata Mesajlari +- Tum hatalar JSON formatinda `message` ve `code` alanlariyla dondurulur. +- Uygulama `NODE_ENV=development` iken `stack` bilgisi eklenir. + +## Gelistirme Ipuclari +- Lokal gelistirme icin `npm run dev` komutu Socket.IO ve API'yi hot-reload modunda calistirir. +- Test yazmadiginiz sürece `npm test` komutu sadece bos bir mesaj basar ancak CI hataya dusmez. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..862b517 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,65 @@ +services: + frontend: + build: ./frontend + container_name: bookibra_frontend + restart: unless-stopped + ports: + - "5173:5173" + depends_on: + api: + condition: service_started + volumes: + - ./frontend:/app + - frontend-node_modules:/app/node_modules + command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 5173" + + api: + build: . + container_name: bookibra_api + restart: unless-stopped + env_file: + - .env + ports: + - "8080:8080" + depends_on: + redis: + condition: service_healthy + postgres: + condition: service_healthy + volumes: + - ./:/app + command: npm run dev + + redis: + image: redis:7-alpine + container_name: bookibra_redis + restart: unless-stopped + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + postgres: + image: postgres:15-alpine + container_name: bookibra_postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-bookibra} + POSTGRES_USER: ${POSTGRES_USER:-bookibra} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-bookibra} + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-bookibra}"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres-data: + frontend-node_modules: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ecfd841 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --legacy-peer-deps +COPY . . +EXPOSE 5173 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..18bc70e --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..cee1e2c --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c20fbd3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4d714f1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.1.0", + "@fortawesome/free-brands-svg-icons": "^7.1.0", + "@fortawesome/free-solid-svg-icons": "^7.1.0", + "@fortawesome/react-fontawesome": "^3.1.0", + "color-thief-react": "^2.1.0", + "framer-motion": "^12.23.24", + "prop-types": "^15.8.1", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-image-glow": "^1.0.6", + "react-qr-barcode-scanner": "^2.1.18", + "react-router-dom": "^7.9.5" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.22", + "globals": "^16.4.0", + "vite": "^7.1.7" + } +} diff --git a/frontend/public/asset/apple.png b/frontend/public/asset/apple.png new file mode 100644 index 0000000..37e50d4 Binary files /dev/null and b/frontend/public/asset/apple.png differ diff --git a/frontend/public/asset/google.png b/frontend/public/asset/google.png new file mode 100644 index 0000000..662d505 Binary files /dev/null and b/frontend/public/asset/google.png differ diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..c628794 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,384 @@ +:root { + font-family: 'DM Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: #1f1f1f; + background: #f8f5f0; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: #f8f5f0; +} + +.mobile-shell { + max-width: 420px; + margin: 0 auto; + min-height: 100vh; + display: flex; + flex-direction: column; + background: #fefefe; + box-shadow: 0 12px 35px rgba(69, 52, 35, 0.12); +} + +.page-container { + flex: 1; + width: 100%; + padding: 1.5rem 1.5rem 8rem; + display: flex; + align-items: flex-start; + justify-content: center; +} + +.page-heading { + text-align: center; +} + +.page-heading h1 { + margin: 0; + font-size: 2.4rem; + letter-spacing: 0.03em; + color: #413122; +} + +.add-books-page { + width: 100%; + display: flex; + flex-direction: column; + gap: 1.2rem; + min-height: 100%; +} + +.search-form { + display: flex; + flex-direction: column; + gap: 0.75rem; + position: sticky; + top: 0.25rem; + background: #fefefe; + padding-bottom: 1rem; + z-index: 2; +} + +.search-field { + display: flex; + align-items: center; + border-radius: 18px; + border: 1px solid rgba(0, 0, 0, 0.08); + background: #fff; + padding: 0.6rem 0.75rem; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.05); +} + +.search-field input { + flex: 1; + border: none; + outline: none; + background: transparent; + font-size: 1rem; +} + +.search-field button { + border: none; + background: transparent; + color: #f2994a; + font-size: 1.2rem; +} + +.primary-btn { + border: none; + border-radius: 18px; + padding: 0.9rem; + font-size: 1rem; + font-weight: 600; + color: #fff; + background: linear-gradient(135deg, #f2994a, #f46b45); + cursor: pointer; +} + +.ghost-copy { + text-align: center; + color: rgba(0, 0, 0, 0.15); + font-size: 1.8rem; + letter-spacing: 0.1em; +} + +.status-text { + text-align: center; + font-weight: 600; +} + +.status-text.loading { + color: #555; +} + +.status-text.error { + color: #c0392b; +} + +.status-text.info { + color: #857c72; +} + +.book-list { + display: flex; + flex-direction: column; + gap: 0.9rem; + width: 100%; +} + +.book-card { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.8rem; + align-items: flex-start; + background: #fff; + padding: 1rem; + border-radius: 22px; + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.08); + border: none; + text-align: left; + cursor: pointer; + outline: none; +} + +.book-card::after { + content: ''; + width: 100%; + height: 3px; + border-radius: 999px; + background: linear-gradient(90deg, #f2994a, #f46b45); + opacity: 0.5; + display: block; + margin-top: 0.2rem; +} + +.book-card:focus-visible { + box-shadow: 0 0 0 3px rgba(242, 153, 74, 0.35); +} + +.book-thumb { + width: 72px; + height: 108px; + object-fit: cover; + border-radius: 14px; + box-shadow: 0 12px 25px rgba(0, 0, 0, 0.18); + align-self: center; +} + +.book-info h3 { + margin: 0; + font-size: 1.05rem; + color: #1f1f1f; +} + +.book-info { + width: 100%; +} + +.book-meta { + margin: 0.2rem 0; + color: #8b8175; + font-size: 0.9rem; +} + +.mybooks-list { + width: 100%; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.mybooks-detail { + width: 100%; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.back-btn { + border: none; + background: transparent; + color: #4f3823; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.detail-hero { + border-radius: 28px; + overflow: hidden; + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem 0; + background: linear-gradient(135deg, #f0eade, #f9f4ec); +} + +.detail-hero-glow { + width: 100%; + display: flex; + justify-content: center; +} + +.detail-hero-img { + width: 130px; + border-radius: 16px; + box-shadow: 0 20px 45px rgba(0, 0, 0, 0.25); +} + +.placeholder-thumb { + width: 110px; + height: 150px; + border-radius: 16px; + border: 1px dashed rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + color: rgba(0, 0, 0, 0.5); + font-weight: 600; +} + +.detail-body { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.book-description { + color: #5c5046; + line-height: 1.6; +} + +.secondary-btn { + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 14px; + padding: 0.85rem; + background: #fff; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + cursor: pointer; +} + +.bottom-nav { + position: fixed; + left: 50%; + bottom: 0; + transform: translateX(-50%); + width: min(420px, 100%); + background: #ffffff; + border-top-left-radius: 24px; + border-top-right-radius: 24px; + box-shadow: 0 -10px 35px rgba(0, 0, 0, 0.12); + padding: 0.65rem 1rem; + display: grid; + grid-template-columns: repeat(4, 1fr); + z-index: 20; +} + +.nav-item { + border: none; + background: transparent; + color: #a6a6a6; + font-weight: 600; + font-size: 0.8rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + padding: 0.4rem 0; + text-decoration: none; +} + +.nav-item svg { + font-size: 1.1rem; +} + +.nav-item.active { + color: #f2994a; +} + +.scanner-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + z-index: 50; +} + +.scanner-fullscreen { + position: relative; + width: 100%; + height: 100%; +} + +.scanner-fullscreen canvas, +.scanner-fullscreen video { + width: 100% !important; + height: 100% !important; + object-fit: cover; +} + +.scanner-overlay-line { + position: absolute; + left: 10%; + right: 10%; + top: 0; + height: 2px; + background: linear-gradient(90deg, transparent, #4ade80, transparent); + box-shadow: 0 0 12px rgba(74, 222, 128, 0.8); +} + +.scanner-overlay-info { + position: absolute; + bottom: 36px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.55); + color: #fff; + padding: 0.8rem 1.2rem; + border-radius: 16px; + text-align: center; + display: flex; + gap: 0.75rem; + align-items: center; +} + +.scanner-overlay-info button { + border: none; + background: rgba(255, 255, 255, 0.15); + color: #fff; + padding: 0.3rem 0.8rem; + border-radius: 999px; +} + +.save-toast { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 0, 0, 0.8); + color: #fff; + padding: 0.5rem 1rem; + border-radius: 16px; + font-weight: 600; + z-index: 70; +} + +@media (max-width: 480px) { + .mobile-shell { + max-width: 100%; + box-shadow: none; + } + + .page-container { + padding: 1rem 1rem 8rem; + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..f20c3ca --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,57 @@ +import { BrowserRouter, NavLink, Route, Routes, useLocation } from 'react-router-dom'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faHouse, faBookOpen, faPlusCircle, faUser } from '@fortawesome/free-solid-svg-icons'; +import HomePage from './pages/HomePage.jsx'; +import MyBooksPage from './pages/MyBooksPage.jsx'; +import AddBooksPage from './pages/AddBooksPage.jsx'; +import ProfilePage from './pages/ProfilePage.jsx'; +import { SavedBooksProvider } from './context/SavedBooksContext.jsx'; +import './App.css'; + +const tabs = [ + { path: '/', label: 'Home', icon: faHouse }, + { path: '/my-books', label: 'My Books', icon: faBookOpen }, + { path: '/add-books', label: 'Add Books', icon: faPlusCircle }, + { path: '/profile', label: 'Profile', icon: faUser } +]; + +function BottomNav() { + const location = useLocation(); + + return ( + + ); +} + +function App() { + return ( + + +
+
+ + } /> + } /> + } /> + } /> + +
+ +
+
+
+ ); +} + +export default App; diff --git a/frontend/src/assets/apple.png b/frontend/src/assets/apple.png new file mode 100644 index 0000000..2f98743 Binary files /dev/null and b/frontend/src/assets/apple.png differ diff --git a/frontend/src/assets/booklibra-logo.png b/frontend/src/assets/booklibra-logo.png new file mode 100644 index 0000000..bf54a05 Binary files /dev/null and b/frontend/src/assets/booklibra-logo.png differ diff --git a/frontend/src/assets/booklibra-logo.svg b/frontend/src/assets/booklibra-logo.svg new file mode 100644 index 0000000..fea2b7e --- /dev/null +++ b/frontend/src/assets/booklibra-logo.svg @@ -0,0 +1,702 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/booklibra-logo_2.svg b/frontend/src/assets/booklibra-logo_2.svg new file mode 100644 index 0000000..38cd2c8 --- /dev/null +++ b/frontend/src/assets/booklibra-logo_2.svg @@ -0,0 +1,4616 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/google.png b/frontend/src/assets/google.png new file mode 100644 index 0000000..f39297a Binary files /dev/null and b/frontend/src/assets/google.png differ diff --git a/frontend/src/assets/pngwing.apple.png b/frontend/src/assets/pngwing.apple.png new file mode 100644 index 0000000..2f98743 Binary files /dev/null and b/frontend/src/assets/pngwing.apple.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/BookCard.jsx b/frontend/src/components/BookCard.jsx new file mode 100644 index 0000000..9d87150 --- /dev/null +++ b/frontend/src/components/BookCard.jsx @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; + +export default function BookCard({ book, onSelect }) { + return ( + + ); +} + +BookCard.propTypes = { + book: PropTypes.shape({ + title: PropTypes.string, + authorName: PropTypes.string, + page: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + rate: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + thumbImage: PropTypes.string + }).isRequired, + onSelect: PropTypes.func +}; + +BookCard.defaultProps = { + onSelect: undefined +}; diff --git a/frontend/src/components/SocialButton.css b/frontend/src/components/SocialButton.css new file mode 100644 index 0000000..167bb8b --- /dev/null +++ b/frontend/src/components/SocialButton.css @@ -0,0 +1,37 @@ +.social-button { + width: 100%; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 999px; + padding: 0.9rem 1.4rem; + background: rgba(255, 255, 255, 0.75); + color: #2f2f2f; + font-weight: 600; + font-size: 0.95rem; + display: flex; + align-items: center; + gap: 0.65rem; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.social-button:hover { + transform: translateY(-1px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); +} + +.social-icon { + width: 40px; + height: 40px; + border-radius: 50%; + background: #fff; + display: grid; + place-items: center; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05); + overflow: hidden; +} + +.social-icon img { + width: 70%; + height: 70%; + object-fit: contain; +} diff --git a/frontend/src/components/SocialButton.jsx b/frontend/src/components/SocialButton.jsx new file mode 100644 index 0000000..25cf191 --- /dev/null +++ b/frontend/src/components/SocialButton.jsx @@ -0,0 +1,23 @@ +import PropTypes from 'prop-types'; +import './SocialButton.css'; + +export default function SocialButton({ provider, iconSrc, onClick }) { + return ( + + ); +} + +SocialButton.propTypes = { + provider: PropTypes.string.isRequired, + iconSrc: PropTypes.string.isRequired, + onClick: PropTypes.func +}; + +SocialButton.defaultProps = { + onClick: () => {} +}; diff --git a/frontend/src/context/SavedBooksContext.jsx b/frontend/src/context/SavedBooksContext.jsx new file mode 100644 index 0000000..df048bc --- /dev/null +++ b/frontend/src/context/SavedBooksContext.jsx @@ -0,0 +1,44 @@ +import { createContext, useContext, useEffect, useState } from 'react'; + +const STORAGE_KEY = 'bookibra_saved_books'; +const SavedBooksContext = createContext(); + +export function SavedBooksProvider({ children }) { + const [savedBooks, setSavedBooks] = useState(() => { + if (typeof window === 'undefined') return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch (error) { + console.warn('Saved books parse error', error); + return []; + } + }); + + useEffect(() => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(savedBooks)); + } + }, [savedBooks]); + + const addBook = (book) => { + setSavedBooks((prev) => { + if (prev.find((item) => item.title === book.title && item.authorName === book.authorName)) { + return prev; + } + return [...prev, book]; + }); + }; + + const removeBook = (title) => { + setSavedBooks((prev) => prev.filter((book) => book.title !== title)); + }; + + return ( + + {children} + + ); +} + +export const useSavedBooks = () => useContext(SavedBooksContext); diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..62f2a91 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,23 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap'); + +:root { + font-family: 'DM Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + line-height: 1.5; + font-weight: 400; + color: #1b1b1b; + background-color: #ffffff; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background-color: #ffffff; +} + +#root { + min-height: 100vh; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..7497ae8 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.jsx'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); diff --git a/frontend/src/pages/AddBooksPage.jsx b/frontend/src/pages/AddBooksPage.jsx new file mode 100644 index 0000000..5feb540 --- /dev/null +++ b/frontend/src/pages/AddBooksPage.jsx @@ -0,0 +1,210 @@ +import { useMemo, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faBarcode } from '@fortawesome/free-solid-svg-icons'; +import BarcodeScannerComponent from 'react-qr-barcode-scanner'; +import { motion } from 'framer-motion'; +import BookCard from '../components/BookCard.jsx'; +import { useSavedBooks } from '../context/SavedBooksContext.jsx'; + +const resolveApiBaseUrl = () => { + if (import.meta.env.VITE_API_BASE_URL) return import.meta.env.VITE_API_BASE_URL; + if (typeof window !== 'undefined') { + const { protocol, hostname } = window.location; + return `${protocol}//${hostname}:8080`; + } + return 'http://localhost:8080'; +}; + +const API_BASE_URL = resolveApiBaseUrl(); +const CAMERA_CONSTRAINTS = { facingMode: 'environment' }; + +const parseSearchInput = (raw) => { + const fragments = raw.split('.').map((piece) => piece.trim()).filter(Boolean); + if (fragments.length <= 1) { + return { title: raw.trim(), published: undefined }; + } + const maybeYear = fragments.at(-1); + if (/^\d{4}$/.test(maybeYear)) { + const title = fragments.slice(0, -1).join(' '); + return { title, published: maybeYear }; + } + return { title: raw.trim(), published: undefined }; +}; + +const normalizeBook = (item) => { + const summary = item.summary || {}; + return { + title: item.title || summary.title, + authorName: item.authorName || summary.author, + thumbImage: item.thumbImage || summary.image, + page: item.page, + rate: item.rate, + publisher: item.publisher, + description: item.description, + isbn: item.isbn || summary.asin || summary.isbn, + raw: item + }; +}; + +const flattenBookResults = (payload) => { + if (!payload?.data) return []; + if (Array.isArray(payload.data)) { + return payload.data.flatMap((entry) => (entry.items || []).map(normalizeBook)); + } + if (typeof payload.data === 'object') { + return Object.values(payload.data) + .filter(Boolean) + .map(normalizeBook); + } + return []; +}; + +export default function AddBooksPage() { + const [searchTerm, setSearchTerm] = useState(''); + const [books, setBooks] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const { addBook } = useSavedBooks(); + const [scannerOpen, setScannerOpen] = useState(false); + const [scanMessage, setScanMessage] = useState(''); + const [scannedIsbn, setScannedIsbn] = useState(''); + const [toast, setToast] = useState(false); + + const hasResults = books.length > 0; + + const handleSearch = async (event) => { + event.preventDefault(); + const trimmed = searchTerm.trim(); + if (!trimmed) return; + + const { title, published } = parseSearchInput(trimmed); + const endpoint = published + ? `/api/books/filter?title=${encodeURIComponent(title)}&published=${published}` + : `/api/books/title?title=${encodeURIComponent(title)}`; + + setLoading(true); + setError(''); + try { + const response = await fetch(`${API_BASE_URL}${endpoint}`); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || 'Arama basarisiz'); + } + const flattened = flattenBookResults(data); + setBooks(flattened); + } catch (err) { + setBooks([]); + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleBarcodeUpdate = async (_, result) => { + if (!result?.text) return; + const cleaned = result.text.replace(/[^0-9Xx]/g, '').toUpperCase(); + if (!cleaned || cleaned === scannedIsbn) return; + setScannedIsbn(cleaned); + setScanMessage('ISBN okundu, kitap getiriliyor...'); + try { + const response = await fetch(`${API_BASE_URL}/api/books/isbn/${cleaned}?locales=en`); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || 'Kitap bulunamadi'); + } + const flattened = flattenBookResults(data); + setBooks(flattened); + setScanMessage('Kitap bulundu.'); + setScannerOpen(false); + } catch (err) { + setScanMessage(err.message); + } + }; + + const viewport = typeof window !== 'undefined' + ? { width: window.innerWidth, height: window.innerHeight } + : { width: 320, height: 480 }; + + const scannerOverlay = useMemo(() => ( + scannerOpen && ( +
+
+ + +
+

{scanMessage || 'Barkodu hizala'}

+ +
+
+
+ ) + ), [scannerOpen, scanMessage]); + + return ( +
+
+
+ setSearchTerm(event.target.value)} + placeholder="Kitap adi veya 'Adi .2020'" + /> + +
+ +
+ + {!hasResults && !loading && !error && ( +

Search Books

+ )} + + {loading &&

Araniyor...

} + {error &&

{error}

} + +
+ {hasResults && books.map((book, index) => ( + { + addBook(item); + setToast(true); + setTimeout(() => setToast(false), 1000); + }} + /> + ))} +
+ + {!hasResults && !loading && !error && ( +

Henüz bir arama yapilmadi.

+ )} + + {toast &&
Saved!
} + {scannerOverlay} +
+ ); +} diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx new file mode 100644 index 0000000..a510679 --- /dev/null +++ b/frontend/src/pages/HomePage.jsx @@ -0,0 +1,7 @@ +export default function HomePage() { + return ( +
+

Home

+
+ ); +} diff --git a/frontend/src/pages/MyBooksPage.jsx b/frontend/src/pages/MyBooksPage.jsx new file mode 100644 index 0000000..3444dfb --- /dev/null +++ b/frontend/src/pages/MyBooksPage.jsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faChevronLeft, faTrash } from '@fortawesome/free-solid-svg-icons'; +import ImageGlow from 'react-image-glow'; +import BookCard from '../components/BookCard.jsx'; +import { useSavedBooks } from '../context/SavedBooksContext.jsx'; + +export default function MyBooksPage() { + const { savedBooks, removeBook } = useSavedBooks(); + const [selected, setSelected] = useState(null); + + const handleRemove = (title) => { + removeBook(title); + setSelected(null); + }; + + if (selected) { + return ( +
+ + +
+ {selected.thumbImage ? ( +
+ + {selected.title} + +
+ ) : ( +
NO COVER
+ )} +
+ +
+

{selected.title}

+

{selected.authorName || 'Unknown author'}

+

+ {selected.page ? `${selected.page} pages` : 'Page count N/A'} · {selected.rate || '-'} ★ +

+

{selected.publisher || 'Publisher unknown'}

+

{selected.description || 'Description not available.'}

+ +
+
+ ); + } + + return ( +
+ {savedBooks.length === 0 &&

Henüz kayitli kitap yok.

} + {savedBooks.map((book, index) => ( + + ))} +
+ ); +} diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx new file mode 100644 index 0000000..1867feb --- /dev/null +++ b/frontend/src/pages/ProfilePage.jsx @@ -0,0 +1,7 @@ +export default function ProfilePage() { + return ( +
+

Profile

+
+ ); +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..e94c51d --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,15 @@ +/* eslint-env node */ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +const allowedHosts = (process.env.VITE_ALLOWED_HOSTS || '') + .split(',') + .map((host) => host.trim()) + .filter(Boolean); + +export default defineConfig({ + plugins: [react()], + server: { + allowedHosts: ['localhost', '127.0.0.1', 'booklibra.wisecolt-panda.net', ...allowedHosts] + } +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..b218d3b --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "bookibra", + "version": "1.0.0", + "description": "Amazon kitap verilerini sunan Express tabanli backend", + "main": "src/server.js", + "scripts": { + "dev": "nodemon src/server.js", + "start": "node src/server.js", + "test": "echo \"Test tanimlanmadi\" && exit 0" + }, + "keywords": [ + "books", + "express", + "docker", + "socket.io" + ], + "author": "", + "license": "ISC", + "dependencies": { + "axios": "^1.13.2", + "bcrypt": "^6.0.0", + "chalk": "^4.1.2", + "cheerio": "^1.1.2", + "cors": "^2.8.5", + "dotenv": "^17.2.3", + "express": "^5.1.0", + "express-validator": "^7.3.0", + "ioredis": "^5.8.2", + "jsonwebtoken": "^9.0.2", + "pg": "^8.16.3", + "socket.io": "^4.8.1", + "szbk-amazon-book-search": "^1.1.1", + "uuid": "^13.0.0" + }, + "devDependencies": { + "nodemon": "^3.1.10" + } +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..2ea2ed6 --- /dev/null +++ b/src/app.js @@ -0,0 +1,34 @@ +const express = require('express'); +const cors = require('cors'); +const bookRoutes = require('./routes/bookRoutes'); +const authRoutes = require('./routes/authRoutes'); +const notFound = require('./middleware/notFound'); +const errorHandler = require('./middleware/errorHandler'); +const requestLogger = require('./middleware/requestLogger'); +const env = require('./config/env'); + +const app = express(); + +const corsOptions = env.allowedOrigins === '*' + ? { origin: '*' } + : { origin: env.allowedOrigins.split(',').map((origin) => origin.trim()) }; + +app.use(cors(corsOptions)); +app.use(express.json({ limit: '1mb' })); +app.use(express.urlencoded({ extended: true })); + +if (env.flags.enableRequestLogging) { + app.use(requestLogger); +} + +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +app.use('/api/auth', authRoutes); +app.use('/api/books', bookRoutes); + +app.use(notFound); +app.use(errorHandler); + +module.exports = app; diff --git a/src/config/env.js b/src/config/env.js new file mode 100644 index 0000000..3027fd3 --- /dev/null +++ b/src/config/env.js @@ -0,0 +1,37 @@ +const path = require('path'); +const dotenv = require('dotenv'); + +dotenv.config({ path: process.env.ENV_PATH || path.resolve(process.cwd(), '.env') }); + +const toBoolean = (value, fallback = false) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); + } + return fallback; +}; + +const env = { + nodeEnv: process.env.NODE_ENV || 'development', + port: parseInt(process.env.PORT || '8080', 10), + geminiApiKey: process.env.GEMINI_API_KEY, + allowedOrigins: process.env.ALLOWED_ORIGINS || '*', + redisUrl: process.env.REDIS_URL || 'redis://redis:6379', + redisCacheTtlSeconds: parseInt(process.env.ISBN_CACHE_TTL_SECONDS || '21600', 10), + jwt: { + secret: process.env.JWT_SECRET || 'super-secret-key-change-me', + expiresIn: process.env.JWT_EXPIRES_IN || '1h' + }, + postgres: { + host: process.env.POSTGRES_HOST || 'postgres', + port: parseInt(process.env.POSTGRES_PORT || '5432', 10), + database: process.env.POSTGRES_DB || 'bookibra', + user: process.env.POSTGRES_USER || 'bookibra', + password: process.env.POSTGRES_PASSWORD || 'bookibra' + }, + flags: { + enableRequestLogging: toBoolean(process.env.REQUEST_LOGGING, true) + } +}; + +module.exports = env; diff --git a/src/config/services.js b/src/config/services.js new file mode 100644 index 0000000..285a623 --- /dev/null +++ b/src/config/services.js @@ -0,0 +1,49 @@ +const Redis = require('ioredis'); +const { Pool } = require('pg'); +const env = require('./env'); +const logger = require('../utils/logger'); + +const redis = new Redis(env.redisUrl, { + lazyConnect: true, + maxRetriesPerRequest: null +}); + +const pgPool = new Pool({ + host: env.postgres.host, + port: env.postgres.port, + database: env.postgres.database, + user: env.postgres.user, + password: env.postgres.password, + idleTimeoutMillis: 30_000 +}); + +const warmupInfrastructure = async () => { + const state = { redis: false, postgres: false }; + + try { + if (!redis.status || redis.status === 'end') { + await redis.connect(); + } + await redis.ping(); + state.redis = true; + } catch (error) { + logger.warn(`[redis] baglanti hazir degil: ${error.message}`); + } + + try { + const client = await pgPool.connect(); + await client.query('SELECT 1'); + client.release(); + state.postgres = true; + } catch (error) { + logger.warn(`[postgres] baglanti hazir degil: ${error.message}`); + } + + return state; +}; + +module.exports = { + redis, + pgPool, + warmupInfrastructure +}; diff --git a/src/config/socket.js b/src/config/socket.js new file mode 100644 index 0000000..d1be4dd --- /dev/null +++ b/src/config/socket.js @@ -0,0 +1,32 @@ +const { Server } = require('socket.io'); +const env = require('./env'); + +let io; + +const initSocket = (httpServer) => { + io = new Server(httpServer, { + cors: { + origin: env.allowedOrigins === '*' ? '*' : env.allowedOrigins.split(','), + methods: ['GET', 'POST'] + } + }); + + io.on('connection', (socket) => { + console.log(`[socket] istemci baglandi: ${socket.id}`); + socket.emit('connection:ack', { message: 'Bookibra soket servisine hos geldiniz.' }); + socket.on('disconnect', () => { + console.log(`[socket] istemci ayrildi: ${socket.id}`); + }); + }); + + return io; +}; + +const getIO = () => { + if (!io) { + throw new Error('Socket.IO baslatilmadi'); + } + return io; +}; + +module.exports = { initSocket, getIO }; diff --git a/src/controllers/authController.js b/src/controllers/authController.js new file mode 100644 index 0000000..311ab14 --- /dev/null +++ b/src/controllers/authController.js @@ -0,0 +1,41 @@ +const { validationResult } = require('express-validator'); +const { createUser, validateCredentials, buildAuthResponse } = require('../services/userService'); + +const handleValidation = (req) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + const error = new Error(errors.array().map((e) => e.msg).join(', ')); + error.status = 400; + throw error; + } +}; + +const register = async (req, res, next) => { + try { + handleValidation(req); + const user = await createUser(req.body); + res.status(201).json(buildAuthResponse(user)); + } catch (error) { + next(error); + } +}; + +const login = async (req, res, next) => { + try { + handleValidation(req); + const user = await validateCredentials(req.body); + res.json(buildAuthResponse(user)); + } catch (error) { + next(error); + } +}; + +const profile = async (req, res) => { + res.json({ user: req.user }); +}; + +module.exports = { + register, + login, + profile +}; diff --git a/src/controllers/bookController.js b/src/controllers/bookController.js new file mode 100644 index 0000000..5906382 --- /dev/null +++ b/src/controllers/bookController.js @@ -0,0 +1,156 @@ +const { getBooksByIsbn, searchBooksByTitle, searchBooksByTitleAndDate } = require('../services/amazonService'); +const { + getIsbnCache, + setIsbnCache, + getTitleCache, + setTitleCache, + getFilterCache, + setFilterCache +} = require('../services/cacheService'); +const { toBoolean, resolveLocales, toInteger } = require('../utils/request'); +const { normalizeIsbnForLookup } = require('../utils/isbn'); +const env = require('../config/env'); + +const ensureGeminiAvailability = (includeGemini) => { + if (includeGemini && !env.geminiApiKey) { + const error = new Error('Gemini entegrasyonu icin GEMINI_API_KEY tanimlanmali'); + error.status = 400; + throw error; + } +}; + +const buildMeta = (req) => ({ + locales: resolveLocales(req.query.locales), + includeGemini: toBoolean(req.query.withGemini, false), + limit: toInteger(req.query.limit, 3) +}); + +const searchByIsbn = async (req, res, next) => { + try { + const requestedIsbn = req.params.isbn; + const normalizedIsbn = normalizeIsbnForLookup(requestedIsbn); + const { locales, includeGemini } = buildMeta(req); + ensureGeminiAvailability(includeGemini); + + const cached = await getIsbnCache(normalizedIsbn, locales, includeGemini); + if (cached) { + res.locals.cacheHit = true; + return res.json({ + ...cached, + cacheHit: true + }); + } + + const data = await getBooksByIsbn(normalizedIsbn, locales, includeGemini); + const responsePayload = { + isbn: requestedIsbn, + normalizedIsbn, + locales, + includeGemini, + data, + cachedAt: new Date().toISOString() + }; + await setIsbnCache(normalizedIsbn, locales, includeGemini, responsePayload); + res.locals.cacheHit = false; + res.json({ + ...responsePayload, + cacheHit: false + }); + } catch (error) { + next(error); + } +}; + +const searchByTitle = async (req, res, next) => { + try { + const title = req.query.title; + if (!title) { + const error = new Error('title parametresi zorunludur'); + error.status = 400; + throw error; + } + + const { locales, includeGemini, limit } = buildMeta(req); + ensureGeminiAvailability(includeGemini); + + const cacheParams = { title, locales, includeGemini, limit }; + const cached = await getTitleCache(cacheParams); + if (cached) { + res.locals.cacheHit = true; + return res.json({ ...cached, cacheHit: true }); + } + + const data = await searchBooksByTitle({ title, locales, includeGemini, limit }); + const responsePayload = { + title, + locales, + limit, + includeGemini, + data, + cachedAt: new Date().toISOString() + }; + await setTitleCache(cacheParams, responsePayload); + res.locals.cacheHit = false; + + res.json({ + ...responsePayload, + cacheHit: false + }); + } catch (error) { + next(error); + } +}; + +const searchByTitleAndDate = async (req, res, next) => { + try { + const { title, published } = req.query; + if (!title || !published) { + const error = new Error('title ve published parametreleri zorunludur'); + error.status = 400; + throw error; + } + + const { locales, includeGemini, limit } = buildMeta(req); + ensureGeminiAvailability(includeGemini); + + const cacheParams = { title, published, locales, includeGemini, limit }; + const cached = await getFilterCache(cacheParams); + if (cached) { + res.locals.cacheHit = true; + return res.json({ ...cached, cacheHit: true }); + } + + const data = await searchBooksByTitleAndDate({ + title, + published, + locales, + includeGemini, + limit + }); + + const responsePayload = { + title, + published, + locales, + limit, + includeGemini, + data, + cachedAt: new Date().toISOString() + }; + await setFilterCache(cacheParams, responsePayload); + res.locals.cacheHit = false; + + res.json({ + ...responsePayload, + cacheHit: false + }); + } catch (error) { + next(error); + } +}; + +module.exports = { + searchByIsbn, + searchByTitle, + searchByTitleAndDate +}; diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..6e74a08 --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,17 @@ +const jwt = require('jsonwebtoken'); +const env = require('../config/env'); + +module.exports = (req, res, next) => { + const authHeader = req.headers.authorization || ''; + const token = authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null; + if (!token) { + return res.status(401).json({ message: 'Kimlik dogrulama gerekiyor' }); + } + try { + const decoded = jwt.verify(token, env.jwt.secret); + req.user = { id: decoded.sub, email: decoded.email }; + next(); + } catch (error) { + res.status(401).json({ message: 'Token gecersiz veya suresi dolmus' }); + } +}; diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js new file mode 100644 index 0000000..040b33a --- /dev/null +++ b/src/middleware/errorHandler.js @@ -0,0 +1,13 @@ +module.exports = (err, req, res, next) => { + const status = err.status || 500; + const payload = { + message: err.message || 'Beklenmeyen bir hata olustu', + code: err.code || 'UNEXPECTED_ERROR' + }; + + if (process.env.NODE_ENV !== 'production' && err.stack) { + payload.stack = err.stack; + } + + res.status(status).json(payload); +}; diff --git a/src/middleware/notFound.js b/src/middleware/notFound.js new file mode 100644 index 0000000..521bf70 --- /dev/null +++ b/src/middleware/notFound.js @@ -0,0 +1,6 @@ +module.exports = (req, res, next) => { + res.status(404).json({ + message: 'Aradiginiz kaynak bulunamadi', + path: req.originalUrl + }); +}; diff --git a/src/middleware/requestLogger.js b/src/middleware/requestLogger.js new file mode 100644 index 0000000..d031102 --- /dev/null +++ b/src/middleware/requestLogger.js @@ -0,0 +1,37 @@ +const logger = require('../utils/logger'); +const { redis } = require('../config/services'); +const { ensureRedisConnection } = require('../services/cacheService'); + +const logRedisStats = async () => { + try { + if (!redis) { + logger.warn('Redis baglantisi bulunamadigi icin kayit sayisi alinamadi'); + return; + } + await ensureRedisConnection(); + const count = await redis.dbsize(); + logger.info(`📊 Redis kayit sayisi: ${count}`); + } catch (error) { + logger.warn(`Redis kayit sayisi alinamadi: ${error.message}`); + } +}; + +module.exports = (req, res, next) => { + const start = process.hrtime.bigint(); + + res.on('finish', () => { + const diff = process.hrtime.bigint() - start; + const durationMs = Number(diff) / 1e6; + const cacheHit = res.locals.cacheHit; + logger.request({ + method: req.method, + url: req.originalUrl, + status: res.statusCode, + durationMs, + cacheHit + }); + logRedisStats(); + }); + + next(); +}; diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js new file mode 100644 index 0000000..d3dd501 --- /dev/null +++ b/src/routes/authRoutes.js @@ -0,0 +1,12 @@ +const { Router } = require('express'); +const { register, login, profile } = require('../controllers/authController'); +const { registerValidator, loginValidator } = require('../validators/authValidators'); +const authMiddleware = require('../middleware/auth'); + +const router = Router(); + +router.post('/register', registerValidator, register); +router.post('/login', loginValidator, login); +router.get('/profile', authMiddleware, profile); + +module.exports = router; diff --git a/src/routes/bookRoutes.js b/src/routes/bookRoutes.js new file mode 100644 index 0000000..80f317d --- /dev/null +++ b/src/routes/bookRoutes.js @@ -0,0 +1,14 @@ +const { Router } = require('express'); +const { + searchByIsbn, + searchByTitle, + searchByTitleAndDate +} = require('../controllers/bookController'); + +const router = Router(); + +router.get('/isbn/:isbn', searchByIsbn); +router.get('/title', searchByTitle); +router.get('/filter', searchByTitleAndDate); + +module.exports = router; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..63ad0a8 --- /dev/null +++ b/src/server.js @@ -0,0 +1,17 @@ +const http = require('http'); +const app = require('./app'); +const env = require('./config/env'); +const { initSocket } = require('./config/socket'); +const { warmupInfrastructure } = require('./config/services'); +const { ensureUserTable } = require('./services/userService'); +const logger = require('./utils/logger'); + +const server = http.createServer(app); +initSocket(server); + +server.listen(env.port, async () => { + logger.success(`Bookibra API ${env.port} portunda ayakta (NODE_ENV=${env.nodeEnv})`); + const readiness = await warmupInfrastructure(); + logger.info(`Servis hazirlik durumu: ${JSON.stringify(readiness)}`); + await ensureUserTable(); +}); diff --git a/src/services/amazonService.js b/src/services/amazonService.js new file mode 100644 index 0000000..7950c78 --- /dev/null +++ b/src/services/amazonService.js @@ -0,0 +1,143 @@ +const axios = require('axios'); +const cheerio = require('cheerio'); +const AmazonBookSearch = require('szbk-amazon-book-search'); +const amazonConfig = require('szbk-amazon-book-search/config'); +const { extractBookDetails } = require('szbk-amazon-book-search/lib/module'); +const env = require('../config/env'); +const logger = require('../utils/logger'); +const { cacheThumbnail } = require('../utils/mediaCache'); + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const buildHeaders = () => ({ ...amazonConfig.headers }); + +const buildSearchUrl = (locale, query) => { + const base = locale === 'tr' ? amazonConfig.tr_base_url : amazonConfig.en_base_url; + return encodeURI(`${base}${query}`); +}; + +const buildDetailUrl = (locale, asin) => { + const base = locale === 'tr' ? amazonConfig.tr_detail_url : amazonConfig.en_detail_url; + return encodeURI(`${base}${asin}`); +}; + +const createAmazonClient = (locale) => new AmazonBookSearch(locale); + +const getGeminiKey = (includeGemini) => (includeGemini ? env.geminiApiKey : undefined); + +const fetchPage = async (url) => { + const response = await axios.get(url, { headers: buildHeaders() }); + if (!response.data) { + throw new Error('Amazon sayfasina erisilemedi'); + } + return response.data; +}; + +const parseSearchResults = (html, limit) => { + const $ = cheerio.load(html); + const items = []; + + $('[data-component-type="s-search-result"]').each((_, element) => { + const asin = $(element).attr('data-asin'); + if (!asin) return; + + const title = $(element).find('h2 a span').first().text().trim(); + const price = $(element).find('[data-a-color="base"] .a-offscreen').first().text().trim(); + const image = $(element).find('img.s-image').attr('src'); + const author = $(element).find('.a-color-secondary .a-size-base+ .a-size-base').first().text().trim(); + + items.push({ asin, title, price, image, author }); + }); + + return items.slice(0, limit); +}; + +const fetchDetailsByAsin = async (asin, locale, includeGemini) => { + await delay(amazonConfig.fetchTimeout); + const detailHtml = await fetchPage(buildDetailUrl(locale, asin)); + const fallbackIsbn = '0000000000'; + const details = await extractBookDetails( + detailHtml, + fallbackIsbn, + getGeminiKey(includeGemini), + locale + ); + const cachedThumbnail = await cacheThumbnail({ + isbn: details.isbn || fallbackIsbn, + locale, + url: details.thumbImage + }); + return { asin, locale, cachedThumbnail, ...details }; +}; + +const fetchBooksForLocale = async ({ query, locale, limit, includeGemini }) => { + const searchHtml = await fetchPage(buildSearchUrl(locale, query)); + const asins = parseSearchResults(searchHtml, limit); + + const detailedResults = []; + for (const asinInfo of asins) { + try { + const detailed = await fetchDetailsByAsin(asinInfo.asin, locale, includeGemini); + detailedResults.push({ ...detailed, summary: asinInfo }); + } catch (error) { + logger.warn(`[amazon:${locale}] ${asinInfo.asin} detayi cekilemedi: ${error.message}`); + } + } + + return detailedResults; +}; + +const getBooksByIsbn = async (isbn, locales, includeGemini) => { + const results = {}; + const tasks = locales.map(async (locale) => { + const client = createAmazonClient(locale); + try { + const data = await client.getBookDetails(isbn, getGeminiKey(includeGemini)); + data.cachedThumbnail = await cacheThumbnail({ isbn, locale, url: data.thumbImage }); + results[locale] = data; + } catch (error) { + results[locale] = { error: error.message }; + } + }); + + await Promise.all(tasks); + return results; +}; + +const searchBooksByTitle = async ({ title, locales, includeGemini, limit }) => { + const tasks = locales.map(async (locale) => { + try { + const list = await fetchBooksForLocale({ + query: title, + locale, + limit, + includeGemini + }); + return { locale, items: list }; + } catch (error) { + return { locale, items: [], error: error.message }; + } + }); + + return Promise.all(tasks); +}; + +const filterBooksByPublished = (books, publishedText) => { + if (!publishedText) return books; + const normalized = publishedText.toLowerCase(); + return books.filter((book) => book.date && book.date.toLowerCase().includes(normalized)); +}; + +const searchBooksByTitleAndDate = async ({ title, published, locales, includeGemini, limit }) => { + const localeResults = await searchBooksByTitle({ title, locales, includeGemini, limit }); + return localeResults.map((result) => ({ + ...result, + items: filterBooksByPublished(result.items, published) + })); +}; + +module.exports = { + getBooksByIsbn, + searchBooksByTitle, + searchBooksByTitleAndDate +}; diff --git a/src/services/cacheService.js b/src/services/cacheService.js new file mode 100644 index 0000000..8e6d9cd --- /dev/null +++ b/src/services/cacheService.js @@ -0,0 +1,76 @@ +const { redis } = require('../config/services'); +const env = require('../config/env'); +const logger = require('../utils/logger'); + +const CACHE_TTL = Number.isFinite(env.redisCacheTtlSeconds) ? env.redisCacheTtlSeconds : 21600; + +const ensureRedisConnection = async () => { + if (!redis) return; + if (redis.status === 'ready') return; + if (!redis.status || redis.status === 'end') { + await redis.connect(); + } +}; + +const readCache = async (cacheKey) => { + try { + await ensureRedisConnection(); + const cached = await redis.get(cacheKey); + if (cached) { + logger.cacheHit(cacheKey); + return JSON.parse(cached); + } + } catch (error) { + logger.warn(`Redis cache okunurken hata: ${error.message}`); + } + return null; +}; + +const writeCache = async (cacheKey, payload) => { + try { + await ensureRedisConnection(); + await redis.set(cacheKey, JSON.stringify(payload), 'EX', CACHE_TTL); + logger.cacheMiss(cacheKey); + } catch (error) { + logger.warn(`Redis cache yazilirken hata: ${error.message}`); + } +}; + +const normalizeLocales = (locales = []) => [...locales].map((locale) => locale.toLowerCase()).sort().join(','); +const encodeText = (value = '') => encodeURIComponent(value.trim().toLowerCase()); + +const buildIsbnCacheKey = (isbn, locales, includeGemini) => { + const geminiFlag = includeGemini ? '1' : '0'; + return `book:isbn:${isbn}:loc=${normalizeLocales(locales)}:gem=${geminiFlag}`; +}; + +const buildTitleCacheKey = ({ title, locales, includeGemini, limit }) => { + const geminiFlag = includeGemini ? '1' : '0'; + return `book:title:q=${encodeText(title)}:loc=${normalizeLocales(locales)}:gem=${geminiFlag}:limit=${limit}`; +}; + +const buildFilterCacheKey = ({ title, published, locales, includeGemini, limit }) => { + const geminiFlag = includeGemini ? '1' : '0'; + return `book:filter:q=${encodeText(title)}:pub=${encodeText(published)}:loc=${normalizeLocales(locales)}:gem=${geminiFlag}:limit=${limit}`; +}; + +const getIsbnCache = (isbn, locales, includeGemini) => + readCache(buildIsbnCacheKey(isbn, locales, includeGemini)); +const setIsbnCache = (isbn, locales, includeGemini, payload) => + writeCache(buildIsbnCacheKey(isbn, locales, includeGemini), payload); + +const getTitleCache = (params) => readCache(buildTitleCacheKey(params)); +const setTitleCache = (params, payload) => writeCache(buildTitleCacheKey(params), payload); + +const getFilterCache = (params) => readCache(buildFilterCacheKey(params)); +const setFilterCache = (params, payload) => writeCache(buildFilterCacheKey(params), payload); + +module.exports = { + getIsbnCache, + setIsbnCache, + getTitleCache, + setTitleCache, + getFilterCache, + setFilterCache, + ensureRedisConnection +}; diff --git a/src/services/userService.js b/src/services/userService.js new file mode 100644 index 0000000..0edb599 --- /dev/null +++ b/src/services/userService.js @@ -0,0 +1,80 @@ +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const { v4: uuid } = require('uuid'); +const { pgPool } = require('../config/services'); +const env = require('../config/env'); +const logger = require('../utils/logger'); + +const USERS_TABLE = 'users'; + +const ensureUserTable = async () => { + const createTableSQL = ` + CREATE TABLE IF NOT EXISTS ${USERS_TABLE} ( + id VARCHAR(36) PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `; + + await pgPool.query(createTableSQL); + logger.info('👥 Kullanici tablosu hazir'); +}; + +const hashPassword = (plain) => bcrypt.hash(plain, 10); +const comparePassword = (plain, hash) => bcrypt.compare(plain, hash); + +const createUser = async ({ email, password }) => { + const existing = await findUserByEmail(email); + if (existing) { + const error = new Error('Bu email ile kayitli kullanici zaten var'); + error.status = 409; + throw error; + } + const passwordHash = await hashPassword(password); + const id = uuid(); + await pgPool.query( + `INSERT INTO ${USERS_TABLE} (id, email, password_hash) VALUES ($1, $2, $3)`, + [id, email, passwordHash] + ); + return { id, email }; +}; + +const findUserByEmail = async (email) => { + const { rows } = await pgPool.query( + `SELECT id, email, password_hash FROM ${USERS_TABLE} WHERE email=$1 LIMIT 1`, + [email] + ); + return rows[0] || null; +}; + +const generateToken = (payload) => + jwt.sign(payload, env.jwt.secret, { expiresIn: env.jwt.expiresIn }); + +const validateCredentials = async ({ email, password }) => { + const user = await findUserByEmail(email); + if (!user) { + const error = new Error('Giris bilgileri hatali'); + error.status = 401; + throw error; + } + const isValid = await comparePassword(password, user.password_hash); + if (!isValid) { + const error = new Error('Giris bilgileri hatali'); + error.status = 401; + throw error; + } + return { id: user.id, email: user.email }; +}; + +const buildAuthResponse = (user) => ({ + token: generateToken({ sub: user.id, email: user.email }), + user +}); + +module.exports = { + ensureUserTable, + createUser, + validateCredentials, + buildAuthResponse +}; diff --git a/src/utils/isbn.js b/src/utils/isbn.js new file mode 100644 index 0000000..9a3a9c0 --- /dev/null +++ b/src/utils/isbn.js @@ -0,0 +1,47 @@ +const cleanIsbnInput = (value = '') => value.replace(/[^0-9Xx]/g, '').toUpperCase(); + +const isIsbn13 = (value) => /^\d{13}$/.test(value); +const isIsbn10 = (value) => /^\d{9}[\dX]$/.test(value); + +const computeIsbn13CheckDigit = (twelveDigits) => { + const sum = twelveDigits + .split('') + .reduce((acc, digit, index) => { + const weight = index % 2 === 0 ? 1 : 3; + return acc + parseInt(digit, 10) * weight; + }, 0); + const mod = sum % 10; + return mod === 0 ? '0' : String(10 - mod); +}; + +const convertIsbn10To13 = (isbn10) => { + const core = isbn10.slice(0, 9); + const base = `978${core}`; + const checkDigit = computeIsbn13CheckDigit(base); + return base + checkDigit; +}; + +const normalizeIsbnForLookup = (value) => { + const cleaned = cleanIsbnInput(value); + + if (isIsbn13(cleaned)) { + return cleaned; + } + + if (/^\d{10}$/.test(cleaned)) { + return cleaned; + } + + if (isIsbn10(cleaned)) { + return convertIsbn10To13(cleaned); + } + + const error = new Error('ISBN formati hatali. 10 veya 13 haneli olmali.'); + error.status = 400; + throw error; +}; + +module.exports = { + cleanIsbnInput, + normalizeIsbnForLookup +}; diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..6ee7954 --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,69 @@ +const Chalk = require('chalk'); +const chalk = new Chalk.Instance({ level: 3 }); + +const levelStyles = { + info: chalk.cyan.bold, + success: chalk.greenBright.bold, + warn: chalk.keyword('orange').bold, + error: chalk.redBright.bold, + request: chalk.magentaBright.bold, + cache: chalk.blueBright.bold +}; + +const iconMap = { + info: 'ℹ️', + success: '✅', + warn: '⚠️', + error: '❌', + request: '🚀', + cacheHit: '📦', + cacheMiss: '🆕' +}; + +const formatDuration = (ms) => `${ms.toFixed(1)}ms`; + +const log = (level, message) => { + const style = levelStyles[level] || ((text) => text); + const icon = iconMap[level] || '•'; + console.log(`${style(icon)} ${message}`); +}; + +const logRequest = ({ method, url, status, durationMs, cacheHit }) => { + const statusColor = + status >= 500 ? chalk.bgRed.white.bold : + status >= 400 ? chalk.keyword('orange').bold : + chalk.greenBright.bold; + + const durationText = chalk.bgBlack.white.bold(`⏱ ${formatDuration(durationMs)}`); + const methodText = chalk.white.bold(method); + const urlText = chalk.gray(url); + + const cacheTag = cacheHit === undefined + ? '' + : cacheHit + ? chalk.greenBright.bold(` [cache-hit ${iconMap.cacheHit}]`) + : chalk.cyanBright.bold(` [cache-miss ${iconMap.cacheMiss}]`); + + const message = `${methodText} ${urlText} ${statusColor(String(status))} ${durationText}${cacheTag}`; + log('request', message); +}; + +const logCacheHit = (key) => { + const message = `${chalk.greenBright.bold(iconMap.cacheHit)} Redis cache bulundu -> ${chalk.white.bold(key)}`; + log('cache', message); +}; + +const logCacheMiss = (key) => { + const message = `${chalk.cyanBright.bold(iconMap.cacheMiss)} Redis cache olusturulacak -> ${chalk.white.bold(key)}`; + log('cache', message); +}; + +module.exports = { + info: (msg) => log('info', msg), + success: (msg) => log('success', msg), + warn: (msg) => log('warn', msg), + error: (msg) => log('error', msg), + request: logRequest, + cacheHit: logCacheHit, + cacheMiss: logCacheMiss +}; diff --git a/src/utils/mediaCache.js b/src/utils/mediaCache.js new file mode 100644 index 0000000..70b9618 --- /dev/null +++ b/src/utils/mediaCache.js @@ -0,0 +1,65 @@ +const fs = require('fs'); +const path = require('path'); +const axios = require('axios'); +const logger = require('./logger'); + +const CACHE_DIR = path.resolve(process.cwd(), 'cache'); + +const ensureCacheDir = () => { + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + } +}; + +const extractExtension = (url) => { + try { + const parsed = new URL(url); + const ext = path.extname(parsed.pathname); + if (ext) return ext.split('?')[0]; + } catch (error) { + // ignore + } + return '.jpg'; +}; + +const buildFileName = ({ isbn, locale, ext }) => { + const safeLocale = locale || 'global'; + return `${isbn}_${safeLocale}_thumbnail${ext}`; +}; + +const downloadImage = async (url, destination) => { + const response = await axios.get(url, { responseType: 'arraybuffer' }); + fs.writeFileSync(destination, response.data); + logger.success(`🖼️ Thumbnail indirildi -> ${destination}`); +}; + +const cacheThumbnail = async ({ isbn, locale, url }) => { + if (!url || !isbn) { + return null; + } + + ensureCacheDir(); + + const ext = extractExtension(url); + const fileName = buildFileName({ isbn, locale, ext }); + const filePath = path.join(CACHE_DIR, fileName); + const relativePath = path.relative(process.cwd(), filePath); + + if (fs.existsSync(filePath)) { + logger.info(`🗂️ Thumbnail cache'de bulundu: ${relativePath}`); + return relativePath; + } + + try { + await downloadImage(url, filePath); + return relativePath; + } catch (error) { + logger.warn(`Thumbnail indirilemedi: ${error.message}`); + return null; + } +}; + +module.exports = { + cacheThumbnail, + CACHE_DIR +}; diff --git a/src/utils/request.js b/src/utils/request.js new file mode 100644 index 0000000..2800386 --- /dev/null +++ b/src/utils/request.js @@ -0,0 +1,31 @@ +const toBoolean = (value, fallback = false) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (['true', '1', 'yes', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'off'].includes(normalized)) return false; + } + return fallback; +}; + +const resolveLocales = (rawLocales) => { + if (!rawLocales) { + return ['en', 'tr']; + } + const locales = rawLocales + .split(',') + .map((locale) => locale.trim().toLowerCase()) + .filter((locale) => ['en', 'tr'].includes(locale)); + return locales.length ? [...new Set(locales)] : ['en', 'tr']; +}; + +const toInteger = (value, fallback) => { + const parsed = parseInt(value, 10); + return Number.isNaN(parsed) ? fallback : parsed; +}; + +module.exports = { + toBoolean, + resolveLocales, + toInteger +}; diff --git a/src/validators/authValidators.js b/src/validators/authValidators.js new file mode 100644 index 0000000..fdfab98 --- /dev/null +++ b/src/validators/authValidators.js @@ -0,0 +1,14 @@ +const { body } = require('express-validator'); + +const emailValidator = body('email').isEmail().withMessage('Gecerli bir email adresi girin'); +const passwordValidator = body('password') + .isLength({ min: 6 }) + .withMessage('Sifre en az 6 karakter olmali'); + +const registerValidator = [emailValidator, passwordValidator]; +const loginValidator = [emailValidator, passwordValidator]; + +module.exports = { + registerValidator, + loginValidator +};