<header class="flex items-center">

<NuxtImg src="logo.png" class="w-18" alt="Dywan Dev - Digital tools & templates" format="webp" quality="80" loading="lazy" />

<Item><nuxt-link href="/">Home</nuxt-link></Item>

<Item><nuxt-link href="/about">About</nuxt-link></Item>

<Item><nuxt-link href="/works"> Portfolio</nuxt-link></Item>

<Item><nuxt-link href="/contact_me"> Contact</nuxt-link></Item>

<a href="/contact-me" title="Get in touch with Dywan Dev"> Contact </a>

<themeswitch class="moon">Dark/Light</themeswitch>

<langswitch class="en">en/fa</langswitch>

</header>

<initilizecontent class="content">Hello World - Dywan Dev</initilizecontent>

<myname is="Dywan Dev" />

<jobtitle is="Digital tools · Templates · Case studies" />

<portfolios are="ready" number="8" />

<experience years="more than 6" />

<birthdate year="1998" month="july" day="11" />

<skills :list="['NUXT', 'Vue', 'React', 'Next', 'Javascript','Responsive Design', 'i18n', 'TypeScript]" />

<contactDetails :list="['contact@dywandev.com', 'linkedin.com/in/dywan-dev', 'github.com/dywan-dev']" />

Dywan Dev
المدونة
كيف تبني بورتفوليو فني Fullstack باستخدام Laravel وInertia.js وVue.js

كيف تبني بورتفوليو فني Fullstack باستخدام Laravel وInertia.js وVue.js

مقدمة

بورتفوليو الفنان ليس بطاقة عمل. إنه معرض حي.

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

هذا هو التحدي الذي حللته عند بناء Chat Rouge Art — منصة بورتفوليو فني Fullstack مبنية بـ Laravel وInertia.js وVue.js. معرض عام يبدو حيًا، مدعوم بلوحة تحكم إدارية كاملة يتحكم بها الفنان بالكامل.

هذا الدليل يوثّق المعمارية، والقرارات، وتفاصيل التنفيذ التي جعلت ذلك يعمل.

لماذا Laravel Inertia.js وVue.js

قبل كتابة أي سطر كود، اختيار الـ Stack مهم. بالنسبة لبورتفوليو فنان مع لوحة تحكم، هناك ثلاثة متطلبات تحدد القرار:

تعقيد الـ Backend حقيقي. مصادقة المستخدمين، إدارة الأعمال الفنية، الفلترة حسب التصنيف، معالجة نموذج التواصل، وتحسين الصور — هذا ليس موقعًا ثابتًا. يحتاج Backend حقيقي. Laravel يتعامل مع كل ذلك بأناقة وبأقل boilerplate.

الواجهة الأمامية يجب أن تبدو Native. انتقالات الصفحات، حركات سلسة، فلترة تفاعلية — SPA بـ Vue.js تقدم هذه التجربة. لكن بناء طبقة API منفصلة يضيف عبئًا لا يستطيع مطور مستقل يعمل لعميل واحد تحمله.

Inertia.js تلغي هذه الفجوة. تربط Backend Laravel مباشرة بمكونات Vue.js بدون REST API. الـ Controllers تعيد Inertia responses. ومكونات Vue تستقبل props مباشرة. النتيجة تطبيق Fullstack يبدو كـ SPA لكنه مُصمَّم كهيكل تطبيق Server-rendered تقليدي — مع الحفاظ على قوة Laravel كاملة.

1. إعداد المشروع

ابدأ بتثبيت Laravel جديد ثم أضف Inertia.js مع محول Vue.js:

composer create-project laravel/laravel chat-rouge-art
cd chat-rouge-art

composer require inertiajs/inertia-laravel
npm install @inertiajs/vue3 vue@3
npm install -D @vitejs/plugin-vue

اضبط Vite لدعم Vue داخل vite.config.js:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [
    laravel({
      input: ['resources/js/app.js'],
      refresh: true
    }),
    vue({
      template: {
        transformAssetUrls: {
          base: null,
          includeAbsolute: false
        }
      }
    })
  ]
});

اضبط Inertia middleware في app/Http/Middleware/HandleInertiaRequests.php:

<?php

namespace App\Http\Middleware;

use Illuminate\Http\Request;
use Inertia\Middleware;

class HandleInertiaRequests extends Middleware
{
    protected $rootView = 'app';

    public function share(Request $request): array
    {
        return array_merge(parent::share($request), [
            'auth' => [
                'user' => $request->user()
            ],
            'flash' => [
                'success' => session('success'),
                'error' => session('error')
            ]
        ]);
    }
}

سجّله في bootstrap/app.php وأنشئ Blade root view في resources/views/app.blade.php:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title inertia>{{ config('app.name') }}</title>
  @vite(['resources/js/app.js'])
  @inertiaHead
</head>
<body class="antialiased">
  @inertia
</body>
</html>

هيّئ Vue مع Inertia داخل resources/js/app.js:

import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';

createInertiaApp({
  title: (title) => `${title} — Chat Rouge Art`,
  resolve: (name) =>
    resolvePageComponent(
      `./Pages/${name}.vue`,
      globalThis._importMeta_.glob('./Pages/**/*.vue')
    ),
  setup({ el, App, props, plugin }) {
    createApp({ render: () => h(App, props) })
      .use(plugin)
      .mount(el);
  }
});

2. معمارية قاعدة البيانات

بورتفوليو الفنان يحتاج قاعدة بيانات نظيفة وقابلة للتوسعة. هذه بنية الـ migrations في Chat Rouge Art:

// Artworks table
Schema::create('artworks', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('slug')->unique();
    $table->text('description')->nullable();
    $table->string('medium')->nullable(); // Oil, watercolor, digital
    $table->string('dimensions')->nullable();
    $table->integer('year')->nullable();
    $table->string('image_path');
    $table->string('thumbnail_path')->nullable();
    $table->foreignId('category_id')->constrained()->onDelete('cascade');
    $table->boolean('featured')->default(false);
    $table->boolean('for_sale')->default(false);
    $table->decimal('price', 10, 2)->nullable();
    $table->integer('sort_order')->default(0);
    $table->timestamps();
});

// Categories table
Schema::create('categories', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->text('description')->nullable();
    $table->string('cover_image')->nullable();
    $table->integer('sort_order')->default(0);
    $table->timestamps();
});

// Contact messages table
Schema::create('contact_messages', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('email');
    $table->string('subject')->nullable();
    $table->text('message');
    $table->boolean('read')->default(false);
    $table->timestamp('read_at')->nullable();
    $table->timestamps();
});

3. نموذج Artwork

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Str;

class Artwork extends Model
{
    use HasFactory;

    protected $fillable = [
        'title', 'slug', 'description', 'medium',
        'dimensions', 'year', 'image_path', 'thumbnail_path',
        'category_id', 'featured', 'for_sale', 'price', 'sort_order'
    ];

    protected $casts = [
        'featured' => 'boolean',
        'for_sale' => 'boolean',
        'price' => 'decimal:2'
    ];

    protected static function boot()
    {
        parent::boot();
        static::creating(function ($artwork) {
            $artwork->slug = Str::slug($artwork->title);
        });
    }

    public function category()
    {
        return $this->belongsTo(Category::class);
    }

    public function scopeFeatured($query)
    {
        return $query->where('featured', true);
    }

    public function scopeForSale($query)
    {
        return $query->where('for_sale', true);
    }

    public function getImageUrlAttribute()
    {
        return asset('storage/' . $this->image_path);
    }

    public function getThumbnailUrlAttribute()
    {
        return $this->thumbnail_path
            ? asset('storage/' . $this->thumbnail_path)
            : $this->image_url;
    }
}

4. Gallery Controller العام

المعرض هو قلب البورتفوليو. ويحتاج فلترة + Pagination + تحميل سريع:

<?php

namespace App\Http\Controllers;

use App\Models\Artwork;
use App\Models\Category;
use Inertia\Inertia;
use Illuminate\Http\Request;

class GalleryController extends Controller
{
    public function index(Request $request)
    {
        $categories = Category::orderBy('sort_order')->get();

        $artworks = Artwork::with('category')
            ->when($request->category, function ($query, $category) {
                $query->whereHas('category', fn($q) =>
                    $q->where('slug', $category)
                );
            })
            ->when($request->search, function ($query, $search) {
                $query->where(function ($q) use ($search) {
                    $q->where('title', 'like', "%{$search}%")
                      ->orWhere('medium', 'like', "%{$search}%")
                      ->orWhere('description', 'like', "%{$search}%");
                });
            })
            ->orderBy('sort_order')
            ->orderBy('created_at', 'desc')
            ->paginate(12)
            ->withQueryString();

        return Inertia::render('Gallery/Index', [
            'artworks' => $artworks,
            'categories' => $categories,
            'filters' => $request->only(['category', 'search'])
        ]);
    }

    public function show(Artwork $artwork)
    {
        $artwork->load('category');

        $related = Artwork::where('category_id', $artwork->category_id)
            ->where('id', '!=', $artwork->id)
            ->limit(4)
            ->get();

        return Inertia::render('Gallery/Show', [
            'artwork' => $artwork,
            'related' => $related
        ]);
    }
}

5. مكوّن Gallery في Vue

<template>
  <PublicLayout>
    <!-- Filter Bar -->
    <div class="filter-bar">
      <button
        v-for="category in categories"
        :key="category.id"
        @click="filterByCategory(category.slug)"
        :class="['filter-btn', filters.category === category.slug ? 'active' : '']"
      >
        {{ category.name }}
      </button>
      <button @click="filterByCategory(null)" class="filter-btn">
        All
      </button>
    </div>

    <!-- Search -->
    <div class="search-bar">
      <input
        v-model="searchQuery"
        @input="debouncedSearch"
        type="text"
        placeholder="Search artworks..."
        class="search-input"
      />
    </div>

    <!-- Artwork Grid -->
    <TransitionGroup
      name="gallery"
      tag="div"
      class="artwork-grid"
    >
      <div
        v-for="artwork in artworks.data"
        :key="artwork.id"
        class="artwork-card"
        @click="openArtwork(artwork)"
      >
        <div class="artwork-image-wrapper">
          <img
            :src="artwork.thumbnail_url"
            :alt="artwork.title"
            class="artwork-image"
            loading="lazy"
          />
          <div class="artwork-overlay">
            <h3>{{ artwork.title }}</h3>
            <p>{{ artwork.medium }} — {{ artwork.year }}</p>
            <span v-if="artwork.for_sale" class="for-sale-badge">
              Available
            </span>
          </div>
        </div>
      </div>
    </TransitionGroup>

    <!-- Pagination -->
    <Pagination :links="artworks.links" />
  </PublicLayout>
</template>

<script setup>
import { ref } from 'vue';
import { router } from '@inertiajs/vue3';
import { useDebounceFn } from '@vueuse/core';
import PublicLayout from '@/Layouts/PublicLayout.vue';
import Pagination from '@/Components/Pagination.vue';

const props = defineProps({
  artworks: Object,
  categories: Array,
  filters: Object
});

const searchQuery = ref(props.filters.search || '');

const filterByCategory = (slug) => {
  router.get(route('gallery.index'), {
    category: slug,
    search: searchQuery.value
  }, {
    preserveState: true,
    replace: true
  });
};

const debouncedSearch = useDebounceFn(() => {
  router.get(route('gallery.index'), {
    search: searchQuery.value,
    category: props.filters.category
  }, {
    preserveState: true,
    replace: true
  });
}, 300);

const openArtwork = (artwork) => {
  router.visit(route('gallery.show', artwork.slug));
};
</script>

6. معمارية لوحة تحكم الإدارة

لوحة التحكم هي ما يجعل هذا المنتج كاملًا وليس مجرد واجهة عرض. الفنان يدير كل شيء — بدون الحاجة لمطور بعد التسليم.

احمِ جميع مسارات الإدارة بـ middleware المصادقة:

// routes/web.php
Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () {
    Route::get('/', [AdminController::class, 'index'])->name('dashboard');
    Route::resource('artworks', AdminArtworkController::class);
    Route::resource('categories', AdminCategoryController::class);
    Route::get('messages', [AdminMessageController::class, 'index'])->name('messages.index');
    Route::patch('messages/{message}/read', [AdminMessageController::class, 'markRead'])->name('messages.read');
});

AdminArtworkController يتكفّل برفع الصور وتحسينها:

<?php

namespace App\Http\Controllers\Admin;

use App\Models\Artwork;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Intervention\Image\Facades\Image;

class AdminArtworkController extends Controller
{
    public function index()
    {
        return Inertia::render('Admin/Artworks/Index', [
            'artworks' => Artwork::with('category')
                ->orderBy('sort_order')
                ->paginate(20)
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'medium' => 'nullable|string|max:100',
            'dimensions' => 'nullable|string|max:100',
            'year' => 'nullable|integer|min:1900|max:' . date('Y'),
            'category_id' => 'required|exists:categories,id',
            'image' => 'required|image|max:10240',
            'featured' => 'boolean',
            'for_sale' => 'boolean',
            'price' => 'nullable|numeric|min:0'
        ]);

        if ($request->hasFile('image')) {
            $image = $request->file('image');
            $path = $image->store('artworks', 'public');

            // Generate optimized thumbnail
            $thumbnail = Image::make($image)
                ->fit(600, 600)
                ->encode('jpg', 85);

            $thumbnailPath = 'artworks/thumbnails/' . basename($path);
            Storage::disk('public')->put($thumbnailPath, $thumbnail);

            $validated['image_path'] = $path;
            $validated['thumbnail_path'] = $thumbnailPath;
        }

        Artwork::create($validated);

        return redirect()->route('admin.artworks.index')
            ->with('success', 'Artwork added successfully');
    }

    public function destroy(Artwork $artwork)
    {
        Storage::disk('public')->delete($artwork->image_path);
        Storage::disk('public')->delete($artwork->thumbnail_path);
        $artwork->delete();

        return redirect()->route('admin.artworks.index')
            ->with('success', 'Artwork deleted successfully');
    }
}

7. الوضع الداكن والفاتح

الفنانون يهتمون جدًا بكيفية عرض أعمالهم. الوضع الداكن يجعل الألوان تنبض. الوضع الفاتح يبرز التفاصيل. كلاهما مهم.

أدر تفضيل الثيم في composable داخل resources/js/composables/useTheme.js:

import { ref, watch, onMounted } from 'vue';

const THEME_KEY = 'chat-rouge-theme';

export const useTheme = () => {
  const isDark = ref(false);

  const applyTheme = (dark) => {
    document.documentElement.classList.toggle('dark', dark);
    localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light');
  };

  const toggleTheme = () => {
    isDark.value = !isDark.value;
    applyTheme(isDark.value);
  };

  onMounted(() => {
    const saved = localStorage.getItem(THEME_KEY);
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    isDark.value = saved ? saved === 'dark' : prefersDark;
    applyTheme(isDark.value);
  });

  return { isDark, toggleTheme };
};

استخدمه في الـ Navbar بزر تبديل سلس يتذكر اختيار المستخدم بين الجلسات.

8. حركات تجعل الفن حيًا

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

حركات دخول متتابعة — الأعمال تظهر واحدة تلو الأخرى عند التحميل، لتعطي العين وقتًا لتستقر على كل قطعة قبل التالية.

انتقالات فلترة سلسة — عند تطبيق تصنيف، الأعمال الخارجة تتلاشى وتصغر قليلًا. والأعمال الداخلة تتلاشى إلى الداخل مع تكبير بسيط. الشبكة لا تقفز فجأة.

كشف العمل الفني — فتح عمل واحد يطلق انتقالًا كامل الشاشة حيث تتمدّد الصورة من موقعها داخل الشبكة لملء الشاشة، مع الحفاظ على الاستمرارية البصرية.

تم تنفيذ هذا باستخدام <Transition> و<TransitionGroup> المدمجين في Vue مع CSS custom properties للتحكم الدقيق بالتوقيت.

9. ما الذي يستطيع الفنان فعله دون مطوّر

هذا هو معيار نجاح المشروع الحقيقي. بعد التسليم، يستطيع الفنان بشكل مستقل:

  • إضافة وتعديل وحذف الأعمال مع رفع الصور
  • إنشاء وإدارة التصنيفات
  • تحديد الأعمال المميزة أو المتاحة للبيع
  • تحديد السعر لكل قطعة
  • قراءة وإدارة رسائل التواصل من الجامعين
  • تبديل تفضيل الوضع الداكن/الفاتح
  • تحديث السيرة ومعلومات التواصل

صفر اعتماد على مطوّر بعد الإطلاق. وهذا ما يجعل العميل راضيًا بما يكفي ليكتب شهادة ويرشحك لعملاء جدد.

الخاتمة

بناء بورتفوليو فني Fullstack هو تمرين على التوازن. الواجهة العامة يجب أن تكون عاطفية وجميلة وسريعة. والـ Backend يجب أن يكون قويًا وموثوقًا وغير مرئي للمستخدم النهائي.

Laravel يمنحك قوة الخلفية. Inertia.js تزيل تعقيد API. Vue.js تقدم تجربة واجهة ممتازة. معًا ينتجون شيئًا لا يستطيع site builder ثابت ولا headless CMS مطابقته — منصة مخصصة بالكامل يملكها العميل بشكل كامل.

Chat Rouge Art كان أول مشروع Fullstack Freelance أكمله فعليًا. منحني أول شهادة حقيقية. وأثبت أن الجمع بين العمق التقني والحس التصميمي يخلق عملاً يقدّره العملاء ويتذكرونه.

وهذا الجمع هو ما بُني عليه Dywan Dev.

هل تحتاج بورتفوليو Fullstack، أو منصة أعمال، أو تطبيق ويب مخصص؟ في Dywan Dev أبني حلولاً كاملة — لا مجرد مواقع. ابدأ محادثة.

تابع القراءة

عرض الكلعرض الكل
واتساب