بناء تطبيق ويب متكامل باستخدام SvelteKit 2: دليل عملي شامل

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

ما ستبنيه

في هذا الدرس، ستبني تطبيق إدارة ملاحظات متكامل باستخدام SvelteKit 2 — الإطار الرسمي لبناء تطبيقات Svelte. بنهاية الدرس، سيكون لديك تطبيق يعمل بالكامل يتضمن:

  • توجيه قائم على الملفات مع تخطيطات متداخلة
  • عرض من جانب الخادم (SSR) لأداء فائق السرعة
  • Form Actions لمعالجة النماذج بدون JavaScript
  • مسارات API لعمليات CRUD كاملة
  • تخزين بيانات باستخدام SQLite عبر better-sqlite3
  • TypeScript مدمج بالكامل
  • تصميم متجاوب مع CSS حديث

الوقت المطلوب: 60-90 دقيقة


المتطلبات الأساسية

قبل البدء، تأكد من توفر:

  1. Node.js 18+ — شغّل node --version للتحقق
  2. معرفة أساسية بـ HTML و CSS و JavaScript
  3. إلمام بأساسيات Svelte (مفيد لكن ليس ضرورياً)
  4. محرر أكواد (يُنصح بـ VS Code مع إضافة Svelte)

لماذا SvelteKit 2؟

ما هو SvelteKit؟

SvelteKit هو الإطار الرسمي لبناء تطبيقات ويب باستخدام Svelte. يجمع بين أفضل ميزات أطر العمل الحديثة:

الميزةالوصف
توجيه الملفاتهيكل المجلدات يحدد المسارات تلقائياً
SSR + CSRعرض هجين من الخادم والعميل
Form Actionsمعالجة النماذج بدون JavaScript على العميل
محولات النشرنشر على أي منصة (Vercel، Node، Cloudflare)
أداء استثنائيحجم حزم أصغر مقارنة بـ React/Next.js

لماذا Svelte بدلاً من React؟

Svelte يعمل كمترجم (compiler) بدلاً من مكتبة وقت التشغيل. هذا يعني:

  • حجم حزم أصغر بـ 40-70% مقارنة بـ React
  • لا Virtual DOM — تحديثات مباشرة على DOM الحقيقي
  • كود أقل — بنية أبسط وأوضح
  • تفاعلية مدمجة — بدون useState أو useEffect

الخطوة 1: إنشاء المشروع

ابدأ بإنشاء مشروع SvelteKit جديد:

npx sv create notes-app

عند ظهور الخيارات، اختر:

┌  Welcome to the Svelte CLI!
│
◇  Which template would you like?
│  SvelteKit minimal
│
◇  Add type checking with TypeScript?
│  Yes, using TypeScript syntax
│
◇  What would you like to add to your project?
│  prettier, eslint
│
◇  Which package manager do you want to install dependencies with?
│  npm
│
└  You're all set!

ادخل إلى المجلد وشغّل الخادم:

cd notes-app
npm run dev

افتح http://localhost:5173 — سترى صفحة الترحيب الافتراضية.


الخطوة 2: فهم هيكل المشروع

notes-app/
├── src/
│   ├── lib/           # مكتبات ومكونات مشتركة
│   │   └── index.ts
│   ├── routes/        # صفحات التطبيق (توجيه الملفات)
│   │   └── +page.svelte
│   ├── app.html       # قالب HTML الرئيسي
│   └── app.d.ts       # أنواع TypeScript
├── static/            # ملفات ثابتة (صور، خطوط)
├── svelte.config.js   # إعدادات SvelteKit
├── tsconfig.json
├── vite.config.ts
└── package.json

الملفات الخاصة في SvelteKit

الملفالوظيفة
+page.svelteواجهة الصفحة (UI)
+page.server.tsمنطق الخادم (تحميل البيانات، Form Actions)
+page.tsتحميل بيانات عالمي (خادم + عميل)
+layout.svelteتخطيط مشترك يلف الصفحات
+layout.server.tsبيانات مشتركة للتخطيط
+server.tsمسار API (GET, POST, PUT, DELETE)
+error.svelteصفحة خطأ مخصصة

الخطوة 3: إعداد قاعدة البيانات

ثبّت better-sqlite3 لإدارة البيانات:

npm install better-sqlite3
npm install -D @types/better-sqlite3

أنشئ ملف إعداد قاعدة البيانات:

// src/lib/server/db.ts
import Database from 'better-sqlite3';
import { dev } from '$app/environment';
import path from 'path';
 
const dbPath = dev ? 'notes.db' : path.join(process.cwd(), 'notes.db');
const db = new Database(dbPath);
 
// تفعيل WAL mode لأداء أفضل
db.pragma('journal_mode = WAL');
 
// إنشاء جدول الملاحظات
db.exec(`
  CREATE TABLE IF NOT EXISTS notes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    content TEXT NOT NULL DEFAULT '',
    color TEXT NOT NULL DEFAULT '#ffffff',
    pinned INTEGER NOT NULL DEFAULT 0,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
  )
`);
 
export default db;

أنشئ ملف الأنواع:

// src/lib/types.ts
export interface Note {
  id: number;
  title: string;
  content: string;
  color: string;
  pinned: number;
  created_at: string;
  updated_at: string;
}

الخطوة 4: بناء التخطيط الرئيسي

استبدل محتوى التخطيط الرئيسي:

<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';
 
  let { children }: { children: Snippet } = $props();
</script>
 
<div class="app">
  <header>
    <nav>
      <a href="/" class="logo">📝 ملاحظاتي</a>
      <div class="nav-links">
        <a href="/">الرئيسية</a>
        <a href="/notes">الملاحظات</a>
        <a href="/about">حول التطبيق</a>
      </div>
    </nav>
  </header>
 
  <main>
    {@render children()}
  </main>
 
  <footer>
    <p>تم بناؤه باستخدام SvelteKit 2 &copy; {new Date().getFullYear()}</p>
  </footer>
</div>
 
<style>
  :global(body) {
    margin: 0;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
    background: #f5f5f5;
    color: #333;
    direction: ltr;
  }
 
  .app {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
  }
 
  header {
    background: #1a1a2e;
    color: white;
    padding: 1rem 2rem;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  }
 
  nav {
    max-width: 1200px;
    margin: 0 auto;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }
 
  .logo {
    font-size: 1.5rem;
    font-weight: bold;
    color: white;
    text-decoration: none;
  }
 
  .nav-links {
    display: flex;
    gap: 1.5rem;
  }
 
  .nav-links a {
    color: #a8a8b3;
    text-decoration: none;
    transition: color 0.2s;
  }
 
  .nav-links a:hover {
    color: white;
  }
 
  main {
    flex: 1;
    max-width: 1200px;
    margin: 0 auto;
    padding: 2rem;
    width: 100%;
    box-sizing: border-box;
  }
 
  footer {
    background: #1a1a2e;
    color: #a8a8b3;
    text-align: center;
    padding: 1rem;
    margin-top: auto;
  }
</style>

الخطوة 5: الصفحة الرئيسية

<!-- src/routes/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';
 
  let { data }: { data: PageData } = $props();
</script>
 
<svelte:head>
  <title>ملاحظاتي — تطبيق SvelteKit</title>
</svelte:head>
 
<section class="hero">
  <h1>مرحباً بك في تطبيق الملاحظات</h1>
  <p>تطبيق متكامل مبني بـ SvelteKit 2 مع TypeScript و SQLite</p>
  <a href="/notes" class="cta-button">ابدأ الآن →</a>
</section>
 
<section class="stats">
  <div class="stat-card">
    <span class="stat-number">{data.totalNotes}</span>
    <span class="stat-label">ملاحظة</span>
  </div>
  <div class="stat-card">
    <span class="stat-number">{data.pinnedNotes}</span>
    <span class="stat-label">مثبتة</span>
  </div>
  <div class="stat-card">
    <span class="stat-number">{data.recentNotes}</span>
    <span class="stat-label">هذا الأسبوع</span>
  </div>
</section>
 
<style>
  .hero {
    text-align: center;
    padding: 4rem 2rem;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    border-radius: 16px;
    color: white;
    margin-bottom: 2rem;
  }
 
  .hero h1 {
    font-size: 2.5rem;
    margin-bottom: 1rem;
  }
 
  .hero p {
    font-size: 1.2rem;
    opacity: 0.9;
    margin-bottom: 2rem;
  }
 
  .cta-button {
    display: inline-block;
    padding: 0.8rem 2rem;
    background: white;
    color: #667eea;
    border-radius: 8px;
    text-decoration: none;
    font-weight: bold;
    font-size: 1.1rem;
    transition: transform 0.2s;
  }
 
  .cta-button:hover {
    transform: translateY(-2px);
  }
 
  .stats {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 1.5rem;
  }
 
  .stat-card {
    background: white;
    padding: 2rem;
    border-radius: 12px;
    text-align: center;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  }
 
  .stat-number {
    display: block;
    font-size: 3rem;
    font-weight: bold;
    color: #667eea;
  }
 
  .stat-label {
    display: block;
    color: #666;
    margin-top: 0.5rem;
  }
</style>

أنشئ ملف تحميل البيانات:

// src/routes/+page.server.ts
import db from '$lib/server/db';
 
export function load() {
  const totalNotes = db.prepare('SELECT COUNT(*) as count FROM notes').get() as { count: number };
  const pinnedNotes = db.prepare('SELECT COUNT(*) as count FROM notes WHERE pinned = 1').get() as { count: number };
  const recentNotes = db.prepare(
    "SELECT COUNT(*) as count FROM notes WHERE created_at >= datetime('now', '-7 days')"
  ).get() as { count: number };
 
  return {
    totalNotes: totalNotes.count,
    pinnedNotes: pinnedNotes.count,
    recentNotes: recentNotes.count
  };
}

الخطوة 6: صفحة قائمة الملاحظات

هذه هي الصفحة الأساسية — عرض وإضافة الملاحظات باستخدام Form Actions:

// src/routes/notes/+page.server.ts
import db from '$lib/server/db';
import type { Note } from '$lib/types';
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
 
export function load() {
  const notes = db.prepare(
    'SELECT * FROM notes ORDER BY pinned DESC, updated_at DESC'
  ).all() as Note[];
 
  return { notes };
}
 
export const actions: Actions = {
  create: async ({ request }) => {
    const formData = await request.formData();
    const title = formData.get('title')?.toString().trim();
    const content = formData.get('content')?.toString().trim() ?? '';
    const color = formData.get('color')?.toString() ?? '#ffffff';
 
    if (!title) {
      return fail(400, { error: 'العنوان مطلوب', title, content, color });
    }
 
    if (title.length > 200) {
      return fail(400, { error: 'العنوان طويل جداً (الحد الأقصى 200 حرف)', title, content, color });
    }
 
    db.prepare(
      'INSERT INTO notes (title, content, color) VALUES (?, ?, ?)'
    ).run(title, content, color);
 
    return { success: true };
  },
 
  delete: async ({ request }) => {
    const formData = await request.formData();
    const id = formData.get('id');
 
    if (!id) {
      return fail(400, { error: 'معرّف الملاحظة مطلوب' });
    }
 
    db.prepare('DELETE FROM notes WHERE id = ?').run(id);
    return { success: true };
  },
 
  togglePin: async ({ request }) => {
    const formData = await request.formData();
    const id = formData.get('id');
 
    if (!id) {
      return fail(400, { error: 'معرّف الملاحظة مطلوب' });
    }
 
    db.prepare(
      'UPDATE notes SET pinned = CASE WHEN pinned = 1 THEN 0 ELSE 1 END, updated_at = datetime(\'now\') WHERE id = ?'
    ).run(id);
 
    return { success: true };
  }
};
<!-- src/routes/notes/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { PageData, ActionData } from './$types';
 
  let { data, form }: { data: PageData; form: ActionData } = $props();
 
  let showForm = $state(false);
 
  const colors = [
    { value: '#ffffff', label: 'أبيض' },
    { value: '#fff3cd', label: 'أصفر' },
    { value: '#d1ecf1', label: 'أزرق' },
    { value: '#d4edda', label: 'أخضر' },
    { value: '#f8d7da', label: 'وردي' },
    { value: '#e2d9f3', label: 'بنفسجي' }
  ];
</script>
 
<svelte:head>
  <title>الملاحظات — تطبيق SvelteKit</title>
</svelte:head>
 
<div class="notes-page">
  <div class="page-header">
    <h1>ملاحظاتي</h1>
    <button class="add-btn" onclick={() => showForm = !showForm}>
      {showForm ? '✕ إلغاء' : '+ ملاحظة جديدة'}
    </button>
  </div>
 
  {#if showForm}
    <form method="POST" action="?/create" use:enhance class="note-form">
      {#if form?.error}
        <div class="error">{form.error}</div>
      {/if}
 
      <input
        type="text"
        name="title"
        placeholder="عنوان الملاحظة..."
        value={form?.title ?? ''}
        required
        maxlength="200"
      />
 
      <textarea
        name="content"
        placeholder="محتوى الملاحظة..."
        rows="4"
      >{form?.content ?? ''}</textarea>
 
      <div class="color-picker">
        <span>اللون:</span>
        {#each colors as color}
          <label class="color-option">
            <input
              type="radio"
              name="color"
              value={color.value}
              checked={color.value === (form?.color ?? '#ffffff')}
            />
            <span
              class="color-swatch"
              style="background: {color.value}"
              title={color.label}
            ></span>
          </label>
        {/each}
      </div>
 
      <button type="submit" class="submit-btn">حفظ الملاحظة</button>
    </form>
  {/if}
 
  {#if data.notes.length === 0}
    <div class="empty-state">
      <p>لا توجد ملاحظات بعد. أضف ملاحظتك الأولى!</p>
    </div>
  {:else}
    <div class="notes-grid">
      {#each data.notes as note (note.id)}
        <div class="note-card" style="background: {note.color}">
          <div class="note-header">
            <h3>{note.title}</h3>
            <div class="note-actions">
              <form method="POST" action="?/togglePin" use:enhance>
                <input type="hidden" name="id" value={note.id} />
                <button type="submit" class="icon-btn" title={note.pinned ? 'إلغاء التثبيت' : 'تثبيت'}>
                  {note.pinned ? '📌' : '📍'}
                </button>
              </form>
              <form method="POST" action="?/delete" use:enhance>
                <input type="hidden" name="id" value={note.id} />
                <button type="submit" class="icon-btn delete" title="حذف">🗑️</button>
              </form>
            </div>
          </div>
 
          {#if note.content}
            <p class="note-content">{note.content}</p>
          {/if}
 
          <time class="note-date">
            {new Date(note.updated_at).toLocaleDateString('ar-SA', {
              year: 'numeric',
              month: 'short',
              day: 'numeric'
            })}
          </time>
        </div>
      {/each}
    </div>
  {/if}
</div>
 
<style>
  .notes-page {
    max-width: 900px;
    margin: 0 auto;
  }
 
  .page-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 2rem;
  }
 
  .page-header h1 {
    font-size: 2rem;
    color: #1a1a2e;
  }
 
  .add-btn {
    padding: 0.6rem 1.2rem;
    background: #667eea;
    color: white;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    font-size: 1rem;
    transition: background 0.2s;
  }
 
  .add-btn:hover {
    background: #5a6fd6;
  }
 
  .note-form {
    background: white;
    padding: 1.5rem;
    border-radius: 12px;
    margin-bottom: 2rem;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
    display: flex;
    flex-direction: column;
    gap: 1rem;
  }
 
  .error {
    background: #f8d7da;
    color: #721c24;
    padding: 0.8rem;
    border-radius: 6px;
    font-size: 0.9rem;
  }
 
  input[type="text"],
  textarea {
    padding: 0.8rem;
    border: 2px solid #e0e0e0;
    border-radius: 8px;
    font-size: 1rem;
    transition: border-color 0.2s;
    font-family: inherit;
  }
 
  input[type="text"]:focus,
  textarea:focus {
    outline: none;
    border-color: #667eea;
  }
 
  .color-picker {
    display: flex;
    align-items: center;
    gap: 0.8rem;
  }
 
  .color-option {
    cursor: pointer;
  }
 
  .color-option input {
    display: none;
  }
 
  .color-swatch {
    display: inline-block;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    border: 2px solid #ddd;
    transition: transform 0.2s;
  }
 
  .color-option input:checked + .color-swatch {
    border-color: #667eea;
    transform: scale(1.2);
  }
 
  .submit-btn {
    padding: 0.8rem;
    background: #667eea;
    color: white;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    font-size: 1rem;
    font-weight: bold;
  }
 
  .submit-btn:hover {
    background: #5a6fd6;
  }
 
  .empty-state {
    text-align: center;
    padding: 4rem 2rem;
    color: #999;
    font-size: 1.1rem;
  }
 
  .notes-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 1.5rem;
  }
 
  .note-card {
    padding: 1.5rem;
    border-radius: 12px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
    transition: transform 0.2s, box-shadow 0.2s;
  }
 
  .note-card:hover {
    transform: translateY(-2px);
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
  }
 
  .note-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    margin-bottom: 0.8rem;
  }
 
  .note-header h3 {
    margin: 0;
    font-size: 1.1rem;
    color: #1a1a2e;
    flex: 1;
  }
 
  .note-actions {
    display: flex;
    gap: 0.3rem;
  }
 
  .icon-btn {
    background: none;
    border: none;
    cursor: pointer;
    font-size: 1rem;
    padding: 0.2rem;
    opacity: 0.6;
    transition: opacity 0.2s;
  }
 
  .icon-btn:hover {
    opacity: 1;
  }
 
  .note-content {
    color: #555;
    font-size: 0.95rem;
    line-height: 1.5;
    margin-bottom: 1rem;
  }
 
  .note-date {
    font-size: 0.8rem;
    color: #999;
  }
</style>

الخطوة 7: صفحة تعديل الملاحظة

أنشئ مسار صفحة التعديل مع معامل ديناميكي:

// src/routes/notes/[id]/+page.server.ts
import db from '$lib/server/db';
import type { Note } from '$lib/types';
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
 
export const load: PageServerLoad = async ({ params }) => {
  const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(params.id) as Note | undefined;
 
  if (!note) {
    throw error(404, 'الملاحظة غير موجودة');
  }
 
  return { note };
};
 
export const actions: Actions = {
  update: async ({ request, params }) => {
    const formData = await request.formData();
    const title = formData.get('title')?.toString().trim();
    const content = formData.get('content')?.toString().trim() ?? '';
    const color = formData.get('color')?.toString() ?? '#ffffff';
 
    if (!title) {
      return fail(400, { error: 'العنوان مطلوب' });
    }
 
    db.prepare(
      "UPDATE notes SET title = ?, content = ?, color = ?, updated_at = datetime('now') WHERE id = ?"
    ).run(title, content, color, params.id);
 
    redirect(303, '/notes');
  }
};
<!-- src/routes/notes/[id]/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { PageData, ActionData } from './$types';
 
  let { data, form }: { data: PageData; form: ActionData } = $props();
 
  const colors = [
    { value: '#ffffff', label: 'أبيض' },
    { value: '#fff3cd', label: 'أصفر' },
    { value: '#d1ecf1', label: 'أزرق' },
    { value: '#d4edda', label: 'أخضر' },
    { value: '#f8d7da', label: 'وردي' },
    { value: '#e2d9f3', label: 'بنفسجي' }
  ];
</script>
 
<svelte:head>
  <title>تعديل: {data.note.title}</title>
</svelte:head>
 
<div class="edit-page">
  <h1>تعديل الملاحظة</h1>
 
  <form method="POST" action="?/update" use:enhance class="edit-form">
    {#if form?.error}
      <div class="error">{form.error}</div>
    {/if}
 
    <label>
      العنوان
      <input type="text" name="title" value={data.note.title} required maxlength="200" />
    </label>
 
    <label>
      المحتوى
      <textarea name="content" rows="8">{data.note.content}</textarea>
    </label>
 
    <div class="color-picker">
      <span>اللون:</span>
      {#each colors as color}
        <label class="color-option">
          <input
            type="radio"
            name="color"
            value={color.value}
            checked={color.value === data.note.color}
          />
          <span class="color-swatch" style="background: {color.value}" title={color.label}></span>
        </label>
      {/each}
    </div>
 
    <div class="form-actions">
      <a href="/notes" class="cancel-btn">إلغاء</a>
      <button type="submit" class="save-btn">حفظ التعديلات</button>
    </div>
  </form>
</div>
 
<style>
  .edit-page {
    max-width: 700px;
    margin: 0 auto;
  }
 
  .edit-page h1 {
    color: #1a1a2e;
    margin-bottom: 1.5rem;
  }
 
  .edit-form {
    background: white;
    padding: 2rem;
    border-radius: 12px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
    display: flex;
    flex-direction: column;
    gap: 1.2rem;
  }
 
  label {
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
    font-weight: 500;
    color: #333;
  }
 
  input[type="text"],
  textarea {
    padding: 0.8rem;
    border: 2px solid #e0e0e0;
    border-radius: 8px;
    font-size: 1rem;
    font-family: inherit;
  }
 
  input[type="text"]:focus,
  textarea:focus {
    outline: none;
    border-color: #667eea;
  }
 
  .error {
    background: #f8d7da;
    color: #721c24;
    padding: 0.8rem;
    border-radius: 6px;
  }
 
  .color-picker {
    display: flex;
    align-items: center;
    gap: 0.8rem;
  }
 
  .color-option input { display: none; }
 
  .color-swatch {
    display: inline-block;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    border: 2px solid #ddd;
    cursor: pointer;
    transition: transform 0.2s;
  }
 
  .color-option input:checked + .color-swatch {
    border-color: #667eea;
    transform: scale(1.2);
  }
 
  .form-actions {
    display: flex;
    justify-content: flex-end;
    gap: 1rem;
    margin-top: 1rem;
  }
 
  .cancel-btn {
    padding: 0.8rem 1.5rem;
    color: #666;
    text-decoration: none;
    border-radius: 8px;
    border: 2px solid #e0e0e0;
  }
 
  .save-btn {
    padding: 0.8rem 1.5rem;
    background: #667eea;
    color: white;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    font-weight: bold;
    font-size: 1rem;
  }
 
  .save-btn:hover {
    background: #5a6fd6;
  }
</style>

الخطوة 8: مسارات API

أنشئ مسارات API RESTful للتعامل مع الملاحظات برمجياً:

// src/routes/api/notes/+server.ts
import db from '$lib/server/db';
import type { Note } from '$lib/types';
import { json } from '@sveltejs/kit';
 
export function GET({ url }) {
  const search = url.searchParams.get('search');
  const pinned = url.searchParams.get('pinned');
 
  let query = 'SELECT * FROM notes';
  const conditions: string[] = [];
  const params: unknown[] = [];
 
  if (search) {
    conditions.push('(title LIKE ? OR content LIKE ?)');
    params.push(`%${search}%`, `%${search}%`);
  }
 
  if (pinned === 'true') {
    conditions.push('pinned = 1');
  }
 
  if (conditions.length > 0) {
    query += ' WHERE ' + conditions.join(' AND ');
  }
 
  query += ' ORDER BY pinned DESC, updated_at DESC';
 
  const notes = db.prepare(query).all(...params) as Note[];
  return json(notes);
}
 
export async function POST({ request }) {
  const { title, content, color } = await request.json();
 
  if (!title?.trim()) {
    return json({ error: 'العنوان مطلوب' }, { status: 400 });
  }
 
  const result = db.prepare(
    'INSERT INTO notes (title, content, color) VALUES (?, ?, ?)'
  ).run(title.trim(), content?.trim() ?? '', color ?? '#ffffff');
 
  const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(result.lastInsertRowid) as Note;
  return json(note, { status: 201 });
}
// src/routes/api/notes/[id]/+server.ts
import db from '$lib/server/db';
import type { Note } from '$lib/types';
import { json, error } from '@sveltejs/kit';
 
export function GET({ params }) {
  const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(params.id) as Note | undefined;
 
  if (!note) {
    throw error(404, 'الملاحظة غير موجودة');
  }
 
  return json(note);
}
 
export async function PUT({ params, request }) {
  const { title, content, color } = await request.json();
 
  if (!title?.trim()) {
    return json({ error: 'العنوان مطلوب' }, { status: 400 });
  }
 
  const existing = db.prepare('SELECT id FROM notes WHERE id = ?').get(params.id);
  if (!existing) {
    throw error(404, 'الملاحظة غير موجودة');
  }
 
  db.prepare(
    "UPDATE notes SET title = ?, content = ?, color = ?, updated_at = datetime('now') WHERE id = ?"
  ).run(title.trim(), content?.trim() ?? '', color ?? '#ffffff', params.id);
 
  const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(params.id) as Note;
  return json(note);
}
 
export function DELETE({ params }) {
  const existing = db.prepare('SELECT id FROM notes WHERE id = ?').get(params.id);
  if (!existing) {
    throw error(404, 'الملاحظة غير موجودة');
  }
 
  db.prepare('DELETE FROM notes WHERE id = ?').run(params.id);
  return json({ success: true });
}

الخطوة 9: صفحة الخطأ المخصصة

<!-- src/routes/+error.svelte -->
<script lang="ts">
  import { page } from '$app/state';
</script>
 
<div class="error-page">
  <h1>{page.status}</h1>
  <p>{page.error?.message ?? 'حدث خطأ غير متوقع'}</p>
  <a href="/">العودة للرئيسية</a>
</div>
 
<style>
  .error-page {
    text-align: center;
    padding: 4rem 2rem;
  }
 
  .error-page h1 {
    font-size: 6rem;
    color: #667eea;
    margin-bottom: 0.5rem;
  }
 
  .error-page p {
    font-size: 1.3rem;
    color: #666;
    margin-bottom: 2rem;
  }
 
  .error-page a {
    padding: 0.8rem 2rem;
    background: #667eea;
    color: white;
    border-radius: 8px;
    text-decoration: none;
  }
</style>

الخطوة 10: Hooks و Middleware

SvelteKit يوفر نظام hooks قوي لمعالجة الطلبات:

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
 
export const handle: Handle = async ({ event, resolve }) => {
  // قياس وقت الاستجابة
  const start = Date.now();
 
  const response = await resolve(event);
 
  const duration = Date.now() - start;
  console.log(`${event.request.method} ${event.url.pathname} — ${duration}ms`);
 
  // إضافة headers أمان
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
 
  return response;
};

الخطوة 11: التحميل التدريجي مع Streaming

ميزة متقدمة في SvelteKit — يمكنك بث البيانات البطيئة تدريجياً:

// src/routes/notes/+page.server.ts (نسخة محسّنة)
import db from '$lib/server/db';
import type { Note } from '$lib/types';
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
 
export function load() {
  // البيانات السريعة تُحمّل فوراً
  const notes = db.prepare(
    'SELECT * FROM notes ORDER BY pinned DESC, updated_at DESC'
  ).all() as Note[];
 
  // البيانات البطيئة تُبث لاحقاً (مثال: إحصائيات معقدة)
  const stats = new Promise<{ wordCount: number }>((resolve) => {
    const result = db.prepare(
      "SELECT SUM(LENGTH(content) - LENGTH(REPLACE(content, ' ', '')) + 1) as wordCount FROM notes"
    ).get() as { wordCount: number | null };
    resolve({ wordCount: result.wordCount ?? 0 });
  });
 
  return {
    notes,
    streamed: { stats }
  };
}
 
// ... actions تبقى كما هي

واستخدام البيانات المبثوثة في الواجهة:

{#await data.streamed.stats}
  <p class="loading">جاري حساب الإحصائيات...</p>
{:then stats}
  <p class="word-count">إجمالي الكلمات: {stats.wordCount}</p>
{/await}

الخطوة 12: إعداد النشر

النشر على Node.js

npm install @sveltejs/adapter-node
// svelte.config.js
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
 
const config = {
  preprocess: vitePreprocess(),
  kit: {
    adapter: adapter({
      out: 'build',
      precompress: true
    })
  }
};
 
export default config;
# بناء ونشر
npm run build
node build

النشر على Vercel

# لا حاجة لتغيير المحول — المحول الافتراضي يعمل مع Vercel
npx vercel

النشر عبر Docker

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
 
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "build"]

اختبار التطبيق

تشغيل التطبيق محلياً

npm run dev

اختبار المسارات

  1. الصفحة الرئيسية: http://localhost:5173 — تعرض الإحصائيات
  2. الملاحظات: http://localhost:5173/notes — أضف وعدّل واحذف
  3. API: curl http://localhost:5173/api/notes — ترجع JSON

اختبار Form Actions

Form Actions تعمل بدون JavaScript! جرّب تعطيل JS في المتصفح — النماذج ستستمر بالعمل لأنها تُعالج على الخادم.


استكشاف الأخطاء

خطأ: Cannot find module '$lib/server/db'

تأكد من إنشاء المجلد src/lib/server/ وأن الملف db.ts موجود فيه.

خطأ: better-sqlite3 لا يعمل

npm rebuild better-sqlite3

صفحة 404 للمسارات الديناميكية

تأكد من أن اسم المجلد يحتوي على أقواس مربعة: [id] وليس id.

البيانات لا تتحدث في الصفحة

تحقق من أن use:enhance مضاف إلى عنصر <form> — بدونه ستُعاد تحميل الصفحة بالكامل.


ما تعلمته

في هذا الدرس، تعلمت كيفية:

  1. إنشاء مشروع SvelteKit 2 من الصفر
  2. نظام التوجيه القائم على الملفات مع المعاملات الديناميكية
  3. Form Actions لمعالجة النماذج بأمان على الخادم
  4. تحميل البيانات باستخدام وظائف load
  5. مسارات API مع GET و POST و PUT و DELETE
  6. بث البيانات (Streaming) للتحميل التدريجي
  7. Hooks لمعالجة الطلبات وإضافة middleware
  8. النشر على Node.js و Vercel و Docker

الخطوات التالية

  • المصادقة: أضف تسجيل دخول باستخدام Lucia Auth
  • قاعدة بيانات إنتاجية: استبدل SQLite بـ PostgreSQL عبر Drizzle ORM
  • اختبارات: أضف اختبارات E2E باستخدام Playwright
  • PWA: حوّل التطبيق إلى تطبيق ويب تقدمي
  • الوثائق الرسمية: svelte.dev/docs/kit

هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على دليل واجهة برمجة تطبيقات قوالب HeyGen.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة