Added frontend directory as part of the main repository
1
frontend
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit 4e3c4575410e2680e8ab82f1b1c3349cf9276255
|
|
||||||
23
frontend/.gitignore
vendored
Normal 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
|
|
@ -0,0 +1 @@
|
||||||
|
shamefully-hoist=true
|
||||||
19
frontend/README.md
Normal 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
|
|
@ -0,0 +1,5 @@
|
||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
'@vue/cli-plugin-babel/preset'
|
||||||
|
]
|
||||||
|
}
|
||||||
184
frontend/ee.js
Normal 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
|
|
@ -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
|
|
@ -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
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/public/img/icons/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
frontend/public/img/icons/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
frontend/public/img/icons/android-chrome-maskable-192x192.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
frontend/public/img/icons/android-chrome-maskable-512x512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/img/icons/apple-touch-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
frontend/public/img/icons/apple-touch-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
frontend/public/img/icons/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/public/img/icons/apple-touch-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/img/icons/apple-touch-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/img/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/public/img/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 799 B |
BIN
frontend/public/img/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/public/img/icons/msapplication-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/img/icons/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
3
frontend/public/img/icons/safari-pinned-tab.svg
Normal 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 |
17
frontend/public/index.html
Normal 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>
|
||||||
2
frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
47
frontend/src/App.vue
Normal 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
|
|
@ -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;
|
||||||
BIN
frontend/src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
5
frontend/src/assets/tailwind.css
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
@tailwind base;
|
||||||
|
|
||||||
|
@tailwind components;
|
||||||
|
|
||||||
|
@tailwind utilities;
|
||||||
20
frontend/src/components/Layout.vue
Normal 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>
|
||||||
155
frontend/src/components/NavBar.vue
Normal 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
|
|
@ -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')
|
||||||
32
frontend/src/registerServiceWorker.js
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
71
frontend/src/router/index.js
Normal 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;
|
||||||
439
frontend/src/utils/WYSIWYG.vue
Normal 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>
|
||||||
131
frontend/src/views/ArticlePage.vue
Normal 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>
|
||||||
118
frontend/src/views/CreateArticle.vue
Normal 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>
|
||||||
134
frontend/src/views/Dashboard.vue
Normal 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
|
|
@ -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>
|
||||||
85
frontend/src/views/account/Login.vue
Normal 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>
|
||||||
156
frontend/src/views/account/Signup.vue
Normal 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>
|
||||||
20
frontend/src/views/store.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
20
frontend/tailwind.config.js
Normal 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
|
|
@ -0,0 +1,5 @@
|
||||||
|
const { defineConfig } = require('@vue/cli-service')
|
||||||
|
module.exports = defineConfig({
|
||||||
|
transpileDependencies: true
|
||||||
|
|
||||||
|
})
|
||||||