Full vanilla HTML/CSS/JS consumer app with mobile-first responsive design. Pages: feed (index), post meal, trades, messages inbox, chat, landing page, and admin login skeleton. Uses Ava's design tokens throughout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
249 lines
7.3 KiB
JavaScript
249 lines
7.3 KiB
JavaScript
/* ============================================
|
|
GrubFlip — Post Meal Form
|
|
Image upload, dietary tag picker, meal
|
|
creation with preview.
|
|
============================================ */
|
|
|
|
const PostMeal = (() => {
|
|
'use strict';
|
|
|
|
const { api, $, $$, el, toast } = GrubFlip;
|
|
|
|
let uploadedImage = null; // { url, thumb_url, upload_id }
|
|
let submitting = false;
|
|
|
|
const DIETARY_TAGS = [
|
|
'vegan', 'vegetarian', 'gluten-free', 'dairy', 'dairy-free',
|
|
'nut-free', 'halal', 'kosher', 'high-protein', 'low-carb', 'spicy'
|
|
];
|
|
|
|
const CUISINES = [
|
|
'Italian', 'Mexican', 'Chinese', 'Japanese', 'Indian', 'Thai',
|
|
'Mediterranean', 'American', 'Korean', 'Vietnamese', 'French',
|
|
'Middle Eastern', 'Caribbean', 'Ethiopian', 'Other'
|
|
];
|
|
|
|
// --- Image Upload ---
|
|
function setupImageUpload() {
|
|
const zone = $('#upload-zone');
|
|
const fileInput = $('#image-input');
|
|
if (!zone || !fileInput) return;
|
|
|
|
// Click to select
|
|
zone.addEventListener('click', (e) => {
|
|
if (e.target.closest('.btn--remove')) return;
|
|
fileInput.click();
|
|
});
|
|
|
|
// File selected
|
|
fileInput.addEventListener('change', () => {
|
|
if (fileInput.files.length) handleFile(fileInput.files[0]);
|
|
});
|
|
|
|
// Drag and drop
|
|
zone.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
zone.classList.add('dragover');
|
|
});
|
|
|
|
zone.addEventListener('dragleave', () => {
|
|
zone.classList.remove('dragover');
|
|
});
|
|
|
|
zone.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
zone.classList.remove('dragover');
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) handleFile(file);
|
|
});
|
|
|
|
// Remove button
|
|
const removeBtn = zone.querySelector('.btn--remove');
|
|
if (removeBtn) {
|
|
removeBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
clearUpload();
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handleFile(file) {
|
|
// Validate
|
|
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
|
if (!validTypes.includes(file.type)) {
|
|
toast('Please upload a JPG, PNG, or WebP image', 'error');
|
|
return;
|
|
}
|
|
|
|
if (file.size > 20 * 1024 * 1024) {
|
|
toast('Image must be under 20MB', 'error');
|
|
return;
|
|
}
|
|
|
|
const zone = $('#upload-zone');
|
|
const previewImg = $('#upload-preview-img');
|
|
|
|
// Show local preview immediately
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
previewImg.src = e.target.result;
|
|
zone.classList.add('has-preview');
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
// Upload to server
|
|
try {
|
|
zone.style.opacity = '0.7';
|
|
const data = await api.upload('/uploads/image', file);
|
|
uploadedImage = {
|
|
url: data.url,
|
|
thumb_url: data.thumb_url,
|
|
upload_id: data.upload_id,
|
|
};
|
|
toast('Image uploaded!', 'success');
|
|
} catch (err) {
|
|
toast('Upload failed — ' + err.message, 'error');
|
|
clearUpload();
|
|
} finally {
|
|
zone.style.opacity = '';
|
|
}
|
|
}
|
|
|
|
function clearUpload() {
|
|
uploadedImage = null;
|
|
const zone = $('#upload-zone');
|
|
const fileInput = $('#image-input');
|
|
const previewImg = $('#upload-preview-img');
|
|
|
|
if (zone) zone.classList.remove('has-preview');
|
|
if (fileInput) fileInput.value = '';
|
|
if (previewImg) previewImg.src = '';
|
|
}
|
|
|
|
// --- Expiry Picker ---
|
|
function setupExpiryPicker() {
|
|
const select = $('#expires-select');
|
|
if (!select) return;
|
|
|
|
// Populate with reasonable time windows
|
|
const options = [
|
|
{ value: '2', label: '2 hours' },
|
|
{ value: '4', label: '4 hours' },
|
|
{ value: '6', label: '6 hours' },
|
|
{ value: '8', label: '8 hours' },
|
|
{ value: '12', label: '12 hours' },
|
|
{ value: '24', label: '24 hours' },
|
|
];
|
|
|
|
select.innerHTML = '<option value="">Pick up within...</option>';
|
|
for (const opt of options) {
|
|
select.appendChild(el('option', { value: opt.value, textContent: opt.label }));
|
|
}
|
|
}
|
|
|
|
// --- Form Validation ---
|
|
function validate() {
|
|
const errors = [];
|
|
const title = $('#meal-title')?.value.trim();
|
|
const description = $('#meal-description')?.value.trim();
|
|
const expiresHours = $('#expires-select')?.value;
|
|
|
|
if (!title || title.length < 3) errors.push({ field: 'meal-title', msg: 'Title must be at least 3 characters' });
|
|
if (title && title.length > 100) errors.push({ field: 'meal-title', msg: 'Title is too long (max 100 chars)' });
|
|
if (!description) errors.push({ field: 'meal-description', msg: 'Add a short description' });
|
|
if (!expiresHours) errors.push({ field: 'expires-select', msg: 'Set a pickup window' });
|
|
if (!uploadedImage) errors.push({ field: 'upload-zone', msg: 'Add a photo of your meal' });
|
|
|
|
// Clear previous errors
|
|
$$('.form-error').forEach(el => el.remove());
|
|
$$('.form-input, .form-textarea, .form-select').forEach(el => el.style.borderColor = '');
|
|
|
|
// Show errors
|
|
for (const error of errors) {
|
|
const fieldEl = $(`#${error.field}`);
|
|
if (fieldEl) {
|
|
fieldEl.style.borderColor = 'var(--gf-error)';
|
|
const errEl = document.createElement('p');
|
|
errEl.className = 'form-error';
|
|
errEl.textContent = error.msg;
|
|
fieldEl.parentElement.appendChild(errEl);
|
|
}
|
|
}
|
|
|
|
return errors.length === 0;
|
|
}
|
|
|
|
// --- Submit ---
|
|
async function handleSubmit(e) {
|
|
e.preventDefault();
|
|
if (submitting) return;
|
|
if (!validate()) return;
|
|
|
|
submitting = true;
|
|
const submitBtn = $('#submit-btn');
|
|
if (submitBtn) {
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = 'Posting...';
|
|
}
|
|
|
|
// Gather selected dietary tags
|
|
const selectedTags = $$('.tag-picker__option:checked').map(cb => cb.value);
|
|
|
|
const expiresHours = parseInt($('#expires-select').value, 10);
|
|
const expiresAt = new Date(Date.now() + expiresHours * 3600000).toISOString();
|
|
|
|
const payload = {
|
|
title: $('#meal-title').value.trim(),
|
|
description: $('#meal-description').value.trim(),
|
|
upload_id: uploadedImage.upload_id,
|
|
dietary_tags: selectedTags,
|
|
cuisine: $('#cuisine-select').value || null,
|
|
portion_size: $('#portion-select').value || 'single',
|
|
trade_type: $('#trade-type-select')?.value || 'swap',
|
|
expires_at: expiresAt,
|
|
};
|
|
|
|
try {
|
|
const result = await api.post('/meals', payload);
|
|
toast('Meal posted! 🎉', 'success');
|
|
// Redirect to feed after short delay
|
|
setTimeout(() => {
|
|
window.location.href = '/grubflip/index.html';
|
|
}, 1000);
|
|
} catch (err) {
|
|
toast('Failed to post — ' + err.message, 'error');
|
|
} finally {
|
|
submitting = false;
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = 'Post Meal';
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Init ---
|
|
function init() {
|
|
const form = $('#post-meal-form');
|
|
if (!form) return;
|
|
|
|
setupImageUpload();
|
|
setupExpiryPicker();
|
|
|
|
form.addEventListener('submit', handleSubmit);
|
|
|
|
// Character counter for description
|
|
const desc = $('#meal-description');
|
|
const counter = $('#desc-counter');
|
|
if (desc && counter) {
|
|
desc.addEventListener('input', () => {
|
|
const len = desc.value.length;
|
|
counter.textContent = `${len}/300`;
|
|
counter.style.color = len > 300 ? 'var(--gf-error)' : 'var(--gf-neutral-400)';
|
|
});
|
|
}
|
|
}
|
|
|
|
return { init };
|
|
})();
|
|
|
|
document.addEventListener('DOMContentLoaded', PostMeal.init);
|