Added frontend directory as part of the main repository

This commit is contained in:
Ahmed Nagi 2025-01-25 18:23:36 +02:00
parent 991bb154b5
commit 884662e5cd
47 changed files with 11852 additions and 1 deletions

@ -1 +0,0 @@
Subproject commit 4e3c4575410e2680e8ab82f1b1c3349cf9276255

23
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
frontend/.npmrc Normal file
View file

@ -0,0 +1 @@
shamefully-hoist=true

19
frontend/README.md Normal file
View file

@ -0,0 +1,19 @@
# frontend
## Project setup
```
pnpm install
```
### Compiles and hot-reloads for development
```
pnpm run serve
```
### Compiles and minifies for production
```
pnpm run build
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
frontend/babel.config.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

184
frontend/ee.js Normal file
View file

@ -0,0 +1,184 @@
import { createRouter, createWebHistory } from "vue-router";
import Login from "@/views/account/Login.vue";
import SignUp from "@/views/account/SignUp.vue";
import EmailConfirmation from "@/views/account/EmailConfirmation.vue";
import ForgetPassword from "@/views/account/ForgetPassword.vue";
import Info from "@/views/app/me/Info.vue";
import ResetPassword from "@/views/account/ResetPassword.vue";
import ChangePassword from "@/views/account/ChangePassword.vue";
import ChangeEmail from "@/views/account/ChangeEmail.vue";
import Home from "@/views/app/Home.vue";
import Layout from "@/components/Layout.vue";
import CreateCourse from "@/views/app/me/CreateCourse.vue";
import Dashboard from "@/views/app/Dashboard.vue";
import InfoCourse from "@/views/app/InfoCourse.vue";
import InfoMyCourse from "@/views/app/me/InfoMyCourse.vue";
import EditCourse from "@/views/app/me/EditCourse.vue";
import Settings from "@/views/app/Settings.vue";
import EnrollCourses from "@/views/app/EnrollCourses.vue";
import MyModule from "@/views/app/me/MyModule.vue";
import Module from "@/views/app/Module.vue";
import LessonDetails from "@/views/app/LessonDetails.vue";
import MyLessonDetails from "@/views/app/me/MyLessonDetails.vue";
import StudentsList from "@/views/app/me/StudentsList.vue";
const account = [
{
path:"/account",
children: [
{
path: "signup",
name: "signup",
component: SignUp,
},
{
path: "login",
name: "login",
component: Login,
},
{
path: "email-confirmation/:key",
name: "emailConfirmation",
component: EmailConfirmation,
},
{
path: "forget-password",
name: "forgetPassword",
component: ForgetPassword,
},
{
path: "reset-password/:uid/:token",
name: "resetPassword",
component: ResetPassword,
},
{
path: "chnage-password",
name: "changePassword",
component: ChangePassword,
},
{
path: "change-email",
name: "changeEmail",
component: ChangeEmail,
},
]
}
];
import Video from "@/views/app/Video.vue";
const app = [
{
path: '/',
redirect: {name:"Home"},
},
{
path: "/app",
component: Layout,
children: [
{
path: "video",
name: "Video",
component: Video,
},
{
path: "home",
name: "Home",
component: Home,
},
{
path: "info",
name: "Info",
component: Info,
},
{
path: "create-course",
name: "CreateCourse",
component: CreateCourse,
},
{
path: "dashboard",
name: "Dashboard",
component: Dashboard,
},
{
path: "info-course/:id",
name: "InfoCourse",
component: InfoCourse,
props: true,
},
{
path: "info-my-course/:id",
name: "InfoMyCourse",
component: InfoMyCourse,
props: true,
},
{
path: "edit-course",
name: "EditCourse",
component: EditCourse,
},
{
path: "settings",
name: "Settings",
component: Settings,
},
{
path: "enroll-courses",
name: "EnrollCourses",
component: EnrollCourses,
},
{
path: "my-module/:id",
name: "MyModule",
component: MyModule,
props: true,
},
{
path: "module/:id",
name: "Module",
component: Module,
props: true,
},
{
path: "lesson-details/:lesson_id/:module_id",
name: "LessonDetails",
component: LessonDetails,
props: true,
},
{
path: "my-lesson-details/:lesson_id/:module_id",
name: "MyLessonDetails",
component: MyLessonDetails,
props: true,
},
{
path: "students-list/:id",
name: "StudentsList",
component: StudentsList,
props: true,
},
]
},
{
path: "/about",
name: "about",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("../views/AboutView.vue"),
},
];
const routes = [...account, ...app]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;

19
frontend/jsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

50
frontend/package.json Normal file
View file

@ -0,0 +1,50 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
},
"dependencies": {
"@tiptap/core": "^2.11.3",
"@tiptap/extension-bold": "^2.11.3",
"@tiptap/extension-color": "^2.11.3",
"@tiptap/extension-font-family": "^2.11.3",
"@tiptap/extension-font-size": "3.0.0-next.3",
"@tiptap/extension-highlight": "^2.11.3",
"@tiptap/extension-image": "^2.11.3",
"@tiptap/extension-link": "^2.11.3",
"@tiptap/extension-text-align": "^2.11.3",
"@tiptap/extension-text-style": "^2.11.3",
"@tiptap/extension-underline": "^2.11.3",
"@tiptap/extension-youtube": "^2.11.3",
"@tiptap/pm": "^2.11.3",
"@tiptap/starter-kit": "^2.11.3",
"@tiptap/vue-3": "^2.11.3",
"autoprefixer": "^10",
"axios": "^1.7.9",
"core-js": "^3.8.3",
"flowbite": "^3.0.0",
"flowbite-typography": "^1.0.5",
"postcss": "^8",
"register-service-worker": "^1.7.2",
"tailwindcss": "^3",
"vue": "^3.2.13",
"vue-router": "^4.0.3",
"vuex": "^4.1.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-pwa": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"vue-cli-plugin-tailwind": "~3.0.0"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
]
}

9801
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.00251 14.9297L0 1.07422H6.14651L8.00251 4.27503L9.84583 1.07422H16L8.00251 14.9297Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 215 B

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View file

@ -0,0 +1,2 @@
User-agent: *
Disallow:

47
frontend/src/App.vue Normal file
View file

@ -0,0 +1,47 @@
<template class="bg-white text-black dark:bg-gray-900 dark:text-white">
<div id="app">
<router-view/>
</div>
</template>
<script>
export default {
name: 'App',
}
import { onMounted } from 'vue'
import {
initAccordions,
initCarousels,
initCollapses,
initDials,
initDismisses,
initDrawers,
initDropdowns,
initModals,
initPopovers,
initTabs,
initTooltips } from 'flowbite'
// initialize components based on data attribute selectors
onMounted(() => {
initAccordions();
initCarousels();
initCollapses();
initDials();
initDismisses();
initDrawers();
initDropdowns();
initModals();
initPopovers();
initTabs();
initTooltips();
})
</script>
<style>
/* يمكنك إضافة أنماط مخصصة هنا إذا لزم الأمر */
</style>

155
frontend/src/api.js Normal file
View file

@ -0,0 +1,155 @@
import axios from "axios";
const apiLink = "http://127.0.0.1:8000/";
const loginPage = "/account/login/";
let setLoadingCallback = null;
const api = axios.create({
baseURL: apiLink,
timeout: 20000,
});
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach((prom) => {
if (token) {
prom.resolve(token);
} else {
prom.reject(error);
}
});
failedQueue = [];
};
// Function to check the validity of the refresh token
const isRefreshTokenValid = (refreshToken) => {
if (!refreshToken) return false;
try {
const payload = JSON.parse(atob(refreshToken.split('.')[1]));
const expirationTime = payload.exp * 1000; // Convert expiration time to milliseconds
return Date.now() < expirationTime;
} catch (error) {
return false;
}
};
// Interceptor to add the Access Token to each request
api.interceptors.request.use((config) => {
if (setLoadingCallback && !config.noL) {
setLoadingCallback(true);
}
const accessToken = localStorage.getItem("access_token");
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// Interceptor to handle errors
api.interceptors.response.use(
(response) => {
if (setLoadingCallback && !response.config.noL) {
setLoadingCallback(false);
}
return response;
},
async (error) => {
const originalRequest = error.config;
if (setLoadingCallback && !originalRequest.noL) {
setLoadingCallback(false);
}
if (
error.response &&
(error.response.status === 401 || error.response.status === 403) &&
!originalRequest._retry
) {
originalRequest._retry = true;
const refreshToken = localStorage.getItem("refresh_token");
if (refreshToken && isRefreshTokenValid(refreshToken)) {
if (!isRefreshing) {
isRefreshing = true;
try {
// Send a request to refresh the Access Token
const response = await axios.post(
`${apiLink}/auth/token/refresh/`,
{ refresh: refreshToken }
);
const newAccessToken = response.data.access;
localStorage.setItem("accessToken", newAccessToken);
// Process failed requests
processQueue(null, newAccessToken);
isRefreshing = false;
// Resend the original request
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return api(originalRequest);
} catch (refreshError) {
// If the refresh request fails, clear tokens and redirect
processQueue(refreshError, null);
isRefreshing = false;
localStorage.clear();
window.location.href = loginPage;
return Promise.reject(refreshError);
}
}
// Manage failed requests during refresh
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
} else {
// If there's no refresh token or it's expired, clear tokens and redirect
localStorage.clear();
window.location.href = loginPage;
}
}
// Handle other errors
if (
error.response &&
error.response.data &&
(error.response.data.detail === "Authentication credentials were not provided." ||
error.response.data.code === "token_not_valid" ||
error.response.data.code === "user_not_found"
)
) {
const refreshToken = localStorage.getItem("refreshToken");
if (!refreshToken || !isRefreshTokenValid(refreshToken)) {
localStorage.clear();
window.location.href = loginPage;
}
}
return Promise.reject(error);
}
);
// Function to set the loading callback for managing loading screens
export const setLoadingHandler = (callback) => {
setLoadingCallback = callback;
};
export default api;

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -0,0 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -0,0 +1,20 @@
<template>
<div class="min-h-screen flex flex-col bg-white dark:bg-gray-900">
<NavBar />
<!-- Main Content -->
<main class="flex-grow container mx-auto p-4">
<router-view />
</main>
</div>
</template>
<script>
import NavBar from './NavBar.vue';
export default {
name: 'Layout',
components: {
NavBar
}
}
</script>

View file

@ -0,0 +1,155 @@
<template>
<nav class="bg-white border-gray-200 dark:bg-gray-900">
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
<a href="https://flowbite.com/" class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="https://flowbite.com/docs/images/logo.svg" class="h-8" alt="Flowbite Logo" />
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Inspire & Ink</span>
</a>
<div class="flex items-center md:order-2 space-x-3 md:space-x-0 rtl:space-x-reverse">
<!-- زر تبديل الثيم -->
<button
id="theme-toggle"
type="button"
class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 me-4 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5"
>
<svg
id="theme-toggle-dark-icon"
class="hidden w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
<svg
id="theme-toggle-light-icon"
class="hidden w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
fill-rule="evenodd"
clip-rule="evenodd"
></path>
</svg>
</button>
<!-- زر القائمة المنسدلة للمستخدم -->
<button
type="button"
class="flex text-sm bg-gray-800 rounded-full md:me-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
id="user-menu-button"
aria-expanded="false"
data-dropdown-toggle="user-dropdown"
data-dropdown-placement="bottom"
>
<span class="sr-only">Open user menu</span>
<img class="w-8 h-8 rounded-full" src="/docs/images/people/profile-picture-3.jpg" alt="user photo" />
</button>
<!-- Dropdown menu -->
<div
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow-sm dark:bg-gray-700 dark:divide-gray-600"
id="user-dropdown"
>
<div class="px-4 py-3">
<span class="block text-sm text-gray-900 dark:text-white">Bonnie Green</span>
<span class="block text-sm text-gray-500 truncate dark:text-gray-400">name@flowbite.com</span>
</div>
<ul class="py-2" aria-labelledby="user-menu-button">
<li>
<router-link
:to="{name:'Dashboard'}"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>Dashboard</router-link
>
</li>
<li>
<a
href="#"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>Settings</a
>
</li>
<li>
<a
href="#"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
@click="logout"
>Sign out</a
>
</li>
</ul>
</div>
<button
data-collapse-toggle="navbar-user"
type="button"
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="navbar-user"
aria-expanded="false"
>
<span class="sr-only">Open main menu</span>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg>
</button>
</div>
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
<ul class="flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
<li>
<router-link :to="{name:'Home'}" class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500" aria-current="page">Home</router-link>
</li>
</ul>
</div>
</div>
</nav>
</template>
<script>
export default {
mounted() {
// الحصول على العناصر المطلوبة
const themeToggleBtn = document.getElementById('theme-toggle');
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
// التحقق من الثيم الحالي
if (
localStorage.getItem('color-theme') === 'dark' ||
(!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark');
themeToggleLightIcon.classList.remove('hidden');
} else {
document.documentElement.classList.remove('dark');
themeToggleDarkIcon.classList.remove('hidden');
}
// إضافة حدث النقر لزر تبديل الثيم
themeToggleBtn.addEventListener('click', () => {
// تبديل الأيقونات
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
// حفظ الثيم في localStorage
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
});
},
methods: {
logout() {
// مسح localStorage
localStorage.clear();
// تحويل المستخدم إلى صفحة تسجيل الدخول
this.$router.push({ name: 'Login' });
},
},
};
</script>

7
frontend/src/main.js Normal file
View file

@ -0,0 +1,7 @@
import { createApp } from 'vue'
import App from './App.vue'
import './registerServiceWorker'
import router from './router'
import './assets/tailwind.css'
createApp(App).use(router).mount('#app')

View file

@ -0,0 +1,32 @@
/* eslint-disable no-console */
import { register } from 'register-service-worker'
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}service-worker.js`, {
ready () {
console.log(
'App is being served from cache by a service worker.\n' +
'For more details, visit https://goo.gl/AFskqB'
)
},
registered () {
console.log('Service worker has been registered.')
},
cached () {
console.log('Content has been cached for offline use.')
},
updatefound () {
console.log('New content is downloading.')
},
updated () {
console.log('New content is available; please refresh.')
},
offline () {
console.log('No internet connection found. App is running in offline mode.')
},
error (error) {
console.error('Error during service worker registration:', error)
}
})
}

View file

@ -0,0 +1,71 @@
import { createRouter, createWebHistory } from "vue-router";
import CreateArticle from "@/views/CreateArticle.vue";
import Login from "@/views/account/Login.vue";
import Signup from "@/views/account/Signup.vue";
import Layout from "@/components/Layout.vue";
import Dashboard from "@/views/Dashboard.vue";
import Home from "@/views/Home.vue";
import ArticlePage from "@/views/ArticlePage.vue";
const account = [
{
path: "/account",
children: [
{
path: "login",
name: "Login",
component: Login,
},
{
path: "signup",
name: "Signup",
component: Signup,
},
],
},
];
const app = [
{
path: '/',
redirect: {name:"Home"},
},
{
path: "/app",
component: Layout,
children: [
{
path: "home",
name: "Home",
component: Home,
},
{
path: "create-article",
name: "CreateArticle",
component: CreateArticle,
},
{
path: "dashboard",
name: "Dashboard",
component: Dashboard ,
},
{
path: "article/:articleId",
name: "ArticlePage",
component: ArticlePage ,
props: true,
},
],
},
];
const routes = [...account, ...app];
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
});
export default router;

View file

@ -0,0 +1,439 @@
<template>
<div class=" border border-gray-200 rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600 p-4">
<!-- شريط الأدوات -->
<div class="px-3 py-2 border-b border-gray-200 dark:border-gray-600">
<div class="flex flex-wrap items-center">
<div class="flex items-center space-x-1 rtl:space-x-reverse flex-wrap">
<!-- RTL & LTR -->
<button
@click="toggleRTL"
type="button"
data-tooltip-target="tooltip-rtl"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16M13 12l3-3m0 0 3 3m-3-3v12"/>
</svg>
<span class="sr-only">RTL</span>
</button>
<div id="tooltip-rtl" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
RTL
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<button
@click="toggleLTR"
type="button"
data-tooltip-target="tooltip-ltr"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h8m4-6 3 3m0 0-3 3m3-3H13"/>
</svg>
<span class="sr-only">LTR</span>
</button>
<div id="tooltip-ltr" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
LTR
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<!-- Bold Button -->
<button
@click="toggleBold"
type="button"
data-tooltip-target="tooltip-bold"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5h4.5a3.5 3.5 0 1 1 0 7H8m0-7v7m0-7H6m2 7h6.5a3.5 3.5 0 1 1 0 7H8m0-7v7m0 0H6"/>
</svg>
<span class="sr-only">Bold</span>
</button>
<div id="tooltip-bold" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Bold
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<!-- Italic Button -->
<button
@click="toggleItalic"
type="button"
data-tooltip-target="tooltip-italic"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m8.874 19 6.143-14M6 19h6.33m-.66-14H18"/>
</svg>
<span class="sr-only">Italic</span>
</button>
<div id="tooltip-italic" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Italic
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<!-- Underline Button -->
<button
@click="toggleUnderline"
type="button"
data-tooltip-target="tooltip-underline"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 19h12M8 5v9a4 4 0 0 0 8 0V5M6 5h4m4 0h4"/>
</svg>
<span class="sr-only">Underline</span>
</button>
<div id="tooltip-underline" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Underline
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<!-- Strike Button -->
<button
@click="toggleStrike"
type="button"
data-tooltip-target="tooltip-strike"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 6.2V5h12v1.2M7 19h6m.2-14-1.677 6.523M9.6 19l1.029-4M5 5l6.523 6.523M19 19l-7.477-7.477"/>
</svg>
<span class="sr-only">Strike</span>
</button>
<div id="tooltip-strike" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Strike
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<!-- Highlight Button -->
<button
@click="toggleHighlight"
type="button"
data-tooltip-target="tooltip-highlight"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M9 19.2H5.5c-.3 0-.5-.2-.5-.5V16c0-.2.2-.4.5-.4h13c.3 0 .5.2.5.4v2.7c0 .3-.2.5-.5.5H18m-6-1 1.4 1.8h.2l1.4-1.7m-7-5.4L12 4c0-.1 0-.1 0 0l4 8.8m-6-2.7h4m-7 2.7h2.5m5 0H17"/>
</svg>
<span class="sr-only">Highlight</span>
</button>
<div id="tooltip-highlight" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Highlight
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<!-- Code Button -->
<button
@click="toggleCode"
type="button"
data-tooltip-target="tooltip-code"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m8 8-4 4 4 4m8 0 4-4-4-4m-2-3-4 14"/>
</svg>
<span class="sr-only">Code</span>
</button>
<div id="tooltip-code" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Code
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<!-- Link Button -->
<button
@click="toggleLink"
type="button"
data-tooltip-target="tooltip-link"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.213 9.787a3.391 3.391 0 0 0-4.795 0l-3.425 3.426a3.39 3.39 0 0 0 4.795 4.794l.321-.304m-.321-4.49a3.39 3.39 0 0 0 4.795 0l3.424-3.426a3.39 3.39 0 0 0-4.794-4.795l-1.028.961"/>
</svg>
<span class="sr-only">Link</span>
</button>
<div id="tooltip-link" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Link
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<!-- Remove Link Button -->
<button
@click="removeLink"
type="button"
data-tooltip-target="tooltip-remove-link"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M13.2 9.8a3.4 3.4 0 0 0-4.8 0L5 13.2A3.4 3.4 0 0 0 9.8 18l.3-.3m-.3-4.5a3.4 3.4 0 0 0 4.8 0L18 9.8A3.4 3.4 0 0 0 13.2 5l-1 1m7.4 14-1.8-1.8m0 0L16 16.4m1.8 1.8 1.8-1.8m-1.8 1.8L16 20"/>
</svg>
<span class="sr-only">Remove link</span>
</button>
<div id="tooltip-remove-link" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Remove link
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<!-- Image Button -->
<button
@click="insertImage"
type="button"
data-tooltip-target="tooltip-image"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 19h14a1 1 0 0 0 .9-1.45L14 7l-4.9 9.55a1 1 0 0 1-.9.45H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1Z"/>
</svg>
<span class="sr-only">Insert Image</span>
</button>
<div id="tooltip-image" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Insert Image
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<!-- Text Size Dropdown -->
<div class="relative">
<button
@click="toggleTextSizeDropdown"
type="button"
data-tooltip-target="tooltip-text-size"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 6.2V5h11v1.2M8 5v14m-3 0h6m2-6.8V11h8v1.2M17 11v8m-1.5 0h3"/>
</svg>
<span class="sr-only">Text size</span>
</button>
<div v-if="isTextSizeDropdownOpen" class="absolute z-10 mt-2 bg-white rounded-lg shadow-lg dark:bg-gray-700">
<ul class="py-1">
<li
v-for="size in textSizes"
:key="size"
@click="setTextSize(size)"
class="px-4 py-2 text-sm text-gray-700 cursor-pointer hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-600"
>
{{ size }}
</li>
</ul>
</div>
</div>
<!-- Font Family Dropdown -->
<div class="relative">
<button
@click="toggleFontFamilyDropdown"
type="button"
data-tooltip-target="tooltip-font-family"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m10.6 19 4.298-10.93a.11.11 0 0 1 .204 0L19.4 19m-8.8 0H9.5m1.1 0h1.65m7.15 0h-1.65m1.65 0h1.1m-7.7-3.985h4.4M3.021 16l1.567-3.985m0 0L7.32 5.07a.11.11 0 0 1 .205 0l2.503 6.945h-5.44Z"/>
</svg>
<span class="sr-only">Font family</span>
</button>
<div v-if="isFontFamilyDropdownOpen" class="absolute z-10 mt-2 bg-white rounded-lg shadow-lg dark:bg-gray-700">
<ul class="py-1">
<li
v-for="font in fontFamilies"
:key="font"
@click="setFontFamily(font)"
class="px-4 py-2 text-sm text-gray-700 cursor-pointer hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-600"
>
{{ font }}
</li>
</ul>
</div>
</div>
<!-- Divider -->
<div class="px-1">
<span class="block w-px h-4 bg-gray-300 dark:bg-gray-600"></span>
</div>
<!-- Align Left Button -->
<button
@click="setTextAlign('left')"
type="button"
data-tooltip-target="tooltip-align-left"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 6h8m-8 4h12M6 14h8m-8 4h12"/>
</svg>
<span class="sr-only">Align left</span>
</button>
<div id="tooltip-align-left" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Align left
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<!-- Align Center Button -->
<button
@click="setTextAlign('center')"
type="button"
data-tooltip-target="tooltip-align-center"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h8M6 10h12M8 14h8M6 18h12"/>
</svg>
<span class="sr-only">Align center</span>
</button>
<div id="tooltip-align-center" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Align center
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<!-- Align Right Button -->
<button
@click="setTextAlign('right')"
type="button"
data-tooltip-target="tooltip-align-right"
class="p-1.5 text-gray-500 rounded-sm cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 6h-8m8 4H6m12 4h-8m8 4H6"/>
</svg>
<span class="sr-only">Align right</span>
</button>
<div id="tooltip-align-right" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Align right
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
</div>
</div>
<!-- المحرر -->
<div class="px-4 py-2 bg-white rounded-b-lg dark:bg-gray-800">
<editor-content :editor="editor" />
</div>
</div>
</template>
<script>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { Editor, EditorContent } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
import Highlight from '@tiptap/extension-highlight';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import TextAlign from '@tiptap/extension-text-align';
import Image from '@tiptap/extension-image';
import YouTube from '@tiptap/extension-youtube';
import TextStyle from '@tiptap/extension-text-style';
import FontFamily from '@tiptap/extension-font-family';
import { Color } from '@tiptap/extension-color';
import FontSize from '@tiptap/extension-font-size';
export default {
components: {
EditorContent,
},
setup() {
const editor = ref(null);
const isTextSizeDropdownOpen = ref(false);
const isTextColorDropdownOpen = ref(false);
const isFontFamilyDropdownOpen = ref(false);
const selectedColor = ref('#000000');
const textSizes = ['12px', '14px', '16px', '18px', '24px', '36px'];
const fontFamilies = ['Arial', 'Courier New', 'Georgia', 'Times New Roman', 'Verdana'];
onMounted(() => {
editor.value = new Editor({
extensions: [
StarterKit,
Highlight,
Underline,
Link,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Image,
YouTube,
TextStyle,
FontFamily,
Color,
FontSize.configure({
defaultSize: '18px',
}),
],
content: '<p style="font-size: 18px;">Write here ...</p>',
});
});
onBeforeUnmount(() => {
editor.value.destroy();
});
const toggleRTL = () => {
editor.value.chain().focus().setTextAlign('right').run();
document.querySelector('.ProseMirror').style.direction = 'rtl';
};
const toggleLTR = () => {
editor.value.chain().focus().setTextAlign('left').run();
document.querySelector('.ProseMirror').style.direction = 'ltr';
};
const toggleBold = () => editor.value.chain().focus().toggleBold().run();
const toggleItalic = () => editor.value.chain().focus().toggleItalic().run();
const toggleUnderline = () => editor.value.chain().focus().toggleUnderline().run();
const toggleStrike = () => editor.value.chain().focus().toggleStrike().run();
const toggleHighlight = () => editor.value.chain().focus().toggleHighlight().run();
const toggleCode = () => editor.value.chain().focus().toggleCode().run();
const toggleLink = () => {
const url = window.prompt('Enter URL');
editor.value.chain().focus().toggleLink({ href: url }).run();
};
const removeLink = () => editor.value.chain().focus().unsetLink().run();
const setTextAlign = (align) => editor.value.chain().focus().setTextAlign(align).run();
const setTextSize = (size) => {
editor.value.chain().focus().setFontSize(size).run();
isTextSizeDropdownOpen.value = false;
};
const setTextColor = () => editor.value.chain().focus().setColor(selectedColor.value).run();
const resetTextColor = () => editor.value.chain().focus().unsetColor().run();
const setFontFamily = (font) => {
editor.value.chain().focus().setFontFamily(font).run();
isFontFamilyDropdownOpen.value = false;
};
const toggleTextSizeDropdown = () => isTextSizeDropdownOpen.value = !isTextSizeDropdownOpen.value;
const toggleTextColorDropdown = () => isTextColorDropdownOpen.value = !isTextColorDropdownOpen.value;
const toggleFontFamilyDropdown = () => isFontFamilyDropdownOpen.value = !isFontFamilyDropdownOpen.value;
const insertImage = () => {
const url = window.prompt('Enter the image URL');
if (url) {
editor.value.chain().focus().setImage({ src: url }).run();
}
};
return {
editor,
isTextSizeDropdownOpen,
isTextColorDropdownOpen,
isFontFamilyDropdownOpen,
selectedColor,
textSizes,
fontFamilies,
toggleRTL,
toggleLTR,
toggleBold,
toggleItalic,
toggleUnderline,
toggleStrike,
toggleHighlight,
toggleCode,
toggleLink,
removeLink,
setTextAlign,
setTextSize,
setTextColor,
resetTextColor,
setFontFamily,
toggleTextSizeDropdown,
toggleTextColorDropdown,
toggleFontFamilyDropdown,
insertImage,
};
},
};
</script>

View file

@ -0,0 +1,131 @@
<template>
<div class="bg-gray-50 dark:bg-gray-900 min-h-screen">
<!-- محتوى المقالة -->
<div class="container mx-auto px-4 py-8">
<!-- حالة التحميل -->
<div v-if="loading" class="text-center">
<p class="text-gray-600 dark:text-gray-300">Loading article...</p>
</div>
<!-- حالة الخطأ -->
<div v-if="error" class="text-center text-red-500">
<p>Failed to load article. Please try again later.</p>
</div>
<!-- عرض المقالة -->
<div v-if="article" class="max-w-4xl mx-auto">
<!-- صورة المقالة -->
<img
:src="article.image"
:alt="article.title"
class="w-full h-96 object-cover rounded-lg shadow-lg"
/>
<!-- عنوان المقالة -->
<h1 class="text-4xl font-bold mt-8 mb-4 text-gray-900 dark:text-white">
{{ article.title }}
</h1>
<!-- وصف المقالة -->
<p class="text-xl text-gray-600 dark:text-gray-300 mb-8">
{{ article.description }}
</p>
<!-- محتوى المقالة -->
<div class="prose prose-lg dark:prose-invert max-w-none" v-html="article.content"></div>
<!-- معلومات المقالة -->
<div class="mt-8 flex items-center justify-between text-gray-500 dark:text-gray-400">
<div class="flex items-center">
<!-- صورة الكاتب -->
<img
:src="article.authorImage"
:alt="article.author"
class="w-10 h-10 rounded-full mr-2"
/>
<span>{{ article.author }}</span>
</div>
<div class="flex items-center space-x-4">
<span class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2 12h4m14 0h4M12 2v4m0 14v4"
/>
</svg>
<span>{{ article.views }} views</span>
</span>
<span class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<span>{{ article.likes_count }} likes</span>
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import api from "@/api";
export default {
props:['articleId'],
data() {
return {
article: null, // بيانات المقالة
loading: true, // حالة التحميل
error: false, // حالة الخطأ
};
},
async mounted() {
try {
const response = await api.get(`app/article/${this.articleId}/`);
this.article = response.data;
} catch (err) {
console.error("Failed to fetch article:", err);
this.error = true;
} finally {
this.loading = false;
}
},
};
</script>
<style>
/* تخصيصات إضافية */
.prose {
color: inherit;
}
.dark .prose {
color: #dbd9d9; /* لون النص في وضع الدارك مود */
}
</style>

View file

@ -0,0 +1,118 @@
<template>
<div class="create-article max-w-2xl mx-auto p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-6 text-gray-900 dark:text-white">Create a New Article</h1>
<form @submit.prevent="saveArticle">
<!-- Title Field -->
<div class="mb-6">
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Article Title</label>
<input
type="text"
id="title"
v-model="article.title"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Enter the article title"
required
/>
</div>
<!-- Introduction Field -->
<div class="mb-6">
<label for="introduction" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Article Introduction</label>
<input
type="text"
id="introduction"
v-model="article.introduction"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Write a brief introduction"
required
/>
</div>
<!-- Image Upload Field -->
<div class="mb-6">
<label for="image" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Article Image</label>
<input
type="file"
id="image"
@change="handleImageUpload"
accept="image/*"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
/>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400" id="file_input_help">SVG, PNG, JPG or GIF.</p>
<img
v-if="article.imagePreview"
:src="article.imagePreview"
alt="Article Image Preview"
class="mt-4 w-full h-48 object-cover rounded-lg"
/>
</div>
<!-- Editor -->
<label for="image" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">The Article</label>
<WYSIWYG ref="wysiwygEditor" />
<!-- Submit Button -->
<br>
<button
id="send"
type="submit"
class="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 dark:bg-blue-700 dark:hover:bg-blue-800"
>
Save
</button>
</form>
</div>
</template>
<script>
import api from '@/api';
import WYSIWYG from '@/utils/WYSIWYG.vue';
export default {
components: {
WYSIWYG
},
data() {
return {
article: {
title: '',
introduction: '',
image: null,
imagePreview: ''
},
saved: false
};
},
methods: {
handleImageUpload(event) {
const file = event.target.files[0];
if (file) {
this.article.image = file;
this.article.imagePreview = URL.createObjectURL(file);
}
},
async saveArticle() {
const editorContent = this.$refs.wysiwygEditor.editor.getHTML();
const formData = new FormData();
formData.append('content', editorContent);
formData.append('title', this.article.title);
formData.append('introduction', this.article.introduction);
formData.append('thumbnail', this.article.image);
try {
const response = await api.post('app/article/', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (response.status === 200 || response.status === 201) {
this.saved = true;
this.$router.push({ name: 'Dashboard' });
}
} catch (error) {
console.error('Error saving the article:', error);
}
}
}
};
</script>

View file

@ -0,0 +1,134 @@
<template>
<!-- Main Content -->
<div class="container mx-auto px-6 py-8 dark:bg-gray-900">
<!-- Create New Article Card -->
<p class="text-gray-600 dark:text-gray-400 text-center mt-2">If you nead to update an article, just enter to create and put the title article you want to update</p>
<router-link
:to="{ name: 'CreateArticle' }"
class="block bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md hover:scale-105 transition-transform duration-300 mb-8"
>
<div class="flex items-center justify-center space-x-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-200">
Create New Article
</h2>
</div>
<p class="text-gray-600 dark:text-gray-400 text-center mt-2">
Start a new article and share your knowledge with the world.
</p>
</router-link>
<!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<h2 class="text-lg font-bold text-gray-800 dark:text-gray-200">
Number of Articles
</h2>
<p class="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-2" v-text="articles.length"></p>
</div>
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<h2 class="text-lg font-bold text-gray-800 dark:text-gray-200">
Total Revenue
</h2>
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">SAR {{ totalRevenue }}</p>
</div>
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<h2 class="text-lg font-bold text-gray-800 dark:text-gray-200">
Number of Followers
</h2>
<p class="text-3xl font-bold text-purple-600 dark:text-purple-400 mt-2">
{{ followersCount }}
</p>
</div>
</div>
<!-- Article List -->
<div class="mt-8">
<h2 class="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-6">
My Articles
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="article in articles"
:key="article.id"
class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:scale-105 transition-transform duration-300"
>
<router-link
:to="{ name: 'ArticlePage', params: { articleId: article.id }}"
class="block"
>
<img
loading="lazy"
:src="article.image || 'https://via.placeholder.com/300'"
:alt="article.title"
class="w-full h-48 object-cover"
/>
<div class="p-6">
<h3 class="text-xl font-bold text-gray-800 dark:text-gray-200 mb-2">
{{ article.title }}
</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
{{ article.introduction }}
</p>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ formatDate(article.created_at) }}
</span>
</div>
</div>
</router-link>
</div>
</div>
</div>
</div>
</template>
<script>
import api from '@/api';
export default {
data() {
return {
articles: [],
totalRevenue: 0,
followersCount: 0,
};
},
async mounted() {
try {
// جلب البيانات من الـ API
const response = await api('app/user/');
const data = response.data;
// تعيين البيانات المسترجعة
this.articles = data.articles;
this.totalRevenue = data.totalRevenue || 0; // إذا لم يكن هناك إيرادات، نعيينها إلى 0
this.followersCount = data.followers.length || 0; // إذا لم يكن هناك متابعين، نعيينها إلى 0
} catch (error) {
console.error('Error fetching data:', error);
}
},
methods: {
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString();
},
},
};
</script>

122
frontend/src/views/Home.vue Normal file
View file

@ -0,0 +1,122 @@
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8 text-gray-900 dark:text-white">Articles</h1>
<!-- Loading State -->
<div v-if="loading" class="text-center">
<p class="text-gray-600 dark:text-gray-300">Loading articles...</p>
</div>
<!-- Error State -->
<div v-if="error" class="text-center text-red-500">
<p>Failed to load articles. Please try again later.</p>
</div>
<!-- Articles Display -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<router-link
v-for="article in articles"
:key="article.id"
:to="{ name: 'ArticlePage', params: { articleId: article.id }}"
class="bg-white dark:bg-gray-800 shadow-lg rounded-lg overflow-hidden transition-all duration-300 hover:shadow-xl"
>
<!-- Article Image -->
<img
:src="article.image"
class="w-full h-48 object-cover"
alt="Article Image"
/>
<div class="p-6">
<!-- Article Category -->
<span
class="inline-block bg-blue-100 text-blue-800 text-sm font-semibold px-2 py-1 rounded-full mb-2 dark:bg-blue-200 dark:text-blue-900"
>
{{ article.category }}
</span>
<!-- Article Title -->
<h2 class="text-xl font-semibold mb-2 text-gray-900 dark:text-white">
{{ article.title }}
</h2>
<!-- Article Description -->
<p class="text-gray-600 dark:text-gray-300 mb-4">
{{ article.description }}
</p>
<!-- Article Info (Author and Views) -->
<div class="flex items-center justify-between text-gray-500 dark:text-gray-400">
<div class="flex items-center">
<!-- Author Image -->
<img
:src="article.authorImage"
class="w-8 h-8 rounded-full mr-2"
/>
<span>{{ article.author }}</span>
</div>
<div class="flex items-center">
<span>{{ article.views }} views</span>
</div>
</div>
<!-- Like Button and Count -->
<div class="mt-4 flex items-center">
<span
class="flex items-center text-gray-500 dark:text-gray-400 hover:text-red-500 transition-colors duration-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<span>{{ article.likes_count }}</span>
</span>
</div>
</div>
</router-link>
</div>
</div>
</template>
<script>
import api from "@/api";
export default {
data() {
return {
articles: [], // سيتم ملء هذا المصفوفة بالبيانات من API
loading: true, // حالة التحميل
error: false, // حالة الخطأ
};
},
async mounted() {
try {
// جلب البيانات من API
const response = await api.get("app/article/"); // استبدل برابط API الفعلي
this.articles = response.data; // تعيين البيانات المستردة إلى المصفوفة
} catch (err) {
console.error("Failed to fetch articles:", err);
this.error = true; // عرض رسالة الخطأ
} finally {
this.loading = false; // إيقاف حالة التحميل
}
},
};
</script>
<style scoped>
/* يمكنك إضافة تخصيصات إضافية هنا */
</style>

View file

@ -0,0 +1,85 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-r from-purple-500 to-pink-500">
<div class="bg-white p-8 rounded-lg shadow-lg w-full max-w-md">
<h2 class="text-3xl font-bold text-center text-gray-800 mb-8">Login</h2>
<form @submit.prevent="handleLogin" class="space-y-6">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Username</label>
<input
type="text"
id="email"
v-model="form.username"
placeholder="Enter your email"
class="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
required
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input
type="password"
id="password"
v-model="form.password"
placeholder="Enter your password"
class="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
required
/>
</div>
<div>
<button
type="submit"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
>
Sign In
</button>
</div>
</form>
<div class="mt-6 text-center">
<p class="text-sm text-gray-600">
Don't have an account?
<router-link :to="{name:'Signup'}" class="font-medium text-purple-600 hover:text-purple-500">Sign up</router-link>
</p>
</div>
</div>
</div>
</template>
<script>
import api from '@/api';
export default {
data() {
return {
form: {
username: '',
password: '',
},
};
},
methods: {
async handleLogin() {
try {
const response = await api.post("auth/jwt/create/", this.form);
localStorage.setItem('access_token', response.data.access);
localStorage.setItem('refresh_token', response.data.refresh);
this.$router.push({ name: 'Home'});
} catch (error) {
console.error('Login failed:', error.response ? error.response.data : error.message);
if (error.response) {
alert(`Login failed: ${error.response.data.detail || 'Invalid credentials'}`);
} else {
alert('Login failed: Network error or server is down.');
}
}
},
},
};
</script>
<style scoped>
/* Add custom styles here if needed */
</style>

View file

@ -0,0 +1,156 @@
<template>
<div
class="min-h-screen flex items-center justify-center bg-gradient-to-r from-blue-500 to-indigo-600"
>
<div class="bg-white p-8 rounded-lg shadow-lg w-full max-w-md">
<h2 class="text-3xl font-bold text-center text-gray-800 mb-8">Sign Up</h2>
<!-- Success Message -->
<div
v-if="successMessage"
class="mb-4 p-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800"
role="alert"
>
{{ successMessage }}
</div>
<!-- Error Message -->
<div
v-if="errorMessage"
class="mb-4 p-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
role="alert"
>
{{ errorMessage }}
</div>
<form @submit.prevent="handleSignUp" class="space-y-6">
<div>
<label for="username" class="block text-sm font-medium text-gray-700"
>Username</label
>
<input
type="text"
id="username"
v-model="form.username"
placeholder="Enter your username"
class="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700"
>Email</label
>
<input
type="email"
id="email"
v-model="form.email"
placeholder="Enter your email"
class="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700"
>Password</label
>
<input
type="password"
id="password"
v-model="form.password"
placeholder="Enter your password"
class="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label
for="confirmPassword"
class="block text-sm font-medium text-gray-700"
>Confirm Password</label
>
<input
type="password"
id="confirmPassword"
v-model="form.confirmPassword"
placeholder="Confirm your password"
class="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<button
type="submit"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Sign Up
</button>
</div>
</form>
<div class="mt-6 text-center">
<p class="text-sm text-gray-600">
Already have an account?
<router-link
:to="{ name: 'Login' }"
class="font-medium text-blue-600 hover:text-blue-500"
>Log in</router-link
>
</p>
</div>
</div>
</div>
</template>
<script>
import api from "@/api"; // Import your API configuration.
export default {
data() {
return {
form: {
username: '',
email: '',
password: '',
confirmPassword: '',
},
successMessage: '',
errorMessage: '',
};
},
methods: {
async handleSignUp() {
try {
// Reset messages
this.successMessage = '';
this.errorMessage = '';
// Send signup request
const response = await api.post("auth/users/", this.form);
// Handle success
if (response.status === 201) {
this.successMessage = "Your account has been created successfully!";
this.form = {
username: '',
email: '',
password: '',
confirmPassword: '',
};
setTimeout(() => {
this.$router.push({ name: 'Login' });
}, 2000); // Redirect after 2 seconds
}
} catch (error) {
// Handle errors
if (error.response) {
this.errorMessage =
error.response.data ||
"There was an error creating your account.";
} else {
this.errorMessage = "Network error. Please try again later.";
}
}
},
},
};
</script>

View file

@ -0,0 +1,20 @@
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
export default new Vuex.Store({
state: {
formData: {}
},
mutations: {
updateFormData(state, payload) {
state.formData = { ...state.formData, ...payload };
}
},
actions: {
updateFormData({ commit }, payload) {
commit('updateFormData', payload);
}
}
});

View file

@ -0,0 +1,20 @@
import flowbitePlugin from "flowbite/plugin";
import flowbiteTypography from 'flowbite-typography';
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
'./node_modules/flowbite/**/*.js',
],
theme: {
extend: {},
},
plugins: [
flowbitePlugin,
flowbiteTypography,
],
};

5
frontend/vue.config.js Normal file
View file

@ -0,0 +1,5 @@
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true
})