تطبيقات الويب التقدمية
بناء مشروع PWA (الجزء الأول)
بناء مشروع PWA (الجزء الأول)
في هذا الدرس، سنبني تطبيق ويب تقدمي كامل من الصفر. سننشئ تطبيق إدارة مهام يسمى "TaskMaster PWA" يعمل دون اتصال بالإنترنت، وقابل للتثبيت، ويوفر تجربة شبيهة بالتطبيقات الأصلية.
نظرة عامة على المشروع
سيتضمن تطبيق TaskMaster PWA الخاص بنا:
- إنشاء وتعديل وحذف المهام
- وظائف دون اتصال بالإنترنت مع التخزين المحلي
- هندسة app shell للتحميل الفوري
- Service worker للتخزين المؤقت والدعم دون اتصال
- ملف manifest لإمكانية التثبيت
- تصميم متجاوب لجميع الأجهزة
إعداد المشروع
أولاً، لننشئ هيكل المشروع:
taskmaster-pwa/
├── index.html
├── manifest.json
├── sw.js
├── css/
│ └── styles.css
├── js/
│ ├── app.js
│ └── db.js
├── images/
│ ├── icons/
│ │ ├── icon-72x72.png
│ │ ├── icon-96x96.png
│ │ ├── icon-128x128.png
│ │ ├── icon-144x144.png
│ │ ├── icon-152x152.png
│ │ ├── icon-192x192.png
│ │ ├── icon-384x384.png
│ │ └── icon-512x512.png
│ └── offline.svg
└── offline.html
إنشاء ملف Web App Manifest
ملف manifest.json يحدد كيف يظهر تطبيقك ويتصرف عند التثبيت:
{
"name": "TaskMaster PWA",
"short_name": "TaskMaster",
"description": "تطبيق قوي لإدارة المهام يعمل دون اتصال",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2196F3",
"orientation": "portrait-primary",
"icons": [
{
"src": "/images/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"screenshots": [
{
"src": "/images/screenshots/mobile-1.png",
"sizes": "540x720",
"type": "image/png",
"form_factor": "narrow"
},
{
"src": "/images/screenshots/desktop-1.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
],
"categories": ["productivity", "utilities"],
"shortcuts": [
{
"name": "إضافة مهمة",
"short_name": "إضافة",
"description": "إنشاء مهمة جديدة",
"url": "/?action=add",
"icons": [
{
"src": "/images/icons/add-icon.png",
"sizes": "96x96"
}
]
},
{
"name": "عرض جميع المهام",
"short_name": "الكل",
"description": "عرض جميع المهام",
"url": "/?view=all",
"icons": [
{
"src": "/images/icons/list-icon.png",
"sizes": "96x96"
}
]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url"
}
}
}
إرشادات الأيقونات: استخدم الأيقونات القابلة للإخفاء (maskable) التي تعمل مع أشكال الأيقونات التكيفية على Android. تأكد من أن أيقونتك لديها منطقة آمنة في وسط 40٪ من الصورة.
هيكل HTML (index.html)
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="تطبيق قوي لإدارة المهام يعمل دون اتصال">
<meta name="theme-color" content="#2196F3">
<!-- Web App Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="/images/icons/icon-152x152.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="TaskMaster">
<!-- Favicon -->
<link rel="icon" type="image/png" sizes="32x32" href="/images/icons/icon-72x72.png">
<title>TaskMaster PWA</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<!-- App Shell -->
<div class="app">
<!-- Header -->
<header class="app-header">
<h1>TaskMaster</h1>
<button id="installBtn" class="install-btn" style="display: none;">
تثبيت التطبيق
</button>
</header>
<!-- Main Content -->
<main class="app-main">
<!-- Add Task Form -->
<section class="add-task-section">
<h2>إضافة مهمة جديدة</h2>
<form id="taskForm">
<input
type="text"
id="taskTitle"
placeholder="عنوان المهمة"
required
autocomplete="off"
>
<textarea
id="taskDescription"
placeholder="وصف المهمة (اختياري)"
rows="3"
></textarea>
<div class="form-row">
<select id="taskPriority">
<option value="low">أولوية منخفضة</option>
<option value="medium" selected>أولوية متوسطة</option>
<option value="high">أولوية عالية</option>
</select>
<input type="date" id="taskDueDate">
</div>
<button type="submit" class="btn btn-primary">إضافة مهمة</button>
</form>
</section>
<!-- Task Filters -->
<section class="filter-section">
<button class="filter-btn active" data-filter="all">الكل</button>
<button class="filter-btn" data-filter="active">نشطة</button>
<button class="filter-btn" data-filter="completed">مكتملة</button>
</section>
<!-- Task List -->
<section class="task-list-section">
<h2>مهامي</h2>
<div id="taskList" class="task-list">
<!-- Tasks will be dynamically inserted here -->
</div>
<div id="emptyState" class="empty-state">
<p>لا توجد مهام بعد. أضف مهمتك الأولى أعلاه!</p>
</div>
</section>
</main>
<!-- Footer -->
<footer class="app-footer">
<div class="connection-status">
<span id="onlineStatus" class="status-indicator online"></span>
<span id="statusText">متصل</span>
</div>
</footer>
</div>
<!-- Loading Skeleton (App Shell) -->
<div id="appShell" class="app-shell" style="display: none;">
<div class="skeleton-header"></div>
<div class="skeleton-content">
<div class="skeleton-item"></div>
<div class="skeleton-item"></div>
<div class="skeleton-item"></div>
</div>
</div>
<script src="/js/db.js"></script>
<script src="/js/app.js"></script>
</body>
</html>
تسجيل Service Worker
في js/app.js، سجل الـ service worker:
// Service Worker Registration
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('Service Worker registered:', registration.scope);
// Check for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
showUpdateNotification();
}
});
});
} catch (error) {
console.error('Service Worker registration failed:', error);
}
});
}
function showUpdateNotification() {
if (confirm('نسخة جديدة متاحة! إعادة التحميل للتحديث؟')) {
window.location.reload();
}
}
تنفيذ App Shell
الـ app shell هو الحد الأدنى من HTML و CSS و JavaScript المطلوب لتشغيل واجهة المستخدم:
// App Shell CSS (styles.css)
:root {
--primary-color: #2196F3;
--secondary-color: #FFC107;
--success-color: #4CAF50;
--danger-color: #F44336;
--text-color: #333;
--bg-color: #f5f5f5;
--white: #ffffff;
--shadow: 0 2px 8px rgba(0,0,0,0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
}
.app {
max-width: 800px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--white);
box-shadow: var(--shadow);
}
/* Header Styles */
.app-header {
background: linear-gradient(135deg, var(--primary-color) 0%, #1976D2 100%);
color: var(--white);
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.app-header h1 {
font-size: 1.5rem;
font-weight: 600;
}
/* Loading Skeleton */
.app-shell {
max-width: 800px;
margin: 0 auto;
background-color: var(--white);
}
.skeleton-header {
height: 60px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
.skeleton-content {
padding: 1rem;
}
.skeleton-item {
height: 80px;
margin-bottom: 1rem;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 8px;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
Service Worker أساسي مع التخزين المؤقت
// sw.js
const CACHE_NAME = 'taskmaster-v1';
const STATIC_CACHE = 'taskmaster-static-v1';
const DYNAMIC_CACHE = 'taskmaster-dynamic-v1';
// Assets to cache on install
const STATIC_ASSETS = [
'/',
'/index.html',
'/offline.html',
'/css/styles.css',
'/js/app.js',
'/js/db.js',
'/manifest.json',
'/images/offline.svg'
];
// Install event - cache static assets
self.addEventListener('install', event => {
console.log('Service Worker installing...');
event.waitUntil(
caches.open(STATIC_CACHE)
.then(cache => {
console.log('Caching app shell');
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting())
);
});
// Activate event - clean up old caches
self.addEventListener('activate', event => {
console.log('Service Worker activating...');
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
.map(name => {
console.log('Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request)
.then(networkResponse => {
// Cache dynamic content
if (event.request.method === 'GET') {
return caches.open(DYNAMIC_CACHE)
.then(cache => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
}
return networkResponse;
})
.catch(() => {
// Offline fallback
if (event.request.destination === 'document') {
return caches.match('/offline.html');
}
});
})
);
});
إنشاء صفحة Offline
<!-- offline.html -->
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>غير متصل - TaskMaster PWA</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
padding: 2rem;
}
.offline-container {
max-width: 500px;
}
h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
p {
font-size: 1.2rem;
margin-bottom: 2rem;
}
.retry-btn {
background-color: white;
color: #667eea;
border: none;
padding: 1rem 2rem;
font-size: 1rem;
border-radius: 50px;
cursor: pointer;
transition: transform 0.2s;
}
.retry-btn:hover {
transform: scale(1.05);
}
</style>
</head>
<body>
<div class="offline-container">
<h1>أنت غير متصل</h1>
<p>لا تقلق! مهامك محفوظة محلياً وستتم مزامنتها عندما تعود عبر الإنترنت.</p>
<button class="retry-btn" onclick="window.location.reload()">
حاول مرة أخرى
</button>
</div>
</body>
</html>
تمرين: قم بإعداد هيكل مشروع TaskMaster PWA، أنشئ ملف manifest.json، ونفذ service worker الأساسي مع التخزين المؤقت للأصول الثابتة. اختبر أن صفحة الـ offline تظهر عند تعطيل اتصال الشبكة.
الخطوات التالية: في الجزء الثاني، سنضيف IndexedDB لاستمرارية البيانات، ونطبق الإشعارات الفورية، ونضيف وظيفة مطالبة التثبيت.