Refactor book management system:
- Updated .gitignore to exclude node_modules directory. - Enhanced index.js with new endpoints for book fetching and checkout requests, integrating nodemailer and express-rate-limit for email notifications and request management. - Added functionality to confirm book presence in the library and improved error handling for external book sources. - Updated package.json and package-lock.json to include new dependencies (nodemailer, express-rate-limit) and their respective versions. - Modified public HTML and JavaScript files to support new features, including a confirm mode for book scanning and improved UI elements. - Updated styles for better user experience in the library interface.
This commit is contained in:
parent
7d788cd094
commit
3de9f3d8ee
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +1,2 @@
|
|||||||
node_modules/
|
node_modules
|
||||||
*.env
|
*.env
|
||||||
|
|||||||
BIN
backup/books copy.db
Normal file
BIN
backup/books copy.db
Normal file
Binary file not shown.
95
bookHelpers.js
Normal file
95
bookHelpers.js
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
const { Book, Location, Checkout, User, Sequelize, Op } = require('./models');
|
||||||
|
// Fetch book from the local database by ISBN
|
||||||
|
const fetchBookFromLocalDatabase = async (isbn) => {
|
||||||
|
return await Book.findOne({ where: { isbn } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch book from Google Books API
|
||||||
|
const fetchBookFromGoogleBooks = async (isbn, apiKey) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`https://www.googleapis.com/books/v1/volumes?q=${isbn}&key=${apiKey}`);
|
||||||
|
if (response.data.items && response.data.items.length > 0) {
|
||||||
|
return library.formatGoogleBooksData(response.data.items[0]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response && (error.response.status === 429 || error.response.status === 408)) {
|
||||||
|
throw error.response.status;
|
||||||
|
}
|
||||||
|
throw new Error('Google Books API Error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch book from Open Library API
|
||||||
|
const fetchBookFromOpenLibrary = async (isbn) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`);
|
||||||
|
return response.data[`ISBN:${isbn}`] ? library.formatOpenLibraryData(response.data[`ISBN:${isbn}`]) : null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response && (error.response.status === 429 || error.response.status === 408)) {
|
||||||
|
throw error.response.status;
|
||||||
|
}
|
||||||
|
throw new Error('Open Library API Error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch book from the Internet Archive
|
||||||
|
const fetchBookFromInternetArchive = async (isbn) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`);
|
||||||
|
return response.data[`ISBN:${isbn}`] ? library.formatArchiveData(response.data[`ISBN:${isbn}`]) : null;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Internet Archive API Error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search books in the local database by title or related fields
|
||||||
|
const searchBooksInLocalDatabase = async (title, searchDescription=false) => {
|
||||||
|
if (!title) {
|
||||||
|
// Return all books if the title is empty
|
||||||
|
return await Book.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchConditions = [
|
||||||
|
{ title: { [Op.like]: `%${title}%` } },
|
||||||
|
{ authors: { [Op.like]: `%${title}%` } },
|
||||||
|
{ publishers: { [Op.like]: `%${title}%` } },
|
||||||
|
{ subjects: { [Op.like]: `%${title}%` } }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (searchDescription) {
|
||||||
|
searchConditions.push({ description: { [Op.like]: `%${title}%` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Book.findAll({
|
||||||
|
where: {
|
||||||
|
[Op.or]: searchConditions
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search books in Open Library by title
|
||||||
|
const searchBooksInOpenLibrary = async (title) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`https://openlibrary.org/search.json?q=${encodeURIComponent(title)}&limit=7`);
|
||||||
|
return response.data.docs.map(result => ({
|
||||||
|
title: result.title,
|
||||||
|
authors: result.author_name || [],
|
||||||
|
publish_date: result.first_publish_year,
|
||||||
|
isbn: result.isbn ? result.isbn[0] : '',
|
||||||
|
publisher: result.publisher ? result.publisher[0] : '',
|
||||||
|
key: result.key
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Open Library API Error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fetchBookFromLocalDatabase,
|
||||||
|
fetchBookFromGoogleBooks,
|
||||||
|
fetchBookFromOpenLibrary,
|
||||||
|
fetchBookFromInternetArchive,
|
||||||
|
searchBooksInLocalDatabase,
|
||||||
|
searchBooksInOpenLibrary
|
||||||
|
};
|
||||||
770
index.js
770
index.js
@ -5,7 +5,23 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
|
const nodemailer = require('nodemailer'); // Add nodemailer for sending emails
|
||||||
|
const rateLimit = require('express-rate-limit'); // Ensure this line is present
|
||||||
|
|
||||||
|
const library = require('./library');
|
||||||
|
const {
|
||||||
|
fetchBookFromLocalDatabase,
|
||||||
|
fetchBookFromGoogleBooks,
|
||||||
|
fetchBookFromOpenLibrary,
|
||||||
|
fetchBookFromInternetArchive,
|
||||||
|
searchBooksInLocalDatabase,
|
||||||
|
searchBooksInOpenLibrary
|
||||||
|
} = require('./bookHelpers'); // Import the helper functions from the new file
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const { Book, Location, Checkout, User } = require('./models');
|
||||||
|
const { Op } = require('sequelize'); // Import Sequelize Operators
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
@ -18,273 +34,301 @@ const credentials = { key: privateKey, cert: certificate };
|
|||||||
// Middleware to parse JSON bodies
|
// Middleware to parse JSON bodies
|
||||||
app.use(express.json()); // Use built-in body-parser for JSON
|
app.use(express.json()); // Use built-in body-parser for JSON
|
||||||
|
|
||||||
const { Sequelize, DataTypes } = require('sequelize');
|
|
||||||
|
|
||||||
// Initialize Sequelize
|
|
||||||
const sequelize = new Sequelize({
|
|
||||||
dialect: 'sqlite',
|
|
||||||
storage: './books.db',
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Test the connection
|
|
||||||
|
|
||||||
sequelize.authenticate()
|
|
||||||
.then(() => {
|
|
||||||
console.log('Connection has been established successfully.');
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Unable to connect to the database:', err);
|
|
||||||
});
|
|
||||||
// TODO: Add physical location of book to database
|
|
||||||
const Book = sequelize.define('Book', {
|
|
||||||
id: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
autoIncrement: true,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
isbn: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: false,
|
|
||||||
unique: true,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
authors: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
publishedDate: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
url: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
number_of_pages: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
},
|
|
||||||
identifiers: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
publishers: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
subjects: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
notes: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
cover_small: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
cover_medium: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
cover_large: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
location_id: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
references: {
|
|
||||||
model: Location,
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
tableName: 'books',
|
|
||||||
timestamps: false, // If your table doesn't have `createdAt` and `updatedAt`
|
|
||||||
});
|
|
||||||
|
|
||||||
const Location = sequelize.define('Location', {
|
|
||||||
id: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
autoIncrement: true,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
shelf: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: false,
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
tableName: 'locations',
|
|
||||||
timestamps: false, // If your table doesn't have `createdAt` and `updatedAt`
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Set up the SQLite database
|
|
||||||
const db = new sqlite3.Database('./books.db', (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Could not connect to database', err);
|
|
||||||
} else {
|
|
||||||
console.log('Connected to database');
|
|
||||||
db.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS books (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
isbn TEXT,
|
|
||||||
title TEXT,
|
|
||||||
authors TEXT,
|
|
||||||
publishedDate TEXT,
|
|
||||||
description TEXT,
|
|
||||||
url TEXT,
|
|
||||||
number_of_pages INTEGER,
|
|
||||||
identifiers TEXT,
|
|
||||||
publishers TEXT,
|
|
||||||
subjects TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
cover_small TEXT,
|
|
||||||
cover_medium TEXT,
|
|
||||||
cover_large TEXT
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Serve static files from the 'public' directory
|
// Serve static files from the 'public' directory
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
// Endpoint to fetch book details by ISBN
|
|
||||||
app.get('/book/:isbn', async (req, res) => {
|
app.get('/book/:isbn', async (req, res) => {
|
||||||
const { isbn } = req.params;
|
const { isbn } = req.params;
|
||||||
console.log(`Fetching book data for ISBN: ${isbn}`);
|
console.log(`Fetching book data for ISBN: ${isbn}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First, check if the book is in the local database
|
// Check if the book is in the local database
|
||||||
const book = await Book.findOne({ where: { isbn } });
|
const localBook = await fetchBookFromLocalDatabase(isbn);
|
||||||
|
|
||||||
if (book) {
|
if (localBook) {
|
||||||
console.log('Book found in the local database');
|
console.log('Book found in the local database');
|
||||||
res.json({ source: 'local', data: book });
|
return res.json({ source: 'local', data: localBook });
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not found locally, try Google Books
|
// If not found locally, try external sources
|
||||||
const apiKey = 'AIzaSyCQikthZ5TlkFTcKTG8n171dRafosK2Mg8';
|
const googleBooksApiKey = 'AIzaSyCQikthZ5TlkFTcKTG8n171dRafosK2Mg8';
|
||||||
const googleBooksResponse = await axios.get(`https://www.googleapis.com/books/v1/volumes?q=${isbn}&key=${apiKey}`);
|
|
||||||
|
|
||||||
if (googleBooksResponse.status === 429) {
|
try {
|
||||||
console.log('Rate limit exceeded for Google Books');
|
const googleBookData = await fetchBookFromGoogleBooks(isbn, googleBooksApiKey);
|
||||||
res.status(429).json({ error: 'Rate limit exceeded' });
|
if (googleBookData) {
|
||||||
return;
|
console.log('Book data found in Google Books');
|
||||||
|
return res.json({ source: 'external', data: googleBookData });
|
||||||
|
}
|
||||||
|
} catch (errorStatus) {
|
||||||
|
if (errorStatus === 429) {
|
||||||
|
console.log('Rate limit exceeded for Google Books');
|
||||||
|
return res.status(429).json({ error: 'Rate limit exceeded' });
|
||||||
|
} else if (errorStatus === 408) {
|
||||||
|
console.log('Request Timeout for Google Books');
|
||||||
|
return res.status(408).json({ error: 'Request Timeout' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (googleBooksResponse.status === 408) {
|
try {
|
||||||
console.log('Request Timeout for Google Books');
|
const openLibraryData = await fetchBookFromOpenLibrary(isbn);
|
||||||
res.status(408).json({ error: 'Request Timeout' });
|
if (openLibraryData) {
|
||||||
return;
|
console.log('Book data found in Open Library');
|
||||||
|
return res.json({ source: 'external', data: openLibraryData });
|
||||||
|
}
|
||||||
|
} catch (errorStatus) {
|
||||||
|
if (errorStatus === 429) {
|
||||||
|
console.log('Rate limit exceeded for Open Library');
|
||||||
|
return res.status(429).json({ error: 'Rate limit exceeded' });
|
||||||
|
} else if (errorStatus === 408) {
|
||||||
|
console.log('Request Timeout for Open Library');
|
||||||
|
return res.status(408).json({ error: 'Request Timeout' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (googleBooksResponse.data.items && googleBooksResponse.data.items.length > 0) {
|
const archiveData = await fetchBookFromInternetArchive(isbn);
|
||||||
const googleBookData = googleBooksResponse.data.items[0];
|
|
||||||
console.log('Book data found in Google Books');
|
|
||||||
res.json({ source: 'external', data: formatGoogleBooksData(googleBookData) });
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
console.log('Book not found in Google Books');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not found in Google Books, try Open Library
|
|
||||||
const openLibraryResponse = await axios.get(`https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`);
|
|
||||||
const bookData = openLibraryResponse.data[`ISBN:${isbn}`];
|
|
||||||
|
|
||||||
if (openLibraryResponse.status === 429) {
|
|
||||||
console.log('Rate limit exceeded for Open Library');
|
|
||||||
res.status(429).json({ error: 'Rate limit exceeded' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (openLibraryResponse.status === 408) {
|
|
||||||
console.log('Request Timeout for Open Library');
|
|
||||||
res.status(408).json({ error: 'Request Timeout' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bookData) {
|
|
||||||
console.log('Book data found in Open Library');
|
|
||||||
res.json({ source: 'external', data: formatOpenLibraryData(bookData) });
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
console.log('Book not found in Open Library');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not found in Google Books or Open Library, try the Internet Archive
|
|
||||||
const archiveResponse = await axios.get(`https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`);
|
|
||||||
const archiveData = archiveResponse.data[`ISBN:${isbn}`];
|
|
||||||
|
|
||||||
if (archiveData) {
|
if (archiveData) {
|
||||||
console.log('Book data found in the Internet Archive');
|
console.log('Book data found in the Internet Archive');
|
||||||
res.json({ source: 'external', data: formatArchiveData(archiveData) });
|
return res.json({ source: 'external', data: archiveData });
|
||||||
} else {
|
|
||||||
console.log('Book not found in the Internet Archive');
|
|
||||||
res.status(404).json({ error: 'Book not found' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Book not found in any source');
|
||||||
|
return res.status(404).json({ error: 'Book not found' });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.status(500).json({ error: 'Failed to fetch book data' });
|
return res.status(500).json({ error: 'Failed to fetch book data' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// New API endpoint to fetch books with images
|
||||||
|
app.get('/api/books-with-images', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const books = await Book.findAll({
|
||||||
|
where: {
|
||||||
|
[Op.or]: [
|
||||||
|
{ cover_small: { [Op.ne]: null } },
|
||||||
|
{ cover_medium: { [Op.ne]: null } },
|
||||||
|
{ cover_large: { [Op.ne]: null } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
attributes: ['isbn', 'title', 'cover_small', 'cover_medium', 'cover_large', 'authors'],
|
||||||
|
});
|
||||||
|
res.json(books);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch books with images:', error);
|
||||||
|
res.status(500).json({ error: 'Error fetching books with images' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/refetch-cover-images', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await refetchCoverImages();
|
||||||
|
res.json({ success: true, message: 'Cover images re-fetching initiated.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to re-fetch cover images:', error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to re-fetch cover images' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
async function refetchCoverImages() {
|
||||||
app.get('/search-title', async (req, res) => {
|
const googleBooksApiKey = process.env.GOOGLE_BOOKS_API_KEY;
|
||||||
const { title, internalOnly=false } = req.query;
|
console.log(googleBooksApiKey)
|
||||||
console.log(`Searching for books by title or related fields: ${title}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First, search in the local database across all relevant fields
|
// Fetch all books from the database
|
||||||
const localBooks = await Book.findAll({
|
const books = await Book.findAll();
|
||||||
where: {
|
|
||||||
[Sequelize.Op.or]: [
|
for (const book of books) {
|
||||||
{ title: { [Sequelize.Op.like]: `%${title}%` } },
|
const isbn = book.isbn;
|
||||||
{ authors: { [Sequelize.Op.like]: `%${title}%` } },
|
|
||||||
{ publishers: { [Sequelize.Op.like]: `%${title}%` } },
|
// Fetch data from Google Books API
|
||||||
{ description: { [Sequelize.Op.like]: `%${title}%` } },
|
|
||||||
{ subjects: { [Sequelize.Op.like]: `%${title}%` } }
|
const url = `https://www.googleapis.com/books/v1/volumes?q=isbn:${isbn}&key=${googleBooksApiKey}`;
|
||||||
]
|
|
||||||
|
console.log(url)
|
||||||
|
try {
|
||||||
|
// Wait a second before making the next request
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
const response = await axios.get(url);
|
||||||
|
|
||||||
|
if (response.data.totalItems > 0) {
|
||||||
|
const volumeInfo = response.data.items[0].volumeInfo;
|
||||||
|
|
||||||
|
// Extract new cover image URLs
|
||||||
|
const imageLinks = volumeInfo.imageLinks || {};
|
||||||
|
const cover_small = imageLinks.smallThumbnail || null;
|
||||||
|
const cover_medium = imageLinks.thumbnail || null;
|
||||||
|
const cover_large = imageLinks.large || null;
|
||||||
|
|
||||||
|
// Update the book entry if any new image URLs are found
|
||||||
|
if (cover_small || cover_medium || cover_large) {
|
||||||
|
await book.update({
|
||||||
|
cover_small: cover_small || book.cover_small,
|
||||||
|
cover_medium: cover_medium || book.cover_medium,
|
||||||
|
cover_large: cover_large || book.cover_large,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Updated cover images for ISBN: ${isbn}`);
|
||||||
|
} else {
|
||||||
|
console.log(`No new cover images found for ISBN: ${isbn}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`No data found in Google Books for ISBN: ${isbn}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response && error.response.status === 429) {
|
||||||
|
console.error('Rate limit exceeded. Pausing requests.');
|
||||||
|
// Implement a delay or exit the loop if necessary
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.error(`Error fetching data for ISBN: ${isbn}`, error.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (localBooks.length > 0) {
|
|
||||||
console.log('Books found in the local database');
|
|
||||||
res.json({ source: 'local', results: localBooks });
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (internalOnly) {
|
|
||||||
res.status(404).json({ error: 'No books found with that title or related fields.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// If no results found locally, proceed to search external sources
|
|
||||||
const openLibraryResponse = await axios.get(`https://openlibrary.org/search.json?q=${encodeURIComponent(title)}&limit=7`);
|
|
||||||
const searchResults = openLibraryResponse.data.docs;
|
|
||||||
|
|
||||||
if (searchResults.length > 0) {
|
console.log('Cover image re-fetching completed.');
|
||||||
console.log('Books found by title in external sources');
|
} catch (error) {
|
||||||
const bookData = searchResults.map(result => ({
|
console.error('Failed to re-fetch cover images:', error.message);
|
||||||
title: result.title,
|
}
|
||||||
authors: result.author_name || [],
|
}
|
||||||
publish_date: result.first_publish_year,
|
|
||||||
isbn: result.isbn ? result.isbn[0] : '',
|
|
||||||
publisher: result.publisher ? result.publisher[0] : '',
|
|
||||||
key: result.key // Unique key to fetch more detailed data later if needed
|
app.post('/api/refetch-book-data', async (req, res) => {
|
||||||
}));
|
// Handle start index for pagination, start index is in json body
|
||||||
res.json({ source: 'external', results: bookData });
|
const { startIndex = 0 } = req.body;
|
||||||
} else {
|
try {
|
||||||
res.status(404).json({ error: 'No books found with that title or related fields.' });
|
await refetchBookData(startIndex);
|
||||||
|
res.json({ success: true, message: 'Book data re-fetching initiated.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to re-fetch book data:', error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to re-fetch book data' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refetchBookData(startIndex = 0) {
|
||||||
|
const googleBooksApiKey = process.env.GOOGLE_BOOKS_API_KEY;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch all books from the database
|
||||||
|
const books = await Book.findAll();
|
||||||
|
|
||||||
|
console.log(`Found ${books.length} books to update.`);
|
||||||
|
console.log(`Starting from index: ${startIndex}`);
|
||||||
|
|
||||||
|
for (const [index, book] of books.entries()) {
|
||||||
|
if (index < startIndex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isbn = book.isbn;
|
||||||
|
|
||||||
|
// Fetch data from Google Books API
|
||||||
|
const url = `https://www.googleapis.com/books/v1/volumes?q=isbn:${isbn}&key=${googleBooksApiKey}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url);
|
||||||
|
|
||||||
|
if (response.data.totalItems > 0) {
|
||||||
|
const volumeInfo = response.data.items[0].volumeInfo;
|
||||||
|
|
||||||
|
// Extract data from the API response
|
||||||
|
const updatedData = {
|
||||||
|
title: volumeInfo.title || book.title,
|
||||||
|
authors: volumeInfo.authors ? volumeInfo.authors.join(', ') : book.authors,
|
||||||
|
publishedDate: volumeInfo.publishedDate || book.publishedDate,
|
||||||
|
description: volumeInfo.description || book.description,
|
||||||
|
number_of_pages: volumeInfo.pageCount || book.number_of_pages,
|
||||||
|
publishers: volumeInfo.publisher || book.publishers,
|
||||||
|
subjects: volumeInfo.categories ? volumeInfo.categories.join(', ') : book.subjects,
|
||||||
|
cover_small: volumeInfo.imageLinks ? volumeInfo.imageLinks.smallThumbnail : book.cover_small,
|
||||||
|
cover_medium: volumeInfo.imageLinks ? volumeInfo.imageLinks.thumbnail : book.cover_medium,
|
||||||
|
cover_large: volumeInfo.imageLinks ? volumeInfo.imageLinks.large : book.cover_large,
|
||||||
|
// Add more fields as necessary
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the book entry in the database
|
||||||
|
await book.update(updatedData);
|
||||||
|
|
||||||
|
console.log(`[${index + 1}/${books.length}] Updated data for ISBN: ${isbn}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[${index + 1}/${books.length}] No data found in Google Books for ISBN: ${isbn}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response && error.response.status === 429) {
|
||||||
|
console.error('Rate limit exceeded. Pausing requests.');
|
||||||
|
// Implement a delay or exit the loop if necessary
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
console.error(`Error fetching data for ISBN: ${isbn}`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Delay to handle rate limits
|
||||||
|
await delay(1000); // Adjust the delay as needed
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Book data re-fetching completed.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to re-fetch book data:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to add delay
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm book is in the library by ISBN
|
||||||
|
app.get('/book/confirm/:isbn', async (req, res) => {
|
||||||
|
const { isbn } = req.params;
|
||||||
|
console.log(`Fetching book data for ISBN: ${isbn}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if the book is in the local database
|
||||||
|
const localBook = await fetchBookFromLocalDatabase(isbn);
|
||||||
|
|
||||||
|
if (localBook) {
|
||||||
|
console.log('Book found in the local database');
|
||||||
|
return res.json({ source: 'local', data: localBook });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.status(500).json({ error: 'Failed to search for book by title or related fields' });
|
return res.status(500).json({ error: 'Failed to fetch book data' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
app.get('/search-title', async (req, res) => {
|
||||||
|
const { title, internalOnly = false } = req.query;
|
||||||
|
console.log(`Searching for books by title or related fields: ${title}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localBooks = await searchBooksInLocalDatabase(title);
|
||||||
|
|
||||||
|
if (localBooks.length > 0) {
|
||||||
|
console.log('Books found in the local database');
|
||||||
|
return res.json({ source: 'local', results: localBooks });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (internalOnly) {
|
||||||
|
return res.status(404).json({ error: 'No books found with that title or related fields.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalBooks = await searchBooksInOpenLibrary(title);
|
||||||
|
|
||||||
|
if (externalBooks.length > 0) {
|
||||||
|
console.log('Books found by title in external sources');
|
||||||
|
return res.json({ source: 'external', results: externalBooks });
|
||||||
|
} else {
|
||||||
|
return res.status(404).json({ error: 'No books found with that title or related fields.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({ error: 'Failed to search for book by title or related fields' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -332,77 +376,217 @@ app.delete('/book/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Function to checkout a book
|
app.get('/locations', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locations = await Location.findAll();
|
||||||
|
res.json(locations);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch locations:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch locations' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: Function to return a book
|
app.post('/location', async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.debug('Creating location:', req.body);
|
||||||
|
const location = await Location.create(req.body);
|
||||||
|
res.json({ success: true, location });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create location:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create location' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function formatOpenLibraryData(data) {
|
app.get('/location/:id', async (req, res) => {
|
||||||
return {
|
try {
|
||||||
isbn: data.identifiers.isbn_13 ? data.identifiers.isbn_13[0] : '',
|
const { id } = req.params;
|
||||||
title: data.title,
|
const location = await Location.findByPk(id);
|
||||||
authors: data.authors ? data.authors.map(author => author.name) : [],
|
if (location) {
|
||||||
publishedDate: data.publish_date,
|
res.json(location);
|
||||||
description: data.excerpts ? data.excerpts[0].text : 'No description available',
|
} else {
|
||||||
url: data.url,
|
res.status(404).json({ error: 'Location not found' });
|
||||||
number_of_pages: data.number_of_pages || null,
|
}
|
||||||
identifiers: JSON.stringify(data.identifiers), // Store as JSON string
|
} catch (error) {
|
||||||
publishers: data.publishers ? data.publishers.map(pub => pub.name).join(', ') : '',
|
console.error('Failed to fetch location:', error);
|
||||||
subjects: data.subjects ? data.subjects.map(sub => sub.name).join(', ') : '',
|
res.status(500).json({ error: 'Failed to fetch location' });
|
||||||
notes: data.notes || '',
|
}
|
||||||
cover_small: data.cover ? data.cover.small : '',
|
});
|
||||||
cover_medium: data.cover ? data.cover.medium : '',
|
|
||||||
cover_large: data.cover ? data.cover.large : ''
|
// Configure nodemailer with your own server
|
||||||
};
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: 'mail.uplink.tel', // Replace with your SMTP server
|
||||||
|
port: 465, // Replace with your SMTP port (587 is common for TLS)
|
||||||
|
secure: true, // Set to true if using port 465
|
||||||
|
auth: {
|
||||||
|
user: process.env.ADMIN_EMAIL, // Admin email
|
||||||
|
pass: process.env.EMAIL_PASSWORD // Email password
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false // Use this if you encounter certificate issues
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limiter middleware
|
||||||
|
const checkoutLimiter = rateLimit({
|
||||||
|
windowMs: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
max: 5, // Limit each email to 5 requests per windowMs
|
||||||
|
keyGenerator: (req) => req.body.email, // Use email as the key
|
||||||
|
handler: (req, res) => {
|
||||||
|
res.status(429).json({ error: 'Too many requests, please try again later.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// New endpoint to handle checkout requests
|
||||||
|
app.post('/api/checkout/:isbn', checkoutLimiter, async (req, res) => {
|
||||||
|
const { isbn } = req.params;
|
||||||
|
const { email } = req.body;
|
||||||
|
const ip = req.ip;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const book = await Book.findOne({ where: { isbn } });
|
||||||
|
if (book) {
|
||||||
|
// Update book status to Pending
|
||||||
|
await book.update({ status: 'Pending' });
|
||||||
|
|
||||||
|
// Log the requestor's email and IP
|
||||||
|
console.log(`Checkout requested by ${email} from IP: ${ip}`);
|
||||||
|
|
||||||
|
// Send email to admin
|
||||||
|
const mailOptions = {
|
||||||
|
from: process.env.ADMIN_EMAIL,
|
||||||
|
to: process.env.ADMIN_EMAIL,
|
||||||
|
subject: `Checkout Request for ISBN: ${isbn}`,
|
||||||
|
html: `
|
||||||
|
<p>A checkout request has been made for the book titled "${book.title}" with ISBN: ${isbn} by ${email}. Please review and approve.</p>
|
||||||
|
<a href="${process.env.DOMAIN}/api/approve-checkout/${isbn}/${email}" style="margin-right: 10px; padding: 10px; background-color: green; color: white; text-decoration: none;">Approve</a>
|
||||||
|
<a href="${process.env.DOMAIN}/api/deny-checkout/${isbn}/${email}" style="padding: 10px; background-color: red; color: white; text-decoration: none;">Deny</a>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
transporter.sendMail(mailOptions, (error, info) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('Error sending email:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to send email' });
|
||||||
|
}
|
||||||
|
console.log('Email sent:', info.response);
|
||||||
|
res.json({ success: true, message: 'Checkout request sent and status updated to Pending.' });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Book not found' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process checkout request:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to process checkout request' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Endpoint to approve checkout request
|
||||||
|
app.get('/api/approve-checkout/:isbn/:email', async (req, res) => {
|
||||||
|
const { isbn, email } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const book = await Book.findOne({ where: { isbn } });
|
||||||
|
if (book && book.status === 'Pending') {
|
||||||
|
await book.update({ status: 'Checked Out' });
|
||||||
|
|
||||||
|
// Send email to the requesting user
|
||||||
|
const mailOptions = {
|
||||||
|
from: process.env.ADMIN_EMAIL,
|
||||||
|
to: email,
|
||||||
|
subject: `Checkout Approved for ISBN: ${isbn}`,
|
||||||
|
text: `Your checkout request for the book with ISBN: ${isbn} has been approved.`
|
||||||
|
};
|
||||||
|
|
||||||
|
transporter.sendMail(mailOptions, (error, info) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('Error sending email:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to send approval email' });
|
||||||
|
}
|
||||||
|
console.log('Approval email sent:', info.response);
|
||||||
|
res.json({ success: true, message: 'Checkout approved and user notified.' });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Book not found or not Pending' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to approve checkout request:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to approve checkout request' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Endpoint to deny checkout request
|
||||||
|
app.get('/api/deny-checkout/:isbn/:email', async (req, res) => {
|
||||||
|
const { isbn, email } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const book = await Book.findOne({ where: { isbn } });
|
||||||
|
if (book && book.status === 'Pending') {
|
||||||
|
await book.update({ status: null });
|
||||||
|
|
||||||
|
// Send email to the requesting user
|
||||||
|
const mailOptions = {
|
||||||
|
from: process.env.ADMIN_EMAIL,
|
||||||
|
to: email,
|
||||||
|
subject: `Checkout Denied for ISBN: ${isbn}`,
|
||||||
|
text: `Your checkout request for the book with ISBN: ${isbn} has been denied.`
|
||||||
|
};
|
||||||
|
|
||||||
|
transporter.sendMail(mailOptions, (error, info) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('Error sending email:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to send denial email' });
|
||||||
|
}
|
||||||
|
console.log('Denial email sent:', info.response);
|
||||||
|
res.json({ success: true, message: 'Checkout denied and user notified.' });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Book not found or not Pending' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to deny checkout request:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to deny checkout request' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Utility function to get a cookie by name
|
||||||
|
function getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Utility function to set a cookie
|
||||||
function formatArchiveData(data) {
|
function setCookie(name, value, days) {
|
||||||
return {
|
const date = new Date();
|
||||||
isbn: data.isbn ? data.isbn[0] : '',
|
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||||
title: data.title,
|
const expires = `expires=${date.toUTCString()}`;
|
||||||
authors: data.contributor ? [data.contributor] : [],
|
document.cookie = `${name}=${value}; ${expires}; path=/`;
|
||||||
publishedDate: data.date ? new Date(data.date).getFullYear() : '',
|
|
||||||
description: data.description ? data.description.join(' ') : 'No description available',
|
|
||||||
url: `https://archive.org/details/${data.identifier}`,
|
|
||||||
number_of_pages: data.imagecount || null,
|
|
||||||
identifiers: JSON.stringify({
|
|
||||||
archive_identifier: data.identifier,
|
|
||||||
oclc: data['external-identifier'] ? data['external-identifier'].filter(id => id.includes('urn:oclc')).map(id => id.split(':')[2]) : []
|
|
||||||
}),
|
|
||||||
publishers: data.publisher || '',
|
|
||||||
subjects: data.subject ? data.subject.join(', ') : '',
|
|
||||||
notes: '', // Archive data does not provide specific notes like Open Library
|
|
||||||
cover_small: '', // Placeholder, as cover image URLs need to be constructed manually
|
|
||||||
cover_medium: '', // Same as above
|
|
||||||
cover_large: '' // Same as above
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatGoogleBooksData(data) {
|
// Client-side function to request checkout
|
||||||
return {
|
function requestCheckout(isbn) {
|
||||||
isbn: data.volumeInfo.industryIdentifiers ? data.volumeInfo.industryIdentifiers.find(id => id.type === 'ISBN_13').identifier : '',
|
fetch(`/api/checkout/${isbn}`, {
|
||||||
title: data.volumeInfo.title,
|
method: 'POST',
|
||||||
authors: data.volumeInfo.authors || [],
|
headers: {
|
||||||
publishedDate: data.volumeInfo.publishedDate,
|
'Content-Type': 'application/json'
|
||||||
description: data.volumeInfo.description || 'No description available',
|
}
|
||||||
url: data.volumeInfo.previewLink,
|
})
|
||||||
number_of_pages: data.volumeInfo.pageCount || null,
|
.then(response => response.json())
|
||||||
identifiers: JSON.stringify(data.volumeInfo.industryIdentifiers),
|
.then(data => {
|
||||||
publishers: data.volumeInfo.publisher || '',
|
if (data.success) {
|
||||||
subjects: data.volumeInfo.categories || [],
|
alert('Checkout request sent successfully.');
|
||||||
notes: '', // Placeholder for additional notes
|
} else {
|
||||||
cover_small: data.volumeInfo.imageLinks.smallThumbnail || '',
|
alert('Failed to send checkout request.');
|
||||||
cover_medium: data.volumeInfo.imageLinks.thumbnail || '',
|
}
|
||||||
cover_large: data.volumeInfo.imageLinks.small || ''
|
})
|
||||||
};
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred while sending the checkout request.');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const httpsServer = https.createServer(credentials, app);
|
const httpsServer = https.createServer(credentials, app);
|
||||||
|
|
||||||
sequelize.sync({ alter: true }).then(() => {
|
|
||||||
console.log('Database & tables synced!');
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
httpsServer.listen(PORT, () => {
|
httpsServer.listen(PORT, () => {
|
||||||
console.log(`HTTPS Server running on https://localhost:${PORT}`);
|
console.log(`HTTPS Server running on https://localhost:${PORT}`);
|
||||||
|
|||||||
103
library.js
Normal file
103
library.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
function checkoutBook(bookId, userId) {
|
||||||
|
// Check if the book is available
|
||||||
|
Book.findByPk(bookId).then(book => {
|
||||||
|
if (book.status === 'Available') {
|
||||||
|
// If available, create a new Checkout record
|
||||||
|
Checkout.create({ book_id: bookId, user_id: userId }).then(checkout => {
|
||||||
|
// Update the Book status to 'Checked Out'
|
||||||
|
book.update({ status: 'Checked Out' }).then(() => {
|
||||||
|
// Return the Checkout record
|
||||||
|
return checkout;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If the book is not available, return null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnBook(bookId) {
|
||||||
|
// Find the Checkout record for the book
|
||||||
|
Checkout.findOne({ where: { book_id: bookId, returned_date: null } }).then(checkout => {
|
||||||
|
if (checkout) {
|
||||||
|
// Update the Checkout record with the returned date
|
||||||
|
checkout.update({ returned_date: new Date() }).then(() => {
|
||||||
|
// Update the Book status to 'Available'
|
||||||
|
Book.findByPk(bookId).then(book => {
|
||||||
|
book.update({ status: 'Available' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOpenLibraryData(data) {
|
||||||
|
return {
|
||||||
|
isbn: data.identifiers.isbn_13 ? data.identifiers.isbn_13[0] : '',
|
||||||
|
title: data.title,
|
||||||
|
authors: data.authors ? data.authors.map(author => author.name) : [],
|
||||||
|
publishedDate: data.publish_date,
|
||||||
|
description: data.excerpts ? data.excerpts[0].text : 'No description available',
|
||||||
|
url: data.url,
|
||||||
|
number_of_pages: data.number_of_pages || null,
|
||||||
|
identifiers: JSON.stringify(data.identifiers), // Store as JSON string
|
||||||
|
publishers: data.publishers ? data.publishers.map(pub => pub.name).join(', ') : '',
|
||||||
|
subjects: data.subjects ? data.subjects.map(sub => sub.name).join(', ') : '',
|
||||||
|
notes: data.notes || '',
|
||||||
|
cover_small: data.cover ? data.cover.small : '',
|
||||||
|
cover_medium: data.cover ? data.cover.medium : '',
|
||||||
|
cover_large: data.cover ? data.cover.large : ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function formatArchiveData(data) {
|
||||||
|
return {
|
||||||
|
isbn: data.isbn ? data.isbn[0] : '',
|
||||||
|
title: data.title,
|
||||||
|
authors: data.contributor ? [data.contributor] : [],
|
||||||
|
publishedDate: data.date ? new Date(data.date).getFullYear() : '',
|
||||||
|
description: data.description ? data.description.join(' ') : 'No description available',
|
||||||
|
url: `https://archive.org/details/${data.identifier}`,
|
||||||
|
number_of_pages: data.imagecount || null,
|
||||||
|
identifiers: JSON.stringify({
|
||||||
|
archive_identifier: data.identifier,
|
||||||
|
oclc: data['external-identifier'] ? data['external-identifier'].filter(id => id.includes('urn:oclc')).map(id => id.split(':')[2]) : []
|
||||||
|
}),
|
||||||
|
publishers: data.publisher || '',
|
||||||
|
subjects: data.subject ? data.subject.join(', ') : '',
|
||||||
|
notes: '', // Archive data does not provide specific notes like Open Library
|
||||||
|
cover_small: '', // Placeholder, as cover image URLs need to be constructed manually
|
||||||
|
cover_medium: '', // Same as above
|
||||||
|
cover_large: '' // Same as above
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGoogleBooksData(data) {
|
||||||
|
return {
|
||||||
|
isbn: data.volumeInfo.industryIdentifiers ? data.volumeInfo.industryIdentifiers.find(id => id.type === 'ISBN_13').identifier : '',
|
||||||
|
title: data.volumeInfo.title,
|
||||||
|
authors: data.volumeInfo.authors || [],
|
||||||
|
publishedDate: data.volumeInfo.publishedDate,
|
||||||
|
description: data.volumeInfo.description || 'No description available',
|
||||||
|
url: data.volumeInfo.previewLink,
|
||||||
|
number_of_pages: data.volumeInfo.pageCount || null,
|
||||||
|
identifiers: JSON.stringify(data.volumeInfo.industryIdentifiers),
|
||||||
|
publishers: data.volumeInfo.publisher || '',
|
||||||
|
subjects: data.volumeInfo.categories || [],
|
||||||
|
notes: '', // Placeholder for additional notes
|
||||||
|
cover_small: data.volumeInfo.imageLinks.smallThumbnail || '',
|
||||||
|
cover_medium: data.volumeInfo.imageLinks.thumbnail || '',
|
||||||
|
cover_large: data.volumeInfo.imageLinks.small || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkoutBook,
|
||||||
|
returnBook,
|
||||||
|
formatOpenLibraryData,
|
||||||
|
formatArchiveData,
|
||||||
|
formatGoogleBooksData
|
||||||
|
};
|
||||||
109
libraryManager.py
Normal file
109
libraryManager.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import requests
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.prompt import Prompt
|
||||||
|
from rich.table import Table
|
||||||
|
from fuzzywuzzy import process
|
||||||
|
|
||||||
|
API_BASE_URL = "https://localhost:3000" # Replace with your actual API base URL
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
def list_books():
|
||||||
|
response = requests.get(f"{API_BASE_URL}/api/books-with-images", verify=False)
|
||||||
|
if response.status_code == 200:
|
||||||
|
books = response.json()
|
||||||
|
table = Table(title="Books")
|
||||||
|
table.add_column("ISBN", justify="right", style="cyan", no_wrap=True)
|
||||||
|
table.add_column("Title", style="magenta")
|
||||||
|
table.add_column("Authors", style="green")
|
||||||
|
for book in books:
|
||||||
|
table.add_row(book['isbn'], book['title'], book['authors'])
|
||||||
|
console.print(table)
|
||||||
|
else:
|
||||||
|
console.print("Failed to fetch books.", style="bold red")
|
||||||
|
|
||||||
|
def add_book():
|
||||||
|
isbn = Prompt.ask("Enter ISBN")
|
||||||
|
title = Prompt.ask("Enter Title")
|
||||||
|
authors = Prompt.ask("Enter Authors")
|
||||||
|
data = {
|
||||||
|
"isbn": isbn,
|
||||||
|
"title": title,
|
||||||
|
"authors": authors
|
||||||
|
}
|
||||||
|
response = requests.post(f"{API_BASE_URL}/store-book", json=data, verify=False)
|
||||||
|
if response.status_code == 200:
|
||||||
|
console.print("Book added successfully.", style="bold green")
|
||||||
|
else:
|
||||||
|
console.print("Failed to add book.", style="bold red")
|
||||||
|
|
||||||
|
def remove_book():
|
||||||
|
isbn = Prompt.ask("Enter ISBN of the book to remove")
|
||||||
|
response = requests.delete(f"{API_BASE_URL}/book/{isbn}", verify=False)
|
||||||
|
if response.status_code == 200:
|
||||||
|
console.print("Book removed successfully.", style="bold green")
|
||||||
|
else:
|
||||||
|
console.print("Failed to remove book.", style="bold red")
|
||||||
|
|
||||||
|
def change_book_status():
|
||||||
|
isbn = Prompt.ask("Enter ISBN of the book to change status")
|
||||||
|
status = Prompt.ask("Enter new status (e.g., Available, Checked Out)")
|
||||||
|
data = {"status": status}
|
||||||
|
response = requests.put(f"{API_BASE_URL}/book/{isbn}", json=data, verify=False)
|
||||||
|
if response.status_code == 200:
|
||||||
|
console.print("Book status updated successfully.", style="bold green")
|
||||||
|
else:
|
||||||
|
console.print("Failed to update book status.", style="bold red")
|
||||||
|
|
||||||
|
def search_books():
|
||||||
|
query = Prompt.ask("Enter search query")
|
||||||
|
response = requests.get(f"{API_BASE_URL}/api/books-with-images", verify=False)
|
||||||
|
if response.status_code == 200:
|
||||||
|
books = response.json()
|
||||||
|
book_titles = [book['title'] for book in books]
|
||||||
|
matches = process.extract(query, book_titles, limit=5)
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
table = Table(title="Search Results")
|
||||||
|
table.add_column("ISBN", justify="right", style="cyan", no_wrap=True)
|
||||||
|
table.add_column("Title", style="magenta")
|
||||||
|
table.add_column("Authors", style="green")
|
||||||
|
table.add_column("Score", justify="right", style="yellow")
|
||||||
|
|
||||||
|
for match in matches:
|
||||||
|
title, score = match
|
||||||
|
book = next(book for book in books if book['title'] == title)
|
||||||
|
table.add_row(book['isbn'], book['title'], book['authors'], str(score))
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
else:
|
||||||
|
console.print("No matches found.", style="bold red")
|
||||||
|
else:
|
||||||
|
console.print("Failed to fetch books.", style="bold red")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
while True:
|
||||||
|
console.print("\n[bold]Admin Console[/bold]")
|
||||||
|
console.print("1. List Books")
|
||||||
|
console.print("2. Add Book")
|
||||||
|
console.print("3. Remove Book")
|
||||||
|
console.print("4. Change Book Status")
|
||||||
|
console.print("5. Search Books")
|
||||||
|
console.print("6. Exit")
|
||||||
|
choice = Prompt.ask("Choose an option", choices=["1", "2", "3", "4", "5", "6"], default="6")
|
||||||
|
|
||||||
|
if choice == "1":
|
||||||
|
list_books()
|
||||||
|
elif choice == "2":
|
||||||
|
add_book()
|
||||||
|
elif choice == "3":
|
||||||
|
remove_book()
|
||||||
|
elif choice == "4":
|
||||||
|
change_book_status()
|
||||||
|
elif choice == "5":
|
||||||
|
search_books()
|
||||||
|
elif choice == "6":
|
||||||
|
console.print("Exiting...", style="bold yellow")
|
||||||
|
break
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
180
models.js
Normal file
180
models.js
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
const { Sequelize, DataTypes, Op } = require('sequelize');
|
||||||
|
|
||||||
|
// Initialize Sequelize
|
||||||
|
const sequelize = new Sequelize({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
storage: './books.db',
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Test the connection
|
||||||
|
|
||||||
|
sequelize.authenticate()
|
||||||
|
.then(() => {
|
||||||
|
console.log('Connection has been established successfully.');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Unable to connect to the database:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const Location = sequelize.define('Location', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
autoIncrement: true,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
shelf: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'locations',
|
||||||
|
timestamps: false, // If your table doesn't have `createdAt` and `updatedAt`
|
||||||
|
});
|
||||||
|
const Book = sequelize.define('Book', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
autoIncrement: true,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
isbn: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
authors: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
publishedDate: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
number_of_pages: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
},
|
||||||
|
identifiers: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
publishers: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
subjects: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
cover_small: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
cover_medium: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
cover_large: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
location_id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
references: {
|
||||||
|
model: Location,
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: { // Available, Checked Out, Lost, etc.
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
defaultValue: 'Available'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'books',
|
||||||
|
timestamps: false, // If your table doesn't have `createdAt` and `updatedAt`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define a table for checkouts
|
||||||
|
const Checkout = sequelize.define('Checkout', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
autoIncrement: true,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
book_id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
references: {
|
||||||
|
model: Book,
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
checkout_date: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
defaultValue: Sequelize.NOW
|
||||||
|
},
|
||||||
|
return_date: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
},
|
||||||
|
returned_date: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'checkouts',
|
||||||
|
timestamps: false, // If your table doesn't have `createdAt` and `updatedAt`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define User table
|
||||||
|
const User = sequelize.define('User', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
autoIncrement: true,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
defaultValue: 'user'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'users',
|
||||||
|
timestamps: false, // If your table doesn't have `createdAt` and `updatedAt`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define the relationships
|
||||||
|
Book.belongsTo(Location, { foreignKey: 'location_id' });
|
||||||
|
Location.hasMany(Book, { foreignKey: 'location_id' });
|
||||||
|
Checkout.belongsTo(Book, { foreignKey: 'book_id' });
|
||||||
|
Book.hasOne(Checkout, { foreignKey: 'book_id' });
|
||||||
|
Checkout.belongsTo(User, { foreignKey: 'user_id' });
|
||||||
|
User.hasMany(Checkout, { foreignKey: 'user_id' });
|
||||||
|
|
||||||
|
sequelize.sync({ alter: true }).then(() => {
|
||||||
|
console.log('Database & tables synced!');
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.Location = Location;
|
||||||
|
exports.Book = Book;
|
||||||
|
exports.Checkout = Checkout;
|
||||||
|
exports.User = User;
|
||||||
|
exports.sequelize = sequelize;
|
||||||
|
exports.Op = Op;
|
||||||
988
package-lock.json
generated
988
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,15 +4,22 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"start": "node index.js",
|
||||||
|
"dev": "nodemon index.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.5",
|
"axios": "^1.7.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
"express-rate-limit": "^7.4.1",
|
||||||
|
"nodemailer": "^6.9.16",
|
||||||
"sequelize": "^6.37.3",
|
"sequelize": "^6.37.3",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
public/books-with-images.html
Normal file
74
public/books-with-images.html
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Books with Images</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
.book-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.book-item img {
|
||||||
|
max-width: 200px;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
cursor: pointer; /* Add cursor style to indicate clickable */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Books with Images</h1>
|
||||||
|
<div id="books-list"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
fetch('/api/books-with-images')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(books => {
|
||||||
|
const booksList = document.getElementById('books-list');
|
||||||
|
books.forEach(book => {
|
||||||
|
const bookDiv = document.createElement('div');
|
||||||
|
bookDiv.className = 'book-item';
|
||||||
|
|
||||||
|
const title = document.createElement('h2');
|
||||||
|
title.textContent = book.title;
|
||||||
|
bookDiv.appendChild(title);
|
||||||
|
|
||||||
|
const isbn = document.createElement('p');
|
||||||
|
isbn.textContent = 'ISBN: ' + book.isbn;
|
||||||
|
bookDiv.appendChild(isbn);
|
||||||
|
|
||||||
|
// Aurthor
|
||||||
|
const author = document.createElement('p');
|
||||||
|
author.textContent = 'Author: ' + book.authors;
|
||||||
|
bookDiv.appendChild(author);
|
||||||
|
|
||||||
|
let imgSrc = book.cover_large || book.cover_medium || book.cover_small;
|
||||||
|
if (imgSrc) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = imgSrc;
|
||||||
|
img.alt = book.title + ' cover';
|
||||||
|
img.addEventListener('click', () => {
|
||||||
|
img.style.display = 'none'; // Hide the clicked image
|
||||||
|
// Hide the title and ISBN as well
|
||||||
|
title.style.display = 'none';
|
||||||
|
isbn.style.display = 'none';
|
||||||
|
});
|
||||||
|
bookDiv.appendChild(img);
|
||||||
|
} else {
|
||||||
|
const noImg = document.createElement('p');
|
||||||
|
noImg.textContent = 'No cover image available.';
|
||||||
|
bookDiv.appendChild(noImg);
|
||||||
|
}
|
||||||
|
|
||||||
|
booksList.appendChild(bookDiv);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching books:', error);
|
||||||
|
const booksList = document.getElementById('books-list');
|
||||||
|
booksList.textContent = 'Failed to load books.';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -11,6 +11,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<label for="camera-select">Select Camera:</label>
|
<label for="camera-select">Select Camera:</label>
|
||||||
<select id="camera-select"></select>
|
<select id="camera-select"></select>
|
||||||
|
<!-- Confirm mode toggle -->
|
||||||
|
<input type="checkbox" id="confirm-mode">
|
||||||
|
<label for="confirm-mode">Confirm mode</label>s
|
||||||
</div>
|
</div>
|
||||||
<div id="interactive" class="viewport"></div>
|
<div id="interactive" class="viewport"></div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -3,46 +3,23 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Book Library</title>
|
<title>Ramsey Library</title>
|
||||||
<style>
|
<link rel="stylesheet" href="styles.css">
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
margin: 20px;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
th, td {
|
|
||||||
padding: 10px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
th {
|
|
||||||
background-color: #f2f2f2;
|
|
||||||
}
|
|
||||||
#search-bar {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
margin-top: 20px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Book Library</h1>
|
<h1>Ramsey Library</h1>
|
||||||
<input type="text" id="search-bar" placeholder="Search by title, author, publisher, etc.">
|
<input type="text" id="search-bar" placeholder="Search by title, author, publisher, etc.">
|
||||||
|
|
||||||
<table id="book-table">
|
<table id="book-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th onclick="sortTable(0)">Title</th>
|
||||||
<th>Authors</th>
|
<th onclick="sortTable(1)">Authors</th>
|
||||||
<th>Publisher</th>
|
<th onclick="sortTable(2)">Publisher</th>
|
||||||
<th>Published Date</th>
|
<th onclick="sortTable(3)">Published Date</th>
|
||||||
<th>ISBN</th>
|
<th onclick="sortTable(4)">ISBN</th>
|
||||||
|
<th onclick="sortTable(5)">Status</th>
|
||||||
|
<th>Checkout</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="book-table-body">
|
<tbody id="book-table-body">
|
||||||
@ -52,7 +29,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Fetch all books when the page loads
|
// Fetch all books when the page loads
|
||||||
window.onload = fetchBooks;
|
window.onload = fetchBooks('');
|
||||||
|
|
||||||
document.getElementById('search-bar').addEventListener('input', function() {
|
document.getElementById('search-bar').addEventListener('input', function() {
|
||||||
fetchBooks(this.value);
|
fetchBooks(this.value);
|
||||||
@ -82,16 +59,89 @@
|
|||||||
|
|
||||||
books.forEach(book => {
|
books.forEach(book => {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
|
const isDisabled = book.status === 'Checked Out' ? 'disabled' : '';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td>${book.title || ''}</td>
|
<td><a href="/book/${book.isbn}" target="_blank">${book.title || ''}</a></td>
|
||||||
<td>${book.authors ? book.authors : ''}</td>
|
<td>${book.authors ? book.authors : ''}</td>
|
||||||
<td>${book.publisher || ''}</td>
|
<td>${book.publishers || ''}</td>
|
||||||
<td>${book.publish_date || ''}</td>
|
<td>${book.publishedDate || ''}</td>
|
||||||
<td>${book.isbn || ''}</td>
|
<td>${book.isbn || ''}</td>
|
||||||
|
<td>${book.status || ''}</td>
|
||||||
|
<td><button id="checkout-btn-${book.isbn}" onclick="requestCheckout('${book.isbn}')" ${isDisabled}>Checkout</button></td>
|
||||||
`;
|
`;
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Utility function to get a cookie by name
|
||||||
|
function getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to set a cookie
|
||||||
|
function setCookie(name, value, days) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||||
|
const expires = `expires=${date.toUTCString()}`;
|
||||||
|
document.cookie = `${name}=${value}; ${expires}; path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side function to request checkout
|
||||||
|
function requestCheckout(isbn) {
|
||||||
|
let email = getCookie('lastEmail') || prompt('Please enter your email:');
|
||||||
|
let name = getCookie('lastName') || prompt('Please enter your name:');
|
||||||
|
|
||||||
|
if (!email || !name) {
|
||||||
|
alert('Email and name are required to proceed with checkout.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the email and name in cookies for future use
|
||||||
|
setCookie('lastEmail', email, 7); // Store for 7 days
|
||||||
|
setCookie('lastName', name, 7); // Store for 7 days
|
||||||
|
|
||||||
|
fetch(`/api/checkout/${isbn}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, name })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert('Checkout request sent successfully.');
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Failed to send checkout request.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred while sending the checkout request.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function sortTable(columnIndex) {
|
||||||
|
const table = document.getElementById('book-table');
|
||||||
|
const rows = Array.from(table.rows).slice(1);
|
||||||
|
const isAscending = table.getAttribute('data-sort-order') === 'asc';
|
||||||
|
const direction = isAscending ? 1 : -1;
|
||||||
|
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aText = a.cells[columnIndex].innerText.toLowerCase();
|
||||||
|
const bText = b.cells[columnIndex].innerText.toLowerCase();
|
||||||
|
|
||||||
|
if (aText < bText) return -1 * direction;
|
||||||
|
if (aText > bText) return 1 * direction;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.forEach(row => table.tBodies[0].appendChild(row));
|
||||||
|
table.setAttribute('data-sort-order', isAscending ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
298
public/script.js
298
public/script.js
@ -1,14 +1,6 @@
|
|||||||
let selectedDeviceId;
|
let selectedDeviceId;
|
||||||
let isScanning = false; // Flag to prevent multiple scans at the same time
|
let isScanning = false; // Flag to prevent multiple scans at the same time
|
||||||
|
let quaggaInitialized = false; // Flag to check if Quagga has been initialized
|
||||||
async function testCameraAccess() {
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
||||||
console.log('Camera access granted', stream);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error accessing camera:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get available video input devices (cameras)
|
// Get available video input devices (cameras)
|
||||||
async function getCameras() {
|
async function getCameras() {
|
||||||
@ -18,23 +10,34 @@ async function getCameras() {
|
|||||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
const videoDevices = devices.filter(device => device.kind === 'videoinput');
|
const videoDevices = devices.filter(device => device.kind === 'videoinput');
|
||||||
|
|
||||||
// Update selectedDeviceId when user selects a camera
|
|
||||||
cameraSelect.addEventListener('change', function() {
|
|
||||||
selectedDeviceId = this.value;
|
|
||||||
startScanner();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (videoDevices.length > 0) {
|
if (videoDevices.length > 0) {
|
||||||
|
// Clear any existing options
|
||||||
|
cameraSelect.innerHTML = '';
|
||||||
videoDevices.forEach((device, index) => {
|
videoDevices.forEach((device, index) => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = device.deviceId;
|
option.value = device.deviceId;
|
||||||
option.text = device.label || `Camera ${index + 1}`;
|
option.text = device.label || `Camera ${index + 1}`;
|
||||||
cameraSelect.appendChild(option);
|
cameraSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
|
// Set selectedDeviceId to the first camera by default
|
||||||
|
selectedDeviceId = cameraSelect.value;
|
||||||
|
startScanner(); // Start the scanner with the default camera
|
||||||
} else {
|
} else {
|
||||||
console.log("No video devices found.");
|
console.log("No video devices found.");
|
||||||
cameraSelect.innerHTML = '<option>No cameras found</option>';
|
cameraSelect.innerHTML = '<option>No cameras found</option>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update selectedDeviceId when user selects a camera
|
||||||
|
cameraSelect.addEventListener('change', function() {
|
||||||
|
selectedDeviceId = this.value;
|
||||||
|
// Stop Quagga and re-initialize with the new deviceId
|
||||||
|
if (quaggaInitialized) {
|
||||||
|
Quagga.stop();
|
||||||
|
quaggaInitialized = false;
|
||||||
|
}
|
||||||
|
startScanner();
|
||||||
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error accessing the camera or enumerating devices:", error);
|
console.error("Error accessing the camera or enumerating devices:", error);
|
||||||
document.getElementById('camera-select').innerHTML = '<option>' + error + '</option>';
|
document.getElementById('camera-select').innerHTML = '<option>' + error + '</option>';
|
||||||
@ -46,51 +49,56 @@ function startScanner() {
|
|||||||
alert('No camera selected or available.');
|
alert('No camera selected or available.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// TODO: Limit the field of view to a smaller area for faster detection
|
|
||||||
Quagga.init({
|
if (quaggaInitialized) {
|
||||||
inputStream: {
|
|
||||||
name: "Live",
|
|
||||||
type: "LiveStream",
|
|
||||||
target: document.querySelector('#interactive'),
|
|
||||||
constraints: {
|
|
||||||
deviceId: selectedDeviceId,
|
|
||||||
facingMode: "environment", // Default to rear camera,
|
|
||||||
advanced: [{torch: true}]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
decoder: {
|
|
||||||
readers: ["ean_reader", "ean_8_reader"]
|
|
||||||
}
|
|
||||||
}, function (err) {
|
|
||||||
if (err) {
|
|
||||||
console.log(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log("Initialization finished. Ready to start");
|
|
||||||
Quagga.start();
|
Quagga.start();
|
||||||
|
} else {
|
||||||
|
Quagga.init({
|
||||||
|
inputStream: {
|
||||||
|
name: "Live",
|
||||||
|
type: "LiveStream",
|
||||||
|
target: document.querySelector('#interactive'),
|
||||||
|
constraints: {
|
||||||
|
deviceId: selectedDeviceId,
|
||||||
|
facingMode: "environment",
|
||||||
|
// Remove advanced constraints if not needed
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decoder: {
|
||||||
|
readers: ["ean_reader", "ean_8_reader"]
|
||||||
|
}
|
||||||
|
}, function (err) {
|
||||||
|
if (err) {
|
||||||
|
console.log(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("Initialization finished. Ready to start");
|
||||||
|
Quagga.start();
|
||||||
|
quaggaInitialized = true;
|
||||||
|
|
||||||
// TODO: Add a button to enable/disable torch
|
// Set up the onDetected handler
|
||||||
Quagga.CameraAccess.enableTorch();
|
Quagga.onDetected(processBarcode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Add a button to enable/disable the "locate" functionality
|
async function processBarcode(data) {
|
||||||
});
|
if (isScanning) return; // Prevent further scans while a scan is being processed
|
||||||
|
isScanning = true; // Set the scanning flag
|
||||||
|
|
||||||
Quagga.onDetected(async function (data) {
|
const isbn = data.codeResult.code;
|
||||||
if (isScanning) return; // Prevent further scans while a scan is being processed
|
console.log("Detected ISBN:", isbn);
|
||||||
isScanning = true; // Set the scanning flag
|
Quagga.stop(); // Stop the scanner once an ISBN is detected
|
||||||
|
|
||||||
const isbn = data.codeResult.code;
|
if (document.getElementById('confirm-mode').checked) {
|
||||||
console.log("Detected ISBN:", isbn);
|
// Confirm book in library
|
||||||
Quagga.stop(); // Stop the scanner once an ISBN is detected
|
await confirmBookInLibrary(isbn);
|
||||||
|
} else {
|
||||||
// TODO: Validate the ISBN before fetching book details
|
// Normal flow
|
||||||
// Use a library like barcode-validator to validate the ISBN
|
|
||||||
|
|
||||||
// Fetch book details
|
|
||||||
await fetchBookInfo(isbn);
|
await fetchBookInfo(isbn);
|
||||||
|
}
|
||||||
|
|
||||||
isScanning = false; // Reset the scanning flag once processing is done
|
isScanning = false; // Reset the scanning flag once processing is done
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchBookInfo(isbn) {
|
async function fetchBookInfo(isbn) {
|
||||||
@ -98,10 +106,6 @@ async function fetchBookInfo(isbn) {
|
|||||||
const response = await fetch(`/book/${isbn}`);
|
const response = await fetch(`/book/${isbn}`);
|
||||||
const bookData = await response.json();
|
const bookData = await response.json();
|
||||||
|
|
||||||
// TODO: If bookData value of "source" is "local", then the book data is already in the database
|
|
||||||
// TODO: Display the book info and ask if they would like to checkout the book
|
|
||||||
|
|
||||||
// TODO: Store scanned ISBN in a different field to check against the one we get from the API
|
|
||||||
if (bookData.title) {
|
if (bookData.title) {
|
||||||
bookData.isbn2 = isbn; // Add the ISBN to the book data
|
bookData.isbn2 = isbn; // Add the ISBN to the book data
|
||||||
promptUserWithBook(bookData);
|
promptUserWithBook(bookData);
|
||||||
@ -136,71 +140,6 @@ function promptUserWithBook(bookData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Function to prompt user for physical location of the book
|
|
||||||
// Should pull from the database and allow the user to select or add a location
|
|
||||||
|
|
||||||
// Add an event listener for the search button
|
|
||||||
document.getElementById('search-title').addEventListener('click', searchByTitle);
|
|
||||||
|
|
||||||
async function searchByTitle() {
|
|
||||||
const title = document.getElementById('title-input').value;
|
|
||||||
if (!title) {
|
|
||||||
alert('Please enter a book title to search.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/search-title?title=${encodeURIComponent(title)}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.results && data.results.length > 0) {
|
|
||||||
displaySearchResults(data.results);
|
|
||||||
} else {
|
|
||||||
alert('No books found with that title.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error searching for book by title:', error);
|
|
||||||
alert('An error occurred while searching for the book.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displaySearchResults(results) {
|
|
||||||
// Display the search results and allow the user to select one
|
|
||||||
const bookInfoDiv = document.getElementById('book-info');
|
|
||||||
bookInfoDiv.innerHTML = ''; // Clear previous results
|
|
||||||
|
|
||||||
results.forEach((book, index) => {
|
|
||||||
const bookElement = document.createElement('div');
|
|
||||||
bookElement.innerHTML = `
|
|
||||||
<p><strong>Title:</strong> ${book.title}</p>
|
|
||||||
<p><strong>Author(s):</strong> ${book.authors.join(', ')}</p>
|
|
||||||
<p><strong>Published:</strong> ${book.publish_date || 'N/A'}</p>
|
|
||||||
<p><strong>ISBN:</strong> ${book.isbn}</p>
|
|
||||||
<p><strong>Publisher:</strong> ${book.publisher || 'N/A'}</p>
|
|
||||||
<button onclick="selectBook(${index})">Select this book</button>
|
|
||||||
`;
|
|
||||||
bookInfoDiv.appendChild(bookElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store the results so that we can reference them when the user makes a selection
|
|
||||||
window.searchResults = results;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectBook(index) {
|
|
||||||
const selectedBook = window.searchResults[index];
|
|
||||||
console.log('Selected book:', selectedBook);
|
|
||||||
|
|
||||||
// You can now fetch more details using the unique key if needed, or directly store this in your database
|
|
||||||
const isbn = selectedBook.isbn;
|
|
||||||
|
|
||||||
if (isbn) {
|
|
||||||
fetchBookInfo(isbn);
|
|
||||||
} else {
|
|
||||||
promptUserWithBook(selectedBook); // You might have to adapt this if there's no ISBN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function storeBookInDatabase(bookData) {
|
async function storeBookInDatabase(bookData) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/store-book', {
|
const response = await fetch('/store-book', {
|
||||||
@ -241,9 +180,118 @@ async function storeBookInDatabase(bookData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmBookInLibrary(isbn) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/book/confirm/${isbn}`);
|
||||||
|
const bookData = await response.json();
|
||||||
|
|
||||||
// Start the scanner when the start button is clicked
|
if (bookData.title) {
|
||||||
//document.getElementById('start-scanner').addEventListener('click', startScanner);
|
// Display the book information and a success message
|
||||||
|
const title = bookData.title;
|
||||||
|
const authors = bookData.authors ? bookData.authors.join(', ') : 'Unknown Author';
|
||||||
|
const description = bookData.description || 'No description available';
|
||||||
|
|
||||||
|
const message = `Title: ${title}\nAuthor(s): ${authors}\nDescription: ${description}\n\nBook found in the library!`;
|
||||||
|
|
||||||
|
alert(message);
|
||||||
|
} else {
|
||||||
|
alert('Book not found in the library.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error confirming book in library:', error);
|
||||||
|
alert('An error occurred while confirming the book in the library.');
|
||||||
|
} finally {
|
||||||
|
startScanner(); // Restart the scanner after processing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an event listener for the search button
|
||||||
|
document.getElementById('search-title').addEventListener('click', function() {
|
||||||
|
const title = document.getElementById('title-input').value;
|
||||||
|
searchByTitle(title);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function searchByTitle(title = '') {
|
||||||
|
if (!title) {
|
||||||
|
alert('Please enter a book title to search.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/search-title?title=${encodeURIComponent(title)}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.results && data.results.length > 0) {
|
||||||
|
displaySearchResults(data.results);
|
||||||
|
} else {
|
||||||
|
alert('No books found with that title.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching for book by title:', error);
|
||||||
|
alert('An error occurred while searching for the book.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displaySearchResults(results) {
|
||||||
|
// Display the search results and allow the user to select one
|
||||||
|
const bookInfoDiv = document.getElementById('book-info');
|
||||||
|
bookInfoDiv.innerHTML = ''; // Clear previous results
|
||||||
|
|
||||||
|
results.forEach((book, index) => {
|
||||||
|
const bookElement = document.createElement('div');
|
||||||
|
bookElement.innerHTML = `
|
||||||
|
<p><strong>Title:</strong> ${book.title}</p>
|
||||||
|
<p><strong>Author(s):</strong> ${book.authors.join(', ')}</p>
|
||||||
|
<p><strong>Published:</strong> ${book.publishDate || 'N/A'}</p>
|
||||||
|
<p><strong>ISBN:</strong> ${book.isbn}</p>
|
||||||
|
<p><strong>Publisher:</strong> ${book.publishers || 'N/A'}</p>
|
||||||
|
<button onclick="selectBook(${index})">Select this book</button>
|
||||||
|
`;
|
||||||
|
bookInfoDiv.appendChild(bookElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the results so that we can reference them when the user makes a selection
|
||||||
|
window.searchResults = results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side function to request checkout
|
||||||
|
function requestCheckout(isbn) {
|
||||||
|
fetch(`/api/checkout/${isbn}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert('Checkout request sent successfully.');
|
||||||
|
} else {
|
||||||
|
alert('Failed to send checkout request.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred while sending the checkout request.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBook(index) {
|
||||||
|
const selectedBook = window.searchResults[index];
|
||||||
|
console.log('Selected book:', selectedBook);
|
||||||
|
|
||||||
|
// You can now fetch more details using the unique key if needed, or directly store this in your database
|
||||||
|
const isbn = selectedBook.isbn;
|
||||||
|
|
||||||
|
if (isbn) {
|
||||||
|
fetchBookInfo(isbn);
|
||||||
|
} else {
|
||||||
|
promptUserWithBook(selectedBook); // You might have to adapt this if there's no ISBN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get cameras on page load
|
// Get cameras on page load
|
||||||
window.onload = getCameras;
|
window.onload = function() {
|
||||||
|
getCameras();
|
||||||
|
searchByTitle('');
|
||||||
|
};
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 400px;
|
height: 400px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border: 2px solid #000;
|
||||||
|
background-color: #c0c0c0;
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.drawing, canvas.drawingBuffer {
|
canvas.drawing, canvas.drawingBuffer {
|
||||||
@ -13,18 +15,109 @@ canvas.drawing, canvas.drawingBuffer {
|
|||||||
|
|
||||||
#book-info {
|
#book-info {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#prompt {
|
#prompt {
|
||||||
display: none;
|
display: none;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#prompt-message {
|
#prompt-message {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
color: #800000;
|
||||||
}
|
}
|
||||||
|
|
||||||
#book-title, #book-author, #book-desc {
|
#book-title, #book-author, #book-desc {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
color: #800000;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
background-color: #ddd4b0;
|
||||||
|
color: #000;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
color: #800000;
|
||||||
|
font-size: 24px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-bar {
|
||||||
|
display: block;
|
||||||
|
margin: 20px auto;
|
||||||
|
width: 80%;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 2px solid #000;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 90%;
|
||||||
|
margin: 20px auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #c0c0c0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:hover {
|
||||||
|
background-color: #a0a0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #800000;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#book-table-body tr:nth-child(even) {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#book-table-body tr:nth-child(odd) {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#book-table-body tr:hover {
|
||||||
|
background-color: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#book-info, #prompt, #book-title, #book-author, #book-desc, th, td, body {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-bar:focus {
|
||||||
|
outline-color: #800000;
|
||||||
|
outline-style: solid;
|
||||||
|
outline-width: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user