تطبيقات الويب التقدمية
بناء مشروع PWA (الجزء الثاني)
بناء مشروع PWA (الجزء الثاني)
في هذا الدرس، سنعزز تطبيق TaskMaster PWA الخاص بنا بميزات متقدمة بما في ذلك IndexedDB لاستمرارية البيانات، والإشعارات الفورية، والمزامنة في الخلفية، ومطالبة التثبيت.
دمج IndexedDB
لننشئ طبقة تجريد لقاعدة البيانات في js/db.js:
// db.js - IndexedDB wrapper
const DB_NAME = 'TaskMasterDB';
const DB_VERSION = 1;
const STORE_NAME = 'tasks';
class TaskDB {
constructor() {
this.db = null;
}
// Initialize database
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object store
if (!db.objectStoreNames.contains(STORE_NAME)) {
const objectStore = db.createObjectStore(STORE_NAME, {
keyPath: 'id',
autoIncrement: true
});
// Create indexes
objectStore.createIndex('priority', 'priority', { unique: false });
objectStore.createIndex('completed', 'completed', { unique: false });
objectStore.createIndex('dueDate', 'dueDate', { unique: false });
objectStore.createIndex('createdAt', 'createdAt', { unique: false });
}
};
});
}
// Add task
async addTask(task) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const taskData = {
title: task.title,
description: task.description || '',
priority: task.priority || 'medium',
dueDate: task.dueDate || null,
completed: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
const request = store.add(taskData);
request.onsuccess = () => {
taskData.id = request.result;
resolve(taskData);
};
request.onerror = () => reject(request.error);
});
}
// Get all tasks
async getAllTasks() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Get task by ID
async getTask(id) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Update task
async updateTask(id, updates) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const task = getRequest.result;
if (!task) {
reject(new Error('Task not found'));
return;
}
Object.assign(task, updates, {
updatedAt: new Date().toISOString()
});
const updateRequest = store.put(task);
updateRequest.onsuccess = () => resolve(task);
updateRequest.onerror = () => reject(updateRequest.error);
};
getRequest.onerror = () => reject(getRequest.error);
});
}
// Delete task
async deleteTask(id) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Get tasks by filter
async getTasksByFilter(filter) {
const allTasks = await this.getAllTasks();
switch (filter) {
case 'active':
return allTasks.filter(task => !task.completed);
case 'completed':
return allTasks.filter(task => task.completed);
default:
return allTasks;
}
}
}
// Export singleton instance
const taskDB = new TaskDB();
تنفيذ منطق التطبيق
الآن لننفذ منطق التطبيق الرئيسي في js/app.js:
// app.js - Main application logic
let currentFilter = 'all';
let deferredPrompt = null;
// Initialize app
async function initApp() {
try {
await taskDB.init();
await loadTasks();
setupEventListeners();
setupOnlineOfflineHandlers();
console.log('App initialized successfully');
} catch (error) {
console.error('Failed to initialize app:', error);
showError('فشل تهيئة التطبيق. يرجى تحديث الصفحة.');
}
}
// Setup event listeners
function setupEventListeners() {
// Task form submission
document.getElementById('taskForm').addEventListener('submit', handleTaskSubmit);
// Filter buttons
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', handleFilterChange);
});
// Install button
window.addEventListener('beforeinstallprompt', handleInstallPrompt);
document.getElementById('installBtn').addEventListener('click', handleInstallClick);
}
// Handle task form submission
async function handleTaskSubmit(e) {
e.preventDefault();
const title = document.getElementById('taskTitle').value.trim();
const description = document.getElementById('taskDescription').value.trim();
const priority = document.getElementById('taskPriority').value;
const dueDate = document.getElementById('taskDueDate').value;
if (!title) {
showError('يرجى إدخال عنوان المهمة');
return;
}
try {
const task = await taskDB.addTask({
title,
description,
priority,
dueDate
});
// Reset form
e.target.reset();
// Reload tasks
await loadTasks();
// Show success message
showSuccess('تمت إضافة المهمة بنجاح!');
// Send push notification if supported
if (Notification.permission === 'granted') {
new Notification('تمت إضافة المهمة', {
body: title,
icon: '/images/icons/icon-192x192.png',
badge: '/images/icons/icon-96x96.png',
tag: 'task-added'
});
}
} catch (error) {
console.error('Failed to add task:', error);
showError('فشل إضافة المهمة. يرجى المحاولة مرة أخرى.');
}
}
// Load and display tasks
async function loadTasks() {
try {
const tasks = await taskDB.getTasksByFilter(currentFilter);
displayTasks(tasks);
} catch (error) {
console.error('Failed to load tasks:', error);
showError('فشل تحميل المهام');
}
}
// Display tasks in UI
function displayTasks(tasks) {
const taskList = document.getElementById('taskList');
const emptyState = document.getElementById('emptyState');
if (tasks.length === 0) {
taskList.innerHTML = '';
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
taskList.innerHTML = tasks.map(task => `
<div class="task-item ${task.completed ? 'completed' : ''} priority-${task.priority}" data-id="${task.id}">
<div class="task-checkbox">
<input
type="checkbox"
${task.completed ? 'checked' : ''}
onchange="toggleTaskComplete(${task.id})"
>
</div>
<div class="task-content">
<h3 class="task-title">${escapeHtml(task.title)}</h3>
${task.description ? `<p class="task-description">${escapeHtml(task.description)}</p>` : ''}
<div class="task-meta">
<span class="priority-badge priority-${task.priority}">
${task.priority}
</span>
${task.dueDate ? `<span class="due-date">تاريخ الاستحقاق: ${formatDate(task.dueDate)}</span>` : ''}
</div>
</div>
<div class="task-actions">
<button class="btn-icon" onclick="deleteTask(${task.id})" title="حذف">
<svg width="20" height="20" viewBox="0 0 24 24">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
</button>
</div>
</div>
`).join('');
}
// Toggle task completion
async function toggleTaskComplete(id) {
try {
const task = await taskDB.getTask(id);
await taskDB.updateTask(id, { completed: !task.completed });
await loadTasks();
} catch (error) {
console.error('Failed to toggle task:', error);
}
}
// Delete task
async function deleteTask(id) {
if (!confirm('هل أنت متأكد من حذف هذه المهمة؟')) {
return;
}
try {
await taskDB.deleteTask(id);
await loadTasks();
showSuccess('تم حذف المهمة بنجاح');
} catch (error) {
console.error('Failed to delete task:', error);
showError('فشل حذف المهمة');
}
}
// Handle filter change
function handleFilterChange(e) {
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active');
});
e.target.classList.add('active');
currentFilter = e.target.dataset.filter;
loadTasks();
}
// Utility functions
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('ar-SA', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
function showSuccess(message) {
// Implement toast notification
console.log('نجاح:', message);
}
function showError(message) {
// Implement toast notification
console.error('خطأ:', message);
}
// Initialize app when DOM is ready
document.addEventListener('DOMContentLoaded', initApp);
تنفيذ الإشعارات الفورية
// Request notification permission
async function requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('هذا المتصفح لا يدعم الإشعارات');
return false;
}
if (Notification.permission === 'granted') {
return true;
}
if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission();
return permission === 'granted';
}
return false;
}
// Setup push notifications
async function setupPushNotifications() {
const hasPermission = await requestNotificationPermission();
if (!hasPermission) {
console.log('تم رفض إذن الإشعارات');
return;
}
// Register push subscription
const registration = await navigator.serviceWorker.ready;
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array('YOUR_VAPID_PUBLIC_KEY')
});
// Send subscription to server
await fetch('/api/push-subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
});
console.log('تم الاشتراك في الإشعارات الفورية بنجاح');
} catch (error) {
console.error('فشل الاشتراك في الإشعارات الفورية:', error);
}
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
المزامنة في الخلفية للإجراءات دون اتصال
أضف إلى service worker (sw.js):
// Background sync event
self.addEventListener('sync', event => {
if (event.tag === 'sync-tasks') {
event.waitUntil(syncTasks());
}
});
async function syncTasks() {
try {
// Get pending sync data from IndexedDB
const db = await openDatabase();
const pendingTasks = await getPendingTasks(db);
// Sync with server
for (const task of pendingTasks) {
try {
const response = await fetch('/api/tasks/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(task)
});
if (response.ok) {
await markTaskSynced(db, task.id);
}
} catch (error) {
console.error('فشل مزامنة المهمة:', task.id, error);
}
}
// Notify user
self.registration.showNotification('تمت المزامنة', {
body: `تمت مزامنة ${pendingTasks.length} مهام بنجاح`,
icon: '/images/icons/icon-192x192.png',
badge: '/images/icons/icon-96x96.png'
});
} catch (error) {
console.error('فشلت المزامنة في الخلفية:', error);
throw error;
}
}
تنفيذ مطالبة التثبيت
// Handle install prompt
function handleInstallPrompt(e) {
// Prevent the mini-infobar from appearing
e.preventDefault();
// Store the event for later use
deferredPrompt = e;
// Show install button
const installBtn = document.getElementById('installBtn');
installBtn.style.display = 'block';
console.log('مطالبة التثبيت جاهزة');
}
// Handle install button click
async function handleInstallClick() {
if (!deferredPrompt) {
return;
}
// Show the install prompt
deferredPrompt.prompt();
// Wait for the user's response
const { outcome } = await deferredPrompt.userChoice;
console.log(`استجابة المستخدم: ${outcome}`);
// Clear the deferred prompt
deferredPrompt = null;
// Hide install button
document.getElementById('installBtn').style.display = 'none';
}
// Listen for app installed event
window.addEventListener('appinstalled', () => {
console.log('تم تثبيت PWA بنجاح');
// Hide install button
document.getElementById('installBtn').style.display = 'none';
// Track installation
if ('ga' in window) {
ga('send', 'event', 'PWA', 'installed');
}
});
معالجة حالة الاتصال/عدم الاتصال
// Setup online/offline handlers
function setupOnlineOfflineHandlers() {
updateOnlineStatus();
window.addEventListener('online', () => {
updateOnlineStatus();
showSuccess('عدت متصلاً!');
// Trigger background sync
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
navigator.serviceWorker.ready.then(registration => {
return registration.sync.register('sync-tasks');
});
}
});
window.addEventListener('offline', () => {
updateOnlineStatus();
showError('أنت غير متصل. ستتم المزامنة عندما تعود للاتصال.');
});
}
function updateOnlineStatus() {
const statusIndicator = document.getElementById('onlineStatus');
const statusText = document.getElementById('statusText');
if (navigator.onLine) {
statusIndicator.classList.remove('offline');
statusIndicator.classList.add('online');
statusText.textContent = 'متصل';
} else {
statusIndicator.classList.remove('online');
statusIndicator.classList.add('offline');
statusText.textContent = 'غير متصل';
}
}
أفضل ممارسة: قدم دائماً ملاحظات مرئية عندما ينقطع الاتصال بالتطبيق أو يعود. يجب أن يفهم المستخدمون حالة الاتصال الحالية والميزات المتاحة.
تمرين: نفذ دمج IndexedDB، أضف دعم الإشعارات الفورية، واختبر التطبيق في وضع عدم الاتصال. تحقق من حفظ المهام محلياً وأن مؤشر عدم الاتصال يعمل بشكل صحيح.
الخطوات التالية: في الجزء الثالث، سنضيف ميزات متقدمة، ونحسّن الأداء، وننشر التطبيق، ونجري تدقيق PWA كامل.