0%
JavaScript الحديث: مشروع تطبيق الملاحظات

مشروع الملاحظات

تطبيق ملاحظات تفاعلي

10-15 دقيقة

JavaScript الحديث: مشروع تطبيق الملاحظات

في هذا الدرس الختامي، سنطبّق جميع مفاهيم JavaScript الحديثة التي تناولناها سابقًا من خلال بناء تطبيق متكامل لتدوين الملاحظات.

هيكل المشروع

notes-app/
├── index.html
├── styles/
│   └── main.css
└── src/
    ├── main.js
    ├── modules/
    │   ├── notes/
    │   │   ├── Note.js
    │   │   ├── NotesManager.js
    │   │   └── NotesStorage.js
    │   ├── ui/
    │   │   ├── NoteForm.js
    │   │   ├── NoteList.js
    │   │   └── Toast.js
    │   └── utils/
    │       ├── EventEmitter.js
    │       └── Storage.js
    └── config.js

الإعداد الأولي

ملف HTML الأساسي

<!-- index.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Notes App</title>
    <link rel="stylesheet" href="styles/main.css">
</head>
<body>
    <div class="container">
        <header>
            <h1>Notes App</h1>
        </header>
        
        <main>
            <form id="note-form" class="note-form">
                <input type="text" id="note-title" placeholder="Titre" required>
                <textarea id="note-content" placeholder="Contenu" required></textarea>
                <button type="submit">Ajouter</button>
            </form>
            
            <div id="notes-list" class="notes-list"></div>
        </main>
    </div>
    
    <div id="toast" class="toast"></div>
    
    <script type="module" src="src/main.js"></script>
</body>
</html>

ملف CSS الأساسي

/* styles/main.css */
:root {
    --primary-color: #4CAF50;
    --secondary-color: #2196F3;
    --error-color: #f44336;
    --text-color: #333;
    --bg-color: #f5f5f5;
}

body {
    font-family: 'Segoe UI', sans-serif;
    line-height: 1.6;
    margin: 0;
    padding: 0;
    background-color: var(--bg-color);
    color: var(--text-color);
}

.container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 2rem;
}

.note-form {
    display: flex;
    flex-direction: column;
    gap: 1rem;
    margin-bottom: 2rem;
}

.notes-list {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
    gap: 1rem;
}

.note-card {
    background: white;
    padding: 1rem;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.toast {
    position: fixed;
    bottom: 20px;
    right: 20px;
    padding: 1rem;
    border-radius: 4px;
    color: white;
    opacity: 0;
    transition: opacity 0.3s;
}

.toast.show {
    opacity: 1;
}

تنفيذ الوحدات

الإعدادات

// src/config.js
export const CONFIG = {
    STORAGE_KEY: 'notes-app-data',
    MAX_TITLE_LENGTH: 50,
    MAX_CONTENT_LENGTH: 1000,
    TOAST_DURATION: 3000
};

الصنف Note

// src/modules/notes/Note.js
export class Note {
    constructor({ id = Date.now(), title, content, createdAt = new Date() }) {
        this.id = id;
        this.title = title;
        this.content = content;
        this.createdAt = new Date(createdAt);
    }
    
    toJSON() {
        return {
            id: this.id,
            title: this.title,
            content: this.content,
            createdAt: this.createdAt.toISOString()
        };
    }
    
    static fromJSON(json) {
        return new Note(json);
    }
}

مدير التخزين

// src/modules/utils/Storage.js
export class Storage {
    constructor(key) {
        this.key = key;
    }
    
    save(data) {
        try {
            localStorage.setItem(this.key, JSON.stringify(data));
            return true;
        } catch (error) {
            console.error('Erreur de sauvegarde:', error);
            return false;
        }
    }
    
    load() {
        try {
            const data = localStorage.getItem(this.key);
            return data ? JSON.parse(data) : null;
        } catch (error) {
            console.error('Erreur de chargement:', error);
            return null;
        }
    }
}

مدير الملاحظات

// src/modules/notes/NotesManager.js
import { Note } from './Note.js';
import { Storage } from '../utils/Storage.js';
import { CONFIG } from '../../config.js';
import { EventEmitter } from '../utils/EventEmitter.js';

export class NotesManager extends EventEmitter {
    constructor() {
        super();
        this.storage = new Storage(CONFIG.STORAGE_KEY);
        this.notes = new Map();
        this.loadNotes();
    }
    
    loadNotes() {
        const data = this.storage.load() || [];
        data.forEach(noteData => {
            const note = Note.fromJSON(noteData);
            this.notes.set(note.id, note);
        });
        this.emit('notesLoaded', Array.from(this.notes.values()));
    }
    
    saveNotes() {
        const data = Array.from(this.notes.values()).map(note => note.toJSON());
        this.storage.save(data);
    }
    
    addNote(title, content) {
        const note = new Note({ title, content });
        this.notes.set(note.id, note);
        this.saveNotes();
        this.emit('noteAdded', note);
        return note;
    }
    
    deleteNote(id) {
        const note = this.notes.get(id);
        if (note) {
            this.notes.delete(id);
            this.saveNotes();
            this.emit('noteDeleted', note);
            return true;
        }
        return false;
    }
    
    updateNote(id, { title, content }) {
        const note = this.notes.get(id);
        if (note) {
            Object.assign(note, { title, content });
            this.saveNotes();
            this.emit('noteUpdated', note);
            return note;
        }
        return null;
    }
    
    getNotes() {
        return Array.from(this.notes.values())
            .sort((a, b) => b.createdAt - a.createdAt);
    }
}

واجهة المستخدم

// src/modules/ui/NoteList.js
export class NoteList {
    constructor(container) {
        this.container = container;
    }
    
    render(notes) {
        this.container.innerHTML = notes.map(note => `
            <div class="note-card" data-id="${note.id}">
                <h3>${this.escapeHtml(note.title)}</h3>
                <p>${this.escapeHtml(note.content)}</p>
                <div class="note-actions">
                    <button class="edit-btn">Modifier</button>
                    <button class="delete-btn">Supprimer</button>
                </div>
            </div>
        `).join('');
        
        this.attachEventListeners();
    }
    
    escapeHtml(unsafe) {
        return unsafe
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&#039;");
    }
    
    attachEventListeners() {
        this.container.addEventListener('click', (e) => {
            const noteCard = e.target.closest('.note-card');
            if (!noteCard) return;
            
            const noteId = noteCard.dataset.id;
            
            if (e.target.classList.contains('delete-btn')) {
                this.onDelete?.(noteId);
            } else if (e.target.classList.contains('edit-btn')) {
                this.onEdit?.(noteId);
            }
        });
    }
}

مدير الإشعارات

// src/modules/ui/Toast.js
export class Toast {
    constructor(container) {
        this.container = container;
        this.timeoutId = null;
    }
    
    show(message, type = 'info') {
        if (this.timeoutId) {
            clearTimeout(this.timeoutId);
        }
        
        this.container.textContent = message;
        this.container.className = `toast ${type} show`;
        
        this.timeoutId = setTimeout(() => {
            this.container.classList.remove('show');
        }, 3000);
    }
}

نقطة الدخول الرئيسية

// src/main.js
import { NotesManager } from './modules/notes/NotesManager.js';
import { NoteList } from './modules/ui/NoteList.js';
import { Toast } from './modules/ui/Toast.js';

class App {
    constructor() {
        this.notesManager = new NotesManager();
        this.noteList = new NoteList(document.getElementById('notes-list'));
        this.toast = new Toast(document.getElementById('toast'));
        
        this.form = document.getElementById('note-form');
        this.titleInput = document.getElementById('note-title');
        this.contentInput = document.getElementById('note-content');
        
        this.setupEventListeners();
    }
    
    setupEventListeners() {
        // النموذج
        this.form.addEventListener('submit', (e) => {
            e.preventDefault();
            this.handleSubmit();
        });
        
        // مدير الملاحظات
        this.notesManager.on('noteAdded', () => {
            this.refreshNotes();
            this.toast.show('Note ajoutée avec succès', 'success');
        });
        
        this.notesManager.on('noteDeleted', () => {
            this.refreshNotes();
            this.toast.show('Note supprimée', 'info');
        });
        
        this.notesManager.on('noteUpdated', () => {
            this.refreshNotes();
            this.toast.show('Note mise à jour', 'success');
        });
        
        // قائمة الملاحظات
        this.noteList.onDelete = (id) => {
            if (confirm('Voulez-vous vraiment supprimer cette note ?')) {
                this.notesManager.deleteNote(id);
            }
        };
        
        this.noteList.onEdit = (id) => {
            const note = this.notesManager.notes.get(id);
            if (note) {
                this.titleInput.value = note.title;
                this.contentInput.value = note.content;
                this.form.dataset.editId = id;
                this.form.querySelector('button').textContent = 'Modifier';
            }
        };
    }
    
    handleSubmit() {
        const title = this.titleInput.value.trim();
        const content = this.contentInput.value.trim();
        
        if (!title || !content) {
            this.toast.show('Veuillez remplir tous les champs', 'error');
            return;
        }
        
        const editId = this.form.dataset.editId;
        
        if (editId) {
            this.notesManager.updateNote(editId, { title, content });
            delete this.form.dataset.editId;
            this.form.querySelector('button').textContent = 'Ajouter';
        } else {
            this.notesManager.addNote(title, content);
        }
        
        this.form.reset();
    }
    
    refreshNotes() {
        this.noteList.render(this.notesManager.getNotes());
    }
    
    init() {
        this.refreshNotes();
    }
}

// تشغيل التطبيق
const app = new App();
app.init();

الميزات المتقدمة

البحث في الملاحظات

// src/modules/notes/NotesManager.js
searchNotes(query) {
    const searchTerm = query.toLowerCase();
    return this.getNotes().filter(note => 
        note.title.toLowerCase().includes(searchTerm) ||
        note.content.toLowerCase().includes(searchTerm)
    );
}

// src/main.js
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', (e) => {
    const query = e.target.value;
    const filteredNotes = this.notesManager.searchNotes(query);
    this.noteList.render(filteredNotes);
});

تصنيفات الملاحظات

// src/modules/notes/Note.js
constructor({ id = Date.now(), title, content, category = 'default', createdAt = new Date() }) {
    this.id = id;
    this.title = title;
    this.content = content;
    this.category = category;
    this.createdAt = new Date(createdAt);
}

// src/modules/notes/NotesManager.js
getNotesByCategory(category) {
    return this.getNotes().filter(note => note.category === category);
}

تصدير الملاحظات واستيرادها

// src/modules/notes/NotesManager.js
exportNotes() {
    const data = JSON.stringify(Array.from(this.notes.values())
        .map(note => note.toJSON()), null, 2);
    
    const blob = new Blob([data], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    
    const a = document.createElement('a');
    a.href = url;
    a.download = `notes-${new Date().toISOString()}.json`;
    a.click();
    
    URL.revokeObjectURL(url);
}

async importNotes(file) {
    try {
        const text = await file.text();
        const data = JSON.parse(text);
        
        data.forEach(noteData => {
            const note = Note.fromJSON(noteData);
            this.notes.set(note.id, note);
        });
        
        this.saveNotes();
        this.emit('notesLoaded', this.getNotes());
        return true;
    } catch (error) {
        console.error('Erreur d'importation:', error);
        return false;
    }
}

الاختبارات الوحدوية

// tests/NotesManager.test.js
import { NotesManager } from '../src/modules/notes/NotesManager.js';

describe('NotesManager', () => {
    let notesManager;
    
    beforeEach(() => {
        localStorage.clear();
        notesManager = new NotesManager();
    });
    
    test('should add a new note', () => {
        const note = notesManager.addNote('Test', 'Content');
        expect(notesManager.notes.size).toBe(1);
        expect(note.title).toBe('Test');
    });
    
    test('should delete a note', () => {
        const note = notesManager.addNote('Test', 'Content');
        expect(notesManager.deleteNote(note.id)).toBe(true);
        expect(notesManager.notes.size).toBe(0);
    });
    
    test('should update a note', () => {
        const note = notesManager.addNote('Test', 'Content');
        const updated = notesManager.updateNote(note.id, {
            title: 'Updated',
            content: 'New content'
        });
        
        expect(updated.title).toBe('Updated');
        expect(updated.content).toBe('New content');
    });
});

الخاتمة

يطبّق هذا المشروع مفاهيم JavaScript الحديثة التالية:

  • وحدات ES لتنظيم الشيفرة
  • الأصناف (Classes) والوراثة للبرمجة كائنية التوجه
  • Map و Set لإدارة البيانات
  • Promises و async/await للعمليات غير المتزامنة
  • Event Emitter للتواصل بين المكوّنات
  • إدارة التخزين المحلي
  • الاختبارات الوحدوية

نقاط التحسين الممكنة:

  • إضافة التحقق من صحة البيانات
  • تنفيذ السحب والإفلات (drag & drop) لإعادة ترتيب الملاحظات
  • إضافة المزامنة مع واجهة API خلفية
  • دعم وضع عدم الاتصال باستخدام Service Workers
  • إضافة اختصارات لوحة المفاتيح

هل تحتاج إلى مراجعة هياكل البيانات الحديثة؟ اطّلع على الدرس السابق حول Map و Set. تهانينا! لقد أنهيتم سلسلة الدروس التعليمية حول JavaScript الحديثة. لا تترددوا في العودة إلى المفاهيم السابقة عبر الاطلاع على بقية الدروس في السلسلة.

التعليقات

التعليقات مدعومة عبر GitHub Discussions

سجّل الدخول باستخدام GitHub للمشاركة في النقاش

تم نسخ الرابط!