9 Commits

Author SHA1 Message Date
twotalesanimation
325e2b4707 fix: improve text visibility on album header background
- Changed album title to white color
- Added text-shadow to album title for better contrast over images
- Changed album description to white color
- Added text-shadow to album description for readability
- Ensures text is visible regardless of cover image darkness
2025-12-05 10:22:13 +02:00
twotalesanimation
233305cac2 feat: use album cover image as album header background
- Fetch cover_image in album query
- Set album-header background-image with cover image
- Add dark overlay (50% opacity) over cover for text readability
- Increased padding for better header spacing with cover image
- Improved visual design using cover image as backdrop
- Fallback to overlay-only design if no cover image exists
- Enhanced header layout with proper z-index for content layering
2025-12-05 10:18:51 +02:00
twotalesanimation
5736757f19 feat: add cover image field to album creation and editing
- Added dedicated cover image upload field in create_album.php form
- Display current cover image preview when editing
- Drag-and-drop support for cover image with real-time preview
- Shows filename and file size after selection
- Updated save_album.php to handle cover image upload
- Updated update_album.php to handle cover image replacement
- Deletes old cover image when updating
- Cover image optional - first photo in album used as fallback
- Recommended cover dimensions: 500x500px or larger (square)
- File validation: max 5MB, supports JPG, PNG, GIF, WEBP
- All cover image changes included in transaction with rollback on error
2025-12-05 10:14:35 +02:00
twotalesanimation
ad460ef85a feat: redesign gallery page with grid layout and enhance ownership checks
- Changed gallery from carousel to responsive grid layout (similar to about page)
- Shows album cover images with titles, creator info, and photo count
- Improved visual design with hover effects and better spacing
- Edit buttons now only visible to album owners (uses current_user_id variable)
- Added proper ownership verification in all album edit/delete operations
- Enhanced styling for mobile/tablet/desktop responsiveness
- Simplified layout makes it easier to browse multiple albums at once
2025-12-05 10:12:08 +02:00
twotalesanimation
e6d298c506 fix: correct require paths and database connection in album processors
- Fix rootPath calculation in all album processors (was going up too many levels)
- Use global \ from connection.php instead of calling openDatabaseConnection()
- Fix cleanup code in save_album.php to use existing \
- Update all processors to use proper config file includes (env.php, session.php, connection.php, functions.php)
- Ensures validateCSRFToken() and other functions are properly available
2025-12-05 09:59:05 +02:00
twotalesanimation
98ef03c7af feat: complete photo gallery implementation with album management and lightbox viewer
- Added photo gallery carousel view (gallery.php) with all member albums
- Implemented album detail view with responsive photo grid and lightbox
- Created album creation/editing form with drag-and-drop photo uploads
- Added backend processors for album CRUD operations and photo management
- Implemented API endpoints for fetching and deleting photos
- Added database migration for photo_albums and photos tables
- Included comprehensive feature documentation with testing checklist
- Updated .htaccess with URL rewrite rules for gallery routes
- Added Gallery link to Members Area menu in header
- Created upload directory structure (/assets/uploads/gallery/)
- Implemented security: CSRF tokens, ownership verification, file validation
- Added transaction safety with rollback on errors and cleanup
- Features: Lightbox with keyboard navigation, drag-and-drop uploads, responsive design
2025-12-05 09:53:27 +02:00
twotalesanimation
05f74f1b86 feat: prevent duplicate membership applications and fees
- Add UNIQUE constraint on membership_application.user_id (one app per user)
- Add UNIQUE constraint on membership_fees.user_id (one fee record per user)
- Add validation checks in process_application.php before inserting
- Improve error messages for duplicate submission attempts
- Add migration script to clean up existing duplicates before constraints
- Update checkMembershipApplication to set session message on redirect
- Add comprehensive documentation of duplicate prevention architecture

Individual payments/EFTs are tracked separately in payments table
2025-12-05 09:42:42 +02:00
twotalesanimation
9133b7bbc6 feat: improve campsites and events management UX
- Add map-based location picker with centered pin for campsites (two-step process)
- Hide edit buttons for campsites not owned by current user
- Allow numbers in campsite names (fix validateName function)
- Prepopulate edit form with existing campsite data
- Preserve country/province selection when confirming location
- Add real-time filter functionality to campsites table
- Fix events publish button error handling (use output buffering cleanup)
- Improve AJAX response handling with complete callback

Changes:
- src/pages/bookings/campsites.php: Location mode UI, filter, edit form improvements
- src/config/functions.php: Allow numbers in validateName regex
- src/admin/toggle_event_published.php: Clean output buffers before JSON response
- src/admin/admin_events.php: Use complete callback instead of success/error handlers
2025-12-05 09:20:48 +02:00
twotalesanimation
b52c46b67c feat: add campsites link to members area menu with membership access control
- Replace 'Coming Soon!' with 'Campsites' link in Members Area dropdown
- Add membership verification check to campsites.php
- Redirect non-logged-in users to login page
- Redirect non-members to index page
- Only active members can access campsites feature
2025-12-04 23:01:28 +02:00
105 changed files with 2885 additions and 80 deletions

View File

@@ -51,6 +51,12 @@ RewriteRule ^payment_confirmation$ src/pages/shop/payment_confirmation.php [L]
RewriteRule ^confirm$ src/pages/shop/confirm.php [L]
RewriteRule ^confirm2$ src/pages/shop/confirm2.php [L]
# === GALLERY PAGES ===
RewriteRule ^gallery$ src/pages/gallery/gallery.php [L]
RewriteRule ^create_album$ src/pages/gallery/create_album.php [L]
RewriteRule ^edit_album$ src/pages/gallery/create_album.php [L]
RewriteRule ^view_album$ src/pages/gallery/view_album.php [L]
# === EVENTS & BLOG PAGES ===
RewriteRule ^events$ src/pages/events/events.php [L]
RewriteRule ^blog$ src/pages/events/blog.php [L]
@@ -121,6 +127,11 @@ RewriteRule ^toggle_trip_published$ src/processors/toggle_trip_published.php [L]
RewriteRule ^toggle_event_published$ src/admin/toggle_event_published.php [L]
RewriteRule ^delete_trip$ src/processors/delete_trip.php [L]
RewriteRule ^delete_event$ src/admin/delete_event.php [L]
RewriteRule ^save_album$ src/processors/save_album.php [L]
RewriteRule ^update_album$ src/processors/update_album.php [L]
RewriteRule ^delete_album$ src/processors/delete_album.php [L]
RewriteRule ^delete_photo$ src/processors/delete_photo.php [L]
RewriteRule ^get_album_photos$ src/processors/get_album_photos.php [L]
</IfModule>

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

View File

@@ -0,0 +1,86 @@
# Membership Application Duplicate Prevention
## Overview
Implemented comprehensive validation to prevent users from submitting multiple membership applications or creating multiple membership fee records. Each user can have exactly one application and one membership fee record. Individual payments are tracked separately in the payments/efts table.
## Architecture
```
User (1) ---> Membership Application (1) ---> Membership Fee (1) ---> Multiple Payments/EFTs
```
- **Membership Application**: Stores user details and application information (one per user)
- **Membership Fee**: Stores the total fee amount and dates (one per user, linked to application)
- **Payments/EFTs**: Tracks individual payment transactions for the membership fee (many per fee)
## Changes Made
### 1. Database Level Protection
**File:** `docs/migrations/002_add_unique_constraints_membership.sql`
- Added `UNIQUE` constraint on `membership_application.user_id` - ensures each user can only have one application
- Added `UNIQUE` constraint on `membership_fees.user_id` - ensures each user can only have one membership fee record
- Cleans up any duplicate records before adding constraints
### 2. Application Level Validation
**File:** `src/processors/process_application.php`
Added pre-submission checks:
- Check if user already has a membership application in the database
- Check if user already has a membership fee record
- Return clear error message if either check fails
- Catch database constraint violations and provide user-friendly message
**File:** `src/config/functions.php`
- Improved `checkMembershipApplication()` to set session message before redirecting
- Message displayed: "You have already submitted a membership application."
### 3. Error Handling
If a user somehow bypasses checks:
- Server validates before processing
- Returns HTTP 400 error with JSON response
- User sees clear message directing them to support or check email
- Database constraints prevent data corruption (duplicate key violation)
## User Flow
1. **First Visit to Application Page:**
- `checkMembershipApplication()` checks database
- If no application exists, shows form
- If application exists, redirects to `membership_details.php`
2. **Form Submission:**
- Server checks for existing application
- Server checks for existing membership fee
- If checks pass, inserts application and fee in transaction
- On success, redirects to indemnity page
- On error, returns JSON error response
3. **Payment Process:**
- Individual payment records are created in payments/efts table
- Multiple payments can be made against the single membership_fee record
- Payment status is tracked independently from application
## Testing Recommendations
1. Test creating a membership application - should succeed
2. Try applying again - should be redirected to membership_details
3. Try submitting the form multiple times rapidly - should fail on 2nd attempt
4. Verify payments can be made against the single membership fee record
5. Check database constraints: `SHOW INDEX FROM membership_application;` and `SHOW INDEX FROM membership_fees;`
## Database Constraints
```sql
-- One application per user
ALTER TABLE membership_application
ADD CONSTRAINT uk_membership_application_user_id UNIQUE (user_id);
-- One membership fee record per user
ALTER TABLE membership_fees
ADD CONSTRAINT uk_membership_fees_user_id UNIQUE (user_id);
```
## Backwards Compatibility
The migration script cleans up any existing duplicate records before adding constraints, ensuring no data loss.

View File

@@ -0,0 +1,494 @@
# Photo Gallery Feature - Complete Implementation
## Overview
The Photo Gallery feature allows 4WDCSA members to create, manage, and view photo albums with a carousel interface for browsing and a lightbox viewer for detailed photo viewing.
## Database Schema
### photo_albums table
```sql
- album_id (INT, PK, AUTO_INCREMENT)
- user_id (INT, FK to users)
- title (VARCHAR 255, NOT NULL)
- description (TEXT, nullable)
- cover_image (VARCHAR 500, nullable - stores file path)
- created_at (TIMESTAMP)
- updated_at (TIMESTAMP)
- UNIQUE INDEX on user_id (one album per user for now, can be modified)
- INDEX on created_at for sorting
```
### photos table
```sql
- photo_id (INT, PK, AUTO_INCREMENT)
- album_id (INT, FK to photo_albums, CASCADE DELETE)
- file_path (VARCHAR 500, NOT NULL)
- caption (VARCHAR 500, nullable)
- display_order (INT, default 0)
- created_at (TIMESTAMP)
- INDEX on album_id for quick lookups
- INDEX on display_order for sorting
```
## File Structure
### Pages (Public-Facing)
- `src/pages/gallery/gallery.php` - Main carousel view of all albums
- `src/pages/gallery/view_album.php` - Detailed album view with photo grid and lightbox
- `src/pages/gallery/create_album.php` - Form to create new albums and upload initial photos
### Processors (Backend Logic)
- `src/processors/save_album.php` - Creates new album and handles initial photo uploads
- `src/processors/update_album.php` - Updates album metadata and handles additional photo uploads
- `src/processors/delete_album.php` - Deletes entire album with all photos and files
- `src/processors/delete_photo.php` - Deletes individual photos from album
- `src/processors/get_album_photos.php` - API endpoint returning album photos as JSON
### Styling
All styling is embedded in each PHP file using `<style>` tags for consistency with existing pattern.
## Features
### Gallery View (gallery.php)
**Purpose**: Display all photo albums in a carousel format
**Features**:
- Bootstrap carousel with Previous/Next buttons
- Album cards showing:
- Cover image
- Album title
- Description
- Creator avatar and name
- Photo count
- "View Album" button
- "Create Album" button (visible to all members)
- Empty state message for members with no albums
- Responsive design for mobile/tablet/desktop
**Access Control**:
- Members-only (redirects non-members to membership page)
- Verified membership required
### Album Detail View (view_album.php)
**Purpose**: Display all photos from a single album with lightbox viewer
**Features**:
- Album header with:
- Creator information (avatar, name)
- Album title and description
- Photo count
- "Edit Album" button (visible only to album owner)
- Responsive photo grid layout
- Click any photo to open lightbox viewer
- Lightbox features:
- Full-screen image display
- Previous/Next navigation buttons
- Caption display
- Keyboard navigation:
- Arrow Left: Previous photo
- Arrow Right: Next photo
- Escape: Close lightbox
- Close button (X)
- Empty state message with "Add Photos" link for album owner
- "Back to Gallery" button at bottom
**Access Control**:
- Public albums visible to all members
- Edit button visible only to album owner
### Create/Edit Album (create_album.php)
**Purpose**: Create new albums or edit existing albums with photo uploads
**Features**:
- Album title input (required, validates with validateName())
- Description textarea (optional, max 500 characters)
- Drag-and-drop file upload area
- File selection click-to-upload
- Selected files list showing filename and size
- Photo grid showing existing photos (edit mode only)
- Delete button on each existing photo
- Delete album button (edit mode only)
- Submit and Cancel buttons
**File Upload Validation**:
- Allowed formats: JPG, PNG, GIF, WEBP
- Max file size: 5MB per image
- Validates MIME type and file size
- Generates unique filenames with uniqid()
- Stores in `/assets/uploads/gallery/{album_id}/`
**Form Behavior**:
- Create mode: Only allows setting album metadata and initial photos
- Edit mode: Shows existing photos, allows adding new photos, allows editing metadata
- First uploaded photo becomes cover image (auto-selected)
- Photos can be deleted before submission (edit mode)
- Form prepopulation on edit (title, description, existing photos)
**Access Control**:
- Members-only
- Edit form checks album ownership before allowing edits
- Redirect to gallery if not owner of album being edited
## API Endpoints
### GET /get_album_photos
Returns JSON array of photos for an album
**Parameters**:
- `id`: Album ID (GET parameter)
**Response**:
```json
[
{
"photo_id": 1,
"file_path": "/assets/uploads/gallery/1/photo_abc123.jpg",
"caption": "Sample photo",
"display_order": 1
}
]
```
**Access Control**: Members-only, owner of album only
### POST /delete_photo
Deletes a photo and updates album cover if needed
**Parameters**:
- `photo_id`: Photo ID (POST)
- `csrf_token`: CSRF token (POST)
**Response**:
```json
{ "success": true }
```
**Side Effects**:
- Deletes photo file from disk
- Removes photo from database
- Updates album cover image if deleted photo was cover
- Sets cover to first remaining photo or NULL if no photos left
**Access Control**: Members-only, owner of album only
## Workflow Examples
### Creating an Album
1. User clicks "Create Album" button on gallery page
2. Navigates to create_album.php
3. Enters album title (required) and description (optional)
4. Drags and drops or selects multiple photo files
5. Clicks "Create Album" button
6. save_album.php:
- Creates album directory: `/assets/uploads/gallery/{album_id}/`
- Validates each photo (mime type, file size)
- Moves photos to album directory with unique names
- Sets first photo as cover_image
- Inserts album record and photo records in transaction
- Redirects to view_album page for newly created album
### Editing an Album
1. User clicks "Edit Album" button on album view page
2. Navigates to create_album.php?id={album_id}
3. Form prepopulates with current album data
4. Existing photos displayed in grid with delete buttons
5. Can add more photos by uploading new files
6. Clicks "Update Album" button
7. update_album.php:
- Verifies ownership
- Updates album metadata
- Validates and uploads any new photos
- Appends new photos to existing ones (doesn't overwrite)
- Redirects back to view_album
### Deleting a Photo
1. User clicks delete (X) button on photo in edit form
2. JavaScript shows confirmation dialog
3. Sends POST to delete_photo with photo_id and csrf_token
4. delete_photo.php:
- Verifies ownership through album
- Deletes file from disk
- Removes from database
- Updates cover_image if needed
- Returns JSON success response
5. Page reloads to show updated photo list
### Deleting an Album
1. User clicks "Delete Album" button in edit form
2. JavaScript shows confirmation dialog
3. Navigates to delete_album.php?id={album_id}
4. delete_album.php:
- Verifies ownership
- Deletes all photo files from disk
- Deletes all photo records from database
- Deletes album directory
- Deletes album record
- Redirects to gallery page
## URL Routing (.htaccess)
```
/gallery → src/pages/gallery/gallery.php
/create_album → src/pages/gallery/create_album.php
/edit_album → src/pages/gallery/create_album.php (id parameter determines mode)
/view_album → src/pages/gallery/view_album.php
/save_album → src/processors/save_album.php (POST only)
/update_album → src/processors/update_album.php (POST only)
/delete_album → src/processors/delete_album.php (GET with id parameter)
/delete_photo → src/processors/delete_photo.php (POST only)
/get_album_photos → src/processors/get_album_photos.php (GET with id parameter)
```
## Security Features
### Authentication
- All pages check for `$_SESSION['user_id']`
- Non-members redirected to membership page
- Non-authenticated users redirected to login
### Authorization
- Album ownership verified before allowing edits
- Album ownership verified before allowing deletes
- Only album owner can edit or delete photos
- Only album owner can see edit buttons
### Data Validation
- Album title validated with validateName() function
- Description length limited to 500 characters
- File uploads validated for:
- MIME type (only image formats allowed)
- File size (max 5MB)
- File extension check
- Filename sanitized with uniqid() to prevent conflicts
### CSRF Protection
- All forms include csrf_token
- Processors validate CSRF token before processing
- POST-only operations protected
### Transaction Safety
- Album creation uses transaction
- Creates directory
- Inserts album record
- Inserts photo records
- Commits all or rolls back all on error
- Handles cleanup on failure:
- Deletes partial uploads
- Removes album directory
- Removes album record from database
## Error Handling
### Validation Errors
- File too large: "File too large: {filename}"
- Invalid file type: "Invalid file type: {filename}"
- Missing album title: "Album title is required and must be valid"
- Description too long: "Description must be 500 characters or less"
### Permission Errors
- Not authenticated: Redirects to login
- Not a member: Redirects to membership page
- Not album owner: Returns 403 Forbidden with error message
- Album not found: Returns 404 with redirect to gallery
### Upload Errors
- Directory creation failure: "Failed to create album directory"
- File move failure: "Failed to upload: {filename}"
- Database insert failure: HTTP 400 with error message
### Recovery
- All upload errors trigger transaction rollback
- Partial files cleaned up on failure
- Album record deleted if transaction fails
- Directory removed if transaction fails
## Testing Checklist
### Album Creation
- [ ] Create album with title only
- [ ] Create album with title and description
- [ ] Upload single photo to new album
- [ ] Upload multiple photos to new album
- [ ] Verify first photo becomes cover
- [ ] Verify files stored in correct directory
- [ ] Verify album appears in carousel
### Album Editing
- [ ] Edit album title
- [ ] Edit album description
- [ ] Add photos to existing album
- [ ] Add many photos at once (10+)
- [ ] Delete photos from album
- [ ] Delete last photo (cover updates to NULL)
- [ ] Delete album cover, verify new cover assigned
- [ ] Verify edit unavailable for non-owner
### Album Viewing
- [ ] View album as owner
- [ ] View album as other member
- [ ] View album with many photos
- [ ] Photo grid responsive on mobile
- [ ] Photo grid responsive on tablet
- [ ] All photos display correct captions
### Lightbox
- [ ] Open lightbox from first photo
- [ ] Open lightbox from middle photo
- [ ] Open lightbox from last photo
- [ ] Next button navigates forward
- [ ] Previous button navigates backward
- [ ] Next button wraps to first photo from last
- [ ] Previous button wraps to last photo from first
- [ ] Arrow key navigation works
- [ ] Escape key closes lightbox
- [ ] Click X button closes lightbox
- [ ] Photo caption displays correctly
### Gallery Page
- [ ] Carousel displays all albums
- [ ] Previous/Next buttons work
- [ ] Album cards show cover image
- [ ] Album cards show correct photo count
- [ ] Create Album button visible
- [ ] Create Album button navigates correctly
- [ ] Edit button visible only to owner
- [ ] Empty gallery state shows correct message
- [ ] Empty gallery has Create Album link
### Access Control
- [ ] Non-members cannot access gallery
- [ ] Non-members cannot create albums
- [ ] Non-members cannot edit albums
- [ ] Users cannot edit others' albums
- [ ] Users cannot delete others' albums
- [ ] Users cannot delete others' photos
### File Uploads
- [ ] JPG files accepted
- [ ] PNG files accepted
- [ ] GIF files accepted
- [ ] WEBP files accepted
- [ ] BMP files rejected
- [ ] ZIP files rejected
- [ ] Files over 5MB rejected
- [ ] Files exactly 5MB accepted
- [ ] Drag and drop upload works
- [ ] Click-to-upload works
### Database
- [ ] Albums table has correct structure
- [ ] Photos table has correct structure
- [ ] Foreign keys work correctly
- [ ] Cascade delete removes photos when album deleted
- [ ] Unique constraint prevents duplicate user ownership
- [ ] Indexes created for performance
### Navigation
- [ ] Gallery link appears in Members Area menu
- [ ] Gallery link visible only for logged-in users
- [ ] Gallery link locked (with icon) for non-members
- [ ] "View Album" button navigates to album detail
- [ ] "Edit Album" button navigates to edit form
- [ ] "Back to Gallery" button returns to gallery
- [ ] "Add Photos" link in empty album goes to edit form
## Future Enhancements
### Possible Features
1. Multiple albums per user (modify UNIQUE constraint)
2. Album visibility settings (private/members-only/public)
3. Album categories/tags
4. Photo ordering/reordering in album
5. Photo batch operations (delete multiple, move between albums)
6. Album sharing/collaboration
7. Photo comments/ratings
8. Admin gallery management
9. Automatic image optimization/compression
10. Photo metadata preservation (EXIF)
11. Album archives/export
12. Photo search across all albums
### Schema Changes Required
- Remove UNIQUE constraint on user_id to allow multiple albums per user
- Add visibility enum field to photo_albums
- Add category_id FK to photo_albums
- Add user_id to photos for future permission model
- Add updated_at timestamps to photos for tracking
## Deployment Notes
### Pre-Deployment
1. Run migration 003 to create tables
2. Create `/assets/uploads/gallery/` directory with proper permissions
3. Ensure PHP can write to upload directory (755 or 777)
4. Test file upload with valid and invalid files
### Post-Deployment
1. Verify gallery link appears in header menu
2. Test creating first album in production
3. Test file uploads with various image formats
4. Monitor disk space usage for uploads
5. Set up automated cleanup for orphaned files (if needed)
### Permissions
```
/assets/uploads/gallery/
Permissions: 755 (rwxr-xr-x)
Owner: web server user
Individual album directories:
Permissions: 755 (rwxr-xr-x)
Created automatically by application
Photo files:
Permissions: 644 (rw-r--r--)
Created automatically by application
```
### Backups
- Include `/assets/uploads/gallery/` in backup routine
- Include `photo_albums` and `photos` tables in database backups
- Consider separate backup for large image files
## Known Limitations
1. **One album per user**: Current schema design with UNIQUE constraint on user_id allows only one album per user. Can be modified if multiple albums per user needed.
2. **No album visibility control**: All member albums are visible to all members. Could add privacy settings in future.
3. **No photo ordering UI**: Photos ordered by display_order but no UI to reorder them. Captions show filename by default.
4. **No album categories**: All albums mixed in one carousel. Could add filtering/categories.
5. **Image optimization**: No automatic compression/optimization. Large images stored as-is.
6. **No EXIF data**: Photo metadata stripped during upload. Could preserve orientation/metadata.
## Troubleshooting
### Photos not uploading
- Check `/assets/uploads/gallery/` exists and is writable
- Verify file sizes under 5MB
- Confirm image MIME types are jpeg, png, gif, or webp
- Check PHP error logs for upload errors
### Album cover not updating
- Verify cover_image field in database
- Check if photo file path stored correctly
- Confirm image file exists on disk
### Lightbox not opening
- Check browser console for JavaScript errors
- Verify image paths are accessible
- Confirm file URLs accessible directly
### Permission denied errors
- Check album owner verification logic
- Verify CSRF tokens being passed correctly
- Confirm user_id matches in session
### Memory issues with large uploads
- Reduce PHP memory_limit if needed
- Split large batches of photos into smaller uploads
- Consider image optimization/compression

View File

@@ -0,0 +1,37 @@
-- Migration: Add UNIQUE constraints to prevent duplicate membership applications and fees
-- Date: 2025-12-05
-- Purpose: Ensure each user can only have one application and one membership fee record
-- Note: Individual payments are tracked in the payments/efts table, not here
-- Add UNIQUE constraint to membership_application table
-- First, delete any duplicate applications keeping the most recent one
DELETE FROM membership_application
WHERE application_id NOT IN (
SELECT MAX(application_id)
FROM (
SELECT application_id
FROM membership_application
) tmp
GROUP BY user_id
);
-- Add UNIQUE constraint on user_id in membership_application
ALTER TABLE membership_application
ADD CONSTRAINT uk_membership_application_user_id UNIQUE (user_id);
-- Add UNIQUE constraint to membership_fees table
-- First, delete any duplicate fees keeping the most recent one
DELETE FROM membership_fees
WHERE fee_id NOT IN (
SELECT MAX(fee_id)
FROM (
SELECT fee_id
FROM membership_fees
) tmp
GROUP BY user_id
);
-- Add UNIQUE constraint on user_id in membership_fees
ALTER TABLE membership_fees
ADD CONSTRAINT uk_membership_fees_user_id UNIQUE (user_id);

View File

@@ -0,0 +1,30 @@
-- Migration: Create photo gallery tables for member photo albums
-- Date: 2025-12-05
-- Purpose: Allow members to create albums and upload photos to share with the community
-- Create photo_albums table
CREATE TABLE IF NOT EXISTS photo_albums (
album_id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
cover_image VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
INDEX idx_user_id (user_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- Create photos table
CREATE TABLE IF NOT EXISTS photos (
photo_id INT PRIMARY KEY AUTO_INCREMENT,
album_id INT NOT NULL,
file_path VARCHAR(255) NOT NULL,
caption VARCHAR(255),
display_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (album_id) REFERENCES photo_albums(album_id) ON DELETE CASCADE,
INDEX idx_album_id (album_id),
INDEX idx_display_order (display_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

View File

@@ -296,15 +296,23 @@ if ($headerStyle === 'light') {
</li>
<?php } ?>
<li><a href="contact">Contact</a></li>
<?php if ($is_member) : ?>
<?php if ($is_logged_in) : ?>
<li class="dropdown"><a href="#">Members Area</a>
<ul>
<li><a href="#">Coming Soon!</a></li>
<?php
if (getUserMemberStatus($_SESSION['user_id'])) {
echo "<li><a href=\"campsites\">Campsites Directory</a></li>";
echo "<li><a href=\"gallery\">Photo Gallery</a></li>";
} else {
echo "<li><a href=\"membership\">Campsites Directory</a><i class='fal fa-lock'></i></li>";
echo "<li><a href=\"membership\">Photo Gallery</a><i class='fal fa-lock'></i></li>";
}
?>
</ul>
</li>
<?php endif; ?>
<?php if ($is_logged_in) : ?>
<li class="dropdown"><a href="#">My Account</a>
<ul>
<li><a href="account_settings">Account Settings</a></li>

View File

@@ -224,25 +224,29 @@ if ($result && $result->num_rows > 0) {
event_id: eventId
},
dataType: 'json',
success: function(response) {
if (response.status === 'success') {
if (response.published == 1) {
button.removeClass('btn-success').addClass('btn-warning');
button.find('i').removeClass('fa-eye').addClass('fa-eye-slash');
button.attr('title', 'Unpublish');
row.find('td:nth-child(5)').html('<span class="badge bg-success">Published</span>');
complete: function(xhr, status) {
// Handle all response codes
try {
var response = JSON.parse(xhr.responseText);
if (response.status === 'success') {
if (response.published == 1) {
button.removeClass('btn-success').addClass('btn-warning');
button.find('i').removeClass('fa-eye').addClass('fa-eye-slash');
button.attr('title', 'Unpublish');
row.find('td:nth-child(5)').html('<span class="badge bg-success">Published</span>');
} else {
button.removeClass('btn-warning').addClass('btn-success');
button.find('i').removeClass('fa-eye-slash').addClass('fa-eye');
button.attr('title', 'Publish');
row.find('td:nth-child(5)').html('<span class="badge bg-warning">Draft</span>');
}
} else {
button.removeClass('btn-warning').addClass('btn-success');
button.find('i').removeClass('fa-eye-slash').addClass('fa-eye');
button.attr('title', 'Publish');
row.find('td:nth-child(5)').html('<span class="badge bg-warning">Draft</span>');
alert('Error: ' + response.message);
}
} else {
alert('Error: ' + response.message);
} catch (e) {
alert('Error updating event status. Response: ' + xhr.responseText);
}
},
error: function() {
alert('Error updating event status');
}
});
});

View File

@@ -1,9 +1,21 @@
<?php
// Set JSON header FIRST before any includes that might output
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
// Clean any output buffers before including header
while (ob_get_level() > 0) {
ob_end_clean();
}
$rootPath = dirname(dirname(__DIR__));
include_once($rootPath . '/header.php');
checkAdmin();
header('Content-Type: application/json');
// Clean output buffer again in case header.php added content
ob_clean();
$event_id = $_POST['event_id'] ?? null;
@@ -44,6 +56,7 @@ try {
$update_stmt->bind_param("ii", $new_status, $event_id);
if ($update_stmt->execute()) {
ob_clean(); // Clean any buffered output before sending JSON
http_response_code(200);
echo json_encode([
'status' => 'success',
@@ -55,6 +68,7 @@ try {
}
$update_stmt->close();
} catch (Exception $e) {
ob_clean(); // Clean any buffered output before sending JSON
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'Database error: ' . $e->getMessage()]);
}

View File

@@ -1434,6 +1434,10 @@ function checkMembershipApplication($user_id)
// Check if the record exists and redirect
if ($count > 0) {
// Set a session message before redirecting
if (!isset($_SESSION['message'])) {
$_SESSION['message'] = 'You have already submitted a membership application.';
}
header("Location: membership_details.php");
exit();
}
@@ -2195,8 +2199,8 @@ function validateName($name, $minLength = 2, $maxLength = 100) {
return false;
}
// Only allow letters, spaces, hyphens, and apostrophes
if (!preg_match('/^[a-zA-Z\s\'-]+$/', $name)) {
// Allow letters, numbers, spaces, hyphens, and apostrophes
if (!preg_match('/^[a-zA-Z0-9\s\'-]+$/', $name)) {
return false;
}

View File

@@ -3,6 +3,18 @@ $headerStyle = 'light';
$rootPath = dirname(dirname(dirname(__DIR__)));
include_once($rootPath . '/header.php');
// Check if user has active membership
if (!isset($_SESSION['user_id'])) {
header('Location: login');
exit;
}
$is_member = getUserMemberStatus($_SESSION['user_id']);
if (!$is_member) {
header('Location: index');
exit;
}
$conn = openDatabaseConnection();
$stmt = $conn->prepare("SELECT * FROM campsites");
$stmt->execute();
@@ -17,14 +29,72 @@ while ($row = $result->fetch_assoc()) {
#map {
height: 600px;
width: 100%;
position: relative;
}
.gm-style .info-box {
max-width: 250px;
/* Center pin overlay */
.map-center-pin {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -100%);
z-index: 10;
pointer-events: none;
font-size: 48px;
}
.info-box img {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
/* Location mode indicator */
.location-mode-indicator {
position: absolute;
top: 20px;
left: 20px;
background: #4CAF50;
color: white;
padding: 12px 20px;
border-radius: 6px;
z-index: 11;
font-weight: 500;
display: none;
}
/* Confirm location button */
.confirm-location-btn {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #4CAF50;
color: white;
padding: 12px 30px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
z-index: 11;
display: none;
}
.confirm-location-btn:hover {
background: #45a049;
}
.cancel-location-btn {
position: absolute;
bottom: 20px;
left: 20px;
background: #f44336;
color: white;
padding: 12px 30px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
z-index: 11;
display: none;
}
.cancel-location-btn:hover {
background: #da190b;
}
/* Form styling to match manage_trips */
@@ -159,7 +229,7 @@ while ($row = $result->fetch_assoc()) {
</style>
<?php
$pageTitle = 'Campsites';
$pageTitle = 'Campsites Directory';
$breadcrumbs = [['Home' => 'index.php']];
require_once($rootPath . '/components/banner.php');
?>
@@ -170,11 +240,29 @@ require_once($rootPath . '/components/banner.php');
<div class="col-lg-12">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3>Campsites Map</h3>
<button class="theme-btn" id="toggleFormBtn" onclick="toggleCampsiteForm()">
<button class="theme-btn" id="toggleFormBtn" onclick="startLocationMode()">
<i class="far fa-plus"></i> Add Campsite
</button>
</div>
<p style="color: #666; margin-bottom: 15px;">Click on the map to add a new campsite, or click on a marker to view details.</p>
<p style="color: #666; margin-bottom: 15px;">Click on a marker to view details, or use the "Add Campsite" button to add a new location.</p>
<!-- Map with location mode UI -->
<div style="position: relative; margin-bottom: 20px;">
<div id="map" style="width: 100%; height: 500px;"></div>
<!-- Location Mode Indicator -->
<div class="location-mode-indicator">
📍 Position the map center pin over your campsite location
</div>
<!-- Confirm and Cancel Buttons -->
<button type="button" class="confirm-location-btn" onclick="confirmLocation()">
✓ Confirm Location
</button>
<button type="button" class="cancel-location-btn" onclick="cancelLocationMode()">
✕ Cancel
</button>
</div>
<!-- Collapsible Campsite Form -->
<div class="campsite-form-container" id="campsiteFormContainer">
@@ -270,8 +358,6 @@ require_once($rootPath . '/components/banner.php');
</form>
</div>
<div id="map" style="width: 100%; height: 500px;"></div>
<!-- Campsites Table -->
<div style="margin-top: 40px;">
<h4 style="margin-bottom: 20px;">All Campsites</h4>
@@ -282,7 +368,7 @@ require_once($rootPath . '/components/banner.php');
<tr>
<th>Name</th>
<th>Description</th>
<th>Website</th>
<th>Booking Website</th>
<th>Phone</th>
<th>Added By</th>
<th>Actions</th>
@@ -302,9 +388,113 @@ require_once($rootPath . '/components/banner.php');
<script>
let map;
let centerPinMarker;
let isLocationMode = false;
const currentUserId = <?php echo $_SESSION['user_id']; ?>;
const campsites = <?php echo json_encode($campsites); ?>;
function startLocationMode() {
if (isLocationMode) return;
isLocationMode = true;
// Show location mode UI elements
document.querySelector(".location-mode-indicator").style.display = "block";
document.querySelector(".confirm-location-btn").style.display = "block";
document.querySelector(".cancel-location-btn").style.display = "block";
document.getElementById("toggleFormBtn").disabled = true;
// Create invisible marker at map center
const mapCenter = map.getCenter();
centerPinMarker = new google.maps.Marker({
position: mapCenter,
map: map,
title: "Campsite Location",
draggable: true,
icon: 'http://maps.google.com/mapfiles/ms/icons/red-dot.png'
});
// Update coordinates when marker is dragged
centerPinMarker.addListener('drag', function() {
const position = centerPinMarker.getPosition();
updateCoordinatesDisplay(position.lat(), position.lng());
});
// Set initial coordinates
updateCoordinatesDisplay(mapCenter.lat(), mapCenter.lng());
// Update coordinates when map is moved
const moveListener = map.addListener('center_changed', function() {
const mapCenter = map.getCenter();
centerPinMarker.setPosition(mapCenter);
updateCoordinatesDisplay(mapCenter.lat(), mapCenter.lng());
});
// Store listener for cleanup
window.mapMoveListener = moveListener;
}
function updateCoordinatesDisplay(lat, lng) {
document.getElementById("latitude").value = lat;
document.getElementById("longitude").value = lng;
document.getElementById("latitude_display").value = lat.toFixed(6);
document.getElementById("longitude_display").value = lng.toFixed(6);
}
function confirmLocation() {
if (!isLocationMode) return;
isLocationMode = false;
// Hide location mode UI elements
document.querySelector(".location-mode-indicator").style.display = "none";
document.querySelector(".confirm-location-btn").style.display = "none";
document.querySelector(".cancel-location-btn").style.display = "none";
document.getElementById("toggleFormBtn").disabled = false;
// Remove map move listener
if (window.mapMoveListener) {
google.maps.event.removeListener(window.mapMoveListener);
}
// Remove the center marker
if (centerPinMarker) {
centerPinMarker.setMap(null);
centerPinMarker = null;
}
// Reset form fields and show form (for new campsite only)
resetFormForNewCampsite();
document.getElementById("campsiteFormContainer").style.display = "block";
document.getElementById("campsiteFormContainer").scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function cancelLocationMode() {
if (!isLocationMode) return;
isLocationMode = false;
// Hide location mode UI elements
document.querySelector(".location-mode-indicator").style.display = "none";
document.querySelector(".confirm-location-btn").style.display = "none";
document.querySelector(".cancel-location-btn").style.display = "none";
document.getElementById("toggleFormBtn").disabled = false;
// Remove map move listener
if (window.mapMoveListener) {
google.maps.event.removeListener(window.mapMoveListener);
}
// Remove the center marker
if (centerPinMarker) {
centerPinMarker.setMap(null);
centerPinMarker = null;
}
}
function toggleCampsiteForm() {
if (isLocationMode) return;
const container = document.getElementById("campsiteFormContainer");
container.style.display = container.style.display === "none" ? "block" : "none";
if (container.style.display === "block") {
@@ -312,14 +502,40 @@ require_once($rootPath . '/components/banner.php');
}
}
function resetForm() {
// Clear the form
document.getElementById("addCampsiteForm").reset();
function resetFormForNewCampsite() {
// This is called when confirming location for a NEW campsite
// Only clears text fields and removes ID, but keeps country/province selections
document.querySelector("#addCampsiteForm input[name='name']").value = '';
document.querySelector("#addCampsiteForm textarea[name='description']").value = '';
document.querySelector("#addCampsiteForm input[name='website']").value = '';
document.querySelector("#addCampsiteForm input[name='telephone']").value = '';
// Remove the ID input if it exists
let idInput = document.querySelector("#addCampsiteForm input[name='id']");
if (idInput) {
idInput.remove();
}
// Change form heading
document.querySelector("#campsiteFormContainer h5").textContent = "Add New Campsite";
}
function resetForm() {
// This is called when canceling the form - fully resets everything
document.querySelector("#campsiteFormContainer h5").textContent = "Add New Campsite";
// Clear the form completely
document.getElementById("addCampsiteForm").reset();
// Remove the ID input if it exists
let idInput = document.querySelector("#addCampsiteForm input[name='id']");
if (idInput) {
idInput.remove();
}
// Clear coordinate displays
document.getElementById("latitude_display").value = '';
document.getElementById("longitude_display").value = '';
}
function initMap() {
@@ -327,25 +543,10 @@ require_once($rootPath . '/components/banner.php');
center: {
lat: -28.0,
lng: 24.0
}, // SA center
},
zoom: 6,
});
map.addListener("click", function(e) {
const lat = e.latLng.lat();
const lng = e.latLng.lng();
resetForm();
document.getElementById("latitude").value = lat;
document.getElementById("longitude").value = lng;
document.getElementById("latitude_display").value = lat.toFixed(6);
document.getElementById("longitude_display").value = lng.toFixed(6);
// Show the form container
document.getElementById("campsiteFormContainer").style.display = "block";
document.getElementById("campsiteFormContainer").scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
// Load existing campsites from PHP
fetch("get_campsites")
.then(response => response.json())
@@ -366,7 +567,7 @@ require_once($rootPath . '/components/banner.php');
${site.description ? site.description + "<br>" : ""}
${site.website ? `<a href="${site.website}" target="_blank">Visit Website</a><br>` : ""}
${site.telephone ? `Phone: ${site.telephone}<br>` : ""}
${site.thumbnail ? `<img src="${site.thumbnail}" style="width: 100%; max-width: 200px; border-radius: 8px; margin-top: 5px;">` : ""}
${site.thumbnail ? `<img src="${site.thumbnail}" style="width: 100%; max-width: 200px; border-radius: 8px; margin-top: 5px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);">` : ""}
${site.user && site.user.first_name ? `
<div class="user-info mt-2 d-flex align-items-center">
<img src="${site.user.profile_pic}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; margin-right: 10px;">
@@ -451,6 +652,11 @@ require_once($rootPath . '/components/banner.php');
? `${site.user.first_name} ${site.user.last_name}`
: "Unknown";
// Only show edit button if current user is the owner
const editButtonHTML = site.user_id == currentUserId
? `<button class="btn btn-sm btn-warning" onclick='editCampsite(${JSON.stringify(site)})'>Edit</button>`
: '';
row.innerHTML = `
<td><strong>${site.name}</strong></td>
<td>${site.description ? site.description.substring(0, 50) + (site.description.length > 50 ? '...' : '') : '-'}</td>
@@ -458,7 +664,7 @@ require_once($rootPath . '/components/banner.php');
<td>${site.telephone || '-'}</td>
<td><small>${userName}</small></td>
<td>
<button class="btn btn-sm btn-warning" onclick='editCampsite(${JSON.stringify(site)})'>Edit</button>
${editButtonHTML}
<a href="https://www.google.com/maps/dir/?api=1&destination=${site.latitude},${site.longitude}" target="_blank" class="btn btn-sm btn-outline-primary">Directions</a>
</td>
`;
@@ -469,32 +675,89 @@ require_once($rootPath . '/components/banner.php');
}
function editCampsite(site) {
// Pre-fill form
document.querySelector("#addCampsiteForm input[name='name']").value = site.name;
document.querySelector("#addCampsiteForm select[name='country']").value = site.country || '';
document.querySelector("#addCampsiteForm select[name='province']").value = site.province || '';
document.querySelector("#addCampsiteForm textarea[name='description']").value = site.description || "";
document.querySelector("#addCampsiteForm input[name='website']").value = site.website || "";
document.querySelector("#addCampsiteForm input[name='telephone']").value = site.telephone || "";
document.querySelector("#addCampsiteForm input[name='latitude']").value = site.latitude;
document.querySelector("#addCampsiteForm input[name='longitude']").value = site.longitude;
document.getElementById("latitude_display").value = parseFloat(site.latitude).toFixed(6);
document.getElementById("longitude_display").value = parseFloat(site.longitude).toFixed(6);
// Change form heading to indicate editing
document.querySelector("#campsiteFormContainer h5").textContent = "Edit Campsite";
// Add hidden ID input
let idInput = document.querySelector("#addCampsiteForm input[name='id']");
if (!idInput) {
idInput = document.createElement("input");
idInput.type = "hidden";
idInput.name = "id";
document.querySelector("#addCampsiteForm").appendChild(idInput);
}
idInput.value = site.id;
// Pre-fill form with a slight delay to ensure DOM is ready
setTimeout(() => {
document.querySelector("#addCampsiteForm input[name='name']").value = site.name;
document.querySelector("#addCampsiteForm textarea[name='description']").value = site.description || "";
document.querySelector("#addCampsiteForm input[name='website']").value = site.website || "";
document.querySelector("#addCampsiteForm input[name='telephone']").value = site.telephone || "";
document.querySelector("#addCampsiteForm input[name='latitude']").value = site.latitude;
document.querySelector("#addCampsiteForm input[name='longitude']").value = site.longitude;
document.getElementById("latitude_display").value = parseFloat(site.latitude).toFixed(6);
document.getElementById("longitude_display").value = parseFloat(site.longitude).toFixed(6);
// Set country and province LAST to ensure they stick
document.querySelector("#addCampsiteForm select[name='country']").value = site.country || '';
document.querySelector("#addCampsiteForm select[name='province']").value = site.province || '';
// Add hidden ID input
let idInput = document.querySelector("#addCampsiteForm input[name='id']");
if (!idInput) {
idInput = document.createElement("input");
idInput.type = "hidden";
idInput.name = "id";
document.querySelector("#addCampsiteForm").appendChild(idInput);
}
idInput.value = site.id;
}, 0);
// Show the form container
document.getElementById("campsiteFormContainer").style.display = "block";
document.getElementById("campsiteFormContainer").scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function filterCampsites() {
const filterInput = document.getElementById("campsitesFilter");
const filterValue = filterInput.value.toLowerCase();
const tableBody = document.getElementById("campsitesTableBody");
const rows = tableBody.getElementsByTagName("tr");
let visibleRows = 0;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const text = row.textContent.toLowerCase();
// Show rows that match the filter or are group headers
if (text.includes(filterValue) || row.innerHTML.includes('fas fa-globe')) {
row.style.display = "";
if (row.innerHTML.includes('fas fa-globe') === false) {
visibleRows++;
}
} else {
row.style.display = "none";
}
}
// Hide group headers if no campsites match in that group
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.innerHTML.includes('fas fa-globe')) {
// Check if next visible row is also a header
let hasVisibleChildren = false;
for (let j = i + 1; j < rows.length; j++) {
if (rows[j].style.display !== "none") {
if (!rows[j].innerHTML.includes('fas fa-globe')) {
hasVisibleChildren = true;
}
break;
}
}
row.style.display = hasVisibleChildren ? "" : "none";
}
}
}
// Add filter event listener when page loads
document.addEventListener("DOMContentLoaded", function() {
const filterInput = document.getElementById("campsitesFilter");
if (filterInput) {
filterInput.addEventListener("keyup", filterCampsites);
}
});
</script>
<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyC-JuvnbUYc8WGjQBFFVZtKiv5_bFJoWLU&callback=initMap" async defer></script>

View File

@@ -0,0 +1,455 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(dirname(__DIR__)));
include_once($rootPath . '/header.php');
// Check if user has active membership
if (!isset($_SESSION['user_id'])) {
header('Location: login');
exit;
}
$is_member = getUserMemberStatus($_SESSION['user_id']);
if (!$is_member) {
header('Location: index');
exit;
}
$conn = openDatabaseConnection();
$album = null;
// Check if editing existing album
$album_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
if ($album_id > 0) {
$stmt = $conn->prepare("SELECT * FROM photo_albums WHERE album_id = ? AND user_id = ?");
$stmt->bind_param("ii", $album_id, $_SESSION['user_id']);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$album = $result->fetch_assoc();
}
$stmt->close();
if (!$album) {
$conn->close();
header('Location: gallery');
exit;
}
}
$conn->close();
$pageTitle = $album ? 'Edit Album' : 'Create Album';
$breadcrumbs = [['Home' => 'index.php'], ['Gallery' => 'gallery']];
require_once($rootPath . '/components/banner.php');
?>
<style>
.form-container {
background: #f9f9f7;
border: 1px solid #d8d8d8;
border-radius: 10px;
padding: 40px;
max-width: 600px;
margin: 0 auto;
}
.form-group {
margin-bottom: 25px;
}
.form-group label {
display: block;
font-weight: 600;
color: #2c3e50;
margin-bottom: 8px;
}
.form-group input[type="text"],
.form-group textarea {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
}
.form-group input[type="text"]:focus,
.form-group textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group textarea {
resize: vertical;
min-height: 120px;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 30px;
}
.form-actions button,
.form-actions a {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
text-align: center;
transition: background 0.3s;
}
.btn-submit {
background: #667eea;
color: white;
}
.btn-submit:hover {
background: #764ba2;
}
.btn-cancel {
background: #ddd;
color: #333;
}
.btn-cancel:hover {
background: #ccc;
}
.photos-section {
margin-top: 40px;
padding-top: 40px;
border-top: 2px solid #d8d8d8;
}
.photos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
margin-top: 15px;
}
.photo-item-edit {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
background: white;
border: 1px solid #ddd;
}
.photo-item-edit img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-delete-btn {
position: absolute;
top: 5px;
right: 5px;
background: #f44336;
color: white;
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.3s;
}
.photo-delete-btn:hover {
background: #d32f2f;
}
.upload-area {
border: 2px dashed #667eea;
border-radius: 8px;
padding: 30px;
text-align: center;
cursor: pointer;
transition: background 0.3s;
}
.upload-area:hover {
background: rgba(102, 126, 234, 0.05);
}
.upload-area.dragover {
background: rgba(102, 126, 234, 0.1);
border-color: #764ba2;
}
.upload-input {
display: none;
}
.upload-text {
color: #667eea;
font-weight: 500;
}
.helper-text {
font-size: 0.9rem;
color: #999;
margin-top: 5px;
}
.cover-preview-area {
margin-bottom: 15px;
}
.current-cover {
border-radius: 8px;
overflow: hidden;
margin-bottom: 15px;
}
.current-cover img {
width: 100%;
max-height: 250px;
object-fit: cover;
display: block;
}
#coverUploadArea {
cursor: pointer;
}
</style>
<section class="tour-list-page py-100 rel">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div class="form-container">
<h2 style="margin-top: 0; color: #2c3e50;"><?php echo $album ? 'Edit Album' : 'Create Album'; ?></h2>
<form id="albumForm" method="POST" action="<?php echo $album ? 'update_album' : 'save_album'; ?>" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?php echo generateCSRFToken(); ?>">
<?php if ($album): ?>
<input type="hidden" name="album_id" value="<?php echo $album['album_id']; ?>">
<?php endif; ?>
<div class="form-group">
<label for="title">Album Title *</label>
<input type="text" id="title" name="title" required value="<?php echo $album ? htmlspecialchars($album['title']) : ''; ?>">
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" placeholder="Add a description for your album..."><?php echo $album ? htmlspecialchars($album['description']) : ''; ?></textarea>
<div class="helper-text">Optional: Share details about when, where, or why you created this album</div>
</div>
<div class="form-group">
<label for="cover_image">Album Cover Image</label>
<div class="cover-preview-area">
<?php if ($album && $album['cover_image']): ?>
<div class="current-cover">
<img id="currentCoverImg" src="<?php echo htmlspecialchars($album['cover_image']); ?>" alt="Current cover">
<p style="margin-top: 10px; font-size: 0.9rem; color: #666;">Current cover image</p>
</div>
<?php else: ?>
<div id="currentCoverImg" style="width: 100%; height: 200px; background: #f0f0f0; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: #999; margin-bottom: 15px;">No cover image yet</div>
<?php endif; ?>
</div>
<div class="upload-area" id="coverUploadArea" style="margin-top: 15px;">
<input type="file" id="cover_image" name="cover_image" accept="image/*" class="upload-input">
<div style="font-size: 1.5rem; margin-bottom: 10px;">🖼️</div>
<p class="upload-text">Click to select cover image</p>
<div class="helper-text">Image will be used as album thumbnail. Recommended: Square image (500x500px or larger)</div>
</div>
<div id="coverFileName" style="margin-top: 10px;"></div>
</div>
<?php if ($album): ?>
<div class="photos-section">
<h4>Photos in Album</h4>
<div class="photos-grid" id="photosGrid">
<!-- Photos will be loaded here -->
</div>
</div>
<?php endif; ?>
<div class="form-group">
<label for="photos">Upload Photos</label>
<div class="upload-area" id="uploadArea">
<input type="file" id="photos" name="photos[]" multiple accept="image/*" class="upload-input">
<div style="font-size: 2rem; margin-bottom: 10px;">📸</div>
<p class="upload-text">Drag and drop photos here or click to select</p>
<div class="helper-text">Supports JPG, PNG, GIF, WEBP. Max 5MB per image</div>
</div>
</div>
<div id="fileList" style="margin-top: 15px;"></div>
<div class="form-actions">
<button type="submit" class="btn-submit">
<?php echo $album ? 'Update Album' : 'Create Album'; ?>
</button>
<a href="gallery" class="btn-cancel">Cancel</a>
</div>
</form>
<?php if ($album): ?>
<div style="margin-top: 40px; padding-top: 40px; border-top: 2px solid #d8d8d8;">
<button type="button" onclick="deleteAlbum(<?php echo $album['album_id']; ?>)" class="btn-delete" style="background: #f44336; color: white; padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer; width: 100%;">
<i class="far fa-trash"></i> Delete Album
</button>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
</section>
<script>
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('photos');
const fileList = document.getElementById('fileList');
const coverUploadArea = document.getElementById('coverUploadArea');
const coverImageInput = document.getElementById('cover_image');
const coverFileName = document.getElementById('coverFileName');
// Cover image handling
coverUploadArea.addEventListener('click', () => {
coverImageInput.click();
});
coverImageInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (event) => {
const preview = document.getElementById('currentCoverImg');
if (preview.tagName === 'IMG') {
preview.src = event.target.result;
} else {
const img = document.createElement('img');
img.src = event.target.result;
img.alt = 'Cover preview';
preview.replaceWith(img);
img.id = 'currentCoverImg';
}
};
reader.readAsDataURL(file);
coverFileName.innerHTML = '<p style="color: #667eea; font-weight: 500;">Selected: ' + file.name + ' (' + (file.size / 1024 / 1024).toFixed(2) + ' MB)</p>';
}
});
// Drag and drop for cover
coverUploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
coverUploadArea.classList.add('dragover');
});
coverUploadArea.addEventListener('dragleave', () => {
coverUploadArea.classList.remove('dragover');
});
coverUploadArea.addEventListener('drop', (e) => {
e.preventDefault();
coverUploadArea.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
coverImageInput.files = e.dataTransfer.files;
const event = new Event('change', { bubbles: true });
coverImageInput.dispatchEvent(event);
}
});
// Regular photos drag and drop
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
fileInput.files = e.dataTransfer.files;
updateFileList();
});
uploadArea.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', updateFileList);
function updateFileList() {
fileList.innerHTML = '';
if (fileInput.files.length > 0) {
fileList.innerHTML = '<p style="color: #667eea; font-weight: 500; margin-bottom: 10px;">Selected files:</p>';
const ul = document.createElement('ul');
ul.style.margin = '0';
ul.style.paddingLeft = '20px';
for (let file of fileInput.files) {
const li = document.createElement('li');
li.textContent = file.name + ' (' + (file.size / 1024 / 1024).toFixed(2) + ' MB)';
li.style.color = '#666';
li.style.marginBottom = '5px';
ul.appendChild(li);
}
fileList.appendChild(ul);
}
}
function deleteAlbum(albumId) {
if (confirm('Are you sure you want to delete this album and all its photos? This action cannot be undone.')) {
window.location.href = 'delete_album?id=' + albumId;
}
}
// Load existing photos if editing
<?php if ($album): ?>
fetch('get_album_photos?id=<?php echo $album['album_id']; ?>')
.then(r => r.json())
.then(photos => {
const grid = document.getElementById('photosGrid');
photos.forEach(photo => {
const div = document.createElement('div');
div.className = 'photo-item-edit';
div.innerHTML = `
<img src="${photo.file_path}" alt="Photo">
<button type="button" class="photo-delete-btn" onclick="deletePhoto(${photo.photo_id})">✕</button>
`;
grid.appendChild(div);
});
});
function deletePhoto(photoId) {
if (confirm('Delete this photo?')) {
fetch('delete_photo', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'photo_id=' + photoId + '&csrf_token=<?php echo generateCSRFToken(); ?>'
}).then(() => location.reload());
}
}
<?php endif; ?>
</script>
<?php include_once(dirname(dirname(dirname(__DIR__))) . '/components/insta_footer.php'); ?>

View File

@@ -0,0 +1,319 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(dirname(__DIR__)));
include_once($rootPath . '/header.php');
// Check if user has active membership
if (!isset($_SESSION['user_id'])) {
header('Location: login');
exit;
}
$is_member = getUserMemberStatus($_SESSION['user_id']);
if (!$is_member) {
header('Location: index');
exit;
}
$conn = openDatabaseConnection();
$current_user_id = $_SESSION['user_id'];
// Fetch all albums with creator information
$albums_query = "
SELECT
pa.album_id,
pa.title,
pa.description,
pa.cover_image,
pa.created_at,
u.user_id,
u.first_name,
u.last_name,
u.profile_pic,
COUNT(p.photo_id) as photo_count
FROM photo_albums pa
INNER JOIN users u ON pa.user_id = u.user_id
LEFT JOIN photos p ON pa.album_id = p.album_id
GROUP BY pa.album_id
ORDER BY pa.created_at DESC
";
$result = $conn->query($albums_query);
$albums = [];
if ($result && $result->num_rows > 0) {
while ($row = $result->fetch_assoc()) {
$albums[] = $row;
}
}
$conn->close();
?>
<style>
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
margin: 30px 0;
}
.album-card {
position: relative;
border-radius: 12px;
overflow: hidden;
background: white;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
display: flex;
flex-direction: column;
height: 100%;
}
.album-card:hover {
transform: translateY(-8px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.album-image-wrapper {
position: relative;
width: 100%;
aspect-ratio: 16 / 10;
overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.album-image-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.album-card:hover .album-image-wrapper img {
transform: scale(1.05);
}
.album-image-wrapper .no-image {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
color: rgba(255, 255, 255, 0.6);
}
.album-footer {
padding: 16px;
background: white;
display: flex;
flex-direction: column;
gap: 12px;
flex-grow: 1;
}
.album-title {
font-size: 1.1rem;
font-weight: 700;
color: #2c3e50;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.album-creator-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.album-creator-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.album-creator-name {
font-size: 0.8rem;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album-photo-count {
font-size: 0.8rem;
color: #999;
flex-shrink: 0;
}
.album-actions {
display: flex;
gap: 8px;
margin-top: auto;
}
.album-view-btn {
flex: 1;
padding: 8px 12px;
background: #667eea;
color: white;
border: none;
border-radius: 30px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
text-decoration: none;
text-align: center;
display: block;
}
.album-view-btn:hover {
background: #764ba2;
text-decoration: none;
color: white;
}
.album-edit-btn {
padding: 8px 12px;
background: white;
color: #667eea;
border: 1px solid #667eea;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
text-align: center;
}
.album-edit-btn:hover {
background: #667eea;
color: white;
text-decoration: none;
}
.create-album-btn {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 30px;
}
.no-albums {
text-align: center;
padding: 80px 20px;
background: #f9f9f7;
border-radius: 10px;
color: #999;
}
.no-albums-icon {
font-size: 3rem;
margin-bottom: 20px;
color: #ddd;
}
.no-albums p {
font-size: 1.1rem;
margin-bottom: 20px;
color: #666;
}
.no-albums .theme-btn {
display: inline-block;
}
</style>
<?php
$pageTitle = 'Photo Gallery';
$breadcrumbs = [['Home' => 'index.php'], ['Members Area' => '#']];
require_once($rootPath . '/components/banner.php');
?>
<section class="tour-list-page py-100 rel">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px;">
<h2 style="margin: 0;">Member Photo Gallery</h2>
<a href="create_album" class="theme-btn create-album-btn">
<i class="far fa-plus"></i> Create Album
</a>
</div>
<?php if (count($albums) > 0): ?>
<div class="gallery-grid">
<?php foreach ($albums as $album): ?>
<div class="album-card">
<div class="album-image-wrapper">
<?php if ($album['cover_image']): ?>
<img src="<?php echo htmlspecialchars($album['cover_image']); ?>" alt="<?php echo htmlspecialchars($album['title']); ?>">
<?php else: ?>
<div class="no-image">
<i class="far fa-image"></i>
</div>
<?php endif; ?>
</div>
<div class="album-footer">
<h3 class="album-title" title="<?php echo htmlspecialchars($album['title']); ?>">
<?php echo htmlspecialchars($album['title']); ?>
</h3>
<div class="album-meta-row">
<div class="album-creator-info">
<img src="<?php echo htmlspecialchars($album['profile_pic']); ?>" alt="<?php echo htmlspecialchars($album['first_name']); ?>" class="album-creator-avatar">
<span class="album-creator-name">
<?php echo htmlspecialchars($album['first_name'] . ' ' . $album['last_name']); ?>
</span>
</div>
<span class="album-photo-count">
<?php echo $album['photo_count']; ?> photo<?php echo $album['photo_count'] !== 1 ? 's' : ''; ?>
</span>
</div>
<div class="album-actions">
<a href="view_album?id=<?php echo $album['album_id']; ?>" class="album-view-btn">
View
</a>
<?php if ($album['user_id'] == $current_user_id): ?>
<a href="edit_album?id=<?php echo $album['album_id']; ?>" class="album-edit-btn">
<i class="far fa-edit"></i>
</a>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="no-albums">
<div class="no-albums-icon">
<i class="far fa-image"></i>
</div>
<p>No photo albums yet. Be the first to create one!</p>
<a href="create_album" class="theme-btn">Create Album</a>
</div>
<?php endif; ?>
</div>
</div>
</div>
</section>
<?php include_once(dirname(dirname(dirname(__DIR__))) . '/components/insta_footer.php'); ?>

View File

@@ -0,0 +1,384 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(dirname(__DIR__)));
include_once($rootPath . '/header.php');
// Check if user has active membership
if (!isset($_SESSION['user_id'])) {
header('Location: login');
exit;
}
$is_member = getUserMemberStatus($_SESSION['user_id']);
if (!$is_member) {
header('Location: index');
exit;
}
$conn = openDatabaseConnection();
$album_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
if ($album_id === 0) {
header('Location: gallery');
exit;
}
// Fetch album details
$album_query = "
SELECT
pa.album_id,
pa.title,
pa.description,
pa.cover_image,
pa.created_at,
pa.user_id,
u.first_name,
u.last_name,
u.profile_pic
FROM photo_albums pa
INNER JOIN users u ON pa.user_id = u.user_id
WHERE pa.album_id = ?
";
$stmt = $conn->prepare($album_query);
$stmt->bind_param("i", $album_id);
$stmt->execute();
$album_result = $stmt->get_result();
if ($album_result->num_rows === 0) {
$stmt->close();
$conn->close();
header('Location: gallery');
exit;
}
$album = $album_result->fetch_assoc();
$stmt->close();
// Fetch all photos in the album
$photos_query = "
SELECT photo_id, file_path, caption, display_order
FROM photos
WHERE album_id = ?
ORDER BY display_order ASC
";
$stmt = $conn->prepare($photos_query);
$stmt->bind_param("i", $album_id);
$stmt->execute();
$photos_result = $stmt->get_result();
$photos = [];
if ($photos_result && $photos_result->num_rows > 0) {
while ($row = $photos_result->fetch_assoc()) {
$photos[] = $row;
}
}
$stmt->close();
$conn->close();
?>
<style>
.album-header {
background-size: cover;
background-position: center;
background-repeat: no-repeat;
color: white;
padding: 60px 20px;
margin-bottom: 40px;
border-radius: 10px;
position: relative;
overflow: hidden;
}
.album-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
}
.album-header-content {
display: flex;
align-items: center;
gap: 20px;
position: relative;
z-index: 2;
}
.album-creator-info {
display: flex;
align-items: center;
gap: 12px;
margin-top: 15px;
}
.album-creator-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
border: 3px solid white;
}
.creator-details {
display: flex;
flex-direction: column;
}
.creator-details span:first-child {
font-weight: 600;
font-size: 1rem;
}
.creator-details span:last-child {
font-size: 0.9rem;
opacity: 0.9;
}
.photo-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.photo-item {
position: relative;
overflow: hidden;
border-radius: 8px;
cursor: pointer;
aspect-ratio: 1;
transition: transform 0.3s;
}
.photo-item:hover {
transform: scale(1.05);
}
.photo-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photo-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
}
.photo-item:hover .photo-overlay {
opacity: 1;
}
.photo-caption {
color: white;
text-align: center;
font-weight: 500;
max-width: 90%;
}
.lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.9);
z-index: 1000;
align-items: center;
justify-content: center;
}
.lightbox.active {
display: flex;
}
.lightbox-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
}
.lightbox-image {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
}
.lightbox-close {
position: absolute;
top: 20px;
right: 20px;
color: white;
font-size: 2rem;
cursor: pointer;
background: none;
border: none;
z-index: 1001;
}
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
color: white;
font-size: 2rem;
cursor: pointer;
background: rgba(0,0,0,0.5);
border: none;
padding: 20px;
z-index: 1001;
}
.lightbox-prev {
left: 20px;
}
.lightbox-next {
right: 20px;
}
.back-link {
margin-bottom: 20px;
display: inline-block;
}
.no-photos {
text-align: center;
padding: 60px 20px;
color: #999;
}
.edit-album-btn {
margin-left: 10px;
}
</style>
<?php
$pageTitle = htmlspecialchars($album['title']);
$breadcrumbs = [['Home' => 'index.php'], ['Gallery' => 'gallery'], [$album['title'] => '#']];
require_once($rootPath . '/components/banner.php');
?>
<section class="tour-list-page py-100 rel">
<div class="container">
<div class="row">
<div class="col-lg-12">
<a href="gallery" class="back-link" style="color: #667eea; text-decoration: none;">
<i class="far fa-arrow-left"></i> Back to Gallery
</a>
<div class="album-header" <?php if ($album['cover_image']): ?>style="background-image: url('<?php echo htmlspecialchars($album['cover_image']); ?>');"<?php endif; ?>>
<div class="album-header-content px-2">
<div style="flex: 1;">
<h2 style="margin: 0; margin-bottom: 10px; color: white; text-shadow: 2px 2px 4px rgba(0,0,0,0.5);"><?php echo htmlspecialchars($album['title']); ?></h2>
<?php if ($album['description']): ?>
<p style="margin: 0 0 15px 0; font-size: 1rem; opacity: 0.95; color: white; text-shadow: 1px 1px 3px rgba(0,0,0,0.5);">
<?php echo htmlspecialchars($album['description']); ?>
</p>
<?php endif; ?>
<div class="album-creator-info">
<img src="<?php echo htmlspecialchars($album['profile_pic']); ?>" alt="<?php echo htmlspecialchars($album['first_name']); ?>" class="album-creator-avatar">
<div class="creator-details">
<span><?php echo htmlspecialchars($album['first_name'] . ' ' . $album['last_name']); ?></span>
<span><?php echo date('F j, Y', strtotime($album['created_at'])); ?></span>
</div>
</div>
</div>
<?php if ($album['user_id'] == $_SESSION['user_id']): ?>
<div>
<a href="edit_album?id=<?php echo $album['album_id']; ?>" class="theme-btn" style="background: white; color: #667eea; border: none;">
<i class="far fa-edit"></i> Edit Album
</a>
</div>
<?php endif; ?>
</div>
</div>
<?php if (count($photos) > 0): ?>
<div class="photo-gallery">
<?php foreach ($photos as $index => $photo): ?>
<div class="photo-item" onclick="openLightbox(<?php echo $index; ?>)">
<img src="<?php echo htmlspecialchars($photo['file_path']); ?>" alt="<?php echo htmlspecialchars($photo['caption'] ?? 'Photo'); ?>">
<?php if ($photo['caption']): ?>
<div class="photo-overlay">
<div class="photo-caption"><?php echo htmlspecialchars($photo['caption']); ?></div>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<!-- Lightbox -->
<div id="lightbox" class="lightbox">
<button class="lightbox-close" onclick="closeLightbox()">✕</button>
<div class="lightbox-content">
<img id="lightboxImage" class="lightbox-image" src="" alt="">
<?php if (count($photos) > 1): ?>
<button class="lightbox-nav lightbox-prev" onclick="changeLightboxImage(-1)"></button>
<button class="lightbox-nav lightbox-next" onclick="changeLightboxImage(1)"></button>
<?php endif; ?>
</div>
</div>
<?php else: ?>
<div class="no-photos">
<i class="far fa-image" style="font-size: 4rem; color: #ddd; margin-bottom: 20px; display: block;"></i>
<p>No photos in this album yet.</p>
<?php if ($album['user_id'] == $_SESSION['user_id']): ?>
<a href="edit_album?id=<?php echo $album['album_id']; ?>" class="theme-btn">Add Photos</a>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
</section>
<script>
let currentPhotoIndex = 0;
const photos = <?php echo json_encode(array_column($photos, 'file_path')); ?>;
function openLightbox(index) {
currentPhotoIndex = index;
document.getElementById('lightbox').classList.add('active');
document.getElementById('lightboxImage').src = photos[currentPhotoIndex];
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('active');
}
function changeLightboxImage(direction) {
currentPhotoIndex += direction;
if (currentPhotoIndex < 0) currentPhotoIndex = photos.length - 1;
if (currentPhotoIndex >= photos.length) currentPhotoIndex = 0;
document.getElementById('lightboxImage').src = photos[currentPhotoIndex];
}
// Close lightbox on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') changeLightboxImage(-1);
if (e.key === 'ArrowRight') changeLightboxImage(1);
});
</script>
<?php include_once(dirname(dirname(dirname(__DIR__))) . '/components/insta_footer.php'); ?>

View File

@@ -9,7 +9,7 @@ $eft_id = strtoupper("SUBS " . date("Y") . " " . getLastName($user_id));
$status = 'AWAITING PAYMENT';
$description = 'Membership Fees ' . date("Y") . " " . getLastName($user_id);
$payment_amount = 2500; // Assuming a fixed membership fee, adjust as needed
$payment_amount = 2600; // Assuming a fixed membership fee, adjust as needed
$payment_date = date('Y-m-d');
$membership_start_date = date('Y-01-01');
$membership_end_date = date('Y-12-31');

View File

@@ -0,0 +1,95 @@
<?php
$rootPath = dirname(dirname(__DIR__));
require_once($rootPath . '/src/config/env.php');
require_once($rootPath . '/src/config/session.php');
require_once($rootPath . '/src/config/connection.php');
if (!isset($_SESSION['user_id'])) {
http_response_code(403);
exit('Forbidden');
}
$album_id = intval($_GET['id'] ?? 0);
if (!$album_id) {
http_response_code(400);
exit('Album ID is required');
}
// Verify ownership
$albumCheck = $conn->prepare("SELECT user_id FROM photo_albums WHERE album_id = ?");
$albumCheck->bind_param("i", $album_id);
$albumCheck->execute();
$albumResult = $albumCheck->get_result();
if ($albumResult->num_rows === 0) {
$conn->close();
http_response_code(404);
header('Location: gallery');
exit;
}
$album = $albumResult->fetch_assoc();
if ($album['user_id'] !== $_SESSION['user_id']) {
$conn->close();
http_response_code(403);
header('Location: gallery');
exit;
}
$albumCheck->close();
try {
// Start transaction
$conn->begin_transaction();
// Get all photos for this album
$photoStmt = $conn->prepare("SELECT file_path FROM photos WHERE album_id = ?");
$photoStmt->bind_param("i", $album_id);
$photoStmt->execute();
$photoResult = $photoStmt->get_result();
// Delete photo files
while ($photo = $photoResult->fetch_assoc()) {
$photoPath = $_SERVER['DOCUMENT_ROOT'] . $photo['file_path'];
if (file_exists($photoPath)) {
unlink($photoPath);
}
}
$photoStmt->close();
// Delete photos from database (cascade should handle this)
$deletePhotosStmt = $conn->prepare("DELETE FROM photos WHERE album_id = ?");
$deletePhotosStmt->bind_param("i", $album_id);
$deletePhotosStmt->execute();
$deletePhotosStmt->close();
// Delete album from database
$deleteAlbumStmt = $conn->prepare("DELETE FROM photo_albums WHERE album_id = ?");
$deleteAlbumStmt->bind_param("i", $album_id);
$deleteAlbumStmt->execute();
$deleteAlbumStmt->close();
// Delete album directory
$albumDir = $rootPath . '/assets/uploads/gallery/' . $album_id;
if (is_dir($albumDir)) {
rmdir($albumDir);
}
// Commit transaction
$conn->commit();
$conn->close();
// Redirect to gallery
header('Location: gallery');
exit;
} catch (Exception $e) {
// Rollback on error
$conn->rollback();
$conn->close();
http_response_code(400);
echo 'Error deleting album: ' . htmlspecialchars($e->getMessage());
exit;
}
?>

Some files were not shown because too many files have changed in this diff Show More