From 35bfec99156a0bd498543b9d9ae9a12c90f8afab Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Thu, 30 Oct 2025 13:18:01 -0300 Subject: [PATCH] feat: add sharing feature --- index.html | 28 ++++++ src/appwrite.ts | 57 ++++++++++++- src/index.ts | 222 +++++++++++++++++++++++++++++++++++++++++++++++- src/state.ts | 8 +- src/types.ts | 10 +++ style.css | 127 +++++++++++++++++++++++++++ 6 files changed, 446 insertions(+), 6 deletions(-) diff --git a/index.html b/index.html index 727d83a..a84c21c 100644 --- a/index.html +++ b/index.html @@ -80,6 +80,7 @@
Welcome, User!
+ @@ -89,6 +90,7 @@ + + + + + +
diff --git a/src/appwrite.ts b/src/appwrite.ts index fa9534a..a7fa75c 100644 --- a/src/appwrite.ts +++ b/src/appwrite.ts @@ -1,5 +1,5 @@ -import { Client, Account, Databases, Query } from 'appwrite'; -import { DailyTotalCalories, FoodStorage, UserSettings } from './types'; +import { Client, Account, Databases, Query, ID } from 'appwrite'; +import { DailyTotalCalories, FoodStorage, UserSettings, SharedDay } from './types'; // Initialize Appwrite client const client = new Client(); @@ -17,6 +17,7 @@ export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DBID export const FOOD_ENTRIES_COLLECTION_ID = import.meta.env.VITE_APPWRITE_FOODENTRIESID export const USER_SETTINGS_COLLECTION_ID = import.meta.env.VITE_APPWRITE_USERSETTINGSID export const MONTHLY_CALORIES_COLLECTION_ID = import.meta.env.VITE_APPWRITE_MONTLYCALORIESID +export const SHARED_DAYS_COLLECTION_ID = import.meta.env.VITE_APPWRITE_SHAREDDAYSID // Auth helper functions export class AppwriteAuth { @@ -277,4 +278,56 @@ export class AppwriteDB { throw error; } } + + // Create shared day snapshot + static async createSharedDay(date: Date, foodEntries: FoodStorage[]) { + try { + const user = await account.get(); + const shareId = ID.unique(); + const localDateTime = new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString(); + + const response = await databases.createDocument( + DATABASE_ID, + SHARED_DAYS_COLLECTION_ID, + 'unique()', + { + shareId: shareId, + userId: user.$id, + userName: user.name, + date: localDateTime.split('T')[0], + foodEntries: JSON.stringify(foodEntries), + createdAt: new Date().toISOString() + } + ); + console.debug('Shared day created:', response); + return response; + } catch (error) { + console.error('Create shared day error:', error); + throw error; + } + } + + // Get shared day by share ID + static async getSharedDay(shareId: string) { + try { + const response = await databases.listDocuments( + DATABASE_ID, + SHARED_DAYS_COLLECTION_ID, + [ + Query.equal('shareId', shareId), + Query.limit(1) + ] + ); + + if (response.documents.length === 0) { + throw new Error('Shared day not found'); + } + + console.debug('Shared day retrieved:', response.documents[0]); + return response.documents[0]; + } catch (error) { + console.error('Get shared day error:', error); + throw error; + } + } } diff --git a/src/index.ts b/src/index.ts index b3191fe..fd93c68 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,11 +21,20 @@ document.addEventListener('DOMContentLoaded', async function() { }); async function initializeAuth() { + // Check if this is a shared view first + const urlParams = new URLSearchParams(window.location.search); + const shareId = urlParams.get('share'); + + if (shareId) { + await checkSharedView(); + return; + } + showLoading(); - + try { const isLoggedIn = await AppwriteAuth.isLoggedIn(); - + if (isLoggedIn) { currentUser = await AppwriteAuth.getCurrentUser(); await showMainApp(); @@ -404,6 +413,12 @@ const setupEventListeners = () => { // Settings form document.getElementById('settingsForm')?.addEventListener('submit', handleSaveSettings); + // Share button event listeners + document.getElementById('shareBtn')?.addEventListener('click', handleShareClick); + document.getElementById('shareBtnMobile')?.addEventListener('click', handleShareClick); + document.getElementById('close-share-modal')?.addEventListener('click', closeShareModal); + document.getElementById('copy-share-link')?.addEventListener('click', copyShareLink); + // Search functionality getInputById('foodSearchInput').addEventListener('input', function(e: Event) { const target = e.target as HTMLInputElement; @@ -1300,4 +1315,207 @@ function createDayElement(day: number, isOtherMonth: boolean, year: number, mont return dayElement; } +// Share functionality +async function handleShareClick() { + if (!currentUser) { + swal('Hey!', 'Please log in to share your food log.', 'info'); + return; + } + + if (appState.isSharedView) { + swal('Info', 'You cannot share a shared view.', 'info'); + return; + } + + closeMobileMenu(); + showLoading(); + + try { + // Get current day's food entries + const entries = await AppwriteDB.getFoodEntries(selectedDate); + + if (entries.length === 0) { + hideLoading(); + swal('Info', 'No food entries to share for this day.', 'info'); + return; + } + + // Convert entries to FoodStorage format + const foodEntries: FoodStorage[] = entries.map(entry => ({ + name: entry.name, + grams: entry.grams, + calories: entry.calories, + protein: entry.protein, + fat: entry.fat, + carbs: entry.carbs, + fiber: entry.fiber, + time: entry.time, + date: entry.date, + alkaline: entry.alkaline, + })); + + // Create shared day snapshot + const sharedDay = await AppwriteDB.createSharedDay(selectedDate, foodEntries); + + // Generate share URL + const shareUrl = `${window.location.origin}${window.location.pathname}?share=${sharedDay.shareId}`; + + // Show modal with link + getInputById('share-link-input').value = shareUrl; + getDivById('share-modal').classList.remove('hidden'); + + hideLoading(); + } catch (error) { + hideLoading(); + console.error('Share error:', error); + if (error instanceof Error) { + swal('Oh no!', 'Failed to create share link: ' + error.message, 'error'); + } + } +} + +function closeShareModal() { + getDivById('share-modal').classList.add('hidden'); +} + +async function copyShareLink() { + const shareInput = getInputById('share-link-input'); + + try { + await navigator.clipboard.writeText(shareInput.value); + const copyBtn = document.getElementById('copy-share-link'); + if (copyBtn) { + const originalText = copyBtn.textContent; + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = originalText; + }, 2000); + } + } catch (error) { + console.error('Copy error:', error); + swal('Oh no!', 'Failed to copy link to clipboard', 'error'); + } +} + +// Check for share parameter on page load +async function checkSharedView() { + const urlParams = new URLSearchParams(window.location.search); + const shareId = urlParams.get('share'); + + if (shareId) { + showLoading(); + + try { + const sharedDay = await AppwriteDB.getSharedDay(shareId); + + // Set shared view mode + appState.isSharedView = true; + appState.sharedData = { + shareId: sharedDay.shareId, + userId: sharedDay.userId, + userName: sharedDay.userName, + date: sharedDay.date, + foodEntries: sharedDay.foodEntries, + createdAt: sharedDay.createdAt + }; + + // Parse the date and set as selected date (as local date, not UTC) + const [year, month, day] = sharedDay.date.split('-').map(Number); + selectedDate = new Date(year, month - 1, day); + currentViewDate = new Date(year, month - 1, day); + + // Show shared view banner + const banner = getDivById('shared-view-banner'); + banner.classList.remove('hidden'); + getDivById('shared-user-name').textContent = sharedDay.userName; + getDivById('shared-date').textContent = selectedDate.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + + // Hide auth section and show app content + getDivById('auth-section').classList.add('hidden'); + getDivById('user-info').classList.add('hidden'); + getDivById('app-content').classList.remove('hidden'); + + // Render shared day view + renderSharedDayView(); + + // Apply read-only mode + applyReadOnlyMode(); + + hideLoading(); + } catch (error) { + hideLoading(); + console.error('Load shared day error:', error); + swal('Oh no!', 'Failed to load shared day: ' + (error instanceof Error ? error.message : 'Unknown error'), 'error'); + } + } +} + +function renderSharedDayView() { + if (!appState.sharedData) return; + + updateCurrentDate(); + + // Parse food entries + const foodEntries: FoodStorage[] = JSON.parse(appState.sharedData.foodEntries); + + // Clear container + getDivById('foodCardsContainer').innerHTML = ''; + + // Add each entry to view + foodEntries.forEach((entry, index) => { + addFoodToView(entry, `shared-${index}`); + }); + + // Calculate and display totals + let totalCalories = 0; + let totalProtein = 0; + let totalFat = 0; + let totalCarbs = 0; + let totalFiber = 0; + + foodEntries.forEach(entry => { + totalCalories += entry.calories; + totalProtein += entry.protein; + totalFat += entry.fat; + totalCarbs += entry.carbs; + totalFiber += entry.fiber; + }); + + getDivById('caloriesCounter').textContent = totalCalories.toString(); + getDivById('proteinValue').textContent = (Math.round(totalProtein * 10) / 10).toString(); + getDivById('fatValue').textContent = (Math.round(totalFat * 10) / 10).toString(); + getDivById('carboValue').textContent = (Math.round(totalCarbs * 10) / 10).toString(); + getDivById('fiberValue').textContent = (Math.round(totalFiber * 10) / 10).toString(); + + // Update alkaline level + updateTotalCalories(); + + renderCalendar(); +} + +function applyReadOnlyMode() { + // Hide add food section + const addFoodSection = document.querySelector('.add-food-section'); + if (addFoodSection) { + addFoodSection.classList.add('read-only-disabled'); + } + + // Hide edit/delete/copy buttons + const actionButtons = document.querySelectorAll('.card-actions button'); + actionButtons.forEach(button => { + (button as HTMLElement).style.display = 'none'; + }); + + // Hide calendar navigation + const calendarSection = document.querySelector('.calendar-section'); + if (calendarSection) { + calendarSection.classList.add('read-only-disabled'); + } +} + (window as any).selectFood = selectFood; diff --git a/src/state.ts b/src/state.ts index 4161ba2..10c422d 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,15 +1,19 @@ -import { DailyTotalCalories, FoodItem } from "./types"; +import { DailyTotalCalories, FoodItem, SharedDay } from "./types"; interface AppState { currentHighlightIndex: number; searchTimeout: number | null; searchResults: FoodItem[]; calendarMonthlyCalories: DailyTotalCalories[]; + isSharedView: boolean; + sharedData: SharedDay | null; } export const appState: AppState = { currentHighlightIndex: -1, searchTimeout: null, searchResults: [], - calendarMonthlyCalories: [] + calendarMonthlyCalories: [], + isSharedView: false, + sharedData: null }; diff --git a/src/types.ts b/src/types.ts index d4e197b..305dcf8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,3 +33,13 @@ export type DailyTotalCalories = { day: number; totalCalories: number; }; + +export type SharedDay = { + id?: string; + shareId: string; + userId: string; + userName: string; + date: string; + foodEntries: string; // JSON stringified FoodStorage[] + createdAt: string; +}; diff --git a/style.css b/style.css index b5146c3..e06dd5d 100644 --- a/style.css +++ b/style.css @@ -774,3 +774,130 @@ label { padding: 16px; } } + +/* Share Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + backdrop-filter: blur(5px); +} + +.modal-overlay.hidden { + display: none; +} + +.modal-content { + background: #2a2a2a; + border-radius: 12px; + padding: 30px; + max-width: 500px; + width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + color: #e0e0e0; +} + +.modal-content h2 { + margin-bottom: 15px; + color: #b0b0b0; + font-size: 1.5rem; +} + +.modal-content p { + margin-bottom: 20px; + color: #999; +} + +.share-link-container { + display: flex; + gap: 10px; + margin-bottom: 20px; +} + +.share-link-input { + flex: 1; + padding: 12px 15px; + background: #1a1a1a; + border: 2px solid #404040; + border-radius: 6px; + color: #e0e0e0; + font-family: monospace; + font-size: 14px; + outline: none; +} + +.copy-btn { + padding: 12px 24px; + background: linear-gradient(135deg, #4CAF50, #45a049); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s ease; +} + +.copy-btn:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(76, 175, 80, 0.3); +} + +.copy-btn:active { + transform: translateY(0); +} + +.modal-buttons { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.modal-btn { + padding: 10px 20px; + background: #404040; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.modal-btn:hover { + background: #505050; +} + +/* Shared View Banner */ +.shared-view-banner { + background: linear-gradient(135deg, #FF6B6B, #FF8E53); + color: white; + padding: 15px 20px; + text-align: center; + font-size: 16px; + font-weight: 500; + box-shadow: 0 2px 10px rgba(255, 107, 107, 0.3); + position: sticky; + top: 0; + z-index: 1000; +} + +.shared-view-banner.hidden { + display: none; +} + +.shared-view-banner strong { + font-weight: 700; +} + +/* Disabled state for read-only mode */ +.read-only-disabled { + opacity: 0.5; + pointer-events: none; + cursor: not-allowed !important; +}