اذهب إلى المحتوى

إنشاء مدوّنة باستخدام Express (الجزء 5): نظام التعليقات واللمسات النهائية


محمد فواز عرابي

بعد أن أنشأنا نظام المستخدمين والجلسات، أصبحنا جاهزين الآن لبناء نظام التّعليقات، ثم إتاحة إمكانية إنشاء تدوينة جديدة وتعديل التّدوينات السّابقة.

إتاحة التّعليق

لحفظ التّعليقات نحتاج أولًا إلى جدول جديد يحفظ نصّ التعليق وتاريخه وكاتبه والتّدوينة التي أُضيف إليها، افتح صدفة MySQL واتصّل بقاعدة البيانات ثم نفّذ هذا الاستعلام:

CREATE TABLE `comments` (id INT PRIMARY KEY AUTO_INCREMENT, post_id INT NOT NULL, user_id INT NOT NULL, body VARCHAR(500) NOT NULL, created TIMESTAMP, FOREIGN KEY (post_id) REFERENCES `posts` (id), FOREIGN KEY (user_id) REFERENCES `users` (id), INDEX (post_id));

سنُعدّل قالب صفحة التّدوينة post.jade ونضيف حقلاً يسمح للمستخدم المُسجّل دخوله بإضافة التّعليق، ويعرض للزوّار إمكانيّة تسجيل الدّخول:

doctype html
html(lang="ar", dir="rtl")
    head
        title مُدوّنتي!
    body
        style
            :css
                body {
                    font-family: Arial, sans-serif;
                }

        h1 مُدوّنتي
        hr
        h2 #{ post.title }
        p #{ post.body }
        small كُتِبَت #{ formatDate(post.date) }
        #comments
            h3 التعليقات
            if post.comments
                for comment in post.comments
                    p يقول 
                        b #{ comment.full_name }: 
                        br
                        | #{ comment.body }
                    small #{ formatDate(comment.created) }
                    hr
            else
                p لا تعليقات إلى الآن

        if user
            form(action="/posts/" + post.id + "/comments", method="POST")
                textarea(name="comment", placeholder="أكتب تعليقك")
                input(type="submit", value="أرسل التّعليق")
        else
            span لإضافة تعليقاتك، 
            a(href="/login") سجّل دخولك
            |  أو 
            a(href="/signup") أنشئ حسابًا جديدًا

لقد استبقنا الأمور قليلاً، سيرسل النّموذج الذي يشمل التّعليق إلى الرّابط ‎/posts/:post_id/comments، لنقم بتوجيه هذا الرّابط:

app.post("/posts/:post_id/comments", parseBody, function(request, response) {
    var body = request.body.comment;
    var user_id = request.user.id;
    var post_id = request.params.post_id;
    var created = new Date();

    connection.query("INSERT INTO `comments` (post_id, user_id, body, created) VALUES (?, ?, ?, ?)", [ post_id, user_id, body, created ], function(err) {
        if (err) {
            response.status(500);
            response.send("تعذّرت إضافة التّعليق، حاول مجدّدًا.");
            return;
        }

        response.status(201);
        response.send("أُضيف التعليق");
    })
})

ملاحظة: اخترنا أن يُرسل الرّابط إلى النّمط ‎/posts/:post_id/comments بدل ‎/posts/:slug/comments على سبيل تبسيط الأمور، لا يُميّز Express بين ‎:slug و‎:post_id فكلاهما بالنّسبه له متُغيّران لا يخضعان لأيّة شروط، يمكننا التأكد من كون ‎:post_id رقمًا باستخدام الوظيفة param()‎ على التّطبيق والّتي نشترط بها أن يطابق المُتغيّر post_id التعبير النظامي (regular expression) التّالي:

app.param('post_id', /^[0-9]+$/);

وعندها لن تتلقّى هذا الدّالة سوى الرّوابط التي تحمل رقمًا في موقع ‎:post_id.

هذه الشّيفرة تكفي لإضافة التّعليق، لكنّنا بحاجة إلى تعديل دالّة توجيه رابط التّدوينة لإضافة التّعليقات إلى الصّفحة:

app.get("/posts/:slug", function(request, response, next) {

    var slug = request.params.slug;

    connection.query("SELECT * from `posts` WHERE slug = ?", [ slug ], function(err, rows) {
        if (err || rows.length == 0) return next();
        var post = rows[0];
        connection.query("SELECT * FROM `comments` JOIN `users` ON comments.user_id=users.id WHERE post_id=?", [ post.id ], function(err, comments) {
            if (err) return next(err);
            post.comments = comments;
            response.render("post", { post: post, formatDate: formatDate, user: request.user });
        })
    });

})

لنُجرّب أولًا زيارة صفحة التّدوينة http://localhost:3000/posts/hello-world دون تسجيل الدّخول وقبل إضافة أيّ تعليق:

comments-none.thumb.jpg.2173fac5c11f4ee0

لنقم الآن بتسجيل الدّخول باسم admin وكلمة مرور 123456، ولنعُد للصفحة ونُحدّثها:

comments-signed-in.thumb.jpg.51b66fc222d

لنجرّب إضافة تعليق:

comments-added.thumb.jpg.2f3fbc63772e672

لنعُد لصفحة التّدوينة ونُعد تحميلها:

comments-first-comment.thumb.jpg.3635420

رائع جدًا! لقد أنشأنا نظام تعليق بسيطًا بخطوات بسيطة بعد أن أتممنا القسم الأكبر من العمل عندما أنشأنا الجلسات ونظام المستخدمين.

إتاحة كتابة التّدوينات وتعديلها لمدير المُدوّنة

لنبدأ أوّلاً بصفحة إنشاء تدوينة جديدة، ولنُنشئ رابطًا جديدًا ‎/new لعرض القالب views/post-editor.jade:

doctype html
html(lang="ar", dir="rtl")
    head
        title تدوينة جديدة
    body
        style
            :css
                body {
                    font-family: Arial, sans-serif;
                }

        h1 مُدوّنتي
        hr
        h2 تسجيل الدخول
        form(action="/posts", method="POST")
            label(for="title") عنوان التدوينة: 
            input(type="text", name="title", required)
            br
            label(for="slug") الرابط الفرعي: 
            input(type="text", name="slug" required)
            br
            label(for="body") نص التّدوينة: 
            textarea(name="body")
            br
            input(type="submit", value="أرسل التّدوينة")
app.get("/new", function(request, response, next) {
    if (request.user && request.user.is_author) {
        response.render("post-editor", { user: request.user });
    } else {
        response.status(403);
        response.send("ليس لديك صلاحيات إضافة تدوينة.");
    }
})

app.post("/posts", parseBody, function(request, response) {
    if (request.user && request.user.is_author) {
        var title = request.body.title,
            body = request.body.body,
            date = new Date(),
            author_id = request.user.id,
            slug = request.body.slug;

        connection.query("INSERT INTO `posts` (title, body, date, author_id, slug) VALUES (?, ?, ?, ?, ?)", [ title, body, date, author_id, slug ], function(err) {
            if (err) {
                response.status(500);
                response.send("تعّذرت إضافة التّدوينة");
                return;
            }

            response.status(201);
            response.send("أضيفت التّدوينة.");
        })
    } else {
        response.status(403);
        response.send("ليس لديك صلاحيات إضافة تدوينة.");
    }
})

الجزء الأكثر أهمّيّة في شفرتنا هو التّحقق من كون المستخدم يحمل صلاحيات الكتابة، فإن لم يكن كذلك، نُرسل الرّمز ‎403 Forbidden (محظور). بإمكاننا تجاوز التّحقق قبل عرض القالب ونترك عرض الرسالة المناسب للقالب ذاته، لكنّ المهمّ هو إجراء التّحقّق عند إدخال التّدوينة في فاعدة البيانات.

سنتيح تعديل التّدوينة على رابط التّدوينة ذاته متبوعًا بـ‎/edit:

app.get("/posts/:slug/edit", function(request, response, next) {
    var user_id = request.user.id;
    var slug = request.params.slug;

    connection.query("SELECT * FROM `posts` WHERE author_id=? AND slug=?", [ user_id, slug ], function(err, rows) {
        if (!err && rows[0]) {
            response.render("post-editor", { post: post });
        } else {
            response.status("401");
            response.send("إمّا أن التّدوينة غير موجودة، أو أنّك لا تملك الصلاحيات للوصول إليها");
        }        
    })
})

الجزء المهمّ من شفرتنا هو اشتراط أن يكون مؤلف التّدوينة المطلوب تعديلها هو صاحب الجلسة ذاته وهو ما كتبناه في استعلام MySQL، وإلّا سيكون بإمكان أن شخص أن يضيف ‎/edit إلى نهاية التّدوينات ويجري ما يشاء من التغييرات عليها. لنقم بتعديل القالب post-editor.jade لجعله يتعامل مع التّدوينات الموجودة مسبقًا بالإضافة إلى التّدوينات الجديدة:

doctype html
html(lang="ar", dir="rtl")
    head
        title تدوينة جديدة
    body
        style
            :css
                body {
                    font-family: Arial, sans-serif;
                }

        h1 مُدوّنتي
        hr
        - var editMode = post && post.id
        h2 #{editMode ? "تعديل التّدوينة" : "تدوينة جديدة" }
        form(action= editMode ? ("/posts/" + post.slug + "?_method=PUT") : "/posts", method="POST")
            label(for="title") عنوان التدوينة: 
            input(type="text", name="title", required, value= editMode ? post.title : "")
            br
            if !editMode
                label(for="slug") الرابط الفرعي: 
                input(type="text", name="slug" required)
                br
            label(for="body") نص التّدوينة: 
            textarea(name="body") #{ editMode ? post.body : "" }
            br
            input(type="submit", value="أرسل التّدوينة")

سيُرسل طلب التّعديل باستخدام الفعل PUT الّذي يُستخدم للطلب من الخادوم "تحديث" محتوى موجود لديه، خلافًا لـPOST المستخدم لإضافة محتوى جديد. المشكلة أنّ المتصفّحات لا تدعم استخدام سوى فعلين ضمن نماذج HTML هما POST وGET، ولذلك سنضطر إلى إيجاد طريقة "ملتوية" لتجاوز هذه المشكلة. استخدمنا الفعل POST ذاته في حالة التّعديل مع إضافة حقل إلى رابط action في النّموذج، سيستخدم هذا الحقل من قبل وحدة method-override الّتي تقرأه وتغيّر من فعل الطّلب إلى PUT ليمرّ عبر دالّة التّوجيه الّتي كتبناها:

var methodOverride = require('method-override');
app.use(methodOverride('_method'));

/*
...
*/

app.put("/posts/:slug", parseBody, function(request, response) {
    if (!requestest.user) {
        response.status(403);
        response.send("يجب تسجيل الدخول لتعديل التدوينات.");
        return;
    }

    var slug = request.params.slug;
    var new_title = request.body.title;
    var new_body = request.body.body;
    var user_id = request.user.id;

    connection.query("UPDATE `posts` SET title=?, body=? WHERE slug=? AND author_id=?", [ new_title, new_body, slug, user_id ], function(err) {
        if (err) {
            console.log(err);
            response.status("500");
            response.send("حدث خطأ أثناء تعديل التدوينة");
            return;
        }

        response.send("حُدِّثت التّدوينة.");
    })
})

تأكد من اشتراطك كون مؤلّف التّدوينة هو ذاته صاحب الجلسة مرّة ثانية قبل إدخال البيانات.

قلنا أنّ أفعال HTTP تستخدم استخدامًا دلاليًّأ (semantic) ولا شيء يُجبرك على استخدام PUT، بل يمكنك استخدام POST للحصول على نفس النّتيجة، لكنّه العرف المتّفق عليه، والذي ستعتاد على اتّباعه عندما تتقدّم في مستويات أعلى كبناء واجهة برمجيّة للمدوّنة (RESTful API) الّتي يتوقّع الطّرف الذي يتعامل معها هذا الأسلوب الدّلاليّ.

يحقّ لنا الاحتفال الآن، فقد أنشأنا مدوّنة حقيقيّة من الصّفر! لنقم الآن بتحسين مظهرها وتنظيف شيفرتنا!

تنظيف الشّيفرة

حسنًا، قد تبدو شيفرتنا طويلة في الملفّ index.js طويلة بعض الشيء وفيها الكثير من التّكرار، وحالما نُشاهد سطورًا مكرّرة في شيفرة برمجيّة، نعلم أنّ بإمكاننا كتابة شيفرة أفضل. لقد أهملنا ذلك قليلًا لنحصل على برنامج يعمل بأسرع وقت ممكن، لكن علينا الآن أن نعود لنلقي نظرة أكثر إمعانًا في برنامجنا.

تكرّر في كثير من المواضع استخدامنا للدّالة parseBody لتفسير متن طلبات POST، وحدة body-parser هي واحدة من البرامج الوسيطة التي يمكن استعمالها على مستوى التّطبيق أيضًا، لنقم بحذف عبارة parseBody من كل طلبات POST ولننقل تفسير متن الطّلب إلى مستوى التّطبيق، ذكرنا أنّه بإمكاننا استخدام app.use()‎ لذلك:

/*
...

*/

var app = express();

var parseBody = bodyParser.urlencoded({ extended: true });

app.use(session({
    secret: "my top secret",
    resave: true,
    saveUninitialized: true
}));

app.use(parseBody);

app.use(cookieParser());

// ...

سيكون من المفيد بعد إضافة تدوينة جديدة أو تعديلها أو تعليق جديد على تدوينة العودة مجدًّدا إلى هذه التّدوينة بدل عرض رسالة تفيد بنجاح العمليّة فقط، يوفّر Express الوظيفة redirect()‎ على الكائن response التي تُخبر المتصفّح بالانتقال إلى صفحة أخرى كجواب على الطّلب الّذي أُرسل. سأدع لك تنفيذ هذه المهمّات:

  • عند كتابة تدوينة جديدة، انتقل إلى صفحة هذه التّدوينة.
  • عند إضافة تعليق جديد، عُد إلى صفحة التّدوينة المعنيّة.
  • عند إنشاء مستخدم جديد، انتقل إلى صفحة تسجيل الدّخول.
  • عند تسجيل الدّخول، انتقل إلى صفحة الملفّ الشّخصيّ.

في معظم دوالّ التّوجيه التي كتبناها، قمنا بالتّحقّق من الخطأ وإرسال رسالة مناسبة مع رمز حالة مثل 404 و403... من الأفضل أن نُصمّم صفحة خطأ خاصّة تتلقّى الخطأ ورسالته وتعرضه للمستخدم بأسلوب موحّد، سنحذف كلّ عبارات response.send()‎ الّتي ترسل رسالة خطأ ونبدلها بالتّوجيه إلى الدّالة التّالية next()‎ الّتي ستعرض قالب صفحة الخطأ views/error.jade:

doctype html
html(lang="ar", dir="rtl")
    head
        title مُدوّنتي! - خطأ
    body
        style
            :css
                body {
                    font-family: Arial, sans-serif;
                }

        h1 مُدوّنتي
        hr
        h2 خطأ #{ response.statusCode }
        p #{ error.message || error.toString() }

هذا مثال عن تعديل دالّة التّوجيه للرّابط ‎/new:

app.get("/new", function(request, response, next) {
    if (request.user && request.user.is_author) {
        response.render("post-editor", { user: request.user });
    } else {
        response.status(403);
        return next(new Error("ليس لديك صلاحيات إضافة تدوينة."));
    }
})

تقبل الدّالة next معاملاً اختياريًّا يشير إلى وجود خطأ، وهي الطّريقة المناسبة لتمرير الخطأ عبر دوالّ التّوجيه، عدم تمرير الخطأ يعني أنّ التّوجيه يسير من دالّة إلى أخرى بشكل سليم، يستفيد Express من وجود هذا المعامل لعرض الخطأ في حال انتهت عمليّة التّوجيه دون توفير دالّة تتعامل معه. سيلجأ Express إلى الدّالة التّالية الّتي يجب أن نُضيفها إلى نهاية شيفرتنا:

app.use(function(err, request, response, next) {
    response.render("error", { error: err, statusCode: response.statusCode })
})

لاحظ أنّه خلافًا لدوالّ التّوجيه السّابقة، فقد استخدمنا 4 معاملات، قد تتساءل كيف يمكن لدالّة واحدة أن تتلقّى عددًا مختلفًا من المعاملات وتتصرّف بطريقة مختلفة، أو كيف تعرف الدّالة أن العنصر الأوّل هو كائن الخطأ وليس كائن الطّلب، الجواب هو أنّ Express يُجري تحقّقًا من عدد المعاملات في دالّة التّوجيه ويغيّر تصرّفه، وهذا الأمر متاح لأن JavaScript توفّر الكائن arguments بشكل تلقائيّ لكلّ الدّوالّ، والذي يمكن التّحقّق من طوله (length) بجملة شرطيّة وتغيير سلوك الدّالة. الهدف النّهائي من هذا أن يكون Express سهل الاستعمال وبديهيًّا، وهذا الأسلوب ستجده كثيرًا في Node.js. من الضّروري استخدام 4 معاملات ليستطيع Express التفريق بين: err, request, response وrequest, response, next.

لنجرّب الآن زيارة بعض الصّفحات الّتي نتوقّع حدوث خطأ عندها:

cleaning-up-error-403.thumb.jpg.0b0b8b29

cleaning-up-error-404.thumb.jpg.a1cf28c9

هذا أفضل! توحيد صفحة الخطأ سيجعلنا نفكّر في تقديم حلول لهذا الخطأ بناء على رمز الحالة، مثلاً نستطيع تقديم مربّع بحث في حال كان الرّمز 404 (غير موجود)، أو نستطيع أن نطلب من المستخدم تسجيل الدّخول في حال كان 403... إلخ.

شيفرة JavaScript نظيفة الآن، لنلقِ نظرةً على القوالب الّتي أنشأناها، يتكرّر في معظمها استخدام ترويسة للصّفحة مع استخدام تنسيق موحّد، قد يبدو تضمين CSS في الصّفحة مقبولًا الآن، لكنّنا سنحتاج إلى نقله إلى ملفّ منفصل عندما نتوسّع في إضافة الأنماط لكي لا نحتاج لتكرارها في كلّ القوالب. لنُنشئ ملفًا للأنماط style.css في مجلّد جديد ضمن المشروع نُسمّيه public، ولننقل إليه شيفرة CSS من أحد القوالب:

body {
    font-family: Arial, sans-serif;
}

لنحذف الآن شيفرة CSS من القوالب ونكتب بدلاً منها رابطًا لملفّنا:

head
    title إنشاء مستخدم جديد
    link(rel="stylesheet", href="/style.css")
body
    h1 مُدوّنتي
    //- ...

حسنًا، لن يعثر الخادوم على الملفّ style.css عندما يطلبه المتصفّح، لأنّنا نحتاج لتوفيره صراحةً. من الشّائع استضافة كلّ الملفّات الثّابتة (static) مثل ملفّات CSS وJavaScript للمتصفّح ضمن مجلّد public، ثمّ توفير هذا المجلّد بكامل محتوياته على الخادوم. تتوفّر آليّة مُدمجة في Express للقيام بذلك:

app.use(express.static(__dirname + '/public'));

تتوفّر أيضًا وحدات خارجيّة يمكنها القيام بالمهمّة ذاتها وبخيارات أكثر مثل تحديد لواحق الملفّات وأذوناتها...

مُلاحظة: المُتغيّر ‎__dirname توفّره Node.js وهو يشير إلى المجلّد الذي يحوي الملفّ الحاليّ (index.js).

سنستفيد من هذا المجلّد في استضافة ملفّات favicon وJavaScript الّتي تعمل في المتصفّح عندما نطوّر مدوّنتنا لتستخدم AJAX.

تحسين مظهر المدوّنة

سنحتاج إلى إجراء تغييرات في القوالب كإضافة بعض المُعرّفات (IDs) والأصناف (classess) لنقوم بتنسيقها وفق القواعد الّتي نكتبها في ملفّ style.css الّذي أنشأناه للتّوّ. يوفّر Express صياغتين مختصرتين للتّعبير عن الأصناف والمُعرّفات لكونهما شديدتي الشّيوع، لإضافة صنفين ومُعرّف على عنصر div ما، يمكن كتابة:

#comments.post-comments.card

وهي مكافئة لكتابة:

div(class="post-comments card", id="comments")

والتي ستُترجم إلى HTML الّتالي:

<div class="post-comments card" id="comments"></div>

لاحظ أنّك لست بحاجة لكتابة div، لأنّ Jade يفهم المغزى من هذا الأسلوب على أنّه كائن div تلقائيًّا، لأنّه الكائن الأكثر استخدامًا لإضافة الأصناف والمعرّفات بهدف تنسيق مكوّنات الصّفحة.

قد تلاحظ أثناء العمل الحاجة لتكرار أجزاء معيّنة من الشّيفرة في كلّ القوالب مثل إظهار عنوان المدوّنة وروابطها على المواقع الاجتماعيّة ومربّع البحث ضمن ترويسة (header) في كلّ الصّفحات، مع تذييل (footer) يحوي بعض الرّوابط الإضافيّة في نهاية كلّ صفحة، يمكنك التّخلصّ من عناء التّكرار باستخدام الكلمة المفتاحية extends لبناء قالب على قالب آخر، فلنقم ببناء قالب يتضمّن الهيكل العامّ لكلّ الصّفحات، ولنسمّه _layout.jade (اجعل اسمّ هذه الملفّات يبدأ بالرّمز _ لتستطيع فيما بعد تمييزها بسرعة بين ملفّات القوالب):

doctype html
html(lang="ar", dir="rtl")
    head
        title مُدوّنتي!
        link(rel="stylesheet", href="/style.css")
    body
        header
            h1#blog-title مُدوّنتي
            ul#blog-nav
                li: a(href="/") الرئيسية
                li: a(href="/login") تسجيل الدخول
                li: a(href="/signup") إنشاء حساب
        block content
        footer
            hr
            p جميع الحقوق غير محفوظة

العبارة block متبوعةً باسم نختاره نحن كما نشاء، تسمح لنا ببناء قوالب تشترك جميعها في الهيكل العّام لهذا الملفّ وتختلف فقط في هذا الجزء، مثلاً يمكننا الآن إعادة كتابة الصّفحة الرئيسيّة (home.jade) لتصبح:

extends _layout
block content
    for post in posts
        .post
            h2.post-title #{ post.title }
            p.post-body #{ post.body }
            small.post-date كُتِبَت #{ formatDate(post.date) }

سيبحث Jade عن القطعة المُسمّاة content ويضيفها في المكان المناسب للقالب. لا داع لإرفاق لاحقة الملفّ فهي معروفة بالنّسبة لـJade.

ملاحظة: التّعبير li: a(href=... هو اختصار تتيحه Jade للاستغناء عن الحاجة لكتابة الوسمين على سطرين.

كثيرًا ما تحتاج إلى إدخال أجزاء متكرّرة من HTML مع إجراء بعض التّعديلات عليها، وهذا ما يمكن تنفيذه من خلال الدّوالّ في Jade والّتي تُسمّى mixins، وهي تشبه كثيرًا الدّوال في أي لغة برمجة، لتوضيح المفهوم أكثر، لنفترض أنّنا نريد توحيد مظهر التّدوينات بين صفحة التّدوينة والصّفحة الرئيسيّة، مع فارق بسيط هو جعل نصّ التّدوينة في الصّفحة الرئيسية محدودًا بمئتي حرف مثلاً، يمكن فعل هذا بنقل شيفرة التّدوينة المفردة إلى دالّة في ملفّ منفصل نُسمّيه _mixins.jade:

mixin post(post, full)
    h2.post-title: a(href="/posts/" + post.slug) #{ post.title }
    p.post-body #{ full ? post.body : (post.body.substr(0, 199) + "...") }
    small.post-date كُتِبَت #{ formatDate(post.date) }

لاحظ كم تشبه هذه الصّياغة صياغة الدّوالّ في لغات البرمجة، حيث يمكن إمرار مُعاملات لها بين قوسين. يمكن استدعاءها في قوالبنا بالرّمز + بعد تضمين الملفّ _mixins.jade بالكلمة المفتاحية include الّتي تشبه استدعاء وحدة خارجية بـrequire في Node.js:

extends _layout
include _mixins
block content
    for post in posts
        .post
            +post(post, false)

سأقوم الآن بتنسيق المُدوّنة بأسلوبي الخاصّ، وسأترك المجال لك لتفعل الأمر ذاته! إذا أردت استلهام بعض الأفكار، أنصحك بالاطّلاع على مواقع مثل Codrops.

في الدّرس القادم سنطّلع على بعض المواضيع الّتي يجب أخذها بالحُسبان قبل نشر المدوّنة على الويب.


تفاعل الأعضاء

أفضل التعليقات



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...