Initial commit: working SD Park Pass Map app with deploy scripts and .gitignore
This commit is contained in:
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# Local binaries
|
||||||
|
bin/
|
||||||
|
|
||||||
|
# PM2 logs
|
||||||
|
/var/log/pm2/
|
||||||
|
|
||||||
|
# OS junk
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE/editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# npm debug
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Nu shell history
|
||||||
|
.nu_history
|
||||||
188
README.md
Normal file
188
README.md
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
# San Diego Beach Park Pass Locator
|
||||||
|
|
||||||
|
A real-time web application that shows the availability of San Diego library beach parking passes on an interactive map with notification and sorting features.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Real-time availability checking** from BiblioCommons API
|
||||||
|
- **Interactive map** showing all library locations with zoom controls hidden for mobile
|
||||||
|
- **Color-coded markers** (green = available, red = not available)
|
||||||
|
- **Information about pass types** (pure pass vs. backpack with pass)
|
||||||
|
- **Push notification system** - Get browser notifications when passes become available
|
||||||
|
- **Distance-based sorting** - Sort libraries by distance from GPS or zip code location
|
||||||
|
- **Smart sorting options** - Sort by name, availability, or distance
|
||||||
|
- **Collapsible control panel** - Hide/show the main panel for better map viewing
|
||||||
|
- **Mobile-friendly responsive design**
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### 🔔 Push Notification System
|
||||||
|
- Web push notifications compatible with Safari and all major browsers
|
||||||
|
- Add notifications for specific libraries and pass types
|
||||||
|
- Real-time browser notifications when passes become available
|
||||||
|
- Permission management with clear enable/disable status
|
||||||
|
|
||||||
|
### 📍 Location-Based Sorting
|
||||||
|
- GPS location or manual San Diego zip code entry (92101-92173)
|
||||||
|
- Calculate accurate distances to libraries
|
||||||
|
- Sort libraries by proximity to your location
|
||||||
|
- Fallback options when GPS is unavailable
|
||||||
|
|
||||||
|
### 🎛️ Collapsible Interface
|
||||||
|
- Expandable/collapsible main control panel
|
||||||
|
- Better map visibility on mobile devices
|
||||||
|
- Smooth animations and transitions
|
||||||
|
- Persistent state during use
|
||||||
|
|
||||||
|
### 📋 Enhanced Library List
|
||||||
|
- Detailed library information with real-time status
|
||||||
|
- Multiple sorting options (name, availability, distance)
|
||||||
|
- Distance display in miles when location is set
|
||||||
|
- Improved accessibility and mobile usability
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The application fetches data from two different BiblioCommons entries:
|
||||||
|
- `S161C1690437` - Pure parking pass
|
||||||
|
- `S161C1805116` - Backpack item containing a parking pass
|
||||||
|
|
||||||
|
It then displays the availability status on an interactive map centered on San Diego, with additional features for notifications and sorting.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
To run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
To build for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `/api/availability` - Returns current availability data for all libraries
|
||||||
|
- `/api/notifications` - Returns stored notification requests (for future expansion)
|
||||||
|
|
||||||
|
## Library Locations
|
||||||
|
|
||||||
|
The application includes all San Diego Public Library branches that have beach parking passes available, with accurate coordinates for proper map positioning.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Next.js 14 with App Router
|
||||||
|
- React Leaflet for interactive maps
|
||||||
|
- TypeScript for type safety
|
||||||
|
- Local storage for notification persistence
|
||||||
|
|
||||||
|
## Usage Guide
|
||||||
|
|
||||||
|
1. **View Current Availability**: The map shows all libraries with colored markers
|
||||||
|
2. **Sort Libraries**: Use the dropdown to sort by name, availability, or distance from your location
|
||||||
|
3. **Get Location-Based Sorting**: Click "📍 Get Location" to enable distance sorting
|
||||||
|
4. **Set Up Notifications**:
|
||||||
|
- Click "+ Add" in the Notifications section
|
||||||
|
- Select a library and pass types you're interested in
|
||||||
|
- Click "Check Now" to manually check your notifications
|
||||||
|
5. **Manage Notifications**: Remove notifications by clicking the "✕" button
|
||||||
|
|
||||||
|
## Manual Check Process
|
||||||
|
|
||||||
|
To manually verify the data, you can:
|
||||||
|
1. Visit https://sandiego.bibliocommons.com/v2/record/S161C1690437 (pure pass)
|
||||||
|
2. Visit https://sandiego.bibliocommons.com/v2/record/S161C1805116 (backpack with pass)
|
||||||
|
3. Click "Availability by location" on each page
|
||||||
|
4. Check the status column for "Available" items
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
The notification system is designed to be expandable for:
|
||||||
|
- Email notifications
|
||||||
|
- Background checking with web workers
|
||||||
|
- Account-based notification management
|
||||||
|
- Mobile push notifications
|
||||||
|
|
||||||
|
## Deployment to Linode
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- SSH access configured to your Linode server (alias: `linode`)
|
||||||
|
- Node.js and npm installed on the remote server
|
||||||
|
- PM2 installed globally on the remote server: `npm install -g pm2`
|
||||||
|
- rsync available on your local machine
|
||||||
|
|
||||||
|
### Quick Deploy
|
||||||
|
|
||||||
|
You can use either the bash or nu shell deployment script:
|
||||||
|
|
||||||
|
**Using bash:**
|
||||||
|
```bash
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Using nu shell:**
|
||||||
|
```bash
|
||||||
|
npm run deploy:nu
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual deployment:**
|
||||||
|
```bash
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment Process
|
||||||
|
|
||||||
|
The deployment script will:
|
||||||
|
|
||||||
|
1. 📦 Sync source files to `/opt/sd-park-pass-locator/` (excluding node_modules, .next, etc.)
|
||||||
|
2. 📥 Install production dependencies on the remote server
|
||||||
|
3. 🏗️ Build the application on the remote server
|
||||||
|
4. 🔄 Restart the PM2 service
|
||||||
|
|
||||||
|
### Remote Server Setup
|
||||||
|
|
||||||
|
On your Linode server, ensure the following:
|
||||||
|
|
||||||
|
1. **Create the application directory:**
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/sd-park-pass-locator
|
||||||
|
sudo chown $USER:$USER /opt/sd-park-pass-locator
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install PM2 globally:**
|
||||||
|
```bash
|
||||||
|
npm install -g pm2
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure PM2 to start on boot:**
|
||||||
|
```bash
|
||||||
|
pm2 startup
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Set up nginx reverse proxy** (optional but recommended):
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
141
app/api/availability/route.ts
Normal file
141
app/api/availability/route.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
// Library branch locations with approximate coordinates
|
||||||
|
const LIBRARY_LOCATIONS: Record<string, { lat: number; lng: number }> = {
|
||||||
|
'Beckwourth': { lat: 32.7157, lng: -117.1611 },
|
||||||
|
'Benjamin': { lat: 32.8328, lng: -117.2713 },
|
||||||
|
'Balboa': { lat: 32.7330, lng: -117.1430 },
|
||||||
|
'Carmel Mountain': { lat: 32.9286, lng: -117.1311 },
|
||||||
|
'Carmel Valley': { lat: 32.9340, lng: -117.2340 },
|
||||||
|
'Central Library': { lat: 32.7216, lng: -117.1574 },
|
||||||
|
'City Heights': { lat: 32.7411, lng: -117.1045 },
|
||||||
|
'Clairemont': { lat: 32.8328, lng: -117.2050 },
|
||||||
|
'College-Rolando': { lat: 32.7482, lng: -117.0704 },
|
||||||
|
'Kensington': { lat: 32.7644, lng: -117.1164 },
|
||||||
|
'La Jolla': { lat: 32.8344, lng: -117.2544 },
|
||||||
|
'Linda Vista': { lat: 32.7714, lng: -117.1789 },
|
||||||
|
'Logan Heights': { lat: 32.7030, lng: -117.1289 },
|
||||||
|
'Malcolm X': { lat: 32.7089, lng: -117.1242 },
|
||||||
|
'Mission Hills': { lat: 32.7469, lng: -117.1978 },
|
||||||
|
'Oak Park': { lat: 32.7328, lng: -117.1461 },
|
||||||
|
'Paradise Hills': { lat: 32.6747, lng: -117.0742 },
|
||||||
|
'Rancho Bernardo': { lat: 33.0200, lng: -117.1156 },
|
||||||
|
'Rancho Penasquitos': { lat: 32.958034, lng: -117.121975 },
|
||||||
|
'San Ysidro': { lat: 32.5592, lng: -117.0431 },
|
||||||
|
'Skyline Hills': { lat: 32.6781, lng: -117.0200 },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BibItem {
|
||||||
|
branchName: string;
|
||||||
|
availability: {
|
||||||
|
statusType: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BiblioResponse {
|
||||||
|
entities: {
|
||||||
|
bibItems: Record<string, BibItem>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationData {
|
||||||
|
name: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
available: boolean;
|
||||||
|
passType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAvailabilityData(itemNumber: string): Promise<BibItem[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/${itemNumber}/availability?locale=en-US`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (compatible; LibraryChecker/1.0)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: BiblioResponse = await response.json();
|
||||||
|
return Object.values(data.entities.bibItems || {});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching data for ${itemNumber}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Fetch both types of passes
|
||||||
|
const [passItems, backpackItems] = await Promise.all([
|
||||||
|
fetchAvailabilityData('S161C1690437'), // Pure parking pass
|
||||||
|
fetchAvailabilityData('S161C1805116'), // Backpack with pass
|
||||||
|
]);
|
||||||
|
|
||||||
|
const locationMap = new Map<string, LocationData>();
|
||||||
|
|
||||||
|
// Process parking passes
|
||||||
|
passItems.forEach(item => {
|
||||||
|
const location = LIBRARY_LOCATIONS[item.branchName];
|
||||||
|
if (location && item.branchName) {
|
||||||
|
const isAvailable = item.availability?.statusType === 'AVAILABLE';
|
||||||
|
const existing = locationMap.get(item.branchName);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing entry - if either item type is available, mark as available
|
||||||
|
existing.available = existing.available || isAvailable;
|
||||||
|
existing.passType = 'Both';
|
||||||
|
} else {
|
||||||
|
locationMap.set(item.branchName, {
|
||||||
|
name: item.branchName,
|
||||||
|
lat: location.lat,
|
||||||
|
lng: location.lng,
|
||||||
|
available: isAvailable,
|
||||||
|
passType: 'Pass Only',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process backpack items
|
||||||
|
backpackItems.forEach(item => {
|
||||||
|
const location = LIBRARY_LOCATIONS[item.branchName];
|
||||||
|
if (location && item.branchName) {
|
||||||
|
const isAvailable = item.availability?.statusType === 'AVAILABLE';
|
||||||
|
const existing = locationMap.get(item.branchName);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing entry - if either item type is available, mark as available
|
||||||
|
existing.available = existing.available || isAvailable;
|
||||||
|
existing.passType = 'Both';
|
||||||
|
} else {
|
||||||
|
locationMap.set(item.branchName, {
|
||||||
|
name: item.branchName,
|
||||||
|
lat: location.lat,
|
||||||
|
lng: location.lng,
|
||||||
|
available: isAvailable,
|
||||||
|
passType: 'Backpack Only',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const locations = Array.from(locationMap.values());
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
locations,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in API route:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch availability data' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/api/notifications/route.ts
Normal file
32
app/api/notifications/route.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { NotificationManager } from '../../utils/notifications';
|
||||||
|
|
||||||
|
// This API endpoint can be used for periodic checking
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const notifications = NotificationManager.getNotifications();
|
||||||
|
|
||||||
|
// In a real implementation, you might want to:
|
||||||
|
// 1. Fetch current availability data
|
||||||
|
// 2. Check against notification preferences
|
||||||
|
// 3. Send email notifications
|
||||||
|
// 4. Return results
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
notificationCount: notifications.length,
|
||||||
|
notifications: notifications.map(n => ({
|
||||||
|
id: n.id,
|
||||||
|
libraryName: n.libraryName,
|
||||||
|
passTypes: n.passTypes,
|
||||||
|
createdAt: n.createdAt,
|
||||||
|
lastChecked: n.lastChecked,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking notifications:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to check notifications' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
222
app/components/LibraryList.tsx
Normal file
222
app/components/LibraryList.tsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { calculateDistance, getCurrentLocation, getCoordinatesFromZip, isValidSanDiegoZip } from '../utils/notifications';
|
||||||
|
|
||||||
|
interface LocationData {
|
||||||
|
name: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
available: boolean;
|
||||||
|
passType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LibraryListProps {
|
||||||
|
locations: LocationData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationWithDistance extends LocationData {
|
||||||
|
distance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LibraryList({ locations }: { readonly locations: LocationData[] }) {
|
||||||
|
// Default to 92129 zip and distance sort
|
||||||
|
const defaultZip = '92129';
|
||||||
|
const defaultCoords = getCoordinatesFromZip(defaultZip);
|
||||||
|
const [sortBy, setSortBy] = useState<'name' | 'distance' | 'availability'>('distance');
|
||||||
|
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(defaultCoords ? { lat: defaultCoords.lat, lng: defaultCoords.lng } : null);
|
||||||
|
const [locationsWithDistance, setLocationsWithDistance] = useState<LocationWithDistance[]>(locations);
|
||||||
|
const [locationPermission, setLocationPermission] = useState<'granted' | 'denied' | 'prompt' | 'loading'>(defaultCoords ? 'granted' : 'prompt');
|
||||||
|
const [zipCode, setZipCode] = useState(defaultZip);
|
||||||
|
const [zipError, setZipError] = useState('');
|
||||||
|
|
||||||
|
const requestLocation = async () => {
|
||||||
|
setLocationPermission('loading');
|
||||||
|
const location = await getCurrentLocation();
|
||||||
|
if (location) {
|
||||||
|
setUserLocation(location);
|
||||||
|
setLocationPermission('granted');
|
||||||
|
setZipCode(''); // Clear zip code when GPS is used
|
||||||
|
} else {
|
||||||
|
setLocationPermission('denied');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZipCode = () => {
|
||||||
|
if (!zipCode.trim()) {
|
||||||
|
setZipError('Please enter a zip code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidSanDiegoZip(zipCode.trim())) {
|
||||||
|
setZipError('Please enter a valid San Diego area zip code (92101-92173)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = getCoordinatesFromZip(zipCode.trim());
|
||||||
|
if (coords) {
|
||||||
|
setUserLocation({ lat: coords.lat, lng: coords.lng });
|
||||||
|
setLocationPermission('granted');
|
||||||
|
setZipError('');
|
||||||
|
} else {
|
||||||
|
setZipError('Invalid zip code');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userLocation) {
|
||||||
|
const withDistances = locations.map(loc => ({
|
||||||
|
...loc,
|
||||||
|
distance: calculateDistance(userLocation.lat, userLocation.lng, loc.lat, loc.lng)
|
||||||
|
}));
|
||||||
|
setLocationsWithDistance(withDistances);
|
||||||
|
} else {
|
||||||
|
setLocationsWithDistance(locations);
|
||||||
|
}
|
||||||
|
}, [locations, userLocation]);
|
||||||
|
|
||||||
|
const sortedLocations = [...locationsWithDistance].sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'name':
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
case 'distance': {
|
||||||
|
if (!a.distance && !b.distance) return 0;
|
||||||
|
if (!a.distance) return 1;
|
||||||
|
if (!b.distance) return -1;
|
||||||
|
// Round to avoid floating point precision issues
|
||||||
|
const aDist = Math.round(a.distance * 10000) / 10000;
|
||||||
|
const bDist = Math.round(b.distance * 10000) / 10000;
|
||||||
|
return aDist - bDist;
|
||||||
|
}
|
||||||
|
case 'availability':
|
||||||
|
if (a.available === b.available) {
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
return a.available ? -1 : 1;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details style={{ marginBottom: '10px' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>
|
||||||
|
All Libraries ({locations.length})
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '8px', marginBottom: '8px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', fontSize: '11px', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontWeight: 'bold' }}>Sort by:</span>
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as 'name' | 'distance' | 'availability')}
|
||||||
|
style={{ fontSize: '11px', padding: '2px' }}
|
||||||
|
>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
<option value="availability">Availability</option>
|
||||||
|
<option value="distance" disabled={!userLocation}>
|
||||||
|
Distance {!userLocation ? '(Location needed)' : ''}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '8px', fontSize: '11px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', marginBottom: '4px' }}>
|
||||||
|
{locationPermission === 'prompt' && (
|
||||||
|
<button
|
||||||
|
onClick={requestLocation}
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
backgroundColor: '#2196F3',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📍 Get GPS
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span style={{ color: '#666' }}>or</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter zip code"
|
||||||
|
value={zipCode}
|
||||||
|
onChange={(e) => setZipCode(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleZipCode()}
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '2px 4px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '3px',
|
||||||
|
width: '80px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleZipCode}
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
backgroundColor: '#4CAF50',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{zipError && (
|
||||||
|
<div style={{ fontSize: '10px', color: '#f44336', marginBottom: '4px' }}>
|
||||||
|
{zipError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{locationPermission === 'loading' && (
|
||||||
|
<span style={{ fontSize: '10px', color: '#666' }}>Getting location...</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{locationPermission === 'denied' && (
|
||||||
|
<span style={{ fontSize: '10px', color: '#f44336' }}>GPS denied - use zip code</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{locationPermission === 'granted' && userLocation && (
|
||||||
|
<span style={{ fontSize: '10px', color: '#4CAF50' }}>📍 Location set</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ maxHeight: '200px', overflowY: 'auto', marginTop: '5px' }}>
|
||||||
|
{sortedLocations.map((loc) => (
|
||||||
|
<div key={loc.name} style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
marginBottom: '3px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '2px 0'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<span style={{ color: loc.available ? '#4CAF50' : '#f44336', marginRight: '6px' }}>●</span>
|
||||||
|
<span>{loc.name}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '10px', color: '#666', textAlign: 'right' }}>
|
||||||
|
{loc.distance && (
|
||||||
|
<div>{loc.distance.toFixed(1)} mi</div>
|
||||||
|
)}
|
||||||
|
<div style={{ color: loc.available ? '#4CAF50' : '#f44336' }}>
|
||||||
|
{loc.available ? 'Available' : 'Not Available'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
app/components/MapComponent.tsx
Normal file
84
app/components/MapComponent.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
// Fix for default markers in react-leaflet
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface LocationData {
|
||||||
|
name: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
available: boolean;
|
||||||
|
passType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapComponentProps {
|
||||||
|
locations: LocationData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCustomIcon = (available: boolean) => {
|
||||||
|
const color = available ? '#4CAF50' : '#f44336';
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'custom-marker',
|
||||||
|
html: `<div style="
|
||||||
|
background-color: ${color};
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
"></div>`,
|
||||||
|
iconSize: [20, 20],
|
||||||
|
iconAnchor: [10, 10],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MapComponent({ locations }: MapComponentProps) {
|
||||||
|
// Center map on San Diego
|
||||||
|
const center: [number, number] = [32.7157, -117.1611];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
center={center}
|
||||||
|
zoom={11}
|
||||||
|
zoomControl={false}
|
||||||
|
style={{ height: '100%', width: '100%' }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{locations.map((location, index) => (
|
||||||
|
<Marker
|
||||||
|
key={`${location.name}-${index}`}
|
||||||
|
position={[location.lat, location.lng]}
|
||||||
|
icon={createCustomIcon(location.available)}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>
|
||||||
|
{location.name} Library
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '4px 0' }}>
|
||||||
|
<strong>Status:</strong> {location.available ? 'Available' : 'Not Available'}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '4px 0' }}>
|
||||||
|
<strong>Pass Type:</strong> {location.passType}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
app/components/NotificationPanel.tsx
Normal file
275
app/components/NotificationPanel.tsx
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { NotificationManager, type NotificationRequest, WebPushManager } from '../utils/notifications';
|
||||||
|
|
||||||
|
interface LocationData {
|
||||||
|
name: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
available: boolean;
|
||||||
|
passType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationPanelProps {
|
||||||
|
locations: LocationData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationPanel({ locations }: { readonly locations: LocationData[] }) {
|
||||||
|
const [notifications, setNotifications] = useState<NotificationRequest[]>(() =>
|
||||||
|
NotificationManager.getNotifications()
|
||||||
|
);
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [selectedLibrary, setSelectedLibrary] = useState('');
|
||||||
|
const [selectedPassTypes, setSelectedPassTypes] = useState<string[]>([]);
|
||||||
|
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>(
|
||||||
|
WebPushManager.isSupported() ? WebPushManager.getPermissionStatus() : 'denied'
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePassTypeChange = (type: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedPassTypes(prev => [...prev, type]);
|
||||||
|
} else {
|
||||||
|
setSelectedPassTypes(prev => prev.filter(t => t !== type));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestNotificationPermission = async () => {
|
||||||
|
const granted = await WebPushManager.requestPermission();
|
||||||
|
setNotificationPermission(granted ? 'granted' : 'denied');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNotification = () => {
|
||||||
|
if (!selectedLibrary || selectedPassTypes.length === 0) return;
|
||||||
|
|
||||||
|
const newNotification = NotificationManager.addNotification({
|
||||||
|
libraryName: selectedLibrary,
|
||||||
|
passTypes: selectedPassTypes as ('Pass Only' | 'Backpack Only' | 'Both')[],
|
||||||
|
});
|
||||||
|
|
||||||
|
setNotifications(prev => [...prev, newNotification]);
|
||||||
|
setShowAddForm(false);
|
||||||
|
setSelectedLibrary('');
|
||||||
|
setSelectedPassTypes([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveNotification = (id: string) => {
|
||||||
|
NotificationManager.removeNotification(id);
|
||||||
|
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkNotifications = async () => {
|
||||||
|
const availableNow = notifications.filter(notification => {
|
||||||
|
const library = locations.find(loc => loc.name === notification.libraryName);
|
||||||
|
return library && library.available && (
|
||||||
|
notification.passTypes.includes('Both') ||
|
||||||
|
notification.passTypes.some(type => library.passType.includes(type.replace(' Only', '')))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (availableNow.length > 0) {
|
||||||
|
const message = availableNow.map(n => `${n.libraryName} has passes available!`).join('\n');
|
||||||
|
|
||||||
|
// Try web notification first
|
||||||
|
if (notificationPermission === 'granted') {
|
||||||
|
for (const notification of availableNow) {
|
||||||
|
await WebPushManager.showNotification(
|
||||||
|
'🎉 Beach Pass Available!',
|
||||||
|
`${notification.libraryName} has ${notification.passTypes.join(' and ')} available!`,
|
||||||
|
{
|
||||||
|
tag: `park-pass-${notification.id}`,
|
||||||
|
requireInteraction: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also show alert for immediate feedback
|
||||||
|
alert(`🎉 Great news!\n\n${message}`);
|
||||||
|
|
||||||
|
// Update last checked for all notifications
|
||||||
|
notifications.forEach(n => NotificationManager.updateLastChecked(n.id));
|
||||||
|
} else {
|
||||||
|
alert('No passes currently available for your requested libraries.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: '15px', borderTop: '1px solid #eee', paddingTop: '15px' }}>
|
||||||
|
<h4 style={{ marginBottom: '10px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span>🔔 Notifications ({notifications.length})</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
backgroundColor: '#4CAF50',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAddForm ? 'Cancel' : '+ Add'}
|
||||||
|
</button>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Notification Permission Status */}
|
||||||
|
{WebPushManager.isSupported() && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '10px',
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: notificationPermission === 'granted' ? '#e8f5e8' : '#fff3cd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: `1px solid ${notificationPermission === 'granted' ? '#4CAF50' : '#ffc107'}`
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
|
||||||
|
Push Notifications: {notificationPermission === 'granted' ? '✅ Enabled' : '❌ Disabled'}
|
||||||
|
</div>
|
||||||
|
{notificationPermission !== 'granted' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '6px', color: '#666' }}>
|
||||||
|
Enable push notifications to get alerts when passes become available.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={requestNotificationPermission}
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
backgroundColor: '#007cba',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enable Notifications
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAddForm && (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#f9f9f9',
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
fontSize: '13px'
|
||||||
|
}}>
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<div style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
|
||||||
|
Library:
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={selectedLibrary}
|
||||||
|
onChange={(e) => setSelectedLibrary(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||||
|
aria-label="Select library"
|
||||||
|
>
|
||||||
|
<option value="">Select a library...</option>
|
||||||
|
{locations.map(loc => (
|
||||||
|
<option key={loc.name} value={loc.name}>{loc.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<div style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
|
||||||
|
Pass Types:
|
||||||
|
</div>
|
||||||
|
{['Pass Only', 'Backpack Only', 'Both'].map(type => (
|
||||||
|
<div key={type} style={{ display: 'block', fontSize: '12px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPassTypes.includes(type)}
|
||||||
|
onChange={(e) => handlePassTypeChange(type, e.target.checked)}
|
||||||
|
style={{ marginRight: '6px' }}
|
||||||
|
id={`passtype-${type}`}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`passtype-${type}`}>
|
||||||
|
{type}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAddNotification}
|
||||||
|
disabled={!selectedLibrary || selectedPassTypes.length === 0}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
backgroundColor: selectedLibrary && selectedPassTypes.length > 0 ? '#007cba' : '#ccc',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: selectedLibrary && selectedPassTypes.length > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Notification
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ maxHeight: '120px', overflowY: 'auto', marginBottom: '8px' }}>
|
||||||
|
{notifications.map(notification => (
|
||||||
|
<div key={notification.id} style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
marginBottom: '6px',
|
||||||
|
padding: '6px',
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
borderRadius: '3px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>{notification.libraryName}</div>
|
||||||
|
<div style={{ color: '#666' }}>
|
||||||
|
{notification.passTypes.join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveNotification(notification.id)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#f44336',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '2px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={checkNotifications}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: '#ff9800',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Check Now
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
app/globals.css
Normal file
75
app/globals.css
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
background: white;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
max-width: 350px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel.collapsed {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 60px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.available {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unavailable {
|
||||||
|
background-color: #f44336;
|
||||||
|
}
|
||||||
22
app/layout.tsx
Normal file
22
app/layout.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import './globals.css'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Inter } from 'next/font/google'
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'SD Beach Park Pass Locator',
|
||||||
|
description: 'Find available beach parking passes at San Diego libraries',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={inter.className}>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
app/page.tsx
Normal file
146
app/page.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import LibraryList from './components/LibraryList';
|
||||||
|
import NotificationPanel from './components/NotificationPanel';
|
||||||
|
|
||||||
|
// Dynamically import the map component to avoid SSR issues
|
||||||
|
const MapComponent = dynamic(() => import('./components/MapComponent'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div>Loading map...</div>
|
||||||
|
});
|
||||||
|
|
||||||
|
interface LocationData {
|
||||||
|
name: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
available: boolean;
|
||||||
|
passType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse {
|
||||||
|
locations: LocationData[];
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [locations, setLocations] = useState<LocationData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<string>('');
|
||||||
|
// Start with menu collapsed only on mobile
|
||||||
|
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Collapse menu on mobile only
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const isMobile = /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(window.navigator.userAgent);
|
||||||
|
if (isMobile) setPanelCollapsed(true);
|
||||||
|
}
|
||||||
|
// Trigger data refresh on page load
|
||||||
|
fetchAvailability();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAvailability = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/availability');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch data');
|
||||||
|
}
|
||||||
|
const data: ApiResponse = await response.json();
|
||||||
|
setLocations(data.locations);
|
||||||
|
setLastUpdated(data.lastUpdated);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableCount = locations.filter(loc => loc.available).length;
|
||||||
|
const totalCount = locations.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', height: '100vh' }}>
|
||||||
|
<div className={`info-panel ${panelCollapsed ? 'collapsed' : ''}`}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px' }}>
|
||||||
|
<h2 style={{ fontSize: '18px', fontWeight: 'bold', margin: 0 }}>
|
||||||
|
SD Beach Park Pass Locator
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setPanelCollapsed(!panelCollapsed)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
title={panelCollapsed ? 'Expand panel' : 'Collapse panel'}
|
||||||
|
>
|
||||||
|
{panelCollapsed ? '▶' : '◀'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!panelCollapsed && (
|
||||||
|
<>
|
||||||
|
{loading && <p>Loading availability data...</p>}
|
||||||
|
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
<p style={{ marginBottom: '10px' }}>
|
||||||
|
<strong>{availableCount}</strong> of <strong>{totalCount}</strong> locations have available passes
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<LibraryList locations={locations} />
|
||||||
|
|
||||||
|
<div className="legend">
|
||||||
|
<h4 style={{ marginBottom: '8px' }}>Legend:</h4>
|
||||||
|
<div className="legend-item">
|
||||||
|
<div className="legend-color available"></div>
|
||||||
|
<span>Available</span>
|
||||||
|
</div>
|
||||||
|
<div className="legend-item">
|
||||||
|
<div className="legend-color unavailable"></div>
|
||||||
|
<span>Not Available</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NotificationPanel locations={locations} />
|
||||||
|
|
||||||
|
{lastUpdated && (
|
||||||
|
<p style={{ fontSize: '12px', color: '#666', marginTop: '10px' }}>
|
||||||
|
Last updated: {new Date(lastUpdated).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={fetchAvailability}
|
||||||
|
style={{
|
||||||
|
marginTop: '10px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#007cba',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh Data
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && !error && <MapComponent locations={locations} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
app/utils/notifications.ts
Normal file
182
app/utils/notifications.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
// Simple notification storage using localStorage
|
||||||
|
export interface NotificationRequest {
|
||||||
|
id: string;
|
||||||
|
libraryName: string;
|
||||||
|
passTypes: ('Pass Only' | 'Backpack Only' | 'Both')[];
|
||||||
|
email?: string; // Optional for future email notifications
|
||||||
|
createdAt: string;
|
||||||
|
lastChecked?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotificationManager {
|
||||||
|
private static STORAGE_KEY = 'park-pass-notifications';
|
||||||
|
|
||||||
|
static getNotifications(): NotificationRequest[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static addNotification(request: Omit<NotificationRequest, 'id' | 'createdAt'>): NotificationRequest {
|
||||||
|
const newRequest: NotificationRequest = {
|
||||||
|
...request,
|
||||||
|
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifications = this.getNotifications();
|
||||||
|
notifications.push(newRequest);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(notifications));
|
||||||
|
}
|
||||||
|
|
||||||
|
return newRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeNotification(id: string): void {
|
||||||
|
const notifications = this.getNotifications().filter(n => n.id !== id);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(notifications));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static updateLastChecked(id: string): void {
|
||||||
|
const notifications = this.getNotifications();
|
||||||
|
const notification = notifications.find(n => n.id === id);
|
||||||
|
if (notification) {
|
||||||
|
notification.lastChecked = new Date().toISOString();
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(notifications));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geolocation utilities
|
||||||
|
export function calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||||
|
const R = 3959; // Earth's radius in miles
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||||
|
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||||
|
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentLocation(): Promise<{ lat: number; lng: number } | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
resolve({
|
||||||
|
lat: position.coords.latitude,
|
||||||
|
lng: position.coords.longitude,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
resolve(null);
|
||||||
|
},
|
||||||
|
{ timeout: 10000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web Push Notification utilities
|
||||||
|
export class WebPushManager {
|
||||||
|
static async requestPermission(): Promise<boolean> {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
console.log('This browser does not support notifications');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'denied') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
return permission === 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
|
static async showNotification(title: string, body: string, options?: NotificationOptions): Promise<void> {
|
||||||
|
if (!await this.requestPermission()) {
|
||||||
|
console.log('Notification permission denied');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
new Notification(title, {
|
||||||
|
body,
|
||||||
|
icon: '/favicon.ico',
|
||||||
|
badge: '/favicon.ico',
|
||||||
|
tag: 'park-pass-notification',
|
||||||
|
requireInteraction: true,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static isSupported(): boolean {
|
||||||
|
return 'Notification' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPermissionStatus(): NotificationPermission {
|
||||||
|
return Notification.permission;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zip code to coordinates lookup (San Diego area zip codes)
|
||||||
|
const ZIP_COORDINATES: Record<string, { lat: number; lng: number; area: string }> = {
|
||||||
|
// Central San Diego
|
||||||
|
'92101': { lat: 32.7157, lng: -117.1611, area: 'Downtown' },
|
||||||
|
'92102': { lat: 32.7081, lng: -117.1367, area: 'Golden Hill' },
|
||||||
|
'92103': { lat: 32.7441, lng: -117.1739, area: 'Hillcrest' },
|
||||||
|
'92104': { lat: 32.7489, lng: -117.1364, area: 'Normal Heights' },
|
||||||
|
'92105': { lat: 32.7089, lng: -117.1242, area: 'City Heights' },
|
||||||
|
'92106': { lat: 32.7330, lng: -117.1430, area: 'Point Loma' },
|
||||||
|
'92107': { lat: 32.7572, lng: -117.2528, area: 'Ocean Beach' },
|
||||||
|
'92108': { lat: 32.7678, lng: -117.1189, area: 'Mission Valley' },
|
||||||
|
'92109': { lat: 32.7889, lng: -117.2306, area: 'Pacific Beach' },
|
||||||
|
'92110': { lat: 32.7742, lng: -117.1936, area: 'Mission Bay' },
|
||||||
|
'92111': { lat: 32.7714, lng: -117.1789, area: 'Linda Vista' },
|
||||||
|
'92113': { lat: 32.6781, lng: -117.0200, area: 'Skyline' },
|
||||||
|
'92114': { lat: 32.7030, lng: -117.1289, area: 'Logan Heights' },
|
||||||
|
'92115': { lat: 32.7328, lng: -117.1461, area: 'Oak Park' },
|
||||||
|
'92116': { lat: 32.7644, lng: -117.1164, area: 'Kensington' },
|
||||||
|
'92117': { lat: 32.8328, lng: -117.2050, area: 'Clairemont' },
|
||||||
|
'92118': { lat: 32.6100, lng: -117.0842, area: 'Coronado' },
|
||||||
|
'92119': { lat: 32.7482, lng: -117.0704, area: 'College Area' },
|
||||||
|
'92120': { lat: 32.7678, lng: -117.0731, area: 'Del Cerro' },
|
||||||
|
'92121': { lat: 32.8797, lng: -117.2072, area: 'Sorrento Valley' },
|
||||||
|
'92122': { lat: 32.8544, lng: -117.2144, area: 'University City' },
|
||||||
|
'92123': { lat: 32.8100, lng: -117.1350, area: 'Serra Mesa' },
|
||||||
|
'92124': { lat: 32.8086, lng: -117.0547, area: 'Tierrasanta' },
|
||||||
|
'92126': { lat: 32.8831, lng: -117.1558, area: 'Mira Mesa' },
|
||||||
|
'92127': { lat: 32.9581, lng: -117.1089, area: 'Rancho Penasquitos' },
|
||||||
|
'92128': { lat: 33.0200, lng: -117.1156, area: 'Rancho Bernardo' },
|
||||||
|
'92129': { lat: 32.9584, lng: -117.1253, area: 'Rancho Penasquitos' },
|
||||||
|
'92130': { lat: 32.9340, lng: -117.2340, area: 'Carmel Valley' },
|
||||||
|
'92131': { lat: 32.9286, lng: -117.1311, area: 'Carmel Mountain' },
|
||||||
|
'92132': { lat: 32.6747, lng: -117.0742, area: 'Paradise Hills' },
|
||||||
|
'92154': { lat: 32.5592, lng: -117.0431, area: 'San Ysidro' },
|
||||||
|
'92173': { lat: 32.6400, lng: -117.0200, area: 'San Diego East' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getCoordinatesFromZip(zipCode: string): { lat: number; lng: number; area: string } | null {
|
||||||
|
return ZIP_COORDINATES[zipCode] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidSanDiegoZip(zipCode: string): boolean {
|
||||||
|
return zipCode in ZIP_COORDINATES;
|
||||||
|
}
|
||||||
2611
curl-response.json
Normal file
2611
curl-response.json
Normal file
File diff suppressed because it is too large
Load Diff
71
deploy.nu
Executable file
71
deploy.nu
Executable file
@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env nu
|
||||||
|
|
||||||
|
# Deploy SD Park Pass Locator to Linode
|
||||||
|
# This script copies source files (excluding node_modules) and restarts the service
|
||||||
|
|
||||||
|
def main [] {
|
||||||
|
print "🚀 Starting deployment to Linode..."
|
||||||
|
|
||||||
|
# Define paths
|
||||||
|
let local_path = "."
|
||||||
|
let remote_host = "linode"
|
||||||
|
let remote_path = "/opt/sd-park-pass-locator"
|
||||||
|
|
||||||
|
# Files and directories to exclude
|
||||||
|
let exclude_patterns = [
|
||||||
|
".next/"
|
||||||
|
"node_modules/"
|
||||||
|
".git/"
|
||||||
|
"*.log"
|
||||||
|
".DS_Store"
|
||||||
|
"coverage/"
|
||||||
|
"dist/"
|
||||||
|
"build/"
|
||||||
|
".env.local"
|
||||||
|
".env.development.local"
|
||||||
|
".env.test.local"
|
||||||
|
".env.production.local"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build exclude arguments for rsync
|
||||||
|
let exclude_args = ($exclude_patterns | each { |pattern| ["--exclude", $pattern] } | flatten)
|
||||||
|
|
||||||
|
print "📦 Syncing files to remote server..."
|
||||||
|
|
||||||
|
# Use rsync to sync files efficiently (do NOT delete remote files)
|
||||||
|
rsync --archive --verbose --compress ...$exclude_args $"($local_path)/" $"($remote_host):($remote_path)/"
|
||||||
|
|
||||||
|
if ($env.LAST_EXIT_CODE == 0) {
|
||||||
|
print "✅ Files synced successfully"
|
||||||
|
|
||||||
|
print "📦 Installing dependencies on remote server..."
|
||||||
|
ssh $remote_host $"cd ($remote_path) && npm install --omit=dev"
|
||||||
|
|
||||||
|
if ($env.LAST_EXIT_CODE == 0) {
|
||||||
|
print "🏗️ Building application on remote server..."
|
||||||
|
ssh $remote_host $"cd ($remote_path) && npm run build"
|
||||||
|
|
||||||
|
if ($env.LAST_EXIT_CODE == 0) {
|
||||||
|
print "🔄 Restarting PM2 service..."
|
||||||
|
ssh $remote_host "pm2 restart sd-park-pass-locator || pm2 start /opt/sd-park-pass-locator/ecosystem.config.js"
|
||||||
|
|
||||||
|
if ($env.LAST_EXIT_CODE == 0) {
|
||||||
|
print "🎉 Deployment completed successfully!"
|
||||||
|
print "🌐 Your app should be available at your Linode server"
|
||||||
|
} else {
|
||||||
|
print "❌ Failed to restart PM2 service"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print "❌ Failed to build application on remote server"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print "❌ Failed to install dependencies on remote server"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print "❌ Failed to sync files to remote server"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
48
deploy.sh
Executable file
48
deploy.sh
Executable file
@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Deploy SD Park Pass Locator to Linode
|
||||||
|
# This script copies source files (excluding node_modules) and restarts the service
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
echo "🚀 Starting deployment to Linode..."
|
||||||
|
|
||||||
|
# Define paths
|
||||||
|
LOCAL_PATH="."
|
||||||
|
REMOTE_HOST="linode"
|
||||||
|
REMOTE_PATH="/opt/sd-park-pass-locator"
|
||||||
|
|
||||||
|
echo "📦 Syncing files to remote server..."
|
||||||
|
|
||||||
|
# Use rsync to sync files efficiently, excluding unnecessary files
|
||||||
|
rsync -avz \
|
||||||
|
--exclude='.next/' \
|
||||||
|
--exclude='node_modules/' \
|
||||||
|
--exclude='.git/' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
--exclude='.DS_Store' \
|
||||||
|
--exclude='coverage/' \
|
||||||
|
--exclude='dist/' \
|
||||||
|
--exclude='build/' \
|
||||||
|
--exclude='.env.local' \
|
||||||
|
--exclude='.env.development.local' \
|
||||||
|
--exclude='.env.test.local' \
|
||||||
|
--exclude='.env.production.local' \
|
||||||
|
"$LOCAL_PATH/" "$REMOTE_HOST:$REMOTE_PATH/"
|
||||||
|
|
||||||
|
echo "✅ Files synced successfully"
|
||||||
|
|
||||||
|
echo "📦 Installing dependencies on remote server..."
|
||||||
|
# shellcheck disable=SC2029
|
||||||
|
ssh "$REMOTE_HOST" "cd $REMOTE_PATH && npm install --omit=dev"
|
||||||
|
|
||||||
|
echo "🏗️ Building application on remote server..."
|
||||||
|
# shellcheck disable=SC2029
|
||||||
|
ssh "$REMOTE_HOST" "cd $REMOTE_PATH && npm run build"
|
||||||
|
|
||||||
|
echo "🔄 Restarting PM2 service..."
|
||||||
|
# shellcheck disable=SC2029
|
||||||
|
ssh "$REMOTE_HOST" "pm2 restart sd-park-pass-locator || pm2 start $REMOTE_PATH/ecosystem.config.js"
|
||||||
|
|
||||||
|
echo "🎉 Deployment completed successfully!"
|
||||||
|
echo "🌐 Your app should be available at your Linode server"
|
||||||
20
ecosystem.config.js
Normal file
20
ecosystem.config.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'sd-park-pass-locator',
|
||||||
|
script: 'npm',
|
||||||
|
args: 'start',
|
||||||
|
cwd: '/opt/sd-park-pass-locator',
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '1G',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3000
|
||||||
|
},
|
||||||
|
error_file: '/var/log/pm2/sd-park-pass-locator-error.log',
|
||||||
|
out_file: '/var/log/pm2/sd-park-pass-locator-out.log',
|
||||||
|
log_file: '/var/log/pm2/sd-park-pass-locator-combined.log',
|
||||||
|
time: true
|
||||||
|
}]
|
||||||
|
};
|
||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
4
next.config.js
Normal file
4
next.config.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
5447
package-lock.json
generated
Normal file
5447
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "sd-park-pass-locator",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"deploy": "./deploy.sh",
|
||||||
|
"deploy:nu": "./deploy.nu"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@humanwhocodes/config-array": "^0.13.0",
|
||||||
|
"@humanwhocodes/object-schema": "^2.0.3",
|
||||||
|
"glob": "^11.0.3",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"next": "14.2.5",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
|
"rimraf": "^6.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/leaflet": "^1.9.12",
|
||||||
|
"@types/node": "^20.19.7",
|
||||||
|
"@types/react": "^18.3.23",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-next": "14.2.5",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "es6"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user