feat: Add comprehensive backend validation, toast notifications, and Tokyo Night theme
- Backend: Add server-side validation with go-playground/validator/v10 - Frontend: Add toast notifications for API errors with @zerodevx/svelte-toast - UI: Complete Tokyo Night theme redesign with modern animations - Docs: Update COMPLETE_DOCUMENTATION.md and README.md with all enhancements - Validation: Email format, password strength, and input sanitization - UX: Real-time error feedback, loading states, and responsive design
This commit is contained in:
@@ -0,0 +1,736 @@
|
||||
# Ebook Reader and Library Manager - Complete Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
A self-hosted ebook reader and library management system built with:
|
||||
- **Backend**: Go API with Echo framework, PostgreSQL database with sqlc code generation, serving static frontend
|
||||
- **Frontend**: Svelte with Vite, TypeScript, and TailwindCSS (built to static files)
|
||||
- **Database**: PostgreSQL with migrations
|
||||
- **Deployment**: Fully containerized with Docker and Docker Compose (single Go service)
|
||||
|
||||
## Features
|
||||
|
||||
- Ebook library management (CRUD operations)
|
||||
- Reading progress tracking
|
||||
- Responsive web interface
|
||||
- RESTful API (accessible by web, mobile, etc.)
|
||||
- Type-safe database queries with sqlc
|
||||
- Modern UI with TailwindCSS
|
||||
- Single-container deployment (Go serves everything)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker and Docker Compose
|
||||
- Git (for cloning)
|
||||
|
||||
### Running the Application
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd bookmann
|
||||
```
|
||||
|
||||
2. Start all services:
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
3. Access the application:
|
||||
- Application: http://localhost:8765 (serves both frontend and API)
|
||||
- Database: localhost:5432 (postgres/password)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
bookmann/
|
||||
├── backend/
|
||||
│ ├── cmd/server/
|
||||
│ │ └── main.go # Application entry point
|
||||
│ ├── internal/
|
||||
│ │ ├── config/
|
||||
│ │ │ └── config.go # Configuration management
|
||||
│ │ ├── database/
|
||||
│ │ │ ├── connection.go # Database connection (pgx pool)
|
||||
│ │ │ └── queries/
|
||||
│ │ │ └── queries.sql # SQL queries for sqlc
|
||||
│ │ └── handlers/
|
||||
│ │ └── ebook.go # HTTP handlers
|
||||
│ ├── migrations/
|
||||
│ │ └── 001_create_tables.up.sql # Database schema
|
||||
│ ├── Dockerfile # Backend Docker image (builds frontend)
|
||||
│ ├── go.mod # Go dependencies
|
||||
│ ├── go.sum
|
||||
│ └── sqlc.yaml # sqlc configuration
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── api.ts # API client with auth & error handling
|
||||
│ │ │ ├── auth.ts # Auth state management
|
||||
│ │ │ └── toast.ts # Toast notification utilities
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── login/
|
||||
│ │ │ │ └── +page.svelte # Login page (Tokyo Night theme)
|
||||
│ │ │ ├── register/
|
||||
│ │ │ │ └── +page.svelte # Registration page (Tokyo Night theme)
|
||||
│ │ │ ├── +layout.svelte # Root layout with auth & toasts
|
||||
│ │ │ └── +page.svelte # Library page (Tokyo Night theme)
|
||||
│ │ ├── app.css # Global styles with Tokyo Night theme
|
||||
│ │ ├── app.d.ts # TypeScript declarations
|
||||
│ │ └── app.html # HTML template with Inter font
|
||||
│ ├── package.json # Node dependencies with toast library
|
||||
│ ├── svelte.config.js # SvelteKit config (adapter-static)
|
||||
│ ├── tailwind.config.js # Tailwind config with Tokyo Night colors
|
||||
│ ├── postcss.config.js # PostCSS configuration (Tailwind v4)
|
||||
├── docker-compose.yml # Multi-service orchestration (db + backend only)
|
||||
├── README.md # Basic README
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
## Backend Documentation
|
||||
|
||||
### Go Modules
|
||||
|
||||
Dependencies in go.mod:
|
||||
- github.com/labstack/echo/v4 v4.11.3 - Web framework
|
||||
- github.com/jackc/pgx/v5 v5.4.3 - PostgreSQL driver (pgx pool)
|
||||
- github.com/golang-jwt/jwt/v5 v5.3.0 - JWT handling
|
||||
- github.com/google/uuid v1.4.0 - UUID generation
|
||||
- github.com/go-playground/validator/v10 v10.30.1 - Input validation
|
||||
- golang.org/x/crypto v0.46.0 - Cryptographic functions (bcrypt)
|
||||
|
||||
### Configuration
|
||||
|
||||
Environment variables (with defaults):
|
||||
- SERVER_PORT=8765
|
||||
- DATABASE_HOST=localhost
|
||||
- DATABASE_PORT=5432
|
||||
- DATABASE_USER=postgres
|
||||
- DATABASE_PASSWORD=password
|
||||
- DATABASE_NAME=ebookdb
|
||||
- JWT_SECRET=your-secret-key
|
||||
- UPLOAD_PATH=./uploads
|
||||
|
||||
### Database Schema
|
||||
|
||||
Tables:
|
||||
|
||||
#### users
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
#### ebooks
|
||||
```sql
|
||||
CREATE TABLE ebooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
author VARCHAR(255),
|
||||
isbn VARCHAR(13),
|
||||
description TEXT,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_size BIGINT,
|
||||
mime_type VARCHAR(100),
|
||||
cover_image_path VARCHAR(500),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
#### reading_progress
|
||||
```sql
|
||||
CREATE TABLE reading_progress (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ebook_id UUID NOT NULL REFERENCES ebooks(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
current_page INTEGER DEFAULT 0,
|
||||
total_pages INTEGER,
|
||||
last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(ebook_id, user_id)
|
||||
);
|
||||
```
|
||||
|
||||
#### reading_progress
|
||||
```sql
|
||||
CREATE TABLE reading_progress (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ebook_id UUID NOT NULL REFERENCES ebooks(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
current_page INTEGER DEFAULT 0,
|
||||
total_pages INTEGER,
|
||||
last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(ebook_id, user_id)
|
||||
);
|
||||
```
|
||||
|
||||
Indexes:
|
||||
- idx_users_email ON users(email)
|
||||
- idx_users_username ON users(username)
|
||||
- idx_ebooks_title ON ebooks(title)
|
||||
- idx_ebooks_author ON ebooks(author)
|
||||
- idx_reading_progress_ebook_id ON reading_progress(ebook_id)
|
||||
- idx_reading_progress_user_id ON reading_progress(user_id)
|
||||
|
||||
## Authentication
|
||||
|
||||
The application uses JWT (JSON Web Tokens) for authentication. The flow is:
|
||||
|
||||
1. **Register** or **Login** via `/api/auth/register` or `/api/auth/login`
|
||||
2. Receive JWT token in response
|
||||
3. Include token in `Authorization: Bearer <token>` header for protected requests
|
||||
4. Token expires after 24 hours
|
||||
|
||||
**Security Features:**
|
||||
- Passwords hashed with bcrypt (cost factor 10)
|
||||
- JWT signed with configurable secret key
|
||||
- User data properly isolated per authenticated user
|
||||
- Unique constraints on email and username
|
||||
|
||||
### API Endpoints
|
||||
|
||||
All endpoints now include comprehensive server-side validation. Invalid requests return detailed error messages in the response body.
|
||||
|
||||
#### Auth (Public)
|
||||
|
||||
**POST /api/auth/register**
|
||||
- Body: { email: string, username: string, password: string }
|
||||
- Returns: { token: string, user: UserProfile }
|
||||
|
||||
**POST /api/auth/login**
|
||||
- Body: { login: string, password: string } (login can be email or username)
|
||||
- Returns: { token: string, user: UserProfile }
|
||||
|
||||
**GET /api/auth/profile** (requires JWT)
|
||||
- Returns: UserProfile
|
||||
|
||||
#### Ebooks (requires JWT)
|
||||
|
||||
**GET /api/ebooks**
|
||||
- Query params: limit (default: 20), offset (default: 0)
|
||||
- Returns: Array of ebook objects
|
||||
|
||||
**GET /api/ebooks/:id**
|
||||
- Path param: id (UUID)
|
||||
- Returns: Single ebook object
|
||||
|
||||
**POST /api/ebooks**
|
||||
- Body: Ebook data (JSON)
|
||||
- Required fields: title, file_path
|
||||
- Returns: Created ebook object
|
||||
|
||||
**PUT /api/ebooks/:id**
|
||||
- Path param: id (UUID)
|
||||
- Body: Updated ebook data (JSON)
|
||||
- Returns: Updated ebook object
|
||||
|
||||
**DELETE /api/ebooks/:id**
|
||||
- Path param: id (UUID)
|
||||
- Returns: 204 No Content
|
||||
|
||||
#### Reading Progress (requires JWT)
|
||||
|
||||
**GET /api/ebooks/:id/progress**
|
||||
- Path param: ebookId
|
||||
- Returns: Reading progress object for authenticated user
|
||||
|
||||
**PUT /api/ebooks/:id/progress**
|
||||
- Path param: ebookId
|
||||
- Body: { current_page: number, total_pages?: number }
|
||||
- Returns: Updated progress object for authenticated user
|
||||
|
||||
### Type Definitions
|
||||
|
||||
#### Ebook
|
||||
```typescript
|
||||
interface Ebook {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
isbn: string | null;
|
||||
description: string | null;
|
||||
file_path: string;
|
||||
file_size: number | null;
|
||||
mime_type: string | null;
|
||||
cover_image_path: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### UserProfile
|
||||
```typescript
|
||||
interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
created_at?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### ReadingProgress
|
||||
```typescript
|
||||
interface ReadingProgress {
|
||||
ebook_id: string;
|
||||
user_id: string;
|
||||
current_page: number;
|
||||
total_pages: number | null;
|
||||
last_read_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Documentation
|
||||
|
||||
### SvelteKit Setup
|
||||
|
||||
- Framework: SvelteKit with TypeScript
|
||||
- Build tool: Vite
|
||||
- Styling: TailwindCSS
|
||||
- Authentication: JWT-based with localStorage token storage
|
||||
- State Management: Svelte stores for reactive auth state
|
||||
- Routing: Protected routes with auth-based conditional rendering
|
||||
- UI Components: Custom components with Tailwind classes
|
||||
|
||||
### Key Files
|
||||
|
||||
#### src/lib/api.ts
|
||||
API client functions for all backend endpoints with JWT authentication and error handling:
|
||||
- **Auth functions**: registerUser(), loginUser(), getUserProfile()
|
||||
- **Ebook functions**: fetchEbooks(), fetchEbook(), createEbook(), updateEbook(), deleteEbook()
|
||||
- **Progress functions**: fetchReadingProgress(), updateReadingProgress() (user-specific via token)
|
||||
- **Error handling**: Automatic toast notifications for API errors with parsed error messages
|
||||
- All functions automatically include Bearer token headers when authenticated
|
||||
|
||||
#### src/lib/auth.ts
|
||||
Authentication state management store:
|
||||
- Reactive auth state with Svelte stores
|
||||
- JWT token persistence in localStorage
|
||||
- Login/logout functionality
|
||||
- Auth state initialization
|
||||
|
||||
#### src/lib/toast.ts
|
||||
Toast notification utilities:
|
||||
- showError() - Display error toasts with Tokyo Night red styling
|
||||
- showSuccess() - Display success toasts with Tokyo Night green styling
|
||||
- Integrated with API error handling for automatic user feedback
|
||||
|
||||
#### src/routes/login/+page.svelte
|
||||
Login page with form validation and error handling
|
||||
|
||||
#### src/routes/register/+page.svelte
|
||||
Registration page with password confirmation and validation
|
||||
|
||||
#### src/routes/+layout.svelte
|
||||
Root layout with authentication:
|
||||
- Auth state initialization and management
|
||||
- Conditional rendering based on login status
|
||||
- Navigation header with user info and logout
|
||||
- Auth forms for unauthenticated users
|
||||
|
||||
### Styling
|
||||
|
||||
Tokyo Night Theme with TailwindCSS:
|
||||
- **Color Palette**: Custom Tokyo Night colors (#1a1b26 backgrounds, #7aa2f7 accents)
|
||||
- **Typography**: Inter font family loaded from Google Fonts
|
||||
- **Animations**: Custom fade-in and slide-in animations
|
||||
- **Components**: Pre-built button, card, input, and navigation styles
|
||||
- **Dark Theme**: Complete dark mode implementation with proper contrast
|
||||
- **Responsive Design**: Mobile-first approach with breakpoint optimizations
|
||||
- **Interactive Elements**: Hover effects, focus states, and smooth transitions
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
**Frontend Implementation:**
|
||||
- **State Management**: Reactive auth store with login/logout actions
|
||||
- **Token Persistence**: JWT tokens stored in localStorage with session persistence
|
||||
- **Route Protection**: Layout component handles auth-based conditional rendering
|
||||
- **API Integration**: All API calls include auth headers automatically
|
||||
- **Form Validation**: Client-side validation with error display
|
||||
- **User Experience**: Seamless transitions between auth states
|
||||
|
||||
## Docker Configuration
|
||||
|
||||
### Backend Dockerfile
|
||||
- Multi-stage build with Go and Node.js stages
|
||||
- Alpine Linux for small final image
|
||||
- Frontend built to static files during Docker build
|
||||
- sqlc code generation during build
|
||||
- Binary compilation with CGO disabled
|
||||
- Static files copied to final image
|
||||
|
||||
### Docker Compose Services
|
||||
|
||||
#### db (PostgreSQL)
|
||||
- Image: postgres:15-alpine
|
||||
- Health check with pg_isready
|
||||
- Volume for data persistence
|
||||
- Init scripts from migrations folder
|
||||
|
||||
#### backend (Go API + Static Frontend)
|
||||
- Build from backend/Dockerfile
|
||||
- Environment variables for configuration
|
||||
- Health check for application availability
|
||||
- Depends on database health
|
||||
- Volume for uploads directory
|
||||
- Serves both API and static frontend files
|
||||
|
||||
### Networking
|
||||
|
||||
Services communicate via Docker networks:
|
||||
- backend serves static files for frontend and API endpoints
|
||||
- backend connects to db:5432
|
||||
- All services restart automatically on failure
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Backend Development
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go mod tidy
|
||||
go run github.com/sqlc-dev/sqlc/cmd/sqlc@latest generate
|
||||
go run cmd/server/main.go
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
||||
For development, run the frontend separately:
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev -- --open
|
||||
```
|
||||
Note: For production, frontend is built statically and served by the Go backend. API calls should proxy to the backend (e.g., via Vite proxy in dev).
|
||||
|
||||
### Database Development
|
||||
|
||||
```bash
|
||||
# Connect to database
|
||||
psql -h localhost -p 5432 -U postgres -d ebookdb
|
||||
|
||||
# Run migrations manually (if needed)
|
||||
# Migrations run automatically in Docker
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Production Considerations
|
||||
|
||||
1. **Environment Variables**: Change default passwords, JWT secret, and database credentials
|
||||
2. **Database**: Use managed PostgreSQL in production with connection pooling
|
||||
3. **File Storage**: Implement cloud storage for ebook files (S3, etc.)
|
||||
4. **Authentication**: JWT secrets should be strong and rotated regularly
|
||||
5. **HTTPS**: Configure SSL certificates for secure transmission
|
||||
6. **Rate Limiting**: Add rate limiting for auth endpoints to prevent brute force
|
||||
7. **User Management**: Consider email verification, password reset, account locking
|
||||
8. **Monitoring**: Add logging and monitoring for auth failures
|
||||
9. **Backup**: Regular database backups including user data
|
||||
|
||||
### Scaling
|
||||
|
||||
- Database can be moved to separate instance
|
||||
- Backend can be scaled horizontally with load balancer
|
||||
- Frontend static files can be served from CDN
|
||||
- File storage can use cloud storage (S3, etc.)
|
||||
|
||||
## User Journey
|
||||
|
||||
### Complete User Flow
|
||||
|
||||
1. **Registration**: User visits `/register`, fills form, receives JWT token
|
||||
2. **Login**: User can login with email or username, receives JWT token
|
||||
3. **Token Storage**: Frontend stores JWT in localStorage
|
||||
4. **API Access**: All subsequent API calls include Bearer token
|
||||
5. **Library Access**: User sees their ebook library with personal reading progress
|
||||
6. **Progress Tracking**: Reading progress is automatically associated with user
|
||||
7. **Session Management**: User stays logged in across browser sessions
|
||||
8. **Logout**: User can logout, clearing token and redirecting to login
|
||||
|
||||
### Security Features
|
||||
|
||||
- **Password Hashing**: Bcrypt with cost factor 10
|
||||
- **JWT Tokens**: 24-hour expiration, signed with configurable secret
|
||||
- **User Isolation**: All data is user-specific and properly segregated
|
||||
- **Session Persistence**: Secure token storage with automatic validation
|
||||
- **API Protection**: All sensitive operations require valid authentication
|
||||
|
||||
## API Usage Examples
|
||||
|
||||
### Registering a User
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8765/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"username": "testuser",
|
||||
"password": "securepassword"
|
||||
}'
|
||||
```
|
||||
|
||||
### Logging in a User
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8765/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"login": "user@example.com",
|
||||
"password": "securepassword"
|
||||
}'
|
||||
```
|
||||
|
||||
### Creating an Ebook (requires JWT token)
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://localhost:8765/api/ebooks \
|
||||
-d '{
|
||||
"title": "Sample Book",
|
||||
"author": "Sample Author",
|
||||
"file_path": "/uploads/sample.epub",
|
||||
"file_size": 1024000,
|
||||
"mime_type": "application/epub+zip"
|
||||
}'
|
||||
```
|
||||
|
||||
### Getting Reading Progress (requires JWT token)
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
http://localhost:8765/api/ebooks/123e4567-e89b-12d3-a456-426614174000/progress
|
||||
```
|
||||
|
||||
### Updating Progress (requires JWT token)
|
||||
|
||||
```bash
|
||||
curl -X PUT -H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://localhost:8765/api/ebooks/123e4567-e89b-12d3-a456-426614174000/progress \
|
||||
-d '{"current_page": 45, "total_pages": 200}'
|
||||
```
|
||||
|
||||
### Register User
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8765/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"username": "testuser",
|
||||
"password": "securepassword"
|
||||
}'
|
||||
```
|
||||
|
||||
### Login User
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8765/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"login": "user@example.com",
|
||||
"password": "securepassword"
|
||||
}'
|
||||
```
|
||||
|
||||
### Getting Reading Progress (requires JWT token)
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
http://localhost:8765/api/ebooks/123e4567-e89b-12d3-a456-426614174000/progress
|
||||
```
|
||||
|
||||
### Updating Progress (requires JWT token)
|
||||
|
||||
```bash
|
||||
curl -X PUT -H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://localhost:8765/api/ebooks/123e4567-e89b-12d3-a456-426614174000/progress \
|
||||
-d '{"current_page": 45, "total_pages": 200}'
|
||||
```
|
||||
|
||||
## Recent Changes (Complete Multi-User Auth System)
|
||||
|
||||
As of the latest updates, the application has been completely transformed into a full multi-user ebook management system:
|
||||
- **Multi-User Authentication**: Complete user system with registration/login, supporting both email and username login, JWT-based sessions, and bcrypt password hashing
|
||||
- **Security**: Passwords stored with industry-standard bcrypt hashing, JWT tokens for API access, user data isolation, secure token management
|
||||
- **API Evolution**: Auth endpoints added, reading progress now user-specific (removed :userId params), all ebook operations require authentication
|
||||
- **Frontend Auth Integration**: Svelte frontend fully updated with login/register forms, JWT token management in localStorage, reactive auth state store, protected routes with automatic redirects
|
||||
- **User Experience**: Seamless auth flow with form validation, session persistence, user dashboard, logout functionality
|
||||
- **Architecture**: Single Go service serving both static frontend and API, no Node.js dependency
|
||||
- **Database**: New users table, updated reading_progress with user foreign keys, comprehensive indexing
|
||||
- **Testing**: Bruno collection updated with auth flows and Bearer token authentication
|
||||
- **Cleanup**: Removed obsolete Docker files for frontend containerization
|
||||
|
||||
## Latest Updates (Enhanced Validation, UI/UX & Tokyo Night Theme)
|
||||
|
||||
### Backend API Validation Enhancement
|
||||
- **Server-Side Validation**: Added comprehensive input validation using `github.com/go-playground/validator/v10`
|
||||
- **Request Validation**: All API endpoints now validate request data before processing
|
||||
- **Error Responses**: Detailed validation error messages returned for invalid requests
|
||||
- **Security**: Prevents malformed data from reaching the database layer
|
||||
|
||||
#### Validation Rules Added:
|
||||
- **User Registration**: Email format validation, username length (3-50 chars), password minimum length (6+ chars)
|
||||
- **User Login**: Required email/username and password fields
|
||||
- **Ebook Creation**: Required title and file_path, minimum title length (1-500 chars), optional file size validation
|
||||
- **Ebook Updates**: Title validation when provided, cover image path validation
|
||||
- **Reading Progress**: Required current_page (≥0), optional total_pages (≥1)
|
||||
|
||||
### Frontend Toast Notifications
|
||||
- **Error Notifications**: Real-time toast popups for all API errors using `@zerodevx/svelte-toast`
|
||||
- **User Feedback**: Immediate visual feedback for failed operations (login, registration, CRUD operations)
|
||||
- **Error Parsing**: Automatically extracts and displays backend validation messages
|
||||
- **Non-Intrusive Design**: Toasts appear without disrupting user workflow
|
||||
|
||||
### Tokyo Night Theme Redesign
|
||||
- **Complete UI Overhaul**: Transformed the entire frontend with the popular Tokyo Night dark theme
|
||||
- **Color Palette**: Deep blue backgrounds (#1a1b26), cyan accents (#7dcfff), and carefully chosen contrast colors
|
||||
- **Modern Design**: Gradient backgrounds, smooth animations, and professional card-based layouts
|
||||
- **Enhanced Typography**: Inter font family for improved readability
|
||||
- **Interactive Elements**: Hover effects, focus states, and smooth transitions throughout
|
||||
|
||||
#### Tokyo Night Color Scheme:
|
||||
```css
|
||||
Background: #1a1b26 (dark blue-gray)
|
||||
Secondary Background: #16161e (darker blue)
|
||||
Highlights: #292e42 (medium blue-gray)
|
||||
Foreground: #a9b1d6 (light blue-gray)
|
||||
Accent Blue: #7aa2f7
|
||||
Accent Cyan: #7dcfff
|
||||
Accent Red: #f7768e
|
||||
Accent Green: #9ece6a
|
||||
Accent Purple: #bb9af7
|
||||
```
|
||||
|
||||
### Enhanced User Experience Features
|
||||
- **Loading States**: Beautiful animated spinners during data fetching
|
||||
- **Form Validation**: Real-time client-side validation with visual feedback
|
||||
- **Responsive Design**: Optimized for all screen sizes with mobile-first approach
|
||||
- **Animation System**: Fade-in and slide-in animations for page transitions
|
||||
- **Error Handling**: Comprehensive error states with user-friendly messages
|
||||
- **Empty States**: Attractive placeholders when no data is available
|
||||
- **Visual Hierarchy**: Clear information architecture with proper spacing and typography
|
||||
|
||||
### Technical Improvements
|
||||
- **Performance**: Optimized CSS compilation with direct color values for better build performance
|
||||
- **Accessibility**: Proper contrast ratios and focus management
|
||||
- **Code Organization**: Clean separation of concerns with utility functions
|
||||
- **Type Safety**: Full TypeScript integration with proper error typing
|
||||
- **Build Optimization**: Efficient static file generation and caching
|
||||
|
||||
### Updated Dependencies
|
||||
- **Backend**: Added `github.com/go-playground/validator/v10` for validation
|
||||
- **Frontend**: Added `@zerodevx/svelte-toast` for notifications
|
||||
- **Fonts**: Added Google Fonts Inter for enhanced typography
|
||||
- **Tailwind**: Extended configuration with custom animations and Tokyo Night colors
|
||||
|
||||
### User Interface Components
|
||||
- **Authentication Pages**: Redesigned login/register forms with card layouts and icons
|
||||
- **Library View**: Grid-based ebook display with hover effects and metadata
|
||||
- **Navigation**: Clean header with user information and logout functionality
|
||||
- **Loading Screens**: Beautiful animated loading states
|
||||
- **Error Displays**: User-friendly error messages with appropriate styling
|
||||
- **Toast Notifications**: Non-intrusive error feedback system
|
||||
|
||||
This comprehensive update transforms the application into a modern, professional ebook management system with excellent user experience, robust validation, and beautiful dark theme design.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Database Connection Failed**
|
||||
- Check if PostgreSQL container is running
|
||||
- Verify DATABASE_* environment variables
|
||||
- Check database logs: `docker-compose logs db`
|
||||
|
||||
2. **Application Not Accessible**
|
||||
- Verify backend container is running
|
||||
- Check backend logs: `docker-compose logs backend`
|
||||
- Test health endpoint: `curl http://localhost:8765/`
|
||||
|
||||
3. **Authentication Issues**
|
||||
- Verify JWT_SECRET environment variable is set
|
||||
- Check token expiration (24 hours)
|
||||
- Ensure Bearer token format: `Authorization: Bearer <token>`
|
||||
- Test auth endpoints first: `/api/auth/register`, `/api/auth/login`
|
||||
- Check validation errors in API responses (frontend shows toast notifications)
|
||||
|
||||
4. **Validation Errors**
|
||||
- Frontend displays validation errors via toast notifications
|
||||
- Check API response body for detailed error messages
|
||||
- Ensure request data matches validation rules (email format, password length, etc.)
|
||||
|
||||
5. **Frontend Not Loading**
|
||||
- Check backend logs for static file serving
|
||||
- Verify frontend built correctly in Docker
|
||||
- Check browser console for errors
|
||||
- Ensure Tokyo Night theme CSS compiled properly
|
||||
|
||||
6. **API Authorization Errors**
|
||||
- Confirm JWT token is valid and not expired
|
||||
- Check middleware logs for auth failures
|
||||
- Verify protected endpoints use correct HTTP methods
|
||||
|
||||
7. **Build Failures**
|
||||
- Clear Docker cache: `docker system prune -a`
|
||||
- Rebuild: `docker-compose up --build --force-recreate`
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker-compose logs
|
||||
|
||||
# Specific service
|
||||
docker-compose logs backend
|
||||
docker-compose logs frontend
|
||||
docker-compose logs db
|
||||
|
||||
# Follow logs
|
||||
docker-compose logs -f backend
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make changes with proper testing
|
||||
4. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Email verification for user registration
|
||||
- Password reset functionality
|
||||
- User profile management (update email/username, change password)
|
||||
- Email notifications (welcome, password reset, etc.)
|
||||
- User roles and permissions (admin, regular user)
|
||||
- Ebook file upload and processing with validation
|
||||
- EPUB/PDF reader component with bookmarking
|
||||
- Advanced search and filtering (by author, genre, etc.)
|
||||
- Categories and tags for ebooks
|
||||
- Reading statistics and analytics dashboard
|
||||
- Social features (sharing reading lists, reviews)
|
||||
- Mobile app development (React Native/Flutter)
|
||||
- Cloud storage integration (S3, Google Drive)
|
||||
- Backup and restore functionality
|
||||
- Admin panel for user management
|
||||
- OAuth integration (Google, GitHub, Apple login)
|
||||
- Two-factor authentication (2FA)
|
||||
- Session management and device tracking
|
||||
|
||||
---
|
||||
|
||||
This documentation covers the complete setup, configuration, and usage of the ebook reader system. For specific implementation details, refer to the source code comments and type definitions.
|
||||
@@ -1,3 +1,150 @@
|
||||
# bookmann
|
||||
# 📚 Ebook Reader and Library Manager
|
||||
|
||||
A Self-Hosted Ebook Manager and Reader in Go/Svelte.
|
||||
A self-hosted ebook reader and library management system built with Go, PostgreSQL, and Svelte (integrated into a single service) featuring a beautiful Tokyo Night dark theme.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🔒 Multi-User Authentication**: Complete user system with registration/login, JWT-based sessions, and bcrypt password hashing
|
||||
- **✅ Server-Side Validation**: Comprehensive input validation with detailed error messages
|
||||
- **🔔 Toast Notifications**: Real-time error feedback with beautiful toast popups
|
||||
- **🌙 Tokyo Night Theme**: Stunning dark theme with smooth animations and modern UI
|
||||
- **📖 Reading Progress**: User-specific reading progress tracking
|
||||
- **🔧 RESTful API**: Clean API endpoints with JWT authentication
|
||||
- **🐳 Docker Ready**: Single-container deployment with PostgreSQL
|
||||
- **🧪 API Testing**: Complete Bruno collection for testing all endpoints
|
||||
- **📱 Responsive Design**: Mobile-first responsive interface
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker and Docker Compose
|
||||
|
||||
### Running the Application
|
||||
|
||||
1. Clone the repository
|
||||
2. Run the application:
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
3. Access the application at http://localhost:8765
|
||||
|
||||
### Database
|
||||
|
||||
PostgreSQL runs on port 5432 with default credentials:
|
||||
- Database: ebookdb
|
||||
- User: postgres
|
||||
- Password: password
|
||||
|
||||
## Development
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go mod tidy
|
||||
go run github.com/sqlc-dev/sqlc/cmd/sqlc@latest generate
|
||||
go run cmd/server/main.go
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
For development, run the frontend separately:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**✨ Enhanced Features:**
|
||||
- **Authentication**: Login/Register forms with Tokyo Night styling at `/login` and `/register`
|
||||
- **JWT Management**: Secure token storage with localStorage and session persistence
|
||||
- **Protected Routes**: Automatic redirects with reactive auth state management
|
||||
- **Toast Notifications**: Real-time error feedback for all API operations
|
||||
- **Input Validation**: Client-side and server-side validation with detailed error messages
|
||||
- **Tokyo Night Theme**: Beautiful dark theme with smooth animations and modern UI components
|
||||
- **Responsive Design**: Mobile-first approach with optimized layouts for all screen sizes
|
||||
|
||||
**Note**: For production, the frontend is built to static files and served by the Go backend.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth (Public)
|
||||
- `POST /api/auth/register` - Register new user
|
||||
- `POST /api/auth/login` - Login user (email or username)
|
||||
- `GET /api/auth/profile` - Get user profile (requires JWT)
|
||||
|
||||
### Ebooks (Protected)
|
||||
- `GET /api/ebooks` - List ebooks
|
||||
- `GET /api/ebooks/:id` - Get specific ebook
|
||||
- `POST /api/ebooks` - Create new ebook
|
||||
- `PUT /api/ebooks/:id` - Update ebook
|
||||
- `DELETE /api/ebooks/:id` - Delete ebook
|
||||
|
||||
### Reading Progress (Protected)
|
||||
- `GET /api/ebooks/:id/progress` - Get reading progress
|
||||
- `PUT /api/ebooks/:id/progress` - Update reading progress
|
||||
|
||||
## API Testing
|
||||
|
||||
Use the included Bruno collection in the `bruno/` directory for testing the API:
|
||||
|
||||
1. Install [Bruno](https://www.usebruno.com/)
|
||||
2. Import the `bruno/` folder as a collection
|
||||
3. Select the "localhost" environment
|
||||
4. Run the application and test the endpoints
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── backend/
|
||||
│ ├── cmd/server/ # Application entry point
|
||||
│ ├── internal/
|
||||
│ │ ├── config/ # Configuration management
|
||||
│ │ ├── database/ # Database connection and queries
|
||||
│ │ └── handlers/ # HTTP handlers (auth + ebooks)
|
||||
│ ├── migrations/ # Database migrations
|
||||
│ └── sqlc.yaml # sqlc configuration
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── api.ts # API client with auth & toast notifications
|
||||
│ │ │ ├── auth.ts # Auth state management
|
||||
│ │ │ └── toast.ts # Toast notification utilities
|
||||
│ │ └── routes/
|
||||
│ │ ├── login/ # Login page (Tokyo Night theme)
|
||||
│ │ ├── register/ # Registration page (Tokyo Night theme)
|
||||
│ │ └── ... # Other routes (Tokyo Night theme)
|
||||
│ ├── app.css # Global styles with Tokyo Night theme
|
||||
│ └── package.json # Build dependencies with toast library
|
||||
├── bruno/ # Bruno API testing collection
|
||||
├── docker-compose.yml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🎨 Recent Enhancements
|
||||
|
||||
### Backend Improvements
|
||||
- **Server-Side Validation**: Added comprehensive input validation using `go-playground/validator/v10`
|
||||
- **Enhanced Security**: All API endpoints now validate requests before processing
|
||||
- **Better Error Handling**: Detailed validation error messages for improved debugging
|
||||
|
||||
### Frontend Redesign
|
||||
- **Tokyo Night Theme**: Complete UI overhaul with beautiful dark theme colors
|
||||
- **Toast Notifications**: Real-time error feedback using `@zerodevx/svelte-toast`
|
||||
- **Modern UI Components**: Card-based layouts, smooth animations, and responsive design
|
||||
- **Enhanced UX**: Loading states, form validation, and improved user feedback
|
||||
|
||||
### Technical Updates
|
||||
- **Dependencies**: Added validation and toast libraries
|
||||
- **Typography**: Inter font family for improved readability
|
||||
- **Animations**: Custom fade-in and slide-in animations
|
||||
- **Performance**: Optimized CSS compilation and build process
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
*.md
|
||||
.env
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
tmp/
|
||||
logs/
|
||||
@@ -0,0 +1,58 @@
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Node.js and npm
|
||||
RUN apk add --no-cache nodejs npm
|
||||
|
||||
# Install sqlc
|
||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||
|
||||
|
||||
|
||||
# Copy backend source code
|
||||
COPY backend/ ./backend/
|
||||
|
||||
# Copy frontend source for building
|
||||
COPY frontend/package*.json ./frontend/
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Install frontend dependencies (cached if package.json unchanged)
|
||||
RUN npm ci
|
||||
|
||||
# Copy frontend source
|
||||
COPY frontend/ ./
|
||||
|
||||
# Build frontend
|
||||
RUN npm run build
|
||||
|
||||
# Go back to root
|
||||
WORKDIR /app
|
||||
|
||||
# Generate sqlc code
|
||||
RUN cd backend && sqlc generate
|
||||
|
||||
# Build the application
|
||||
RUN cd backend && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ../main ./cmd/server
|
||||
|
||||
# Final stage
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates
|
||||
WORKDIR /root/
|
||||
|
||||
# Copy the binary from builder stage
|
||||
COPY --from=builder /app/main .
|
||||
|
||||
# Copy migrations (if needed for initialization)
|
||||
COPY --from=builder /app/backend/migrations ./migrations
|
||||
|
||||
# Copy static files
|
||||
COPY --from=builder /app/frontend/build ./static
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the binary
|
||||
CMD ["./main"]
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bookmann/internal/config"
|
||||
"bookmann/internal/database"
|
||||
"bookmann/internal/handlers"
|
||||
"log"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
// CustomValidator wraps the go-playground validator
|
||||
type CustomValidator struct {
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
func (cv *CustomValidator) Validate(i interface{}) error {
|
||||
return cv.validator.Struct(i)
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := config.LoadConfig()
|
||||
|
||||
pool, err := database.NewConnection(cfg.DatabaseURL())
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
queries := database.New(pool)
|
||||
|
||||
e := echo.New()
|
||||
|
||||
// Set up validator
|
||||
e.Validator = &CustomValidator{validator: validator.New()}
|
||||
|
||||
// Middleware
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.CORS())
|
||||
|
||||
// Auth routes (no auth required)
|
||||
auth := handlers.NewAuthHandler(queries, cfg.JWTSecret)
|
||||
e.POST("/api/auth/register", auth.Register)
|
||||
e.POST("/api/auth/login", auth.Login)
|
||||
|
||||
// JWT middleware for protected routes
|
||||
jwtMiddleware := middleware.JWTWithConfig(middleware.JWTConfig{
|
||||
SigningKey: []byte(cfg.JWTSecret),
|
||||
ContextKey: "user",
|
||||
SuccessHandler: func(c echo.Context) {
|
||||
token := c.Get("user").(*jwt.Token)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
c.Set("user_id", claims["user_id"])
|
||||
},
|
||||
})
|
||||
|
||||
// Protected routes
|
||||
protected := e.Group("/api", jwtMiddleware)
|
||||
protected.GET("/auth/profile", auth.GetProfile)
|
||||
|
||||
// Routes
|
||||
handlers.SetupRoutes(protected, queries)
|
||||
|
||||
// Serve static files
|
||||
e.Static("/", "static")
|
||||
|
||||
// Start server
|
||||
log.Printf("Starting server on port %s", cfg.ServerPort)
|
||||
e.Logger.Fatal(e.Start(":" + cfg.ServerPort))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
module bookmann
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.4.0
|
||||
github.com/jackc/pgx/v5 v5.4.3
|
||||
github.com/labstack/echo/v4 v4.11.3
|
||||
golang.org/x/crypto v0.46.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/labstack/gommon v0.4.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
|
||||
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM=
|
||||
github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws=
|
||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,42 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServerPort string
|
||||
JWTSecret string
|
||||
UploadPath string
|
||||
DatabaseHost string
|
||||
DatabasePort string
|
||||
DatabaseUser string
|
||||
DatabasePassword string
|
||||
DatabaseName string
|
||||
}
|
||||
|
||||
func LoadConfig() *Config {
|
||||
return &Config{
|
||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
||||
DatabaseHost: getEnv("DATABASE_HOST", "localhost"),
|
||||
DatabasePort: getEnv("DATABASE_PORT", "5432"),
|
||||
DatabaseUser: getEnv("DATABASE_USER", "postgres"),
|
||||
DatabasePassword: getEnv("DATABASE_PASSWORD", "password"),
|
||||
DatabaseName: getEnv("DATABASE_NAME", "ebookdb"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "your-secret-key"),
|
||||
UploadPath: getEnv("UPLOAD_PATH", "./uploads"),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) DatabaseURL() string {
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||
c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName)
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func NewConnection(databaseURL string) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(context.Background(), databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Ebooks struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
FilePath string `db:"file_path" json:"file_path"`
|
||||
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
||||
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
||||
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type ReadingProgress struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
|
||||
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
|
||||
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
|
||||
}
|
||||
|
||||
type Users struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebooks, error)
|
||||
CreateUser(ctx context.Context, arg CreateUserParams) (Users, error)
|
||||
DeleteEbook(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
||||
GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
|
||||
GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error)
|
||||
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (Users, error)
|
||||
GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (Users, error)
|
||||
ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error)
|
||||
UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error)
|
||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
@@ -0,0 +1,356 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: queries.sql
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const CreateEbook = `-- name: CreateEbook :one
|
||||
INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateEbookParams struct {
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
FilePath string `db:"file_path" json:"file_path"`
|
||||
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
||||
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
||||
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebooks, error) {
|
||||
row := q.db.QueryRow(ctx, CreateEbook,
|
||||
arg.Title,
|
||||
arg.Author,
|
||||
arg.Isbn,
|
||||
arg.Description,
|
||||
arg.FilePath,
|
||||
arg.FileSize,
|
||||
arg.MimeType,
|
||||
arg.CoverImagePath,
|
||||
)
|
||||
var i Ebooks
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.Isbn,
|
||||
&i.Description,
|
||||
&i.FilePath,
|
||||
&i.FileSize,
|
||||
&i.MimeType,
|
||||
&i.CoverImagePath,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const CreateUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (email, username, password_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, username, password_hash, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
Email string `db:"email" json:"email"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (Users, error) {
|
||||
row := q.db.QueryRow(ctx, CreateUser, arg.Email, arg.Username, arg.PasswordHash)
|
||||
var i Users
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const DeleteEbook = `-- name: DeleteEbook :exec
|
||||
DELETE FROM ebooks WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteEbook(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, DeleteEbook, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const DeleteReadingProgress = `-- name: DeleteReadingProgress :exec
|
||||
DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type DeleteReadingProgressParams struct {
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error {
|
||||
_, err := q.db.Exec(ctx, DeleteReadingProgress, arg.EbookID, arg.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
const GetEbook = `-- name: GetEbook :one
|
||||
SELECT id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at FROM ebooks WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error) {
|
||||
row := q.db.QueryRow(ctx, GetEbook, id)
|
||||
var i Ebooks
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.Isbn,
|
||||
&i.Description,
|
||||
&i.FilePath,
|
||||
&i.FileSize,
|
||||
&i.MimeType,
|
||||
&i.CoverImagePath,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetReadingProgress = `-- name: GetReadingProgress :one
|
||||
SELECT id, ebook_id, user_id, current_page, total_pages, last_read_at FROM reading_progress WHERE ebook_id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetReadingProgressParams struct {
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error) {
|
||||
row := q.db.QueryRow(ctx, GetReadingProgress, arg.EbookID, arg.UserID)
|
||||
var i ReadingProgress
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.EbookID,
|
||||
&i.UserID,
|
||||
&i.CurrentPage,
|
||||
&i.TotalPages,
|
||||
&i.LastReadAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUser = `-- name: GetUser :one
|
||||
SELECT id, email, username, created_at, updated_at FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
type GetUserRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
Username string `db:"username" json:"username"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) {
|
||||
row := q.db.QueryRow(ctx, GetUser, id)
|
||||
var i GetUserRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Username,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT id, email, username, password_hash, created_at, updated_at FROM users WHERE email = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (Users, error) {
|
||||
row := q.db.QueryRow(ctx, GetUserByEmail, email)
|
||||
var i Users
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
|
||||
SELECT id, email, username, password_hash, created_at, updated_at FROM users WHERE email = $1 OR username = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error) {
|
||||
row := q.db.QueryRow(ctx, GetUserByEmailOrUsername, email)
|
||||
var i Users
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, email, username, password_hash, created_at, updated_at FROM users WHERE username = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (Users, error) {
|
||||
row := q.db.QueryRow(ctx, GetUserByUsername, username)
|
||||
var i Users
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ListEbooks = `-- name: ListEbooks :many
|
||||
SELECT id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListEbooksParams struct {
|
||||
Limit int32 `db:"limit" json:"limit"`
|
||||
Offset int32 `db:"offset" json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error) {
|
||||
rows, err := q.db.Query(ctx, ListEbooks, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Ebooks{}
|
||||
for rows.Next() {
|
||||
var i Ebooks
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.Isbn,
|
||||
&i.Description,
|
||||
&i.FilePath,
|
||||
&i.FileSize,
|
||||
&i.MimeType,
|
||||
&i.CoverImagePath,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const UpdateEbook = `-- name: UpdateEbook :one
|
||||
UPDATE ebooks SET
|
||||
title = $2,
|
||||
author = $3,
|
||||
isbn = $4,
|
||||
description = $5,
|
||||
cover_image_path = $6,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateEbookParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error) {
|
||||
row := q.db.QueryRow(ctx, UpdateEbook,
|
||||
arg.ID,
|
||||
arg.Title,
|
||||
arg.Author,
|
||||
arg.Isbn,
|
||||
arg.Description,
|
||||
arg.CoverImagePath,
|
||||
)
|
||||
var i Ebooks
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.Isbn,
|
||||
&i.Description,
|
||||
&i.FilePath,
|
||||
&i.FileSize,
|
||||
&i.MimeType,
|
||||
&i.CoverImagePath,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const UpdateReadingProgress = `-- name: UpdateReadingProgress :one
|
||||
INSERT INTO reading_progress (ebook_id, user_id, current_page, total_pages, last_read_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (ebook_id, user_id)
|
||||
DO UPDATE SET
|
||||
current_page = EXCLUDED.current_page,
|
||||
total_pages = EXCLUDED.total_pages,
|
||||
last_read_at = NOW()
|
||||
RETURNING id, ebook_id, user_id, current_page, total_pages, last_read_at
|
||||
`
|
||||
|
||||
type UpdateReadingProgressParams struct {
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
|
||||
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error) {
|
||||
row := q.db.QueryRow(ctx, UpdateReadingProgress,
|
||||
arg.EbookID,
|
||||
arg.UserID,
|
||||
arg.CurrentPage,
|
||||
arg.TotalPages,
|
||||
)
|
||||
var i ReadingProgress
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.EbookID,
|
||||
&i.UserID,
|
||||
&i.CurrentPage,
|
||||
&i.TotalPages,
|
||||
&i.LastReadAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO users (email, username, password_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUserByEmail :one
|
||||
SELECT * FROM users WHERE email = $1;
|
||||
|
||||
-- name: GetUserByUsername :one
|
||||
SELECT * FROM users WHERE username = $1;
|
||||
|
||||
-- name: GetUserByEmailOrUsername :one
|
||||
SELECT * FROM users WHERE email = $1 OR username = $1;
|
||||
|
||||
-- name: GetUser :one
|
||||
SELECT id, email, username, created_at, updated_at FROM users WHERE id = $1;
|
||||
|
||||
-- name: GetEbook :one
|
||||
SELECT * FROM ebooks WHERE id = $1;
|
||||
|
||||
-- name: ListEbooks :many
|
||||
SELECT * FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: CreateEbook :one
|
||||
INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateEbook :one
|
||||
UPDATE ebooks SET
|
||||
title = $2,
|
||||
author = $3,
|
||||
isbn = $4,
|
||||
description = $5,
|
||||
cover_image_path = $6,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteEbook :exec
|
||||
DELETE FROM ebooks WHERE id = $1;
|
||||
|
||||
-- name: GetReadingProgress :one
|
||||
SELECT * FROM reading_progress WHERE ebook_id = $1 AND user_id = $2;
|
||||
|
||||
-- name: UpdateReadingProgress :one
|
||||
INSERT INTO reading_progress (ebook_id, user_id, current_page, total_pages, last_read_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (ebook_id, user_id)
|
||||
DO UPDATE SET
|
||||
current_page = EXCLUDED.current_page,
|
||||
total_pages = EXCLUDED.total_pages,
|
||||
last_read_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteReadingProgress :exec
|
||||
DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2;
|
||||
@@ -0,0 +1,166 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookmann/internal/database"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
db *database.Queries
|
||||
jwtKey []byte
|
||||
}
|
||||
|
||||
func NewAuthHandler(db *database.Queries, jwtSecret string) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
db: db,
|
||||
jwtKey: []byte(jwtSecret),
|
||||
}
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Username string `json:"username" validate:"required,min=3,max=50"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Login string `json:"login" validate:"required"` // email or username
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User UserProfile `json:"user"`
|
||||
}
|
||||
|
||||
type UserProfile struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// Register handles POST /api/auth/register
|
||||
func (h *AuthHandler) Register(c echo.Context) error {
|
||||
var req RegisterRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "email already exists"})
|
||||
}
|
||||
|
||||
if _, err := h.db.GetUserByUsername(c.Request().Context(), req.Username); err == nil {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"})
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
||||
}
|
||||
|
||||
// Create user
|
||||
user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{
|
||||
Email: req.Email,
|
||||
Username: req.Username,
|
||||
PasswordHash: string(hashedPassword),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
token, err := h.generateJWT(uuid.UUID(user.ID.Bytes).String())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, AuthResponse{
|
||||
Token: token,
|
||||
User: UserProfile{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Login handles POST /api/auth/login
|
||||
func (h *AuthHandler) Login(c echo.Context) error {
|
||||
var req LoginRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Get user by email or username
|
||||
user, err := h.db.GetUserByEmailOrUsername(c.Request().Context(), req.Login)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
}
|
||||
|
||||
// Check password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
token, err := h.generateJWT(uuid.UUID(user.ID.Bytes).String())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, AuthResponse{
|
||||
Token: token,
|
||||
User: UserProfile{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetProfile handles GET /api/auth/profile
|
||||
func (h *AuthHandler) GetProfile(c echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
user, err := h.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, UserProfile{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) generateJWT(userID string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(h.jwtKey)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookmann/internal/database"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *database.Queries
|
||||
}
|
||||
|
||||
func NewHandler(db *database.Queries) *Handler {
|
||||
return &Handler{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func SetupRoutes(g *echo.Group, db *database.Queries) {
|
||||
h := NewHandler(db)
|
||||
|
||||
g.GET("/ebooks", h.ListEbooks)
|
||||
g.GET("/ebooks/:id", h.GetEbook)
|
||||
g.POST("/ebooks", h.CreateEbook)
|
||||
g.PUT("/ebooks/:id", h.UpdateEbook)
|
||||
g.DELETE("/ebooks/:id", h.DeleteEbook)
|
||||
|
||||
g.GET("/ebooks/:id/progress", h.GetReadingProgress)
|
||||
g.PUT("/ebooks/:id/progress", h.UpdateReadingProgress)
|
||||
}
|
||||
|
||||
// ListEbooks handles GET /api/ebooks
|
||||
func (h *Handler) ListEbooks(c echo.Context) error {
|
||||
limitStr := c.QueryParam("limit")
|
||||
offsetStr := c.QueryParam("offset")
|
||||
|
||||
limit := int32(20) // default
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil {
|
||||
limit = int32(l)
|
||||
}
|
||||
}
|
||||
|
||||
offset := int32(0)
|
||||
if offsetStr != "" {
|
||||
if o, err := strconv.Atoi(offsetStr); err == nil {
|
||||
offset = int32(o)
|
||||
}
|
||||
}
|
||||
|
||||
ebooks, err := h.db.ListEbooks(c.Request().Context(), database.ListEbooksParams{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, ebooks)
|
||||
}
|
||||
|
||||
// GetEbook handles GET /api/ebooks/:id
|
||||
func (h *Handler) GetEbook(c echo.Context) error {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
}
|
||||
|
||||
ebook, err := h.db.GetEbook(c.Request().Context(), pgtype.UUID{Bytes: id, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, ebook)
|
||||
}
|
||||
|
||||
// CreateEbookRequest represents the request for creating an ebook
|
||||
type CreateEbookRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=500"`
|
||||
Author string `json:"author"`
|
||||
ISBN string `json:"isbn"`
|
||||
Description string `json:"description"`
|
||||
FilePath string `json:"file_path" validate:"required"`
|
||||
FileSize int64 `json:"file_size" validate:"required,min=1"`
|
||||
MimeType string `json:"mime_type" validate:"required"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
}
|
||||
|
||||
// CreateEbook handles POST /api/ebooks
|
||||
func (h *Handler) CreateEbook(c echo.Context) error {
|
||||
var req CreateEbookRequest
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
ebook, err := h.db.CreateEbook(c.Request().Context(), database.CreateEbookParams{
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
|
||||
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
||||
FilePath: req.FilePath,
|
||||
FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0},
|
||||
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
|
||||
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, ebook)
|
||||
}
|
||||
|
||||
// UpdateEbookRequest represents the request for updating an ebook
|
||||
type UpdateEbookRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=500"`
|
||||
Author string `json:"author"`
|
||||
ISBN string `json:"isbn"`
|
||||
Description string `json:"description"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
}
|
||||
|
||||
// UpdateEbook handles PUT /api/ebooks/:id
|
||||
func (h *Handler) UpdateEbook(c echo.Context) error {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
}
|
||||
|
||||
var req UpdateEbookRequest
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
ebook, err := h.db.UpdateEbook(c.Request().Context(), database.UpdateEbookParams{
|
||||
ID: pgtype.UUID{Bytes: id, Valid: true},
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
|
||||
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
||||
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, ebook)
|
||||
}
|
||||
|
||||
// DeleteEbook handles DELETE /api/ebooks/:id
|
||||
func (h *Handler) DeleteEbook(c echo.Context) error {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
}
|
||||
|
||||
err = h.db.DeleteEbook(c.Request().Context(), pgtype.UUID{Bytes: id, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetReadingProgress handles GET /api/ebooks/:id/progress
|
||||
func (h *Handler) GetReadingProgress(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
progress, err := h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
// If no progress found, return default
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"ebook_id": ebookIdStr,
|
||||
"user_id": userID,
|
||||
"current_page": 0,
|
||||
"total_pages": nil,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, progress)
|
||||
}
|
||||
|
||||
// UpdateReadingProgressRequest represents the request for updating reading progress
|
||||
type UpdateReadingProgressRequest struct {
|
||||
CurrentPage int32 `json:"current_page" validate:"required,min=0"`
|
||||
TotalPages int32 `json:"total_pages" validate:"omitempty,min=1"`
|
||||
}
|
||||
|
||||
// UpdateReadingProgress handles PUT /api/ebooks/:id/progress
|
||||
func (h *Handler) UpdateReadingProgress(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
var req UpdateReadingProgressRequest
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
progress, err := h.db.UpdateReadingProgress(c.Request().Context(), database.UpdateReadingProgressParams{
|
||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
|
||||
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, progress)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
01f32890b5356f7cc554a887ad06bc0a298c4dd3
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Create users table
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create ebooks table
|
||||
CREATE TABLE ebooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
author VARCHAR(255),
|
||||
isbn VARCHAR(13),
|
||||
description TEXT,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_size BIGINT,
|
||||
mime_type VARCHAR(100),
|
||||
cover_image_path VARCHAR(500),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create reading_progress table
|
||||
CREATE TABLE reading_progress (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ebook_id UUID NOT NULL REFERENCES ebooks(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
current_page INTEGER DEFAULT 0,
|
||||
total_pages INTEGER,
|
||||
last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(ebook_id, user_id)
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_ebooks_title ON ebooks(title);
|
||||
CREATE INDEX idx_ebooks_author ON ebooks(author);
|
||||
CREATE INDEX idx_reading_progress_ebook_id ON reading_progress(ebook_id);
|
||||
CREATE INDEX idx_reading_progress_user_id ON reading_progress(user_id);
|
||||
Generated
+977
@@ -0,0 +1,977 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"body-parser": "^1.20.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.5",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"body-parser": "^1.20.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
a6aeff4b597d6c2bdc67497032e46b85148c773e
|
||||
@@ -0,0 +1,107 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const cors = require('cors');
|
||||
const bodyParser = require('body-parser');
|
||||
|
||||
const app = express();
|
||||
const PORT = 8080;
|
||||
const JWT_SECRET = 'your-secret-key'; // In production, use env var
|
||||
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json());
|
||||
|
||||
// In-memory user store (for demo purposes)
|
||||
const users = [];
|
||||
|
||||
// Password validation regex
|
||||
const passwordRegex = /^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
|
||||
|
||||
// Middleware to verify token
|
||||
function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) return res.status(401).json({ error: 'Access token required' });
|
||||
|
||||
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||||
if (err) return res.status(403).json({ error: 'Invalid token' });
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Auth routes
|
||||
app.post('/api/auth/register', async (req, res) => {
|
||||
const { email, username, password } = req.body;
|
||||
|
||||
if (!email || !username || !password) {
|
||||
return res.status(400).json({ error: 'All fields are required' });
|
||||
}
|
||||
|
||||
// Check if password meets requirements
|
||||
if (!passwordRegex.test(password)) {
|
||||
return res.status(400).json({
|
||||
error: 'Password must be at least 8 characters and include at least one uppercase letter, one number, and one symbol'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = users.find(u => u.email === email || u.username === username);
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ error: 'User already exists' });
|
||||
}
|
||||
|
||||
try {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const user = { id: users.length + 1, email, username, passwordHash };
|
||||
users.push(user);
|
||||
|
||||
const token = jwt.sign({ id: user.id, email: user.email, username: user.username }, JWT_SECRET);
|
||||
res.json({ token, user: { id: user.id, email: user.email, username: user.username } });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Registration failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
const { login, password } = req.body; // login can be email or username
|
||||
|
||||
if (!login || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
const user = users.find(u => u.email === login || u.username === login);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = jwt.sign({ id: user.id, email: user.email, username: user.username }, JWT_SECRET);
|
||||
res.json({ token, user: { id: user.id, email: user.email, username: user.username } });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/auth/profile', authenticateToken, (req, res) => {
|
||||
res.json(req.user);
|
||||
});
|
||||
|
||||
// Stub routes for ebooks (to avoid errors)
|
||||
app.get('/api/ebooks', authenticateToken, (req, res) => {
|
||||
res.json([]);
|
||||
});
|
||||
|
||||
app.get('/api/ebooks/:id', authenticateToken, (req, res) => {
|
||||
res.json({ id: req.params.id, title: 'Stub' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
schema: "migrations"
|
||||
queries: "internal/database/queries"
|
||||
gen:
|
||||
go:
|
||||
package: "database"
|
||||
out: "internal/database"
|
||||
sql_package: "pgx/v5"
|
||||
emit_db_tags: true
|
||||
emit_prepared_queries: true
|
||||
emit_interface: true
|
||||
emit_exact_table_names: true
|
||||
emit_empty_slices: true
|
||||
emit_exported_queries: true
|
||||
emit_json_tags: true
|
||||
@@ -0,0 +1 @@
|
||||
export const env={}
|
||||
@@ -0,0 +1 @@
|
||||
.static{position:static}.container{width:100%}.mx-auto{margin-inline:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.contents{display:contents}.flex{display:flex}.grid{display:grid}.aspect-\[3\/4\]{aspect-ratio:3/4}.h-full{height:100%}.w-full{width:100%}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.items-center{align-items:center}.justify-center{justify-content:center}.overflow-hidden{overflow:hidden}.object-cover{-o-object-fit:cover;object-fit:cover}.text-center{text-align:center}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,ease);transition-duration:var(--tw-duration,0s)}
|
||||
@@ -0,0 +1 @@
|
||||
.line-clamp-2.svelte-1uha8ag{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.line-clamp-3.svelte-1uha8ag{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
||||
@@ -0,0 +1 @@
|
||||
import{b as c,h as _,a as o,E as d,r as b,H as E,s as T,c as p,d as f}from"./DnPHkIdi.js";import{B as y}from"./kSfNJqxT.js";function v(t,i,h=!1){_&&o();var e=new y(t),u=h?d:0;function n(a,r){if(_){const l=b(t)===E;if(a===l){var s=T();p(s),e.anchor=s,f(!1),e.ensure(a,r),f(!0);return}}e.ensure(a,r)}c(()=>{var a=!1;i((r,s=!0)=>{a=!0,n(s,r)}),a||n(!1,null)},u)}export{v as i};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as o,q as n,C as c,v as l}from"./DnPHkIdi.js";function u(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){n===null&&u(),c&&n.l!==null?a(n).m.push(e):o(()=>{const t=l(e);if(typeof t=="function")return t})}function a(e){var t=e.l;return t.u??={a:[],b:[],m:[]}}export{r as o};
|
||||
@@ -0,0 +1 @@
|
||||
import{h as c,N as f,L as p,g as a,e as g}from"./DnPHkIdi.js";const d=Symbol("is custom element"),h=Symbol("is html");function M(s,r,o,i){var t=A(s);c&&(t[r]=s.getAttribute(r),r==="src"||r==="srcset"||r==="href"&&s.nodeName==="LINK")||t[r]!==(t[r]=o)&&(r==="loading"&&(s[p]=o),o==null?s.removeAttribute(r):typeof o!="string"&&N(s).includes(r)?s[r]=o:s.setAttribute(r,o))}function A(s){return s.__attributes??={[d]:s.nodeName.includes("-"),[h]:s.namespaceURI===f}}var e=new Map;function N(s){var r=s.getAttribute("is")||s.nodeName,o=e.get(r);if(o)return o;e.set(r,o=[]);for(var i,t=s,_=Element.prototype;_!==t;){i=g(t);for(var n in i)i[n].set&&o.push(n);t=a(t)}return o}export{M as s};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{k as d,O as f,a3 as v,a4 as u,a5 as E,a6 as p,h as i,m as o,a7 as h,a as T,a8 as N,c as g}from"./DnPHkIdi.js";function y(r){var n=document.createElement("template");return n.innerHTML=r.replaceAll("<!>","<!---->"),n.content}function a(r,n){var e=u;e.nodes===null&&(e.nodes={start:r,end:n,a:null,t:null})}function A(r,n){var e=(n&E)!==0,l=(n&p)!==0,t,_=!r.startsWith("<!>");return()=>{if(i)return a(o,null),o;t===void 0&&(t=y(_?r:"<!>"+r),e||(t=f(t)));var s=l||v?document.importNode(t,!0):t.cloneNode(!0);if(e){var c=f(s),m=s.lastChild;a(c,m)}else a(s,s);return s}}function M(r=""){if(!i){var n=d(r+"");return a(n,n),n}var e=o;return e.nodeType!==N&&(e.before(e=d()),g(e)),a(e,e),e}function O(){if(i)return a(o,null),o;var r=document.createDocumentFragment(),n=document.createComment(""),e=d();return r.append(n,e),a(n,e),r}function C(r,n){if(i){var e=u;((e.f&h)===0||e.nodes.end===null)&&(e.nodes.end=o),T();return}r!==null&&r.before(n)}const w="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(w);export{C as a,a as b,O as c,A as f,M as t};
|
||||
@@ -0,0 +1,2 @@
|
||||
import{aa as U,y as P,V as j,ab as G,v as J,ac as B,ad as W,m as d,h as c,a4 as b,b as K,a as X,Q as x,H as Z,l as _,ae as T,p as k,k as $,af as ee,ag as S,ah as y,ai as C,aj as te,ak as O,q as z,n as se,al as M,am as ie,an as V,ao as re,S as ne,j as A,c as D,ap as ae,s as he,aq as q,ar as oe,E as fe,as as le,at as ue,au as _e,av as de,aw as F,O as ce,ax as pe,a0 as ge,ay as I,d as w,az as ve,a1 as ye,T as me,aA as be,D as Ee,R as Te,aB as we,I as Re}from"./DnPHkIdi.js";import{b as Se}from"./DUCk1qN8.js";function De(t){let e=0,i=j(0),r;return()=>{U()&&(P(i),G(()=>(e===0&&(r=J(()=>t(()=>B(i)))),e+=1,()=>{W(()=>{e-=1,e===0&&(r?.(),r=void 0,B(i))})})))}}var Ne=fe|le|ue;function ke(t,e,i){new Ae(t,e,i)}class Ae{parent;is_pending=!1;#t;#v=c?d:null;#i;#l;#r;#s=null;#e=null;#n=null;#a=null;#o=null;#u=0;#h=0;#_=!1;#d=new Set;#c=new Set;#f=null;#b=De(()=>(this.#f=j(this.#u),()=>{this.#f=null}));constructor(e,i,r){this.#t=e,this.#i=i,this.#l=r,this.parent=b.b,this.is_pending=!!this.#i.pending,this.#r=K(()=>{if(b.b=this,c){const s=this.#v;X(),s.nodeType===x&&s.data===Z?this.#T():(this.#E(),this.#h===0&&(this.is_pending=!1))}else{var a=this.#y();try{this.#s=_(()=>r(a))}catch(s){this.error(s)}this.#h>0?this.#g():this.is_pending=!1}return()=>{this.#o?.remove()}},Ne),c&&(this.#t=d)}#E(){try{this.#s=_(()=>this.#l(this.#t))}catch(e){this.error(e)}}#T(){const e=this.#i.pending;e&&(this.#e=_(()=>e(this.#t)),T.enqueue(()=>{var i=this.#y();this.#s=this.#p(()=>(T.ensure(),_(()=>this.#l(i)))),this.#h>0?this.#g():(k(this.#e,()=>{this.#e=null}),this.is_pending=!1)}))}#y(){var e=this.#t;return this.is_pending&&(this.#o=$(),this.#t.before(this.#o),e=this.#o),e}defer_effect(e){ee(e,this.#d,this.#c)}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!this.#i.pending}#p(e){var i=b,r=O,a=z;S(this.#r),y(this.#r),C(this.#r.ctx);try{return e()}catch(s){return te(s),null}finally{S(i),y(r),C(a)}}#g(){const e=this.#i.pending;this.#s!==null&&(this.#a=document.createDocumentFragment(),this.#a.append(this.#o),se(this.#s,this.#a)),this.#e===null&&(this.#e=_(()=>e(this.#t)))}#m(e){if(!this.has_pending_snippet()){this.parent&&this.parent.#m(e);return}if(this.#h+=e,this.#h===0){this.is_pending=!1;for(const i of this.#d)M(i,ie),V(i);for(const i of this.#c)M(i,re),V(i);this.#d.clear(),this.#c.clear(),this.#e&&k(this.#e,()=>{this.#e=null}),this.#a&&(this.#t.before(this.#a),this.#a=null)}}update_pending_count(e){this.#m(e),this.#u+=e,this.#f&&ne(this.#f,this.#u)}get_effect_pending(){return this.#b(),P(this.#f)}error(e){var i=this.#i.onerror;let r=this.#i.failed;if(this.#_||!i&&!r)throw e;this.#s&&(A(this.#s),this.#s=null),this.#e&&(A(this.#e),this.#e=null),this.#n&&(A(this.#n),this.#n=null),c&&(D(this.#v),ae(),D(he()));var a=!1,s=!1;const o=()=>{if(a){_e();return}a=!0,s&&oe(),T.ensure(),this.#u=0,this.#n!==null&&k(this.#n,()=>{this.#n=null}),this.is_pending=this.has_pending_snippet(),this.#s=this.#p(()=>(this.#_=!1,_(()=>this.#l(this.#t)))),this.#h>0?this.#g():this.is_pending=!1};var l=O;try{y(null),s=!0,i?.(e,o),s=!1}catch(f){q(f,this.#r&&this.#r.parent)}finally{y(l)}r&&W(()=>{this.#n=this.#p(()=>{T.ensure(),this.#_=!0;try{return _(()=>{r(this.#t,()=>e,()=>o)})}catch(f){return q(f,this.#r.parent),null}finally{this.#_=!1}})})}}const Oe=["touchstart","touchmove"];function Fe(t){return Oe.includes(t)}const Ie=new Set,H=new Set;let L=null;function R(t){var e=this,i=e.ownerDocument,r=t.type,a=t.composedPath?.()||[],s=a[0]||t.target;L=t;var o=0,l=L===t&&t.__root;if(l){var f=a.indexOf(l);if(f!==-1&&(e===document||e===window)){t.__root=e;return}var p=a.indexOf(e);if(p===-1)return;f<=p&&(o=f)}if(s=a[o]||t.target,s!==e){de(t,"currentTarget",{configurable:!0,get(){return s||i}});var N=O,u=b;y(null),S(null);try{for(var n,h=[];s!==null;){var g=s.assignedSlot||s.parentNode||s.host||null;try{var m=s["__"+r];m!=null&&(!s.disabled||t.target===s)&&m.call(s,t)}catch(E){n?h.push(E):n=E}if(t.cancelBubble||g===e||g===null)break;s=g}if(n){for(let E of h)queueMicrotask(()=>{throw E});throw n}}finally{t.__root=e,delete t.currentTarget,y(N),S(u)}}}function Ce(t,e){var i=e==null?"":typeof e=="object"?e+"":e;i!==(t.__t??=t.nodeValue)&&(t.__t=i,t.nodeValue=i+"")}function Ye(t,e){return Q(t,e)}function Me(t,e){F(),e.intro=e.intro??!1;const i=e.target,r=c,a=d;try{for(var s=ce(i);s&&(s.nodeType!==x||s.data!==pe);)s=ge(s);if(!s)throw I;w(!0),D(s);const o=Q(t,{...e,anchor:s});return w(!1),o}catch(o){if(o instanceof Error&&o.message.split(`
|
||||
`).some(l=>l.startsWith("https://svelte.dev/e/")))throw o;return o!==I&&console.warn("Failed to hydrate: ",o),e.recover===!1&&ve(),F(),ye(i),w(!1),Ye(t,e)}finally{w(r),D(a)}}const v=new Map;function Q(t,{target:e,anchor:i,props:r={},events:a,context:s,intro:o=!0}){F();var l=new Set,f=u=>{for(var n=0;n<u.length;n++){var h=u[n];if(!l.has(h)){l.add(h);var g=Fe(h);e.addEventListener(h,R,{passive:g});var m=v.get(h);m===void 0?(document.addEventListener(h,R,{passive:g}),v.set(h,1)):v.set(h,m+1)}}};f(me(Ie)),H.add(f);var p=void 0,N=be(()=>{var u=i??e.appendChild($());return ke(u,{pending:()=>{}},n=>{if(s){Ee({});var h=z;h.c=s}if(a&&(r.$$events=a),c&&Se(n,null),p=t(n,r)||{},c&&(b.nodes.end=d,d===null||d.nodeType!==x||d.data!==Te))throw we(),I;s&&Re()}),()=>{for(var n of l){e.removeEventListener(n,R);var h=v.get(n);--h===0?(document.removeEventListener(n,R),v.delete(n)):v.set(n,h)}H.delete(f),u!==i&&u.parentNode?.removeChild(u)}});return Y.set(p,N),p}let Y=new WeakMap;function Ve(t,e){const i=Y.get(t);return i?(Y.delete(t),i(e)):Promise.resolve()}export{Me as h,Ye as m,Ce as s,Ve as u};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{q as d,u as g,t as c,v as m,w as i,x as b,y as p,z as v,A as y,B as h}from"./DnPHkIdi.js";function x(n=!1){const s=d,e=s.l.u;if(!e)return;let f=()=>v(s.s);if(n){let a=0,t={};const _=y(()=>{let l=!1;const r=s.s;for(const o in r)r[o]!==t[o]&&(t[o]=r[o],l=!0);return l&&a++,a});f=()=>p(_)}e.b.length&&g(()=>{u(s,f),i(e.b)}),c(()=>{const a=m(()=>e.m.map(b));return()=>{for(const t of a)typeof t=="function"&&t()}}),e.a.length&&c(()=>{u(s,f),i(e.a)})}function u(n,s){if(n.l.s)for(const e of n.l.s)p(e);s()}h();export{x as i};
|
||||
@@ -0,0 +1 @@
|
||||
import{f as n,i as p,j as o,p as u,k as d,l,h as m,m as _,n as v,o as b}from"./DnPHkIdi.js";class w{anchor;#t=new Map;#s=new Map;#e=new Map;#i=new Set;#a=!0;constructor(e,s=!0){this.anchor=e,this.#a=s}#f=()=>{var e=n;if(this.#t.has(e)){var s=this.#t.get(e),t=this.#s.get(s);if(t)p(t),this.#i.delete(s);else{var a=this.#e.get(s);a&&(this.#s.set(s,a.effect),this.#e.delete(s),a.fragment.lastChild.remove(),this.anchor.before(a.fragment),t=a.effect)}for(const[i,f]of this.#t){if(this.#t.delete(i),i===e)break;const h=this.#e.get(f);h&&(o(h.effect),this.#e.delete(f))}for(const[i,f]of this.#s){if(i===s||this.#i.has(i))continue;const h=()=>{if(Array.from(this.#t.values()).includes(i)){var c=document.createDocumentFragment();v(f,c),c.append(d()),this.#e.set(i,{effect:f,fragment:c})}else o(f);this.#i.delete(i),this.#s.delete(i)};this.#a||!t?(this.#i.add(i),u(f,h,!1)):h()}}};#h=e=>{this.#t.delete(e);const s=Array.from(this.#t.values());for(const[t,a]of this.#e)s.includes(t)||(o(a.effect),this.#e.delete(t))};ensure(e,s){var t=n,a=b();if(s&&!this.#s.has(e)&&!this.#e.has(e))if(a){var i=document.createDocumentFragment(),f=d();i.append(f),this.#e.set(e,{effect:l(()=>s(f)),fragment:i})}else this.#s.set(e,l(()=>s(this.anchor)));if(this.#t.set(t,e),a){for(const[h,r]of this.#s)h===e?t.skipped_effects.delete(r):t.skipped_effects.add(r);for(const[h,r]of this.#e)h===e?t.skipped_effects.delete(r.effect):t.skipped_effects.add(r.effect);t.oncommit(this.#f),t.ondiscard(this.#h)}else m&&(this.anchor=_),this.#f()}}export{w as B};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{l as o,a as r}from"../chunks/CKL1QNnB.js";export{o as load_css,r as start};
|
||||
@@ -0,0 +1 @@
|
||||
import{c as p,a as r,f as v}from"../chunks/DUCk1qN8.js";import{b as m,E as g,k as h,a9 as u,h as n,O as _,Q as y,a0 as o,d as f,c,m as w,F as E,G as x}from"../chunks/DnPHkIdi.js";import{B as T}from"../chunks/kSfNJqxT.js";import{s as b}from"../chunks/C9HAc536.js";function A(l,s,...a){var i=new T(l);m(()=>{const t=s()??null;i.ensure(t,t&&(e=>t(e,...a)))},g)}function F(l,s){let a=null,i=n;var t;if(n){a=w;for(var e=_(document.head);e!==null&&(e.nodeType!==y||e.data!==l);)e=o(e);if(e===null)f(!1);else{var d=o(e);e.remove(),c(d)}}n||(t=document.head.appendChild(h()));try{m(()=>s(t),u)}finally{i&&(f(!0),c(a))}}const M="data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='107'%20height='128'%20viewBox='0%200%20107%20128'%3e%3ctitle%3esvelte-logo%3c/title%3e%3cpath%20d='M94.157%2022.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282%2029.608A29.92%2029.92%200%200%200%208.764%2049.65a31.5%2031.5%200%200%200%203.108%2020.231%2030%2030%200%200%200-4.477%2011.183%2031.9%2031.9%200%200%200%205.448%2024.116c10.402%2014.887%2030.942%2019.297%2045.791%209.835l26.083-16.624A29.92%2029.92%200%200%200%2098.235%2078.35a31.53%2031.53%200%200%200-3.105-20.232%2030%2030%200%200%200%204.474-11.182%2031.88%2031.88%200%200%200-5.447-24.116'%20style='fill:%23ff3e00'/%3e%3cpath%20d='M45.817%20106.582a20.72%2020.72%200%200%201-22.237-8.243%2019.17%2019.17%200%200%201-3.277-14.503%2018%2018%200%200%201%20.624-2.435l.49-1.498%201.337.981a33.6%2033.6%200%200%200%2010.203%205.098l.97.294-.09.968a5.85%205.85%200%200%200%201.052%203.878%206.24%206.24%200%200%200%206.695%202.485%205.8%205.8%200%200%200%201.603-.704L69.27%2076.28a5.43%205.43%200%200%200%202.45-3.631%205.8%205.8%200%200%200-.987-4.371%206.24%206.24%200%200%200-6.698-2.487%205.7%205.7%200%200%200-1.6.704l-9.953%206.345a19%2019%200%200%201-5.296%202.326%2020.72%2020.72%200%200%201-22.237-8.243%2019.17%2019.17%200%200%201-3.277-14.502%2017.99%2017.99%200%200%201%208.13-12.052l26.081-16.623a19%2019%200%200%201%205.3-2.329%2020.72%2020.72%200%200%201%2022.237%208.243%2019.17%2019.17%200%200%201%203.277%2014.503%2018%2018%200%200%201-.624%202.435l-.49%201.498-1.337-.98a33.6%2033.6%200%200%200-10.203-5.1l-.97-.294.09-.968a5.86%205.86%200%200%200-1.052-3.878%206.24%206.24%200%200%200-6.696-2.485%205.8%205.8%200%200%200-1.602.704L37.73%2051.72a5.42%205.42%200%200%200-2.449%203.63%205.79%205.79%200%200%200%20.986%204.372%206.24%206.24%200%200%200%206.698%202.486%205.8%205.8%200%200%200%201.602-.704l9.952-6.342a19%2019%200%200%201%205.295-2.328%2020.72%2020.72%200%200%201%2022.237%208.242%2019.17%2019.17%200%200%201%203.277%2014.503%2018%2018%200%200%201-8.13%2012.053l-26.081%2016.622a19%2019%200%200%201-5.3%202.328'%20style='fill:%23fff'/%3e%3c/svg%3e";var k=v('<link rel="icon"/>');function O(l,s){var a=p();F("12qhfyh",t=>{var e=k();x(()=>b(e,"href",M)),r(t,e)});var i=E(a);A(i,()=>s.children),r(l,a)}export{O as component};
|
||||
@@ -0,0 +1 @@
|
||||
import{f as u,a as h}from"../chunks/DUCk1qN8.js";import{i as g}from"../chunks/VM9GfMNA.js";import{D as l,F as v,G as d,I as _,J as a,K as e,M as x}from"../chunks/DnPHkIdi.js";import{s as o}from"../chunks/DZQwV0xP.js";import{s as $,p}from"../chunks/CKL1QNnB.js";const k={get error(){return p.error},get status(){return p.status}};$.updated.check;const m=k;var b=u("<h1> </h1> <p> </p>",1);function J(i,f){l(f,!1),g();var t=b(),r=v(t),n=a(r,!0);e(r);var s=x(r,2),c=a(s,!0);e(s),d(()=>{o(n,m.status),o(c,m.error?.message)}),h(i,t),_()}export{J as component};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"version":"1768960079047"}
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<link rel="modulepreload" href="/_app/immutable/entry/start.BrST8BG9.js">
|
||||
<link rel="modulepreload" href="/_app/immutable/chunks/CKL1QNnB.js">
|
||||
<link rel="modulepreload" href="/_app/immutable/chunks/DnPHkIdi.js">
|
||||
<link rel="modulepreload" href="/_app/immutable/chunks/BOU_Z_Ye.js">
|
||||
<link rel="modulepreload" href="/_app/immutable/entry/app.o34c63v3.js">
|
||||
<link rel="modulepreload" href="/_app/immutable/chunks/DZQwV0xP.js">
|
||||
<link rel="modulepreload" href="/_app/immutable/chunks/DUCk1qN8.js">
|
||||
<link rel="modulepreload" href="/_app/immutable/chunks/6OCXa8_L.js">
|
||||
<link rel="modulepreload" href="/_app/immutable/chunks/kSfNJqxT.js">
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_1nk8k67 = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.BrST8BG9.js"),
|
||||
import("/_app/immutable/entry/app.o34c63v3.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,47 @@
|
||||
# Bruno API Tests for Ebook Reader
|
||||
|
||||
This directory contains Bruno collection for testing the Ebook Reader API with comprehensive REST documentation.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install Bruno: https://www.usebruno.com/
|
||||
2. Open Bruno and import this collection folder
|
||||
3. Select the "localhost" environment
|
||||
4. Start the application with `docker-compose up --build`
|
||||
5. Register/Login first, then use Bearer token for protected endpoints
|
||||
|
||||
## Available Tests
|
||||
|
||||
### Auth (Public Endpoints)
|
||||
- **Register User**: POST /api/auth/register - Create new account
|
||||
- **Login User**: POST /api/auth/login - Authenticate (email or username)
|
||||
- **Get Profile**: GET /api/auth/profile - Get user info (requires token)
|
||||
|
||||
### Ebooks (Protected - JWT Required)
|
||||
- **List Ebooks**: GET /api/ebooks - Paginated ebook list
|
||||
- **Get Ebook**: GET /api/ebooks/:id - Single ebook details
|
||||
- **Create Ebook**: POST /api/ebooks - Add new ebook
|
||||
- **Update Ebook**: PUT /api/ebooks/:id - Modify ebook metadata
|
||||
- **Delete Ebook**: DELETE /api/ebooks/:id - Remove ebook
|
||||
|
||||
### Reading Progress (Protected - JWT Required)
|
||||
- **Get Reading Progress**: GET /api/ebooks/:id/progress - User's progress
|
||||
- **Update Reading Progress**: PUT /api/ebooks/:id/progress - Update progress
|
||||
|
||||
## Documentation Features
|
||||
|
||||
Each request includes:
|
||||
- **Detailed descriptions** of functionality
|
||||
- **Parameter specifications** (required/optional, types)
|
||||
- **Request/Response examples**
|
||||
- **Error response codes** and meanings
|
||||
- **Authentication requirements**
|
||||
|
||||
## Notes
|
||||
|
||||
- **Authentication Flow**: Register → Login → Use Bearer token for all other requests
|
||||
- **JWT Tokens**: Valid for 24 hours, include in `Authorization: Bearer <token>` header
|
||||
- **User Isolation**: Progress and data are user-specific
|
||||
- **Variables**: Update `ebook_id` for testing specific ebooks
|
||||
- **Security**: Passwords hashed with bcrypt, unique email/username constraints
|
||||
- **JSON**: All requests/responses use JSON format
|
||||
@@ -0,0 +1,23 @@
|
||||
meta:
|
||||
name: Get Profile
|
||||
seq: 3
|
||||
http:
|
||||
method: get
|
||||
url: "{{base_url}}/api/auth/profile"
|
||||
auth:
|
||||
type: bearer
|
||||
docs: |
|
||||
## Get User Profile
|
||||
|
||||
Retrieves the profile of the authenticated user.
|
||||
|
||||
**Authentication:** Required (Bearer token)
|
||||
|
||||
**Response:**
|
||||
- `id` (string): User UUID
|
||||
- `email` (string): User's email address
|
||||
- `username` (string): User's username
|
||||
|
||||
**Error Responses:**
|
||||
- 401: Invalid or missing JWT token
|
||||
- 404: User not found
|
||||
@@ -0,0 +1,29 @@
|
||||
meta:
|
||||
name: Login User
|
||||
seq: 2
|
||||
http:
|
||||
method: post
|
||||
url: "{{base_url}}/api/auth/login"
|
||||
body:
|
||||
type: json
|
||||
data: |
|
||||
{
|
||||
"login": "test@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
docs: |
|
||||
## Login User
|
||||
|
||||
Authenticates a user and returns a JWT token.
|
||||
|
||||
**Request Body:**
|
||||
- `login` (string, required): Email address or username
|
||||
- `password` (string, required): User's password
|
||||
|
||||
**Response:**
|
||||
- `token` (string): JWT authentication token (24h expiry)
|
||||
- `user` (object): User profile with id, email, username
|
||||
|
||||
**Error Responses:**
|
||||
- 401: Invalid credentials
|
||||
- 400: Invalid request data
|
||||
@@ -0,0 +1,31 @@
|
||||
meta:
|
||||
name: Register User
|
||||
seq: 1
|
||||
http:
|
||||
method: post
|
||||
url: "{{base_url}}/api/auth/register"
|
||||
body:
|
||||
type: json
|
||||
data: |
|
||||
{
|
||||
"email": "test@example.com",
|
||||
"username": "testuser",
|
||||
"password": "password123"
|
||||
}
|
||||
docs: |
|
||||
## Register User
|
||||
|
||||
Creates a new user account with email, username, and password.
|
||||
|
||||
**Request Body:**
|
||||
- `email` (string, required): Valid email address
|
||||
- `username` (string, required): Unique username (3-50 chars)
|
||||
- `password` (string, required): Password (min 6 chars)
|
||||
|
||||
**Response:**
|
||||
- `token` (string): JWT authentication token
|
||||
- `user` (object): User profile with id, email, username
|
||||
|
||||
**Error Responses:**
|
||||
- 409: Email or username already exists
|
||||
- 400: Invalid request data
|
||||
@@ -0,0 +1,4 @@
|
||||
version: "1"
|
||||
name: "Ebook Reader API"
|
||||
type: collection
|
||||
items: []
|
||||
@@ -0,0 +1,43 @@
|
||||
meta:
|
||||
name: Create Ebook
|
||||
seq: 3
|
||||
http:
|
||||
method: post
|
||||
url: "{{base_url}}/api/ebooks"
|
||||
auth:
|
||||
type: bearer
|
||||
body:
|
||||
type: json
|
||||
data: |
|
||||
{
|
||||
"title": "Sample Book",
|
||||
"author": "Sample Author",
|
||||
"isbn": "1234567890",
|
||||
"description": "A sample ebook",
|
||||
"file_path": "/uploads/sample.epub",
|
||||
"file_size": 1024000,
|
||||
"mime_type": "application/epub+zip",
|
||||
"cover_image_path": "/uploads/cover.jpg"
|
||||
}
|
||||
docs: |
|
||||
## Create Ebook
|
||||
|
||||
Creates a new ebook entry in the library.
|
||||
|
||||
**Authentication:** Required (Bearer token)
|
||||
|
||||
**Request Body:**
|
||||
- `title` (string, required): Book title
|
||||
- `author` (string, optional): Book author
|
||||
- `isbn` (string, optional): ISBN number
|
||||
- `description` (string, optional): Book description
|
||||
- `file_path` (string, required): Path to ebook file
|
||||
- `file_size` (number, optional): File size in bytes
|
||||
- `mime_type` (string, optional): MIME type
|
||||
- `cover_image_path` (string, optional): Path to cover image
|
||||
|
||||
**Response:** Created ebook object
|
||||
|
||||
**Error Responses:**
|
||||
- 401: Invalid authentication
|
||||
- 400: Invalid request data
|
||||
@@ -0,0 +1,27 @@
|
||||
meta:
|
||||
name: Delete Ebook
|
||||
seq: 5
|
||||
http:
|
||||
method: delete
|
||||
url: "{{base_url}}/api/ebooks/{{ebook_id}}"
|
||||
auth:
|
||||
type: bearer
|
||||
vars:
|
||||
pre-request:
|
||||
- name: ebook_id
|
||||
value: "123e4567-e89b-12d3-a456-426614174000"
|
||||
docs: |
|
||||
## Delete Ebook
|
||||
|
||||
Removes an ebook from the library.
|
||||
|
||||
**Authentication:** Required (Bearer token)
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (string): Ebook UUID
|
||||
|
||||
**Response:** 204 No Content
|
||||
|
||||
**Error Responses:**
|
||||
- 401: Invalid authentication
|
||||
- 404: Ebook not found
|
||||
@@ -0,0 +1,27 @@
|
||||
meta:
|
||||
name: Get Ebook
|
||||
seq: 2
|
||||
http:
|
||||
method: get
|
||||
url: "{{base_url}}/api/ebooks/{{ebook_id}}"
|
||||
auth:
|
||||
type: bearer
|
||||
vars:
|
||||
pre-request:
|
||||
- name: ebook_id
|
||||
value: "123e4567-e89b-12d3-a456-426614174000"
|
||||
docs: |
|
||||
## Get Ebook
|
||||
|
||||
Retrieves details of a specific ebook.
|
||||
|
||||
**Authentication:** Required (Bearer token)
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (string): Ebook UUID
|
||||
|
||||
**Response:** Complete ebook object with all metadata
|
||||
|
||||
**Error Responses:**
|
||||
- 401: Invalid authentication
|
||||
- 404: Ebook not found
|
||||
@@ -0,0 +1,23 @@
|
||||
meta:
|
||||
name: List Ebooks
|
||||
seq: 1
|
||||
http:
|
||||
method: get
|
||||
url: "{{base_url}}/api/ebooks"
|
||||
auth:
|
||||
type: bearer
|
||||
docs: |
|
||||
## List Ebooks
|
||||
|
||||
Retrieves a paginated list of ebooks.
|
||||
|
||||
**Authentication:** Required (Bearer token)
|
||||
|
||||
**Query Parameters:**
|
||||
- `limit` (number, optional): Number of results (default: 20, max: 100)
|
||||
- `offset` (number, optional): Pagination offset (default: 0)
|
||||
|
||||
**Response:** Array of ebook objects with id, title, author, etc.
|
||||
|
||||
**Error Responses:**
|
||||
- 401: Invalid authentication
|
||||
@@ -0,0 +1,43 @@
|
||||
meta:
|
||||
name: Update Ebook
|
||||
seq: 4
|
||||
http:
|
||||
method: put
|
||||
url: "{{base_url}}/api/ebooks/{{ebook_id}}"
|
||||
auth:
|
||||
type: bearer
|
||||
body:
|
||||
type: json
|
||||
data: |
|
||||
{
|
||||
"title": "Updated Book Title",
|
||||
"author": "Updated Author",
|
||||
"description": "Updated description"
|
||||
}
|
||||
vars:
|
||||
pre-request:
|
||||
- name: ebook_id
|
||||
value: "123e4567-e89b-12d3-a456-426614174000"
|
||||
docs: |
|
||||
## Update Ebook
|
||||
|
||||
Updates an existing ebook's metadata.
|
||||
|
||||
**Authentication:** Required (Bearer token)
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (string): Ebook UUID
|
||||
|
||||
**Request Body:** (all fields optional)
|
||||
- `title` (string): Book title
|
||||
- `author` (string): Book author
|
||||
- `isbn` (string): ISBN number
|
||||
- `description` (string): Book description
|
||||
- `cover_image_path` (string): Path to cover image
|
||||
|
||||
**Response:** Updated ebook object
|
||||
|
||||
**Error Responses:**
|
||||
- 401: Invalid authentication
|
||||
- 404: Ebook not found
|
||||
- 400: Invalid request data
|
||||
@@ -0,0 +1,4 @@
|
||||
name: localhost
|
||||
variables:
|
||||
- name: base_url
|
||||
value: http://localhost:8765
|
||||
@@ -0,0 +1,31 @@
|
||||
meta:
|
||||
name: Get Reading Progress
|
||||
seq: 6
|
||||
http:
|
||||
method: get
|
||||
url: "{{base_url}}/api/ebooks/{{ebook_id}}/progress"
|
||||
auth:
|
||||
type: bearer
|
||||
vars:
|
||||
pre-request:
|
||||
- name: ebook_id
|
||||
value: "123e4567-e89b-12d3-a456-426614174000"
|
||||
docs: |
|
||||
## Get Reading Progress
|
||||
|
||||
Retrieves the authenticated user's reading progress for an ebook.
|
||||
|
||||
**Authentication:** Required (Bearer token)
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (string): Ebook UUID
|
||||
|
||||
**Response:**
|
||||
- `ebook_id` (string): Ebook UUID
|
||||
- `user_id` (string): User UUID
|
||||
- `current_page` (number): Current page number
|
||||
- `total_pages` (number, nullable): Total pages
|
||||
- `last_read_at` (string): Last read timestamp
|
||||
|
||||
**Error Responses:**
|
||||
- 401: Invalid authentication
|
||||
@@ -0,0 +1,38 @@
|
||||
meta:
|
||||
name: Update Reading Progress
|
||||
seq: 7
|
||||
http:
|
||||
method: put
|
||||
url: "{{base_url}}/api/ebooks/{{ebook_id}}/progress"
|
||||
auth:
|
||||
type: bearer
|
||||
body:
|
||||
type: json
|
||||
data: |
|
||||
{
|
||||
"current_page": 45,
|
||||
"total_pages": 200
|
||||
}
|
||||
vars:
|
||||
pre-request:
|
||||
- name: ebook_id
|
||||
value: "123e4567-e89b-12d3-a456-426614174000"
|
||||
docs: |
|
||||
## Update Reading Progress
|
||||
|
||||
Updates the authenticated user's reading progress for an ebook.
|
||||
|
||||
**Authentication:** Required (Bearer token)
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (string): Ebook UUID
|
||||
|
||||
**Request Body:**
|
||||
- `current_page` (number, required): Current page number
|
||||
- `total_pages` (number, optional): Total pages in book
|
||||
|
||||
**Response:** Updated progress object
|
||||
|
||||
**Error Responses:**
|
||||
- 401: Invalid authentication
|
||||
- 400: Invalid request data
|
||||
@@ -0,0 +1,3 @@
|
||||
version: "1"
|
||||
name: "Ebook Reader API Workspace"
|
||||
type: workspace
|
||||
@@ -0,0 +1,51 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ebookdb
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${DBPASS}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./backend/migrations:/docker-entrypoint-initdb.d
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./backend/Dockerfile
|
||||
environment:
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_USER: postgres
|
||||
DATABASE_PASSWORD: ${DBPASS}
|
||||
DATABASE_NAME: ebookdb
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_PORT: 8765
|
||||
ports:
|
||||
- "8765:8765"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend/uploads:/app/uploads
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8765/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv create --template minimal --types ts --install npm .
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
Generated
+2895
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.1",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@zerodevx/svelte-toast": "^0.9.6",
|
||||
"sveltekit-superforms": "^2.29.1",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
background-color: #1a1b26;
|
||||
color: #a9b1d6;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #1a1b26;
|
||||
color: #a9b1d6;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: #16161e;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #292e42;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #565f89;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Custom button styles */
|
||||
.btn-primary {
|
||||
background-color: #7aa2f7;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #3b82f6;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #292e42;
|
||||
color: #a9b1d6;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #364a82;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #f7768e;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
background-color: #16161e;
|
||||
border: 1px solid #292e42;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-bottom: 1px solid #292e42;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input-field {
|
||||
background-color: #1a1b26;
|
||||
border: 1px solid #292e42;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #a9b1d6;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
ring-color: #7aa2f7;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: #565f89;
|
||||
}
|
||||
|
||||
/* Navigation styles */
|
||||
.nav-link {
|
||||
color: #a9b1d6;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #7aa2f7;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: #7aa2f7;
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,170 @@
|
||||
import { showError } from './toast';
|
||||
|
||||
const API_BASE = import.meta.env.DEV ? 'http://localhost:8080/api' : '/api';
|
||||
|
||||
// Helper function to handle API responses and show error toasts
|
||||
async function handleResponse<T>(response: Response, errorMessage: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let message = errorMessage;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.error) {
|
||||
message = errorData.error;
|
||||
}
|
||||
} catch (e) {
|
||||
// If we can't parse JSON, use the default message
|
||||
}
|
||||
showError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface Ebook {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
isbn: string | null;
|
||||
description: string | null;
|
||||
file_path: string;
|
||||
file_size: number | null;
|
||||
mime_type: string | null;
|
||||
cover_image_path: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReadingProgress {
|
||||
ebook_id: string;
|
||||
user_id: string;
|
||||
current_page: number;
|
||||
total_pages: number | null;
|
||||
last_read_at: string;
|
||||
}
|
||||
|
||||
// Get stored token
|
||||
function getToken(): string | null {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create headers with auth
|
||||
function createHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Auth functions
|
||||
export async function registerUser(email: string, username: string, password: string): Promise<AuthResponse> {
|
||||
const response = await fetch(`${API_BASE}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, username, password }),
|
||||
});
|
||||
return handleResponse<AuthResponse>(response, 'Registration failed');
|
||||
}
|
||||
|
||||
export async function loginUser(login: string, password: string): Promise<AuthResponse> {
|
||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ login, password }),
|
||||
});
|
||||
return handleResponse<AuthResponse>(response, 'Login failed');
|
||||
}
|
||||
|
||||
export async function getUserProfile(): Promise<User> {
|
||||
const response = await fetch(`${API_BASE}/auth/profile`, {
|
||||
headers: createHeaders(),
|
||||
});
|
||||
return handleResponse<User>(response, 'Failed to get profile');
|
||||
}
|
||||
|
||||
export async function fetchEbooks(limit: number = 20, offset: number = 0): Promise<Ebook[]> {
|
||||
const response = await fetch(`${API_BASE}/ebooks?limit=${limit}&offset=${offset}`, {
|
||||
headers: createHeaders(),
|
||||
});
|
||||
return handleResponse<Ebook[]>(response, 'Failed to fetch ebooks');
|
||||
}
|
||||
|
||||
export async function fetchEbook(id: string): Promise<Ebook> {
|
||||
const response = await fetch(`${API_BASE}/ebooks/${id}`, {
|
||||
headers: createHeaders(),
|
||||
});
|
||||
return handleResponse<Ebook>(response, 'Failed to fetch ebook');
|
||||
}
|
||||
|
||||
export async function createEbook(data: Partial<Ebook>): Promise<Ebook> {
|
||||
const response = await fetch(`${API_BASE}/ebooks`, {
|
||||
method: 'POST',
|
||||
headers: createHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<Ebook>(response, 'Failed to create ebook');
|
||||
}
|
||||
|
||||
export async function updateEbook(id: string, data: Partial<Ebook>): Promise<Ebook> {
|
||||
const response = await fetch(`${API_BASE}/ebooks/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: createHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<Ebook>(response, 'Failed to update ebook');
|
||||
}
|
||||
|
||||
export async function deleteEbook(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/ebooks/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: createHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = 'Failed to delete ebook';
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.error) {
|
||||
message = errorData.error;
|
||||
}
|
||||
} catch (e) {
|
||||
// If we can't parse JSON, use the default message
|
||||
}
|
||||
showError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchReadingProgress(ebookId: string): Promise<ReadingProgress> {
|
||||
const response = await fetch(`${API_BASE}/ebooks/${ebookId}/progress`, {
|
||||
headers: createHeaders(),
|
||||
});
|
||||
return handleResponse<ReadingProgress>(response, 'Failed to fetch reading progress');
|
||||
}
|
||||
|
||||
export async function updateReadingProgress(ebookId: string, currentPage: number, totalPages?: number): Promise<ReadingProgress> {
|
||||
const response = await fetch(`${API_BASE}/ebooks/${ebookId}/progress`, {
|
||||
method: 'PUT',
|
||||
headers: createHeaders(),
|
||||
body: JSON.stringify({ current_page: currentPage, total_pages: totalPages }),
|
||||
});
|
||||
return handleResponse<ReadingProgress>(response, 'Failed to update reading progress');
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,53 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { User } from './api';
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function createAuthStore() {
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: null,
|
||||
loading: true,
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<AuthState>(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
login: (token: string, user: User) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
set({ user, token, loading: false });
|
||||
},
|
||||
logout: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
set({ user: null, token: null, loading: false });
|
||||
},
|
||||
setLoading: (loading: boolean) => {
|
||||
update(state => ({ ...state, loading }));
|
||||
},
|
||||
initialize: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
// Token exists, but we need to validate it by fetching profile
|
||||
// For now, just set loading to false and let components handle
|
||||
set({ user: null, token, loading: false });
|
||||
} else {
|
||||
set({ user: null, token: null, loading: false });
|
||||
}
|
||||
} else {
|
||||
set({ user: null, token: null, loading: false });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const authStore = createAuthStore();
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -0,0 +1,21 @@
|
||||
import { toast } from '@zerodevx/svelte-toast';
|
||||
|
||||
export function showError(message: string) {
|
||||
toast.push(message, {
|
||||
theme: {
|
||||
'--toastBackground': '#f7768e',
|
||||
'--toastBarBackground': '#f7768e',
|
||||
'--toastColor': '#1a1b26',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function showSuccess(message: string) {
|
||||
toast.push(message, {
|
||||
theme: {
|
||||
'--toastBackground': '#9ece6a',
|
||||
'--toastBarBackground': '#9ece6a',
|
||||
'--toastColor': '#1a1b26',
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { authStore } from '$lib/auth';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteToast } from '@zerodevx/svelte-toast';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Initialize auth state on mount
|
||||
onMount(() => {
|
||||
authStore.initialize();
|
||||
});
|
||||
|
||||
// Subscribe to auth state
|
||||
let auth = $state({ user: null as any, token: null as string | null, loading: true });
|
||||
authStore.subscribe((state) => {
|
||||
auth = state;
|
||||
});
|
||||
|
||||
function logout() {
|
||||
authStore.logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
{#if auth.loading}
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-tokyo-bg to-tokyo-bg-dark">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-16 w-16 border-4 border-tokyo-bg-highlight border-t-tokyo-blue mx-auto mb-4"></div>
|
||||
<p class="text-tokyo-fg-dark animate-pulse">Loading your library...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !auth.token}
|
||||
<!-- Auth required -->
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-tokyo-bg to-tokyo-bg-dark">
|
||||
<div class="text-center animate-slide-in">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-4xl font-bold text-tokyo-fg mb-2">📚 Ebook Reader</h1>
|
||||
<p class="text-tokyo-fg-dark text-lg">Your personal digital library</p>
|
||||
</div>
|
||||
<div class="space-x-6">
|
||||
<a
|
||||
href="/login"
|
||||
class="btn-primary inline-flex items-center text-sm font-medium shadow-lg hover:shadow-tokyo-purple/25"
|
||||
>
|
||||
Sign In
|
||||
</a>
|
||||
<a
|
||||
href="/register"
|
||||
class="btn-secondary inline-flex items-center text-sm font-medium"
|
||||
>
|
||||
Create Account
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-12 text-tokyo-fg-dark text-sm">
|
||||
<p>Organize • Read • Enjoy</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Authenticated user -->
|
||||
<header class="bg-tokyo-bg-dark border-b border-tokyo-bg-highlight shadow-lg">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center py-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold text-tokyo-fg">📚 Ebook Reader</h1>
|
||||
<span class="hidden sm:inline text-sm text-tokyo-fg-dark">•</span>
|
||||
<span class="hidden sm:inline text-sm text-tokyo-fg-dark">Welcome back, {auth.user?.username}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm text-tokyo-fg">Hello, <span class="text-tokyo-cyan font-medium">{auth.user?.username}</span></span>
|
||||
<button
|
||||
onclick={logout}
|
||||
class="btn-danger inline-flex items-center text-sm font-medium shadow-lg hover:shadow-tokyo-red/25 transition-all duration-200"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto py-8 sm:px-6 lg:px-8 min-h-[calc(100vh-80px)]">
|
||||
{@render children()}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<SvelteToast />
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchEbooks, type Ebook } from '$lib/api';
|
||||
|
||||
let ebooks: Ebook[] = [];
|
||||
let loading = true;
|
||||
let error: string | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
ebooks = await fetchEbooks();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load ebooks';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<div class="mb-12 text-center">
|
||||
<h1 class="text-4xl font-bold text-tokyo-fg mb-2 animate-fade-in">📚 Your Ebook Library</h1>
|
||||
<p class="text-tokyo-fg-dark text-lg">Discover and organize your digital reading collection</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-4 border-tokyo-bg-highlight border-t-tokyo-cyan mb-4"></div>
|
||||
<p class="text-tokyo-fg-dark animate-pulse">Loading your ebooks...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="card max-w-md mx-auto text-center">
|
||||
<div class="card-body">
|
||||
<div class="text-4xl mb-4">⚠️</div>
|
||||
<h3 class="text-xl font-semibold text-tokyo-red mb-2">Oops! Something went wrong</h3>
|
||||
<p class="text-tokyo-fg-dark">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if ebooks.length === 0}
|
||||
<div class="card max-w-lg mx-auto text-center">
|
||||
<div class="card-body">
|
||||
<div class="text-6xl mb-4">📖</div>
|
||||
<h3 class="text-2xl font-semibold text-tokyo-fg mb-2">Welcome to your library!</h3>
|
||||
<p class="text-tokyo-fg-dark mb-6">You haven't added any ebooks yet. Start building your collection by uploading your favorite books.</p>
|
||||
<button class="btn-primary">Add Your First Ebook</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{#each ebooks as ebook (ebook.id)}
|
||||
<div class="card group hover:scale-105 transition-all duration-300 animate-slide-in">
|
||||
<div class="aspect-[3/4] bg-gradient-to-br from-tokyo-bg-highlight to-tokyo-bg-selection flex items-center justify-center relative overflow-hidden">
|
||||
{#if ebook.cover_image_path}
|
||||
<img
|
||||
src={ebook.cover_image_path}
|
||||
alt="{ebook.title} cover"
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-tokyo-bg-dark/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
{:else}
|
||||
<div class="text-tokyo-fg-dark text-center p-6">
|
||||
<div class="text-4xl mb-3">📚</div>
|
||||
<div class="text-tokyo-fg text-sm font-medium">No Cover</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="font-bold text-lg text-tokyo-fg mb-2 line-clamp-2 group-hover:text-tokyo-cyan transition-colors duration-200">
|
||||
{ebook.title}
|
||||
</h3>
|
||||
{#if ebook.author}
|
||||
<p class="text-tokyo-cyan text-sm mb-3 font-medium">by {ebook.author}</p>
|
||||
{/if}
|
||||
{#if ebook.description}
|
||||
<p class="text-tokyo-fg-dark text-sm line-clamp-3 leading-relaxed">{ebook.description}</p>
|
||||
{/if}
|
||||
<div class="mt-4 pt-3 border-t border-tokyo-bg-highlight">
|
||||
<div class="flex justify-between items-center text-xs text-tokyo-fg-dark">
|
||||
<span>{new Date(ebook.created_at).toLocaleDateString()}</span>
|
||||
{#if ebook.file_size}
|
||||
<span>{Math.round(ebook.file_size / 1024 / 1024)}MB</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/auth';
|
||||
import { loginUser } from '$lib/api';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let username = '';
|
||||
let password = '';
|
||||
let loading = false;
|
||||
let error = '';
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!username || !password) return;
|
||||
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const response = await loginUser(username, password);
|
||||
authStore.login(response.token, response.user);
|
||||
goto('/');
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Login failed';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - Ebook Reader</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-tokyo-bg to-tokyo-bg-dark py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full animate-slide-in">
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<div class="text-4xl mb-4">🔐</div>
|
||||
<h2 class="text-2xl font-bold text-tokyo-fg">
|
||||
Welcome Back
|
||||
</h2>
|
||||
<p class="text-tokyo-fg-dark mt-2">Sign in to access your library</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form class="space-y-6" on:submit|preventDefault={handleSubmit}>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-tokyo-fg mb-2">
|
||||
Username or Email
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
class="input-field w-full"
|
||||
placeholder="Enter your username or email"
|
||||
bind:value={username}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-tokyo-fg mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
class="input-field w-full"
|
||||
placeholder="Enter your password"
|
||||
bind:value={password}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="bg-tokyo-red/10 border border-tokyo-red/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="text-tokyo-red mr-2">⚠️</div>
|
||||
<div class="text-sm text-tokyo-red">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="btn-primary w-full flex justify-center items-center disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></div>
|
||||
{/if}
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/register" class="text-tokyo-blue hover:text-tokyo-cyan transition-colors duration-200 text-sm">
|
||||
Don't have an account? Create one here
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
import { superValidate } from 'sveltekit-superforms/server';
|
||||
import { z } from 'zod';
|
||||
import { registerUser } from '$lib/api';
|
||||
import { redirect, fail } from '@sveltejs/kit';
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
username: z.string().min(3, 'Username must be at least 3 characters'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters').regex(/^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, 'Password must include at least one uppercase letter, one number, and one symbol'),
|
||||
confirmPassword: z.string(),
|
||||
}).refine(data => data.password === data.confirmPassword, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
export const load = async () => {
|
||||
const form = await superValidate(schema);
|
||||
return { form };
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const form = await superValidate(request, schema);
|
||||
if (!form.valid) {
|
||||
return fail(400, { form });
|
||||
}
|
||||
const data = form.data as FormData;
|
||||
try {
|
||||
await registerUser(data.email, data.username, data.password);
|
||||
throw redirect(302, '/login');
|
||||
} catch (err) {
|
||||
if (err instanceof Response) throw err;
|
||||
return fail(500, { form, error: err instanceof Error ? err.message : 'Registration failed' });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { superForm } from 'sveltekit-superforms';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let { form, errors, enhance, submitting } = superForm($page.data.form);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Register - Ebook Reader</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-tokyo-bg to-tokyo-bg-dark py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full animate-slide-in">
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<div class="text-4xl mb-4">✨</div>
|
||||
<h2 class="text-2xl font-bold text-tokyo-fg">
|
||||
Join Our Library
|
||||
</h2>
|
||||
<p class="text-tokyo-fg-dark mt-2">Create your account to start reading</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form class="space-y-6" method="POST" use:enhance>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-tokyo-fg mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
class="input-field w-full"
|
||||
placeholder="Enter your email"
|
||||
bind:value={$form.email}
|
||||
/>
|
||||
{#if $errors.email}
|
||||
<p class="text-tokyo-red text-sm mt-1">{$errors.email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-tokyo-fg mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
class="input-field w-full"
|
||||
placeholder="Choose a username"
|
||||
bind:value={$form.username}
|
||||
/>
|
||||
{#if $errors.username}
|
||||
<p class="text-tokyo-red text-sm mt-1">{$errors.username}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-tokyo-fg mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
class="input-field w-full"
|
||||
placeholder="Create a password"
|
||||
bind:value={$form.password}
|
||||
/>
|
||||
{#if $errors.password}
|
||||
<p class="text-tokyo-red text-sm mt-1">{$errors.password}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirmPassword" class="block text-sm font-medium text-tokyo-fg mb-2">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
class="input-field w-full"
|
||||
placeholder="Confirm your password"
|
||||
bind:value={$form.confirmPassword}
|
||||
/>
|
||||
{#if $errors.confirmPassword}
|
||||
<p class="text-tokyo-red text-sm mt-1">{$errors.confirmPassword}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $page.data.error}
|
||||
<div class="bg-tokyo-red/10 border border-tokyo-red/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="text-tokyo-red mr-2">⚠️</div>
|
||||
<div class="text-sm text-tokyo-red">{$page.data.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={$submitting}
|
||||
class="btn-primary w-full flex justify-center items-center disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if $submitting}
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></div>
|
||||
{/if}
|
||||
{$submitting ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/login" class="text-tokyo-blue hover:text-tokyo-cyan transition-colors duration-200 text-sm">
|
||||
Already have an account? Sign in here
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,16 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: true
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,45 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
tokyo: {
|
||||
bg: '#1a1b26',
|
||||
'bg-dark': '#16161e',
|
||||
'bg-highlight': '#292e42',
|
||||
'bg-selection': '#364a82',
|
||||
fg: '#a9b1d6',
|
||||
'fg-dark': '#565f89',
|
||||
'fg-light': '#c0caf5',
|
||||
red: '#f7768e',
|
||||
orange: '#ff9e64',
|
||||
yellow: '#e0af68',
|
||||
green: '#9ece6a',
|
||||
cyan: '#7dcfff',
|
||||
blue: '#7aa2f7',
|
||||
purple: '#bb9af7',
|
||||
magenta: '#c0caf5',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-in': 'slideIn 0.3s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideIn: {
|
||||
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
module bookmann
|
||||
|
||||
go 1.25.5
|
||||
go 1.25
|
||||
|
||||
require github.com/labstack/echo/v4 v4.15.0
|
||||
|
||||
require (
|
||||
github.com/labstack/echo/v4 v4.15.0 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
|
||||
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
@@ -6,6 +8,10 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
@@ -19,3 +25,5 @@ golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
Reference in New Issue
Block a user