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
|
||||
|
||||
})
|
||||