60 |
65 |
66 | {/* Overlay when sidebar is open (all devices) */}
67 | {sidebarOpen && (
68 |
setSidebarOpen(false)}
70 | className="fixed inset-0 bg-black bg-opacity-50 z-10"
71 | />
72 | )}
73 |
74 |
75 |
76 |
77 |
78 | setContactOpen(false)} />
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | );
87 | }
88 |
89 | export default App;
90 |
--------------------------------------------------------------------------------
/scripts/security-check.cjs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Security Check Script
5 | * Scans for common security issues in the codebase
6 | */
7 |
8 | const fs = require('fs');
9 | const path = require('path');
10 |
11 | const SECURITY_PATTERNS = [
12 | {
13 | pattern: /console\.log.*process\.env\./g,
14 | description: 'Environment variable logging',
15 | severity: 'HIGH'
16 | },
17 | {
18 | pattern: /console\.log.*password\s*:|console\.log.*secret\s*:|console\.log.*\$\{.*key.*\}/gi,
19 | description: 'Potential sensitive data logging',
20 | severity: 'HIGH'
21 | },
22 | {
23 | pattern: /sk-[a-zA-Z0-9]+/g,
24 | description: 'Potential OpenAI API key',
25 | severity: 'CRITICAL'
26 | },
27 | {
28 | pattern: /password\s*=\s*["'][^"']+["']/gi,
29 | description: 'Hardcoded password',
30 | severity: 'CRITICAL'
31 | },
32 | {
33 | pattern: /api_key\s*=\s*["'][^"']+["']/gi,
34 | description: 'Hardcoded API key',
35 | severity: 'CRITICAL'
36 | }
37 | ];
38 |
39 | function scanFile(filePath) {
40 | const content = fs.readFileSync(filePath, 'utf8');
41 | const issues = [];
42 |
43 | SECURITY_PATTERNS.forEach(({ pattern, description, severity }) => {
44 | const matches = content.match(pattern);
45 | if (matches) {
46 | matches.forEach(match => {
47 | issues.push({
48 | file: filePath,
49 | issue: description,
50 | severity,
51 | match: match.substring(0, 50) + (match.length > 50 ? '...' : '')
52 | });
53 | });
54 | }
55 | });
56 |
57 | return issues;
58 | }
59 |
60 | function scanDirectory(dir, ignore = ['node_modules', '.git', 'dist', 'logs']) {
61 | const issues = [];
62 |
63 | function scan(currentDir) {
64 | const items = fs.readdirSync(currentDir);
65 |
66 | items.forEach(item => {
67 | const fullPath = path.join(currentDir, item);
68 | const stats = fs.statSync(fullPath);
69 |
70 | if (stats.isDirectory() && !ignore.includes(item)) {
71 | scan(fullPath);
72 | } else if (stats.isFile() && /\.(js|ts|tsx|jsx)$/.test(item) && !item.includes('package-lock')) {
73 | // Skip package-lock.json to avoid false positives
74 | issues.push(...scanFile(fullPath));
75 | }
76 | });
77 | }
78 |
79 | scan(dir);
80 | return issues;
81 | }
82 |
83 | function main() {
84 | console.log('🔍 Running security scan...\n');
85 |
86 | const issues = scanDirectory(process.cwd());
87 |
88 | if (issues.length === 0) {
89 | console.log('✅ No security issues found!');
90 | return;
91 | }
92 |
93 | console.log(`⚠️ Found ${issues.length} potential security issue(s):\n`);
94 |
95 | issues.forEach((issue, index) => {
96 | console.log(`${index + 1}. [${issue.severity}] ${issue.issue}`);
97 | console.log(` File: ${issue.file}`);
98 | console.log(` Match: ${issue.match}`);
99 | console.log('');
100 | });
101 |
102 | const criticalIssues = issues.filter(i => i.severity === 'CRITICAL').length;
103 | const highIssues = issues.filter(i => i.severity === 'HIGH').length;
104 |
105 | console.log(`Summary: ${criticalIssues} critical, ${highIssues} high priority issues`);
106 |
107 | if (criticalIssues > 0) {
108 | process.exit(1);
109 | }
110 | }
111 |
112 | if (require.main === module) {
113 | main();
114 | }
115 |
116 | module.exports = { scanDirectory, scanFile };
--------------------------------------------------------------------------------
/client/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Drawer as DrawerPrimitive } from "vaul"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Drawer = ({
9 | shouldScaleBackground = true,
10 | ...props
11 | }: React.ComponentProps
) => (
12 |
16 | )
17 | Drawer.displayName = "Drawer"
18 |
19 | const DrawerTrigger = DrawerPrimitive.Trigger
20 |
21 | const DrawerPortal = DrawerPrimitive.Portal
22 |
23 | const DrawerClose = DrawerPrimitive.Close
24 |
25 | const DrawerOverlay = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
34 | ))
35 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
36 |
37 | const DrawerContent = React.forwardRef<
38 | React.ElementRef,
39 | React.ComponentPropsWithoutRef
40 | >(({ className, children, ...props }, ref) => (
41 |
42 |
43 |
51 |
52 | {children}
53 |
54 |
55 | ))
56 | DrawerContent.displayName = "DrawerContent"
57 |
58 | const DrawerHeader = ({
59 | className,
60 | ...props
61 | }: React.HTMLAttributes) => (
62 |
66 | )
67 | DrawerHeader.displayName = "DrawerHeader"
68 |
69 | const DrawerFooter = ({
70 | className,
71 | ...props
72 | }: React.HTMLAttributes) => (
73 |
77 | )
78 | DrawerFooter.displayName = "DrawerFooter"
79 |
80 | const DrawerTitle = React.forwardRef<
81 | React.ElementRef,
82 | React.ComponentPropsWithoutRef
83 | >(({ className, ...props }, ref) => (
84 |
92 | ))
93 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName
94 |
95 | const DrawerDescription = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, ...props }, ref) => (
99 |
104 | ))
105 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName
106 |
107 | export {
108 | Drawer,
109 | DrawerPortal,
110 | DrawerOverlay,
111 | DrawerTrigger,
112 | DrawerClose,
113 | DrawerContent,
114 | DrawerHeader,
115 | DrawerFooter,
116 | DrawerTitle,
117 | DrawerDescription,
118 | }
119 |
--------------------------------------------------------------------------------
/server/vision.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { log } from './simple-logger.js';
3 |
4 | interface VisionRequest {
5 | requests: {
6 | image: {
7 | content: string;
8 | };
9 | features: {
10 | type: string;
11 | maxResults: number;
12 | }[];
13 | }[];
14 | }
15 |
16 | interface VisionResponse {
17 | responses: {
18 | labelAnnotations?: {
19 | description: string;
20 | score: number;
21 | }[];
22 | textAnnotations?: {
23 | description: string;
24 | }[];
25 | logoAnnotations?: {
26 | description: string;
27 | score: number;
28 | }[];
29 | fullTextAnnotation?: {
30 | text: string;
31 | };
32 | error?: {
33 | message: string;
34 | };
35 | }[];
36 | }
37 |
38 | export async function analyzeImage(base64Image: string): Promise {
39 | try {
40 | const apiKey = process.env.GOOGLE_VISION_API_KEY;
41 | if (!apiKey) {
42 | throw new Error('Google Vision API key is not configured');
43 | }
44 |
45 | // Remove data URL prefix if present and ensure proper formatting
46 | let imageContent = base64Image;
47 | if (imageContent.includes(',')) {
48 | imageContent = imageContent.split(',')[1];
49 | }
50 |
51 | if (!imageContent || imageContent.length < 100) {
52 | throw new Error('Invalid image data provided');
53 | }
54 |
55 | log(`Processing image with Google Vision API, content length: ${imageContent.length}`);
56 |
57 | const visionRequest: VisionRequest = {
58 | requests: [
59 | {
60 | image: {
61 | content: imageContent,
62 | },
63 | features: [
64 | {
65 | type: 'TEXT_DETECTION',
66 | maxResults: 5,
67 | },
68 | {
69 | type: 'LABEL_DETECTION',
70 | maxResults: 5,
71 | },
72 | ],
73 | },
74 | ],
75 | };
76 |
77 | const response = await axios.post(
78 | `https://vision.googleapis.com/v1/images:annotate?key=${apiKey}`,
79 | visionRequest
80 | );
81 |
82 | const visionResponse = response.data.responses[0];
83 |
84 | if (visionResponse.error) {
85 | throw new Error(`Vision API error: ${visionResponse.error.message}`);
86 | }
87 |
88 | // Extract text that might represent book titles or authors
89 | let extractedText = '';
90 | if (visionResponse.fullTextAnnotation) {
91 | extractedText = visionResponse.fullTextAnnotation.text;
92 | } else if (visionResponse.textAnnotations && visionResponse.textAnnotations.length > 0) {
93 | extractedText = visionResponse.textAnnotations[0].description;
94 | }
95 |
96 | // Check if labels indicate it's a book
97 | const isBookshelf = visionResponse.labelAnnotations?.some(
98 | label => label.description.toLowerCase().includes('book') ||
99 | label.description.toLowerCase().includes('shelf') ||
100 | label.description.toLowerCase().includes('library')
101 | );
102 |
103 | return {
104 | isBookshelf,
105 | text: extractedText,
106 | labels: visionResponse.labelAnnotations || [],
107 | };
108 | } catch (error) {
109 | log(`Error analyzing image: ${error instanceof Error ? error.message : String(error)}`, 'vision');
110 |
111 | // Return empty data so that the user knows there was an error
112 | return {
113 | isBookshelf: false,
114 | text: "Error analyzing image. Please try again with a clearer photo.",
115 | labels: []
116 | };
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/tests/client/lib/deviceId.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2 | import { getDeviceId, clearDeviceId } from '../../../client/src/lib/deviceId';
3 |
4 | // Mock localStorage
5 | const localStorageMock = {
6 | getItem: vi.fn(),
7 | setItem: vi.fn(),
8 | removeItem: vi.fn(),
9 | clear: vi.fn(),
10 | };
11 | Object.defineProperty(global, 'localStorage', { value: localStorageMock });
12 |
13 | // Mock uuid
14 | vi.mock('uuid', () => ({
15 | v4: () => 'test-uuid-1234-5678-abcd'
16 | }));
17 |
18 | describe('Device ID Utils', () => {
19 | beforeEach(() => {
20 | vi.clearAllMocks();
21 | localStorageMock.getItem.mockReturnValue(null);
22 | });
23 |
24 | afterEach(() => {
25 | vi.clearAllMocks();
26 | });
27 |
28 | describe('getDeviceId', () => {
29 | it('should return existing device ID from localStorage', () => {
30 | const existingId = 'existing-device-id-123';
31 | localStorageMock.getItem.mockReturnValue(existingId);
32 |
33 | const result = getDeviceId();
34 |
35 | expect(result).toBe(existingId);
36 | expect(localStorageMock.getItem).toHaveBeenCalledWith('book_app_device_id');
37 | expect(localStorageMock.setItem).not.toHaveBeenCalled();
38 | });
39 |
40 | it('should generate new device ID when none exists', () => {
41 | localStorageMock.getItem.mockReturnValue(null);
42 |
43 | const result = getDeviceId();
44 |
45 | expect(result).toBe('test-uuid-1234-5678-abcd');
46 | expect(localStorageMock.setItem).toHaveBeenCalledWith(
47 | 'book_app_device_id',
48 | 'test-uuid-1234-5678-abcd'
49 | );
50 | });
51 |
52 | it('should handle null return from localStorage', () => {
53 | localStorageMock.getItem.mockReturnValue(null);
54 |
55 | const result = getDeviceId();
56 |
57 | expect(result).toBe('test-uuid-1234-5678-abcd');
58 | expect(localStorageMock.getItem).toHaveBeenCalledWith('book_app_device_id');
59 | expect(localStorageMock.setItem).toHaveBeenCalledWith(
60 | 'book_app_device_id',
61 | 'test-uuid-1234-5678-abcd'
62 | );
63 | });
64 | });
65 |
66 | describe('clearDeviceId', () => {
67 | it('should remove device ID from localStorage', () => {
68 | clearDeviceId();
69 |
70 | expect(localStorageMock.removeItem).toHaveBeenCalledWith('book_app_device_id');
71 | });
72 |
73 | it('should work even when no existing data', () => {
74 | localStorageMock.getItem.mockReturnValue(null);
75 |
76 | clearDeviceId();
77 |
78 | expect(localStorageMock.removeItem).toHaveBeenCalledWith('book_app_device_id');
79 | });
80 | });
81 |
82 | describe('Integration tests', () => {
83 | it('should handle complete workflow: generate -> clear', () => {
84 | // Start with no device ID
85 | localStorageMock.getItem.mockReturnValue(null);
86 |
87 | // Generate device ID
88 | const deviceId = getDeviceId();
89 | expect(deviceId).toBe('test-uuid-1234-5678-abcd');
90 |
91 | // Clear everything
92 | clearDeviceId();
93 | expect(localStorageMock.removeItem).toHaveBeenCalledWith('book_app_device_id');
94 | });
95 |
96 | it('should consistently return same ID if it exists', () => {
97 | const testId = 'consistent-test-id';
98 | localStorageMock.getItem.mockReturnValue(testId);
99 |
100 | const result1 = getDeviceId();
101 | const result2 = getDeviceId();
102 |
103 | expect(result1).toBe(testId);
104 | expect(result2).toBe(testId);
105 | expect(localStorageMock.setItem).not.toHaveBeenCalled();
106 | });
107 | });
108 | });
--------------------------------------------------------------------------------
/client/src/components/ui/DonationModal.tsx:
--------------------------------------------------------------------------------
1 | import { X, Heart, Coffee, Gift } from "lucide-react";
2 |
3 | interface DonationModalProps {
4 | isOpen: boolean;
5 | onClose: () => void;
6 | }
7 |
8 | export default function DonationModal({ isOpen, onClose }: DonationModalProps) {
9 |
10 | if (!isOpen) {
11 | return null;
12 | }
13 |
14 | const handleDonate = () => {
15 | // Open PayPal donation link in new tab
16 | window.open(
17 | "https://www.paypal.com/donate/?business=S8Z878CBE5F3U&no_recurring=0&item_name=Thanks+for+supporting+ShelfScanner%21¤cy_code=USD",
18 | "_blank",
19 | "noopener,noreferrer"
20 | );
21 | onClose();
22 | };
23 |
24 | return (
25 |
26 |
27 | {/* Close button */}
28 |
32 |
33 |
34 |
35 | {/* Header with cute illustration */}
36 |
37 |
42 |
43 | Support ShelfScanner
44 |
45 |
46 | Help us keep the book recommendations flowing! ✨
47 |
48 |
49 |
50 | {/* Content */}
51 |
52 |
53 |
54 |
55 |
56 | Buy us a coffee!
57 |
58 |
59 |
60 |
61 | Your support helps us maintain our servers, improve our AI recommendations,
62 | and keep ShelfScanner free for book lovers everywhere! 📚
63 |
64 |
65 |
66 |
67 |
68 | {/* Action buttons */}
69 |
70 |
74 |
75 | Donate with PayPal
76 |
77 |
78 |
82 | Maybe later
83 |
84 |
85 |
86 | {/* Thank you note */}
87 |
88 |
89 | Thank you for considering a donation!
90 | Every contribution, no matter the size, means the world to us. 💝
91 |
92 |
93 |
94 |
95 |
96 | );
97 | }
--------------------------------------------------------------------------------
/tests/client/lib/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { cn } from '../../../client/src/lib/utils';
2 |
3 | describe('Utils', () => {
4 | describe('cn', () => {
5 | test('should merge simple class names', () => {
6 | expect(cn('class1', 'class2')).toBe('class1 class2');
7 | expect(cn('bg-red-500', 'text-white')).toBe('bg-red-500 text-white');
8 | });
9 |
10 | test('should handle empty inputs', () => {
11 | expect(cn()).toBe('');
12 | expect(cn('')).toBe('');
13 | expect(cn('', '')).toBe('');
14 | });
15 |
16 | test('should handle undefined and null inputs', () => {
17 | expect(cn(undefined)).toBe('');
18 | expect(cn(null)).toBe('');
19 | expect(cn('class1', undefined, 'class2')).toBe('class1 class2');
20 | expect(cn('class1', null, 'class2')).toBe('class1 class2');
21 | });
22 |
23 | test('should handle conditional classes', () => {
24 | const isTrue = true;
25 | const isFalse = false;
26 | const nullValue = null;
27 | const undefinedValue = undefined;
28 |
29 | expect(cn('base', isTrue && 'conditional')).toBe('base conditional');
30 | expect(cn('base', isFalse && 'conditional')).toBe('base');
31 | expect(cn('base', nullValue && 'conditional')).toBe('base');
32 | expect(cn('base', undefinedValue && 'conditional')).toBe('base');
33 | });
34 |
35 | test('should handle arrays of classes', () => {
36 | expect(cn(['class1', 'class2'])).toBe('class1 class2');
37 | expect(cn(['class1', 'class2'], 'class3')).toBe('class1 class2 class3');
38 | const shouldInclude = false;
39 | expect(cn(['class1', shouldInclude && 'class2', 'class3'])).toBe('class1 class3');
40 | });
41 |
42 | test('should handle objects with boolean values', () => {
43 | expect(cn({ 'class1': true, 'class2': false })).toBe('class1');
44 | expect(cn({ 'class1': true, 'class2': true })).toBe('class1 class2');
45 | expect(cn('base', { 'conditional': true })).toBe('base conditional');
46 | expect(cn('base', { 'conditional': false })).toBe('base');
47 | });
48 |
49 | test('should merge conflicting Tailwind classes correctly', () => {
50 | // twMerge should handle conflicting Tailwind classes
51 | expect(cn('px-2', 'px-4')).toBe('px-4');
52 | expect(cn('bg-red-500', 'bg-blue-500')).toBe('bg-blue-500');
53 | expect(cn('text-sm', 'text-lg')).toBe('text-lg');
54 | });
55 |
56 | test('should handle complex combinations', () => {
57 | const result = cn(
58 | 'base-class',
59 | ['array-class1', 'array-class2'],
60 | { 'conditional-true': true, 'conditional-false': false },
61 | undefined,
62 | 'final-class'
63 | );
64 | expect(result).toBe('base-class array-class1 array-class2 conditional-true final-class');
65 | });
66 |
67 | test('should handle Tailwind modifiers correctly', () => {
68 | expect(cn('hover:bg-red-500', 'focus:bg-blue-500')).toBe('hover:bg-red-500 focus:bg-blue-500');
69 | expect(cn('sm:text-sm', 'md:text-md', 'lg:text-lg')).toBe('sm:text-sm md:text-md lg:text-lg');
70 | });
71 |
72 | test('should handle identical classes (may or may not deduplicate depending on twMerge)', () => {
73 | // twMerge may or may not deduplicate identical classes
74 | const result1 = cn('class1', 'class1');
75 | expect(result1).toContain('class1');
76 |
77 | const result2 = cn('class1', 'class2', 'class1');
78 | expect(result2).toContain('class1');
79 | expect(result2).toContain('class2');
80 | });
81 |
82 | test('should handle responsive and state variants with conflicts', () => {
83 | // Test that twMerge properly handles responsive variants
84 | expect(cn('p-2', 'sm:p-4', 'p-6')).toBe('sm:p-4 p-6');
85 | expect(cn('bg-red-500', 'hover:bg-red-500', 'bg-blue-500')).toBe('hover:bg-red-500 bg-blue-500');
86 | });
87 |
88 | test('should preserve custom CSS classes that are not Tailwind', () => {
89 | expect(cn('custom-class', 'another-custom')).toBe('custom-class another-custom');
90 | expect(cn('bg-red-500', 'my-custom-bg')).toBe('bg-red-500 my-custom-bg');
91 | });
92 | });
93 | });
--------------------------------------------------------------------------------
/client/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js';
2 | import typescript from '@typescript-eslint/eslint-plugin';
3 | import typescriptParser from '@typescript-eslint/parser';
4 | import react from 'eslint-plugin-react';
5 | import reactHooks from 'eslint-plugin-react-hooks';
6 |
7 | export default [
8 | // Base JavaScript recommended rules
9 | js.configs.recommended,
10 |
11 | // Configuration for TypeScript files
12 | {
13 | files: ['**/*.{ts,tsx}'],
14 | languageOptions: {
15 | parser: typescriptParser,
16 | parserOptions: {
17 | ecmaVersion: 'latest',
18 | sourceType: 'module',
19 | ecmaFeatures: {
20 | jsx: true
21 | },
22 | project: true
23 | },
24 | globals: {
25 | console: 'readonly',
26 | process: 'readonly',
27 | Buffer: 'readonly',
28 | __dirname: 'readonly',
29 | __filename: 'readonly',
30 | global: 'readonly',
31 | module: 'readonly',
32 | require: 'readonly',
33 | exports: 'readonly'
34 | }
35 | },
36 | plugins: {
37 | '@typescript-eslint': typescript,
38 | 'react': react,
39 | 'react-hooks': reactHooks
40 | },
41 | rules: {
42 | // TypeScript specific rules
43 | '@typescript-eslint/no-unused-vars': ['error', {
44 | argsIgnorePattern: '^_',
45 | varsIgnorePattern: '^_'
46 | }],
47 | '@typescript-eslint/no-explicit-any': 'warn',
48 | '@typescript-eslint/no-inferrable-types': 'error',
49 |
50 | // React specific rules
51 | 'react/jsx-uses-react': 'error',
52 | 'react/jsx-uses-vars': 'error',
53 | 'react/prop-types': 'off', // We use TypeScript for prop validation
54 | 'react/react-in-jsx-scope': 'off', // Not needed in React 17+
55 |
56 | // React Hooks rules
57 | 'react-hooks/rules-of-hooks': 'error',
58 | 'react-hooks/exhaustive-deps': 'warn',
59 |
60 | // General code quality rules
61 | 'no-console': 'warn',
62 | 'no-debugger': 'error',
63 | 'no-unused-vars': 'off', // Use TypeScript version instead
64 | 'prefer-const': 'error',
65 | 'no-var': 'error',
66 | 'eqeqeq': ['error', 'always'],
67 | 'curly': ['error', 'all'],
68 |
69 | // Disable problematic rules for this codebase
70 | 'no-undef': 'off' // TypeScript handles this
71 | },
72 | settings: {
73 | react: {
74 | version: 'detect'
75 | }
76 | }
77 | },
78 |
79 | // Configuration for test files
80 | {
81 | files: ['**/*.test.{ts,tsx}', '**/tests/**/*.{ts,tsx}'],
82 | rules: {
83 | 'no-console': 'off',
84 | '@typescript-eslint/no-explicit-any': 'off'
85 | }
86 | },
87 |
88 | // Configuration for JavaScript test files and scripts
89 | {
90 | files: ['**/tests/**/*.js', '**/*.test.js', '**/scripts/**/*.js'],
91 | languageOptions: {
92 | ecmaVersion: 'latest',
93 | sourceType: 'module',
94 | globals: {
95 | console: 'readonly',
96 | process: 'readonly',
97 | Buffer: 'readonly',
98 | require: 'readonly',
99 | module: 'readonly',
100 | __dirname: 'readonly',
101 | __filename: 'readonly',
102 | global: 'readonly',
103 | exports: 'readonly'
104 | }
105 | },
106 | rules: {
107 | 'no-console': 'off',
108 | 'no-undef': 'off'
109 | }
110 | },
111 |
112 | // Configuration for config files and scripts
113 | {
114 | files: ['**/*.config.{js,ts}', '**/vite.config.ts', '**/tailwind.config.ts', '**/scripts/**/*.{js,cjs}'],
115 | languageOptions: {
116 | globals: {
117 | console: 'readonly',
118 | process: 'readonly',
119 | require: 'readonly',
120 | module: 'readonly',
121 | __dirname: 'readonly',
122 | Buffer: 'readonly'
123 | }
124 | },
125 | rules: {
126 | 'no-console': 'off',
127 | '@typescript-eslint/no-explicit-any': 'off',
128 | 'no-undef': 'off'
129 | }
130 | },
131 |
132 | // Ignore patterns
133 | {
134 | ignores: [
135 | 'node_modules/**',
136 | 'dist/**',
137 | 'build/**',
138 | 'coverage/**',
139 | 'playwright-report/**',
140 | 'test-results/**',
141 | '*.min.js',
142 | 'vitest.config.*.ts',
143 | 'migrations/**'
144 | ]
145 | }
146 | ];
--------------------------------------------------------------------------------
/client/src/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import type {
4 | ToastActionElement,
5 | ToastProps,
6 | } from "@/components/ui/toast"
7 |
8 | const TOAST_LIMIT = 1
9 | const TOAST_REMOVE_DELAY = 1000000
10 |
11 | type ToasterToast = ToastProps & {
12 | id: string
13 | title?: React.ReactNode
14 | description?: React.ReactNode
15 | action?: ToastActionElement
16 | }
17 |
18 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
19 | const actionTypes = {
20 | ADD_TOAST: "ADD_TOAST",
21 | UPDATE_TOAST: "UPDATE_TOAST",
22 | DISMISS_TOAST: "DISMISS_TOAST",
23 | REMOVE_TOAST: "REMOVE_TOAST",
24 | } as const
25 |
26 | let count = 0
27 |
28 | function genId() {
29 | count = (count + 1) % Number.MAX_SAFE_INTEGER
30 | return count.toString()
31 | }
32 |
33 | type ActionType = typeof actionTypes
34 |
35 | type Action =
36 | | {
37 | type: ActionType["ADD_TOAST"]
38 | toast: ToasterToast
39 | }
40 | | {
41 | type: ActionType["UPDATE_TOAST"]
42 | toast: Partial
43 | }
44 | | {
45 | type: ActionType["DISMISS_TOAST"]
46 | toastId?: ToasterToast["id"]
47 | }
48 | | {
49 | type: ActionType["REMOVE_TOAST"]
50 | toastId?: ToasterToast["id"]
51 | }
52 |
53 | interface State {
54 | toasts: ToasterToast[]
55 | }
56 |
57 | const toastTimeouts = new Map>()
58 |
59 | const addToRemoveQueue = (toastId: string) => {
60 | if (toastTimeouts.has(toastId)) {
61 | return
62 | }
63 |
64 | const timeout = setTimeout(() => {
65 | toastTimeouts.delete(toastId)
66 | dispatch({
67 | type: "REMOVE_TOAST",
68 | toastId: toastId,
69 | })
70 | }, TOAST_REMOVE_DELAY)
71 |
72 | toastTimeouts.set(toastId, timeout)
73 | }
74 |
75 | export const reducer = (state: State, action: Action): State => {
76 | switch (action.type) {
77 | case "ADD_TOAST":
78 | return {
79 | ...state,
80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
81 | }
82 |
83 | case "UPDATE_TOAST":
84 | return {
85 | ...state,
86 | toasts: state.toasts.map((t) =>
87 | t.id === action.toast.id ? { ...t, ...action.toast } : t
88 | ),
89 | }
90 |
91 | case "DISMISS_TOAST": {
92 | const { toastId } = action
93 |
94 | // ! Side effects ! - This could be extracted into a dismissToast() action,
95 | // but I'll keep it here for simplicity
96 | if (toastId) {
97 | addToRemoveQueue(toastId)
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id)
101 | })
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map((t) =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false,
111 | }
112 | : t
113 | ),
114 | }
115 | }
116 | case "REMOVE_TOAST":
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: [],
121 | }
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
126 | }
127 | }
128 | }
129 |
130 | const listeners: Array<(state: State) => void> = []
131 |
132 | let memoryState: State = { toasts: [] }
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action)
136 | listeners.forEach((listener) => {
137 | listener(memoryState)
138 | })
139 | }
140 |
141 | type Toast = Omit
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId()
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: "UPDATE_TOAST",
149 | toast: { ...props, id },
150 | })
151 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
152 |
153 | dispatch({
154 | type: "ADD_TOAST",
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open) {dismiss()}
161 | },
162 | },
163 | })
164 |
165 | return {
166 | id: id,
167 | dismiss,
168 | update,
169 | }
170 | }
171 |
172 | function useToast() {
173 | const [state, setState] = React.useState(memoryState)
174 |
175 | React.useEffect(() => {
176 | listeners.push(setState)
177 | return () => {
178 | const index = listeners.indexOf(setState)
179 | if (index > -1) {
180 | listeners.splice(index, 1)
181 | }
182 | }
183 | }, [state])
184 |
185 | return {
186 | ...state,
187 | toast,
188 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
189 | }
190 | }
191 |
192 | export { useToast, toast }
193 |
--------------------------------------------------------------------------------
/client/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Sheet = SheetPrimitive.Root
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger
13 |
14 | const SheetClose = SheetPrimitive.Close
15 |
16 | const SheetPortal = SheetPrimitive.Portal
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | )
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | ))
75 | SheetContent.displayName = SheetPrimitive.Content.displayName
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | )
89 | SheetHeader.displayName = "SheetHeader"
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | )
103 | SheetFooter.displayName = "SheetFooter"
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ))
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | }
141 |
--------------------------------------------------------------------------------
/client/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { Slot } from "@radix-ui/react-slot"
6 | import {
7 | Controller,
8 | FormProvider,
9 | useFormContext,
10 | type ControllerProps,
11 | type FieldPath,
12 | type FieldValues,
13 | } from "react-hook-form"
14 |
15 | import { cn } from "@/lib/utils"
16 | import { Label } from "@/components/ui/label"
17 |
18 | const Form = FormProvider
19 |
20 | type FormFieldContextValue<
21 | TFieldValues extends FieldValues = FieldValues,
22 | TName extends FieldPath = FieldPath
23 | > = {
24 | name: TName
25 | }
26 |
27 | const FormFieldContext = React.createContext(
28 | {} as FormFieldContextValue
29 | )
30 |
31 | const FormField = <
32 | TFieldValues extends FieldValues = FieldValues,
33 | TName extends FieldPath = FieldPath
34 | >({
35 | ...props
36 | }: ControllerProps) => {
37 | return (
38 |
39 |
40 |
41 | )
42 | }
43 |
44 | const useFormField = () => {
45 | const fieldContext = React.useContext(FormFieldContext)
46 | const itemContext = React.useContext(FormItemContext)
47 | const { getFieldState, formState } = useFormContext()
48 |
49 | const fieldState = getFieldState(fieldContext.name, formState)
50 |
51 | if (!fieldContext) {
52 | throw new Error("useFormField should be used within ")
53 | }
54 |
55 | const { id } = itemContext
56 |
57 | return {
58 | id,
59 | name: fieldContext.name,
60 | formItemId: `${id}-form-item`,
61 | formDescriptionId: `${id}-form-item-description`,
62 | formMessageId: `${id}-form-item-message`,
63 | ...fieldState,
64 | }
65 | }
66 |
67 | type FormItemContextValue = {
68 | id: string
69 | }
70 |
71 | const FormItemContext = React.createContext(
72 | {} as FormItemContextValue
73 | )
74 |
75 | const FormItem = React.forwardRef<
76 | HTMLDivElement,
77 | React.HTMLAttributes
78 | >(({ className, ...props }, ref) => {
79 | const id = React.useId()
80 |
81 | return (
82 |
83 |
84 |
85 | )
86 | })
87 | FormItem.displayName = "FormItem"
88 |
89 | const FormLabel = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => {
93 | const { error, formItemId } = useFormField()
94 |
95 | return (
96 |
102 | )
103 | })
104 | FormLabel.displayName = "FormLabel"
105 |
106 | const FormControl = React.forwardRef<
107 | React.ElementRef,
108 | React.ComponentPropsWithoutRef
109 | >(({ ...props }, ref) => {
110 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
111 |
112 | return (
113 |
124 | )
125 | })
126 | FormControl.displayName = "FormControl"
127 |
128 | const FormDescription = React.forwardRef<
129 | HTMLParagraphElement,
130 | React.HTMLAttributes
131 | >(({ className, ...props }, ref) => {
132 | const { formDescriptionId } = useFormField()
133 |
134 | return (
135 |
141 | )
142 | })
143 | FormDescription.displayName = "FormDescription"
144 |
145 | const FormMessage = React.forwardRef<
146 | HTMLParagraphElement,
147 | React.HTMLAttributes
148 | >(({ className, children, ...props }, ref) => {
149 | const { error, formMessageId } = useFormField()
150 | const body = error ? String(error?.message ?? "") : children
151 |
152 | if (!body) {
153 | return null
154 | }
155 |
156 | return (
157 |
163 | {body}
164 |
165 | )
166 | })
167 | FormMessage.displayName = "FormMessage"
168 |
169 | export {
170 | useFormField,
171 | Form,
172 | FormItem,
173 | FormLabel,
174 | FormControl,
175 | FormDescription,
176 | FormMessage,
177 | FormField,
178 | }
179 |
--------------------------------------------------------------------------------
/tests/server/utils/book-utils.test.ts:
--------------------------------------------------------------------------------
1 | import { getEstimatedBookRating } from '../../../server/utils/book-utils';
2 |
3 | describe('Book Utils', () => {
4 | describe('getEstimatedBookRating', () => {
5 | test('should return known ratings for popular books', () => {
6 | // Test exact matches
7 | expect(getEstimatedBookRating('Atomic Habits', 'James Clear')).toBe('4.8');
8 | expect(getEstimatedBookRating('Dune', 'Frank Herbert')).toBe('4.7');
9 | expect(getEstimatedBookRating('1984', 'George Orwell')).toBe('4.7');
10 | expect(getEstimatedBookRating('The Alchemist', 'Paulo Coelho')).toBe('4.7');
11 | });
12 |
13 | test('should handle case variations in titles and authors', () => {
14 | // Test case insensitive matching
15 | expect(getEstimatedBookRating('ATOMIC HABITS', 'JAMES CLEAR')).toBe('4.8');
16 | expect(getEstimatedBookRating('atomic habits', 'james clear')).toBe('4.8');
17 | expect(getEstimatedBookRating('Atomic habits', 'James clear')).toBe('4.8');
18 |
19 | // Test with extra whitespace
20 | expect(getEstimatedBookRating(' Dune ', ' Frank Herbert ')).toBe('4.7');
21 | });
22 |
23 | test('should handle partial matches correctly', () => {
24 | // Test partial title matches
25 | expect(getEstimatedBookRating('This is how you lose the time war', 'Amal El-Mohtar')).toBe('4.5');
26 | expect(getEstimatedBookRating('This is how you lose the time war', 'Max Gladstone')).toBe('4.5');
27 |
28 | // Test author partial matches
29 | expect(getEstimatedBookRating('The Psychology of Money', 'Morgan')).toBe('4.7');
30 | });
31 |
32 | test('should generate consistent ratings for same input', () => {
33 | const title = 'Unknown Book Title';
34 | const author = 'Unknown Author';
35 |
36 | const rating1 = getEstimatedBookRating(title, author);
37 | const rating2 = getEstimatedBookRating(title, author);
38 | const rating3 = getEstimatedBookRating(title, author);
39 |
40 | expect(rating1).toBe(rating2);
41 | expect(rating2).toBe(rating3);
42 | });
43 |
44 | test('should return ratings between 3.0 and 4.9 for unknown books', () => {
45 | const unknownBooks = [
46 | ['Random Book Title', 'Random Author'],
47 | ['Another Book', 'Another Author'],
48 | ['Test Book', 'Test Author'],
49 | ['Fiction Novel', 'Some Writer'],
50 | ['Technical Manual', 'Expert Author']
51 | ];
52 |
53 | unknownBooks.forEach(([title, author]) => {
54 | const rating = parseFloat(getEstimatedBookRating(title, author));
55 | expect(rating).toBeGreaterThanOrEqual(3.0);
56 | expect(rating).toBeLessThanOrEqual(4.9);
57 | });
58 | });
59 |
60 | test('should generate different ratings for different books', () => {
61 | const rating1 = getEstimatedBookRating('Book One', 'Author One');
62 | const rating2 = getEstimatedBookRating('Book Two', 'Author Two');
63 | const rating3 = getEstimatedBookRating('Completely Different', 'Different Author');
64 |
65 | // While we can't guarantee they'll all be different due to hash collisions,
66 | // at least some should be different
67 | const ratings = [rating1, rating2, rating3];
68 | const uniqueRatings = new Set(ratings);
69 | expect(uniqueRatings.size).toBeGreaterThan(1);
70 | });
71 |
72 | test('should return valid decimal format', () => {
73 | const rating = getEstimatedBookRating('Test Book', 'Test Author');
74 |
75 | // Should be a valid number string with one decimal place
76 | expect(rating).toMatch(/^\d\.\d$/);
77 | expect(parseFloat(rating)).not.toBeNaN();
78 | });
79 |
80 | test('should handle empty strings gracefully', () => {
81 | const rating = getEstimatedBookRating('', '');
82 | expect(rating).toMatch(/^\d\.\d$/);
83 | expect(parseFloat(rating)).toBeGreaterThanOrEqual(3.0);
84 | expect(parseFloat(rating)).toBeLessThanOrEqual(4.9);
85 | });
86 |
87 | test('should handle special characters in titles and authors', () => {
88 | const rating1 = getEstimatedBookRating('Book: A Story!', 'Author & Co.');
89 | const rating2 = getEstimatedBookRating('Book (Revised)', 'Dr. Author');
90 |
91 | expect(rating1).toMatch(/^\d\.\d$/);
92 | expect(rating2).toMatch(/^\d\.\d$/);
93 | });
94 |
95 | test('should prefer popular book ratings over generated ones', () => {
96 | // Test that popular books always return their known rating
97 | // regardless of what the hash algorithm would generate
98 | const popularBookRating = getEstimatedBookRating('Sapiens', 'Yuval Noah Harari');
99 | expect(popularBookRating).toBe('4.7');
100 |
101 | // Test multiple times to ensure consistency
102 | for (let i = 0; i < 10; i++) {
103 | expect(getEstimatedBookRating('Thinking, Fast and Slow', 'Daniel Kahneman')).toBe('4.6');
104 | }
105 | });
106 | });
107 | });
--------------------------------------------------------------------------------
/client/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
3 |
4 | import { cn } from "@/lib/utils"
5 | import { buttonVariants } from "@/components/ui/button"
6 |
7 | const AlertDialog = AlertDialogPrimitive.Root
8 |
9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
10 |
11 | const AlertDialogPortal = AlertDialogPrimitive.Portal
12 |
13 | const AlertDialogOverlay = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, ...props }, ref) => (
17 |
25 | ))
26 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
27 |
28 | const AlertDialogContent = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, ...props }, ref) => (
32 |
33 |
34 |
42 |
43 | ))
44 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
45 |
46 | const AlertDialogHeader = ({
47 | className,
48 | ...props
49 | }: React.HTMLAttributes) => (
50 |
57 | )
58 | AlertDialogHeader.displayName = "AlertDialogHeader"
59 |
60 | const AlertDialogFooter = ({
61 | className,
62 | ...props
63 | }: React.HTMLAttributes) => (
64 |
71 | )
72 | AlertDialogFooter.displayName = "AlertDialogFooter"
73 |
74 | const AlertDialogTitle = React.forwardRef<
75 | React.ElementRef,
76 | React.ComponentPropsWithoutRef
77 | >(({ className, ...props }, ref) => (
78 |
83 | ))
84 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
85 |
86 | const AlertDialogDescription = React.forwardRef<
87 | React.ElementRef,
88 | React.ComponentPropsWithoutRef
89 | >(({ className, ...props }, ref) => (
90 |
95 | ))
96 | AlertDialogDescription.displayName =
97 | AlertDialogPrimitive.Description.displayName
98 |
99 | const AlertDialogAction = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
110 |
111 | const AlertDialogCancel = React.forwardRef<
112 | React.ElementRef,
113 | React.ComponentPropsWithoutRef
114 | >(({ className, ...props }, ref) => (
115 |
124 | ))
125 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
126 |
127 | export {
128 | AlertDialog,
129 | AlertDialogPortal,
130 | AlertDialogOverlay,
131 | AlertDialogTrigger,
132 | AlertDialogContent,
133 | AlertDialogHeader,
134 | AlertDialogFooter,
135 | AlertDialogTitle,
136 | AlertDialogDescription,
137 | AlertDialogAction,
138 | AlertDialogCancel,
139 | }
140 |
--------------------------------------------------------------------------------
/client/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ToastPrimitives from "@radix-ui/react-toast"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 | import { X } from "lucide-react"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ToastProvider = ToastPrimitives.Provider
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27 | {
28 | variants: {
29 | variant: {
30 | default: "border bg-background text-foreground",
31 | destructive:
32 | "destructive group border-destructive bg-destructive text-destructive-foreground",
33 | },
34 | },
35 | defaultVariants: {
36 | variant: "default",
37 | },
38 | }
39 | )
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef &
44 | VariantProps
45 | >(({ className, variant, ...props }, ref) => {
46 | return (
47 |
52 | )
53 | })
54 | Toast.displayName = ToastPrimitives.Root.displayName
55 |
56 | const ToastAction = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
68 | ))
69 | ToastAction.displayName = ToastPrimitives.Action.displayName
70 |
71 | const ToastClose = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
84 |
85 |
86 | ))
87 | ToastClose.displayName = ToastPrimitives.Close.displayName
88 |
89 | const ToastTitle = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
98 | ))
99 | ToastTitle.displayName = ToastPrimitives.Title.displayName
100 |
101 | const ToastDescription = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | ToastDescription.displayName = ToastPrimitives.Description.displayName
112 |
113 | type ToastProps = React.ComponentPropsWithoutRef
114 |
115 | type ToastActionElement = React.ReactElement
116 |
117 | export {
118 | type ToastProps,
119 | type ToastActionElement,
120 | ToastProvider,
121 | ToastViewport,
122 | Toast,
123 | ToastTitle,
124 | ToastDescription,
125 | ToastClose,
126 | ToastAction,
127 | }
128 |
--------------------------------------------------------------------------------
/server/utils/book-utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Utility functions for book-related operations
3 | * Moved from amazon.ts during code cleanup
4 | */
5 |
6 | /**
7 | * Local database of popular book ratings
8 | */
9 | function getPopularBookRating(title: string, author: string): string | null {
10 | // Normalize inputs for better matching
11 | const normalizedTitle = title.toLowerCase().trim();
12 | const normalizedAuthor = author.toLowerCase().trim();
13 |
14 | // Database of known book ratings
15 | const popularBooks: {title: string, author: string, rating: string}[] = [
16 | // Bestsellers & Popular fiction
17 | {title: "atomic habits", author: "james clear", rating: "4.8"},
18 | {title: "the creative act", author: "rick rubin", rating: "4.8"},
19 | {title: "american gods", author: "neil gaiman", rating: "4.6"},
20 | {title: "the psychology of money", author: "morgan housel", rating: "4.7"},
21 | {title: "stumbling on happiness", author: "daniel gilbert", rating: "4.3"},
22 | {title: "this is how you lose the time war", author: "amal el-mohtar", rating: "4.5"},
23 | {title: "this is how you lose the time war", author: "max gladstone", rating: "4.5"},
24 | {title: "the book of five rings", author: "miyamoto musashi", rating: "4.7"},
25 | {title: "economics for everyone", author: "jim stanford", rating: "4.5"},
26 | {title: "apocalypse never", author: "michael shellenberger", rating: "4.7"},
27 | {title: "economic facts and fallacies", author: "thomas sowell", rating: "4.8"},
28 | {title: "thinking, fast and slow", author: "daniel kahneman", rating: "4.6"},
29 | {title: "sapiens", author: "yuval noah harari", rating: "4.7"},
30 | {title: "educated", author: "tara westover", rating: "4.7"},
31 | {title: "becoming", author: "michelle obama", rating: "4.8"},
32 | {title: "the silent patient", author: "alex michaelides", rating: "4.5"},
33 | {title: "where the crawdads sing", author: "delia owens", rating: "4.8"},
34 | {title: "dune", author: "frank herbert", rating: "4.7"},
35 | {title: "project hail mary", author: "andy weir", rating: "4.8"},
36 | {title: "the martian", author: "andy weir", rating: "4.7"},
37 | {title: "the midnight library", author: "matt haig", rating: "4.3"},
38 | {title: "1984", author: "george orwell", rating: "4.7"},
39 | {title: "to kill a mockingbird", author: "harper lee", rating: "4.8"},
40 | {title: "the great gatsby", author: "f. scott fitzgerald", rating: "4.5"},
41 | {title: "pride and prejudice", author: "jane austen", rating: "4.7"},
42 | {title: "the alchemist", author: "paulo coelho", rating: "4.7"},
43 | {title: "the four agreements", author: "don miguel ruiz", rating: "4.7"},
44 | {title: "the power of now", author: "eckhart tolle", rating: "4.7"},
45 | {title: "man's search for meaning", author: "viktor e. frankl", rating: "4.7"},
46 | {title: "a brief history of time", author: "stephen hawking", rating: "4.7"},
47 | {title: "the 7 habits of highly effective people", author: "stephen r. covey", rating: "4.7"},
48 | {title: "the immortal life of henrietta lacks", author: "rebecca skloot", rating: "4.7"},
49 | {title: "thinking in systems", author: "donella h. meadows", rating: "4.6"},
50 | {title: "meditations", author: "marcus aurelius", rating: "4.7"}
51 | ];
52 |
53 | // Check for exact or partial matches
54 | for (const book of popularBooks) {
55 | // Exact match case
56 | if (normalizedTitle === book.title && normalizedAuthor.includes(book.author)) {
57 | return book.rating;
58 | }
59 |
60 | // Partial match case - if title contains the entire book title or vice versa
61 | if ((normalizedTitle.includes(book.title) || book.title.includes(normalizedTitle)) &&
62 | (normalizedAuthor.includes(book.author) || book.author.includes(normalizedAuthor))) {
63 | return book.rating;
64 | }
65 | }
66 |
67 | return null;
68 | }
69 |
70 | /**
71 | * Fallback function to get an estimated book rating
72 | * This is used when no rating data is available
73 | *
74 | * @param title Book title
75 | * @param author Book author
76 | * @returns A reasonable rating string between 3.0 and 4.9
77 | */
78 | export function getEstimatedBookRating(title: string, author: string): string {
79 | // First check our popular books database for known ratings
80 | const popularBookRating = getPopularBookRating(title, author);
81 | if (popularBookRating) {
82 | return popularBookRating;
83 | }
84 |
85 | // Use a deterministic approach based on the book details
86 | const combinedString = `${title}${author}`.toLowerCase();
87 |
88 | // Generate a pseudorandom but deterministic number based on the string
89 | let hash = 0;
90 | for (let i = 0; i < combinedString.length; i++) {
91 | hash = ((hash << 5) - hash) + combinedString.charCodeAt(i);
92 | hash = hash & hash; // Convert to 32bit integer
93 | }
94 |
95 | // Use the hash to generate a rating between 3.0 and 4.9
96 | // Most books on Amazon are in this range
97 | const minRating = 3.0;
98 | const maxRating = 4.9;
99 | const ratingRange = maxRating - minRating;
100 |
101 | // Normalize the hash to a positive number between 0 and 1
102 | const normalizedHash = Math.abs(hash) / 2147483647;
103 |
104 | // Calculate rating in the desired range
105 | const rating = minRating + (normalizedHash * ratingRange);
106 |
107 | // Return with one decimal place
108 | return rating.toFixed(1);
109 | }
--------------------------------------------------------------------------------
/api/preferences.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | // Import using ES modules for Vercel compatibility
3 | import 'dotenv/config';
4 |
5 | /**
6 | * API handler for preferences
7 | * @param {import('@vercel/node').VercelRequest} req - The request object
8 | * @param {import('@vercel/node').VercelResponse} res - The response object
9 | */
10 | export default async function handler(req, res) {
11 | // Add comprehensive logging for debugging
12 | console.log('=== PREFERENCES API CALLED ===');
13 | console.log('Method:', req.method);
14 | console.log('URL:', req.url);
15 |
16 | // Handle CORS
17 | res.setHeader('Access-Control-Allow-Credentials', 'true');
18 | res.setHeader('Access-Control-Allow-Origin', '*');
19 | res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
20 | res.setHeader('Access-Control-Allow-Headers', 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version');
21 |
22 | if (req.method === 'OPTIONS') {
23 | return res.status(200).end();
24 | }
25 |
26 | try {
27 | // Import storage dynamically to avoid issues with module resolution
28 | const { storage } = await import('../server/storage.js');
29 | const { insertPreferenceSchema } = await import('../shared/schema.js');
30 | const { logInfo, logError } = await import('../server/simple-error-logger.js');
31 |
32 | console.log('Modules imported successfully');
33 |
34 | if (req.method === 'GET') {
35 | try {
36 | const deviceId = req.query.deviceId || req.cookies?.deviceId;
37 |
38 | if (!deviceId) {
39 | return res.status(400).json({ error: 'Device ID is required' });
40 | }
41 |
42 | const preferences = await storage.getPreferencesByDeviceId(deviceId);
43 |
44 | logInfo('Preferences retrieved', {
45 | deviceId,
46 | action: 'preferences_get',
47 | metadata: {
48 | found: preferences ? 'yes' : 'no',
49 | count: preferences ? 1 : 0
50 | }
51 | });
52 |
53 | return res.status(200).json({
54 | preferences: preferences || null,
55 | deviceId: deviceId
56 | });
57 |
58 | } catch (error) {
59 | console.error('GET preferences error:', error);
60 | return res.status(500).json({ error: 'Failed to retrieve preferences' });
61 | }
62 | }
63 |
64 | if (req.method === 'POST') {
65 | try {
66 |
67 | // Get deviceId from request
68 | const deviceId = req.query.deviceId || req.cookies?.deviceId;
69 |
70 | if (!deviceId) {
71 | return res.status(400).json({ error: 'Device ID is required' });
72 | }
73 |
74 | // Add deviceId to the request body for validation
75 | const dataToValidate = {
76 | ...req.body,
77 | deviceId: deviceId
78 | };
79 |
80 | const validation = insertPreferenceSchema.safeParse(dataToValidate);
81 |
82 | if (!validation.success) {
83 | console.log('Validation failed:', validation.error);
84 | return res.status(400).json({
85 | error: 'Invalid request data',
86 | details: validation.error.errors
87 | });
88 | }
89 |
90 | const preferenceData = validation.data;
91 |
92 | // Check if preferences already exist for this device
93 | const existingPreferences = await storage.getPreferencesByDeviceId(preferenceData.deviceId);
94 |
95 | let result;
96 | if (existingPreferences) {
97 | // Update existing preferences
98 | result = await storage.updatePreference(existingPreferences.id, preferenceData);
99 | } else {
100 | // Create new preferences
101 | result = await storage.createPreference(preferenceData);
102 | }
103 |
104 |
105 | logInfo(existingPreferences ? 'Preferences updated successfully' : 'Preferences saved successfully', {
106 | deviceId: preferenceData.deviceId,
107 | action: existingPreferences ? 'preferences_update' : 'preferences_save',
108 | metadata: { success: 'yes' }
109 | });
110 |
111 | return res.status(200).json({
112 | success: true,
113 | preference: result,
114 | message: existingPreferences ? 'Preferences updated successfully' : 'Preferences saved successfully'
115 | });
116 |
117 | } catch (error) {
118 | console.error('POST preferences error:', error);
119 |
120 | // Log the error
121 | const deviceId = req.body?.deviceId;
122 | if (deviceId) {
123 | logError('Failed to save preference', error, {
124 | deviceId,
125 | action: 'preferences_save',
126 | metadata: { success: 'no' }
127 | });
128 | }
129 |
130 | return res.status(500).json({ error: 'Failed to save preference' });
131 | }
132 | }
133 |
134 | return res.status(405).json({ error: 'Method not allowed' });
135 |
136 | } catch (error) {
137 | console.error('Preferences API error:', error);
138 | return res.status(500).json({
139 | error: 'Internal server error',
140 | message: error.message,
141 | stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
142 | });
143 | }
144 | }
--------------------------------------------------------------------------------
/client/src/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { type DialogProps } from "@radix-ui/react-dialog"
3 | import { Command as CommandPrimitive } from "cmdk"
4 | import { Search } from "lucide-react"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { Dialog, DialogContent } from "@/components/ui/dialog"
8 |
9 | const Command = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 | ))
22 | Command.displayName = CommandPrimitive.displayName
23 |
24 | const CommandDialog = ({ children, ...props }: DialogProps) => {
25 | return (
26 |
27 |
28 |
29 | {children}
30 |
31 |
32 |
33 | )
34 | }
35 |
36 | const CommandInput = React.forwardRef<
37 | React.ElementRef,
38 | React.ComponentPropsWithoutRef
39 | >(({ className, ...props }, ref) => (
40 |
41 |
42 |
50 |
51 | ))
52 |
53 | CommandInput.displayName = CommandPrimitive.Input.displayName
54 |
55 | const CommandList = React.forwardRef<
56 | React.ElementRef,
57 | React.ComponentPropsWithoutRef
58 | >(({ className, ...props }, ref) => (
59 |
64 | ))
65 |
66 | CommandList.displayName = CommandPrimitive.List.displayName
67 |
68 | const CommandEmpty = React.forwardRef<
69 | React.ElementRef,
70 | React.ComponentPropsWithoutRef
71 | >((props, ref) => (
72 |
77 | ))
78 |
79 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName
80 |
81 | const CommandGroup = React.forwardRef<
82 | React.ElementRef,
83 | React.ComponentPropsWithoutRef
84 | >(({ className, ...props }, ref) => (
85 |
93 | ))
94 |
95 | CommandGroup.displayName = CommandPrimitive.Group.displayName
96 |
97 | const CommandSeparator = React.forwardRef<
98 | React.ElementRef,
99 | React.ComponentPropsWithoutRef
100 | >(({ className, ...props }, ref) => (
101 |
106 | ))
107 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName
108 |
109 | const CommandItem = React.forwardRef<
110 | React.ElementRef,
111 | React.ComponentPropsWithoutRef
112 | >(({ className, ...props }, ref) => (
113 |
121 | ))
122 |
123 | CommandItem.displayName = CommandPrimitive.Item.displayName
124 |
125 | const CommandShortcut = ({
126 | className,
127 | ...props
128 | }: React.HTMLAttributes) => {
129 | return (
130 |
137 | )
138 | }
139 | CommandShortcut.displayName = "CommandShortcut"
140 |
141 | export {
142 | Command,
143 | CommandDialog,
144 | CommandInput,
145 | CommandList,
146 | CommandEmpty,
147 | CommandGroup,
148 | CommandItem,
149 | CommandShortcut,
150 | CommandSeparator,
151 | }
152 |
--------------------------------------------------------------------------------
/client/src/pages/debug.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Button } from "@/components/ui/button";
3 | import { Card } from "@/components/ui/card";
4 |
5 | interface ApiResponse {
6 | status: number;
7 | data: any;
8 | error?: string;
9 | }
10 |
11 | export default function Debug() {
12 | const [healthCheck, setHealthCheck] = useState(null);
13 | const [preferencesTest, setPreferencesTest] = useState(null);
14 | const [savedBooksTest, setSavedBooksTest] = useState(null);
15 | const [loading, setLoading] = useState(null);
16 |
17 | const testEndpoint = async (
18 | url: string,
19 | method = 'GET',
20 | body?: any,
21 | setter?: (response: ApiResponse) => void
22 | ): Promise => {
23 | try {
24 | const options: RequestInit = {
25 | method,
26 | credentials: 'include',
27 | headers: {
28 | 'Accept': 'application/json',
29 | 'Content-Type': 'application/json'
30 | }
31 | };
32 |
33 | if (body && method !== 'GET') {
34 | options.body = JSON.stringify(body);
35 | }
36 |
37 | const response = await fetch(url, options);
38 | const data = await response.json();
39 |
40 | const result = {
41 | status: response.status,
42 | data,
43 | error: response.ok ? undefined : `HTTP ${response.status}`
44 | };
45 |
46 | if (setter) {setter(result);}
47 | return result;
48 | } catch (error) {
49 | const result = {
50 | status: 0,
51 | data: null,
52 | error: error instanceof Error ? error.message : String(error)
53 | };
54 |
55 | if (setter) {setter(result);}
56 | return result;
57 | }
58 | };
59 |
60 | const runHealthCheck = async () => {
61 | setLoading('health');
62 | await testEndpoint('/api/health-check', 'GET', undefined, setHealthCheck);
63 | setLoading(null);
64 | };
65 |
66 | const testPreferences = async () => {
67 | setLoading('preferences');
68 | // First try to get preferences
69 | const getResult = await testEndpoint('/api/preferences', 'GET', undefined, setPreferencesTest);
70 |
71 | // If we get a 404, try to create some test preferences
72 | if (getResult.status === 404) {
73 | const testPrefs = {
74 | genres: ['Fiction', 'Science Fiction'],
75 | authors: ['Test Author'],
76 | goodreadsData: null
77 | };
78 |
79 | await testEndpoint('/api/preferences', 'POST', testPrefs, setPreferencesTest);
80 | }
81 | setLoading(null);
82 | };
83 |
84 | const testSavedBooks = async () => {
85 | setLoading('savedBooks');
86 | await testEndpoint('/api/saved-books', 'GET', undefined, setSavedBooksTest);
87 | setLoading(null);
88 | };
89 |
90 | const renderResponse = (response: ApiResponse | null, title: string) => (
91 |
92 | {title}
93 | {response ? (
94 |
95 |
= 200 && response.status < 300 ? 'bg-green-100' : 'bg-red-100'}`}>
96 | Status: {response.status} {response.error && `- ${response.error}`}
97 |
98 |
99 | {JSON.stringify(response.data, null, 2)}
100 |
101 |
102 | ) : (
103 | Not tested yet
104 | )}
105 |
106 | );
107 |
108 | return (
109 |
110 |
111 |
API Debug Page
112 |
113 |
114 |
119 | {loading === 'health' ? 'Testing...' : 'Test Health Check'}
120 |
121 |
122 |
127 | {loading === 'preferences' ? 'Testing...' : 'Test Preferences API'}
128 |
129 |
130 |
135 | {loading === 'savedBooks' ? 'Testing...' : 'Test Saved Books API'}
136 |
137 |
138 |
139 |
140 | {renderResponse(healthCheck, 'Health Check (/api/health-check)')}
141 | {renderResponse(preferencesTest, 'Preferences API (/api/preferences)')}
142 | {renderResponse(savedBooksTest, 'Saved Books API (/api/saved-books)')}
143 |
144 |
145 |
146 | Environment Info
147 |
148 |
URL: {window.location.href}
149 |
User Agent: {navigator.userAgent}
150 |
Cookies: {document.cookie || 'None'}
151 |
152 |
153 |
154 |
155 | );
156 | }
--------------------------------------------------------------------------------
/client/src/components/ui/navigation-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
3 | import { cva } from "class-variance-authority"
4 | import { ChevronDown } from "lucide-react"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const NavigationMenu = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
20 | {children}
21 |
22 |
23 | ))
24 | NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
25 |
26 | const NavigationMenuList = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, ...props }, ref) => (
30 |
38 | ))
39 | NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
40 |
41 | const NavigationMenuItem = NavigationMenuPrimitive.Item
42 |
43 | const navigationMenuTriggerStyle = cva(
44 | "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
45 | )
46 |
47 | const NavigationMenuTrigger = React.forwardRef<
48 | React.ElementRef,
49 | React.ComponentPropsWithoutRef
50 | >(({ className, children, ...props }, ref) => (
51 |
56 | {children}{" "}
57 |
61 |
62 | ))
63 | NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
64 |
65 | const NavigationMenuContent = React.forwardRef<
66 | React.ElementRef,
67 | React.ComponentPropsWithoutRef
68 | >(({ className, ...props }, ref) => (
69 |
77 | ))
78 | NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
79 |
80 | const NavigationMenuLink = NavigationMenuPrimitive.Link
81 |
82 | const NavigationMenuViewport = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
87 |
95 |
96 | ))
97 | NavigationMenuViewport.displayName =
98 | NavigationMenuPrimitive.Viewport.displayName
99 |
100 | const NavigationMenuIndicator = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, ...props }, ref) => (
104 |
112 |
113 |
114 | ))
115 | NavigationMenuIndicator.displayName =
116 | NavigationMenuPrimitive.Indicator.displayName
117 |
118 | export {
119 | navigationMenuTriggerStyle,
120 | NavigationMenu,
121 | NavigationMenuList,
122 | NavigationMenuItem,
123 | NavigationMenuContent,
124 | NavigationMenuTrigger,
125 | NavigationMenuLink,
126 | NavigationMenuIndicator,
127 | NavigationMenuViewport,
128 | }
129 |
--------------------------------------------------------------------------------