Compare commits
2 Commits
29d3322682
...
3aa27efa36
Author | SHA1 | Date | |
---|---|---|---|
|
3aa27efa36 | ||
|
c583dd0b72 |
|
@ -10,9 +10,6 @@
|
|||
"startFree": "Start for free",
|
||||
"learnMore": "Learn more"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© 2023 Draw. All rights reserved."
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Log in",
|
||||
|
@ -64,6 +61,8 @@
|
|||
"newDrawingButton": "Create New Drawing",
|
||||
"newDrawingPlaceholder": "Enter drawing title...",
|
||||
"noDrawings": "You don't have any drawings yet. Create one!",
|
||||
"myDrawingsTitle": "My Drawings",
|
||||
"sharedDrawingsTitle": "Shared with me",
|
||||
"errors": {
|
||||
"createFailed": "Failed to create new drawing.",
|
||||
"titleRequired": "Title is required."
|
||||
|
@ -73,6 +72,7 @@
|
|||
"lastUpdatedPrefix": "Last updated:",
|
||||
"editTitleAria": "Edit title",
|
||||
"deleteAria": "Delete drawing",
|
||||
"shareAria": "Share drawing",
|
||||
"confirmDelete": {
|
||||
"title": "Confirm Deletion",
|
||||
"message": "Are you sure you want to delete \"{{title}}\"? This action cannot be undone.",
|
||||
|
@ -82,7 +82,19 @@
|
|||
"errors": {
|
||||
"titleRequired": "Title is required.",
|
||||
"renameFailed": "Failed to rename drawing.",
|
||||
"deleteFailed": "Failed to delete drawing."
|
||||
"deleteFailed": "Failed to delete drawing.",
|
||||
"loadUsersFailed": "Failed to load users.",
|
||||
"userNotSelected": "Please select a user to share with.",
|
||||
"shareFailed": "Failed to share drawing."
|
||||
},
|
||||
"shareModal": {
|
||||
"title": "Share Drawing",
|
||||
"searchPlaceholder": "Search user by name or email",
|
||||
"confirmButton": "Share",
|
||||
"cancelButton": "Cancel",
|
||||
"noUsersFound": "No users available to share with or match your search.",
|
||||
"loadingUsers": "Loading users...",
|
||||
"sharingButton": "Sharing..."
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
|
@ -90,10 +102,18 @@
|
|||
"saving": "Saving...",
|
||||
"saved": "Saved!",
|
||||
"loadingDrawing": "Loading drawing...",
|
||||
"homePage": "Home Page",
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved Changes",
|
||||
"message": "You have unsaved changes. Do you want to save them before leaving?",
|
||||
"save": "Save",
|
||||
"discard": "Discard",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"errors": {
|
||||
"cannotSave": "Cannot save drawing. Missing reference or ID.",
|
||||
"saveFailed": "Failed to save drawing.",
|
||||
"loadFailedSimple": "Error loading drawing."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,9 +10,6 @@
|
|||
"startFree": "Comenzar gratis",
|
||||
"learnMore": "Conocer más"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© 2023 Draw. Todos los derechos reservados."
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Iniciar sesión",
|
||||
|
@ -64,6 +61,8 @@
|
|||
"newDrawingButton": "Crear Nuevo Dibujo",
|
||||
"newDrawingPlaceholder": "Introduce el título del dibujo...",
|
||||
"noDrawings": "Aún no tienes dibujos. ¡Crea uno!",
|
||||
"myDrawingsTitle": "Mis Dibujos",
|
||||
"sharedDrawingsTitle": "Compartidos conmigo",
|
||||
"errors": {
|
||||
"createFailed": "Error al crear nuevo dibujo.",
|
||||
"titleRequired": "El título es obligatorio."
|
||||
|
@ -73,6 +72,7 @@
|
|||
"lastUpdatedPrefix": "Última act.:",
|
||||
"editTitleAria": "Editar título",
|
||||
"deleteAria": "Eliminar dibujo",
|
||||
"shareAria": "Compartir dibujo",
|
||||
"confirmDelete": {
|
||||
"title": "Confirmar Eliminación",
|
||||
"message": "¿Estás seguro de que quieres eliminar \"{{title}}\"? Esta acción no se puede deshacer.",
|
||||
|
@ -82,7 +82,19 @@
|
|||
"errors": {
|
||||
"titleRequired": "El título es obligatorio.",
|
||||
"renameFailed": "Error al renombrar el dibujo.",
|
||||
"deleteFailed": "Error al eliminar el dibujo."
|
||||
"deleteFailed": "Error al eliminar el dibujo.",
|
||||
"loadUsersFailed": "Error al cargar usuarios.",
|
||||
"userNotSelected": "Por favor, selecciona un usuario para compartir.",
|
||||
"shareFailed": "Error al compartir el dibujo."
|
||||
},
|
||||
"shareModal": {
|
||||
"title": "Compartir Dibujo",
|
||||
"searchPlaceholder": "Buscar usuario por nombre o email",
|
||||
"confirmButton": "Compartir",
|
||||
"cancelButton": "Cancelar",
|
||||
"noUsersFound": "No hay usuarios disponibles para compartir o que coincidan con tu búsqueda.",
|
||||
"loadingUsers": "Cargando usuarios...",
|
||||
"sharingButton": "Compartiendo..."
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
|
@ -90,10 +102,18 @@
|
|||
"saving": "Guardando...",
|
||||
"saved": "¡Guardado!",
|
||||
"loadingDrawing": "Cargando dibujo...",
|
||||
"homePage": "Inicio",
|
||||
"unsavedChanges": {
|
||||
"title": "Cambios sin guardar",
|
||||
"message": "Tienes cambios sin guardar. ¿Quieres guardarlos antes de salir?",
|
||||
"save": "Guardar",
|
||||
"discard": "Descartar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"errors": {
|
||||
"cannotSave": "No se puede guardar el dibujo. Falta referencia o ID.",
|
||||
"saveFailed": "Error al guardar el dibujo.",
|
||||
"loadFailedSimple": "Error al cargar el dibujo."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { shareDrawingWithUser } from "@/controllers/drawingsController";
|
||||
import { authMiddleware } from "@/lib/helpers/api-middlewares/auth";
|
||||
|
||||
interface ShareRequestBody {
|
||||
userIdToShareWith: string;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authResult = await authMiddleware(request);
|
||||
if (!authResult.isAuthenticated || !authResult.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const drawingId = params.id;
|
||||
const ownerUserId = authResult.user.id;
|
||||
|
||||
try {
|
||||
const body = await request.json() as ShareRequestBody;
|
||||
const { userIdToShareWith } = body;
|
||||
|
||||
if (!userIdToShareWith) {
|
||||
return NextResponse.json(
|
||||
{ error: "userIdToShareWith is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await shareDrawingWithUser(
|
||||
drawingId,
|
||||
ownerUserId,
|
||||
userIdToShareWith
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
return NextResponse.json({ error: result.error }, { status: result.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(result.data, { status: result.status });
|
||||
} catch (error) {
|
||||
console.error("Error in POST /api/drawings/[id]/share:", error);
|
||||
if (error instanceof SyntaxError) {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { unshareDrawingWithUser } from "@/controllers/drawingsController";
|
||||
import { authMiddleware } from "@/lib/helpers/api-middlewares/auth";
|
||||
|
||||
interface UnshareRequestBody {
|
||||
userIdToUnshare: string;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authResult = await authMiddleware(request);
|
||||
if (!authResult.isAuthenticated || !authResult.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const drawingId = params.id;
|
||||
const ownerUserId = authResult.user.id;
|
||||
|
||||
try {
|
||||
const body = await request.json() as UnshareRequestBody;
|
||||
const { userIdToUnshare } = body;
|
||||
|
||||
if (!userIdToUnshare) {
|
||||
return NextResponse.json(
|
||||
{ error: "userIdToUnshare is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await unshareDrawingWithUser(
|
||||
drawingId,
|
||||
ownerUserId,
|
||||
userIdToUnshare
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
return NextResponse.json({ error: result.error }, { status: result.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(result.data, { status: result.status });
|
||||
} catch (error) {
|
||||
console.error("Error in POST /api/drawings/[id]/unshare:", error);
|
||||
if (error instanceof SyntaxError) {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getUsers } from "@/controllers/usersController";
|
||||
import { authMiddleware } from "@/lib/helpers/api-middlewares/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { isAuthenticated } = await authMiddleware(request);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getUsers();
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error in GET /api/users:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,18 @@
|
|||
.page {
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
font-family: sans-serif;
|
||||
color: #333;
|
||||
background-color: #f9f9f9;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:global(.dark) .page {
|
||||
|
@ -87,7 +93,14 @@
|
|||
--text-primary: #fff;
|
||||
}
|
||||
|
||||
.logoutButtonText {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.createButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background-color: #0070f3;
|
||||
color: white;
|
||||
border: none;
|
||||
|
@ -232,6 +245,10 @@
|
|||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out, transform 0.1s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.actionButton:active {
|
||||
|
@ -254,4 +271,120 @@
|
|||
|
||||
.cancelButton:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.loaderWrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drawingsSection {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:global(.dark) .sectionTitle {
|
||||
color: #f0f0f0;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.createButton {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logoutButton {
|
||||
padding: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logoutButtonText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drawingsGrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.inlineCreateForm {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.inlineInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
padding: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.headerSwitchers {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.drawingsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.inlineCreateForm {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inlineInput {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
|
@ -3,17 +3,19 @@ import { useEffect, useState, useRef } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import styles from "./page.module.css";
|
||||
import { useI18n } from '@/lib/i18n/useI18n';
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
import AuthGuard from '@/components/AuthGuard/AuthGuard';
|
||||
import { getAllDrawings, createDrawing, Drawing, logout } from '@/lib/api';
|
||||
import DrawingCard from '@/components/DrawingCard';
|
||||
import ThemeSwitcher from "@/components/ThemeSwitcher";
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
import DrawingCard from '@/components/DrawingCard/DrawingCard';
|
||||
import ThemeSwitcher from "@/components/ThemeSwitcher/ThemeSwitcher";
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher/LanguageSwitcher";
|
||||
import Icon from "@/components/Icon/Icon";
|
||||
import Loader from "@/components/Loader/Loader";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
export default function Dashboard() {
|
||||
const { t, isLoading: i18nIsLoading } = useI18n();
|
||||
const router = useRouter();
|
||||
const [drawings, setDrawings] = useState<Drawing[]>([]);
|
||||
const [myDrawings, setMyDrawings] = useState<Drawing[]>([]);
|
||||
const [sharedDrawings, setSharedDrawings] = useState<Drawing[]>([]);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
@ -28,7 +30,14 @@ export default function Dashboard() {
|
|||
setError(null);
|
||||
const response = await getAllDrawings();
|
||||
if (response.data) {
|
||||
setDrawings(response.data);
|
||||
const token = localStorage.getItem('token');
|
||||
const decodedToken = jwtDecode(token || "") as { userId: string };
|
||||
const userId = decodedToken.userId;
|
||||
const own = response.data.filter(drawing => drawing.owner_id === userId);
|
||||
const shared = response.data.filter(drawing => drawing.owner_id !== userId);
|
||||
|
||||
setMyDrawings(own);
|
||||
setSharedDrawings(shared);
|
||||
} else if (response.error) {
|
||||
setError(response.error);
|
||||
}
|
||||
|
@ -90,10 +99,9 @@ export default function Dashboard() {
|
|||
};
|
||||
const response = await createDrawing(newDrawingData);
|
||||
if (response.data) {
|
||||
router.push(`/editor?id=${response.data._id}`);
|
||||
router.push(`/editor/${response.data._id}`);
|
||||
setIsCreating(false);
|
||||
setNewDrawingTitle("");
|
||||
// fetchDrawings();
|
||||
} else if (response.error) {
|
||||
setError(response.error);
|
||||
}
|
||||
|
@ -114,15 +122,31 @@ export default function Dashboard() {
|
|||
};
|
||||
|
||||
const handleDrawingRenamed = (updatedDrawing: Drawing) => {
|
||||
setDrawings(prevDrawings =>
|
||||
prevDrawings.map(d => d._id === updatedDrawing._id ? updatedDrawing : d)
|
||||
);
|
||||
const userId = localStorage.getItem('userId');
|
||||
if (updatedDrawing.owner_id === userId) {
|
||||
setMyDrawings(prevDrawings =>
|
||||
prevDrawings.map(d => d._id === updatedDrawing._id ? updatedDrawing : d)
|
||||
);
|
||||
} else {
|
||||
setSharedDrawings(prevDrawings =>
|
||||
prevDrawings.map(d => d._id === updatedDrawing._id ? updatedDrawing : d)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrawingDeleted = (drawingId: string) => {
|
||||
setDrawings(prevDrawings =>
|
||||
setMyDrawings(prevDrawings =>
|
||||
prevDrawings.filter(d => d._id !== drawingId)
|
||||
);
|
||||
setSharedDrawings(prevDrawings =>
|
||||
prevDrawings.filter(d => d._id !== drawingId)
|
||||
);
|
||||
};
|
||||
|
||||
const handleDrawingShared = (updatedDrawing: Drawing) => {
|
||||
setMyDrawings(prevDrawings =>
|
||||
prevDrawings.map(d => d._id === updatedDrawing._id ? updatedDrawing : d)
|
||||
);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
|
@ -139,7 +163,9 @@ export default function Dashboard() {
|
|||
return (
|
||||
<AuthGuard>
|
||||
<div className={styles.page}>
|
||||
<p>Loading translations...</p>
|
||||
<div className={styles.loaderWrapper}>
|
||||
<Loader size="medium" text="Loading translations..." />
|
||||
</div>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
@ -149,7 +175,9 @@ export default function Dashboard() {
|
|||
return (
|
||||
<AuthGuard>
|
||||
<div className={styles.page}>
|
||||
<p>{t('dashboard.loading') || "Loading dashboard..."}</p>
|
||||
<div className={styles.loaderWrapper}>
|
||||
<Loader size="medium" text={t('dashboard.loading') || "Loading dashboard..."} />
|
||||
</div>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
@ -160,7 +188,7 @@ export default function Dashboard() {
|
|||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<h1>{t('dashboard.title') || "My Drawings"}</h1>
|
||||
<h1>Draw</h1>
|
||||
<div className={styles.headerSwitchers}>
|
||||
<LanguageSwitcher />
|
||||
<ThemeSwitcher />
|
||||
|
@ -169,6 +197,7 @@ export default function Dashboard() {
|
|||
<div className={styles.headerRight}>
|
||||
{!isCreating ? (
|
||||
<button onClick={handleStartCreate} className={styles.createButton}>
|
||||
<Icon name="plus" size={20} />
|
||||
{t('dashboard.newDrawingButton') || "Create New Drawing"}
|
||||
</button>
|
||||
) : (
|
||||
|
@ -182,6 +211,20 @@ export default function Dashboard() {
|
|||
placeholder={t('dashboard.newDrawingPlaceholder') || "Enter drawing title..."}
|
||||
className={styles.inlineInput}
|
||||
/>
|
||||
<button
|
||||
onClick={handleConfirmCreate}
|
||||
className={`${styles.actionButton} ${styles.confirmButton}`}
|
||||
aria-label={t('dashboard.create') || "Create"}
|
||||
>
|
||||
<Icon name="check" size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelCreate}
|
||||
className={`${styles.actionButton} ${styles.cancelButton}`}
|
||||
aria-label={t('dashboard.cancel') || "Cancel"}
|
||||
>
|
||||
<Icon name="x" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleLogout} className={styles.logoutButton} title={t('header.logout') || 'Log out'}>
|
||||
|
@ -193,20 +236,44 @@ export default function Dashboard() {
|
|||
|
||||
{error && <p className={styles.errorText}>{error}</p>}
|
||||
|
||||
{drawings.length === 0 && !error && !isCreating && (
|
||||
{myDrawings.length === 0 && sharedDrawings.length === 0 && !error && !isCreating && (
|
||||
<p>{t('dashboard.noDrawings') || "You don\'t have any drawings yet. Create one!"}</p>
|
||||
)}
|
||||
|
||||
<div className={styles.drawingsGrid}>
|
||||
{drawings.map((drawing) => (
|
||||
<DrawingCard
|
||||
key={drawing._id}
|
||||
drawing={drawing}
|
||||
onDrawingRenamed={handleDrawingRenamed}
|
||||
onDrawingDeleted={handleDrawingDeleted}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{myDrawings.length > 0 && (
|
||||
<section className={styles.drawingsSection}>
|
||||
<h2 className={styles.sectionTitle}>{t('dashboard.myDrawingsTitle') || "Mis dibujos"}</h2>
|
||||
<div className={styles.drawingsGrid}>
|
||||
{myDrawings.map((drawing) => (
|
||||
<DrawingCard
|
||||
owned={true}
|
||||
key={drawing._id}
|
||||
drawing={drawing}
|
||||
onDrawingRenamed={handleDrawingRenamed}
|
||||
onDrawingDeleted={handleDrawingDeleted}
|
||||
onDrawingShared={handleDrawingShared}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{sharedDrawings.length > 0 && (
|
||||
<section className={styles.drawingsSection}>
|
||||
<h2 className={styles.sectionTitle}>{t('dashboard.sharedDrawingsTitle') || "Compartidos conmigo"}</h2>
|
||||
<div className={styles.drawingsGrid}>
|
||||
{sharedDrawings.map((drawing) => (
|
||||
<DrawingCard
|
||||
owned={false}
|
||||
key={drawing._id}
|
||||
drawing={drawing}
|
||||
onDrawingRenamed={handleDrawingRenamed}
|
||||
onDrawingDeleted={handleDrawingDeleted}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
|
|
@ -18,7 +18,19 @@
|
|||
display: none; /* O visibility: hidden; */
|
||||
}
|
||||
|
||||
.loadingOverlay,
|
||||
.loadingOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--background-color);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.errorOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
@ -46,32 +58,134 @@
|
|||
background-color: var(--color-danger-dark, #a94442);
|
||||
}
|
||||
|
||||
.savingIndicator,
|
||||
.savingIndicator {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.saveErrorIndicator {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 100;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
z-index: 20;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.savingIndicator {
|
||||
background-color: var(--color-primary, #0070f3);
|
||||
}
|
||||
|
||||
:global(.dark) .savingIndicator {
|
||||
background-color: var(--color-primary-dark, #005bb5);
|
||||
}
|
||||
|
||||
.saveErrorIndicator {
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
background-color: var(--color-danger, #d9534f);
|
||||
}
|
||||
|
||||
:global(.dark) .saveErrorIndicator {
|
||||
background-color: var(--color-danger-dark, #a94442);
|
||||
}
|
||||
|
||||
.homeButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-primary, #0070f3);
|
||||
color: white;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.homeButton:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
|
||||
background-color: var(--color-primary-dark, #005bb5);
|
||||
}
|
||||
|
||||
:global(.dark) .homeButton {
|
||||
background-color: var(--color-primary-dark, #005bb5);
|
||||
}
|
||||
|
||||
:global(.dark) .homeButton:hover {
|
||||
background-color: var(--color-primary, #0070f3);
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modalFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.modalButtonCancel,
|
||||
.modalButtonDiscard,
|
||||
.modalButtonSave {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modalButtonCancel {
|
||||
background-color: #f8f9fa;
|
||||
color: #333;
|
||||
border: 1px solid #ced4da;
|
||||
}
|
||||
|
||||
:global(.dark) .modalButtonCancel {
|
||||
background-color: #333;
|
||||
color: #e0e0e0;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.modalButtonCancel:hover {
|
||||
background-color: #e2e6ea;
|
||||
border-color: #dae0e5;
|
||||
}
|
||||
|
||||
:global(.dark) .modalButtonCancel:hover {
|
||||
background-color: #444;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.modalButtonDiscard {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
border: 1px solid #6c757d;
|
||||
}
|
||||
|
||||
.modalButtonDiscard:hover {
|
||||
background-color: #5a6268;
|
||||
border-color: #545b62;
|
||||
}
|
||||
|
||||
.modalButtonSave {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: 1px solid #28a745;
|
||||
}
|
||||
|
||||
.modalButtonSave:hover {
|
||||
background-color: #218838;
|
||||
border-color: #1e7e34;
|
||||
}
|
||||
|
||||
.modalButtonSave:disabled {
|
||||
background-color: #87d3a0;
|
||||
border-color: #87d3a0;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:global(.dark) .modalButtonSave:disabled {
|
||||
background-color: #1a6b2c;
|
||||
border-color: #1a6b2c;
|
||||
}
|
||||
|
|
|
@ -1,23 +1,27 @@
|
|||
"use client";
|
||||
import styles from "./page.module.css";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { useRef, useState, useEffect, useCallback, use } from "react";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import { MainMenu } from "@excalidraw/excalidraw";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useTheme } from "@/lib/theme-context";
|
||||
import { useLanguage } from "@/lib/i18n/language-context";
|
||||
import { useI18n } from '@/lib/i18n/useI18n';
|
||||
import Icon from '@/components/Icon';
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
import Icon from '@/components/Icon/Icon';
|
||||
import AuthGuard from '@/components/AuthGuard/AuthGuard';
|
||||
import { getOneDrawing, updateDrawing, UpdateDrawingRequest } from '@/lib/api';
|
||||
import Modal from '@/components/Modal/Modal';
|
||||
import Loader from '@/components/Loader/Loader';
|
||||
|
||||
const ExcalidrawComponent = dynamic(
|
||||
() => import("@excalidraw/excalidraw").then((mod) => mod.Excalidraw),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export default function Editor({ params }: { params: { id: string } }) {
|
||||
export default function Editor({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = use(params);
|
||||
const { t, isLoading } = useI18n();
|
||||
const excalidrawWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
@ -26,6 +30,7 @@ export default function Editor({ params }: { params: { id: string } }) {
|
|||
const drawingIdRef = useRef<string | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const excalidrawRef = useRef<any | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const [initialData, setInitialData] = useState<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
@ -39,16 +44,21 @@ export default function Editor({ params }: { params: { id: string } }) {
|
|||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [pendingNavigation, setPendingNavigation] = useState<string | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const initialElementsRef = useRef<readonly any[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
if (params.id) {
|
||||
drawingIdRef.current = params.id;
|
||||
if (resolvedParams.id) {
|
||||
drawingIdRef.current = resolvedParams.id;
|
||||
} else {
|
||||
console.warn("No drawing ID found in route params.");
|
||||
setIsLoadingDrawing(false);
|
||||
}
|
||||
}, [params.id]);
|
||||
}, [resolvedParams.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClient || !drawingIdRef.current) {
|
||||
|
@ -66,11 +76,13 @@ export default function Editor({ params }: { params: { id: string } }) {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const drawingContent = response.data.data as { elements: any[], appState: Partial<any> };
|
||||
if (drawingContent && Array.isArray(drawingContent.elements)) {
|
||||
initialElementsRef.current = drawingContent.elements;
|
||||
setInitialData({
|
||||
elements: drawingContent.elements,
|
||||
appState: drawingContent.appState || {}
|
||||
});
|
||||
} else {
|
||||
initialElementsRef.current = [];
|
||||
setInitialData({
|
||||
elements: [],
|
||||
appState: {}
|
||||
|
@ -80,6 +92,7 @@ export default function Editor({ params }: { params: { id: string } }) {
|
|||
setLoadError(response.error);
|
||||
console.error("Error fetching drawing:", response.error);
|
||||
} else {
|
||||
initialElementsRef.current = [];
|
||||
setInitialData({
|
||||
elements: [],
|
||||
appState: {}
|
||||
|
@ -96,6 +109,63 @@ export default function Editor({ params }: { params: { id: string } }) {
|
|||
fetchDrawing();
|
||||
}, [isClient, theme, t]);
|
||||
|
||||
// Handle beforeunload event to warn about unsaved changes
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsavedChanges) {
|
||||
// Standard way to show a browser confirmation dialog
|
||||
e.preventDefault();
|
||||
// Chrome requires returnValue to be set
|
||||
e.returnValue = '';
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
// Helper function to deeply compare elements and determine if there are actual changes
|
||||
const hasActualChanges = useCallback((): boolean => {
|
||||
if (!excalidrawRef.current || !initialElementsRef.current) return false;
|
||||
|
||||
const currentElements = excalidrawRef.current.getSceneElements();
|
||||
const initialElements = initialElementsRef.current;
|
||||
|
||||
// Quick check for length differences
|
||||
if (currentElements.length !== initialElements.length) return true;
|
||||
|
||||
// Simple deep comparison focusing on key properties
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const compareElements = (a: any, b: any): boolean => {
|
||||
// Compare core properties that indicate meaningful changes
|
||||
if (a.type !== b.type) return false;
|
||||
if (a.x !== b.x || a.y !== b.y) return false;
|
||||
if (a.width !== b.width || a.height !== b.height) return false;
|
||||
if (a.points && b.points) {
|
||||
if (a.points.length !== b.points.length) return false;
|
||||
for (let i = 0; i < a.points.length; i++) {
|
||||
if (a.points[i][0] !== b.points[i][0] || a.points[i][1] !== b.points[i][1]) return false;
|
||||
}
|
||||
}
|
||||
if (a.text !== b.text) return false;
|
||||
if (a.backgroundColor !== b.backgroundColor) return false;
|
||||
if (a.strokeColor !== b.strokeColor) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Compare each element
|
||||
for (let i = 0; i < currentElements.length; i++) {
|
||||
if (!compareElements(currentElements[i], initialElements[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const handleSaveDrawing = async () => {
|
||||
if (!excalidrawRef.current || !drawingIdRef.current) {
|
||||
setSaveError(t('editor.errors.cannotSave') || 'Cannot save drawing. Missing reference or ID.');
|
||||
|
@ -128,7 +198,17 @@ export default function Editor({ params }: { params: { id: string } }) {
|
|||
const response = await updateDrawing(drawingIdRef.current, payload);
|
||||
if (response.data) {
|
||||
setSaveSuccess(true);
|
||||
setHasUnsavedChanges(false);
|
||||
initialElementsRef.current = elements;
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
|
||||
// If there's a pending navigation after save, execute it
|
||||
if (pendingNavigation) {
|
||||
const destination = pendingNavigation;
|
||||
setPendingNavigation(null);
|
||||
setShowSaveModal(false);
|
||||
router.push(destination);
|
||||
}
|
||||
} else {
|
||||
setSaveError(response.error || t('editor.errors.saveFailed') || 'Failed to save drawing.');
|
||||
}
|
||||
|
@ -148,6 +228,37 @@ export default function Editor({ params }: { params: { id: string } }) {
|
|||
const newLocale = locale === 'es' ? 'en' : 'es';
|
||||
setLocale(newLocale);
|
||||
}, [locale, setLocale]);
|
||||
|
||||
const checkNavigationAndPrompt = useCallback((destination: string) => {
|
||||
const actualChanges = hasActualChanges();
|
||||
if (hasUnsavedChanges && actualChanges) {
|
||||
setPendingNavigation(destination);
|
||||
setShowSaveModal(true);
|
||||
} else {
|
||||
// No actual changes or no marked changes, proceed with navigation
|
||||
router.push(destination);
|
||||
}
|
||||
}, [hasUnsavedChanges, router, hasActualChanges]);
|
||||
|
||||
const handleHomeClick = useCallback(() => {
|
||||
checkNavigationAndPrompt("/dashboard");
|
||||
}, [checkNavigationAndPrompt]);
|
||||
|
||||
const handleDiscardChanges = useCallback(() => {
|
||||
setHasUnsavedChanges(false);
|
||||
setShowSaveModal(false);
|
||||
|
||||
if (pendingNavigation) {
|
||||
const destination = pendingNavigation;
|
||||
setPendingNavigation(null);
|
||||
router.push(destination);
|
||||
}
|
||||
}, [pendingNavigation, router]);
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setShowSaveModal(false);
|
||||
setPendingNavigation(null);
|
||||
}, []);
|
||||
|
||||
const getMenuItemText = useCallback((key: string, defaultText?: string) => {
|
||||
if (isLoading || !t) {
|
||||
|
@ -160,13 +271,30 @@ export default function Editor({ params }: { params: { id: string } }) {
|
|||
return translatedText;
|
||||
}, [t, isLoading]);
|
||||
|
||||
// Track changes to the drawing
|
||||
const handleExcalidrawChange = useCallback(() => {
|
||||
if (!isLoadingDrawing && initialData) {
|
||||
if (hasActualChanges()) {
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}
|
||||
}, [isLoadingDrawing, initialData, hasActualChanges]);
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<div className={styles.page}>
|
||||
{isLoadingDrawing && <div className={styles.loadingOverlay}><p>{getMenuItemText('editor.loadingDrawing', "Loading drawing...")}</p></div>}
|
||||
{isLoadingDrawing && (
|
||||
<div className={styles.loadingOverlay}>
|
||||
<Loader size="medium" text={getMenuItemText('editor.loadingDrawing', "Loading drawing...")} />
|
||||
</div>
|
||||
)}
|
||||
{loadError && <div className={styles.errorOverlay}><p>{loadError}</p></div>}
|
||||
|
||||
{isSaving && <div className={styles.savingIndicator}>{getMenuItemText('editor.saving', 'Saving...')}</div>}
|
||||
{isSaving && (
|
||||
<div className={styles.savingIndicator}>
|
||||
<Loader size="small" text={getMenuItemText('editor.saving', 'Saving...')} />
|
||||
</div>
|
||||
)}
|
||||
{saveError && <div className={styles.saveErrorIndicator}>{saveError}</div>}
|
||||
|
||||
<div ref={excalidrawWrapperRef} className={`${styles.excalidrawContainer} ${isLoadingDrawing || loadError ? styles.hidden : ''}`}>
|
||||
|
@ -176,6 +304,16 @@ export default function Editor({ params }: { params: { id: string } }) {
|
|||
initialData={initialData}
|
||||
theme={theme}
|
||||
langCode={locale === 'es' ? 'es-ES' : 'en-US'}
|
||||
onChange={handleExcalidrawChange}
|
||||
renderTopRightUI={() => (
|
||||
<button
|
||||
className={styles.homeButton}
|
||||
onClick={handleHomeClick}
|
||||
title={getMenuItemText('editor.homePage', 'Home Page')}
|
||||
>
|
||||
<Icon name="home" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<MainMenu>
|
||||
<MainMenu.DefaultItems.LoadScene />
|
||||
|
@ -211,6 +349,28 @@ export default function Editor({ params }: { params: { id: string } }) {
|
|||
</ExcalidrawComponent>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showSaveModal}
|
||||
onClose={handleCloseModal}
|
||||
title={getMenuItemText('editor.unsavedChanges.title', 'Unsaved Changes') || ''}
|
||||
>
|
||||
<p>{getMenuItemText('editor.unsavedChanges.message', 'You have unsaved changes. Do you want to save them before leaving?')}</p>
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<button onClick={handleCloseModal} className={styles.modalButtonCancel}>
|
||||
{getMenuItemText('editor.unsavedChanges.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button onClick={handleDiscardChanges} className={styles.modalButtonDiscard}>
|
||||
{getMenuItemText('editor.unsavedChanges.discard', 'Discard')}
|
||||
</button>
|
||||
<button onClick={handleSaveDrawing} className={styles.modalButtonSave} disabled={isSaving}>
|
||||
{isSaving
|
||||
? getMenuItemText('editor.saving', 'Saving...')
|
||||
: getMenuItemText('editor.unsavedChanges.save', 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
|
|
@ -1,77 +0,0 @@
|
|||
.page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.excalidrawContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
max-width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.excalidrawContainer.hidden {
|
||||
display: none; /* O visibility: hidden; */
|
||||
}
|
||||
|
||||
.loadingOverlay,
|
||||
.errorOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.errorOverlay p {
|
||||
background-color: var(--color-danger, #d9534f);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:global(.dark) .errorOverlay p {
|
||||
background-color: var(--color-danger-dark, #a94442);
|
||||
}
|
||||
|
||||
.savingIndicator,
|
||||
.saveErrorIndicator {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
z-index: 20;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.savingIndicator {
|
||||
background-color: var(--color-primary, #0070f3);
|
||||
}
|
||||
|
||||
:global(.dark) .savingIndicator {
|
||||
background-color: var(--color-primary-dark, #005bb5);
|
||||
}
|
||||
|
||||
.saveErrorIndicator {
|
||||
background-color: var(--color-danger, #d9534f);
|
||||
}
|
||||
|
||||
:global(.dark) .saveErrorIndicator {
|
||||
background-color: var(--color-danger-dark, #a94442);
|
||||
}
|
|
@ -1,217 +0,0 @@
|
|||
"use client";
|
||||
import styles from "./page.module.css";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import { MainMenu } from "@excalidraw/excalidraw";
|
||||
|
||||
import { useTheme } from "@/lib/theme-context";
|
||||
import { useLanguage } from "@/lib/i18n/language-context";
|
||||
import { useI18n } from '@/lib/i18n/useI18n';
|
||||
import Icon from '@/components/Icon';
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
import { getOneDrawing, updateDrawing, UpdateDrawingRequest } from '@/lib/api';
|
||||
|
||||
const ExcalidrawComponent = dynamic(
|
||||
() => import("@excalidraw/excalidraw").then((mod) => mod.Excalidraw),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export default function Editor({ params }: { params: { id: string } }) {
|
||||
const { t, isLoading } = useI18n();
|
||||
const excalidrawWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { locale, setLocale } = useLanguage();
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const drawingIdRef = useRef<string | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const excalidrawRef = useRef<any | null>(null);
|
||||
|
||||
const [initialData, setInitialData] = useState<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
elements: readonly any[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
appState?: Partial<any>;
|
||||
} | null>(null);
|
||||
const [isLoadingDrawing, setIsLoadingDrawing] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
if (params.id) {
|
||||
drawingIdRef.current = params.id;
|
||||
} else {
|
||||
console.warn("No drawing ID found in route params.");
|
||||
setIsLoadingDrawing(false);
|
||||
}
|
||||
}, [params.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClient || !drawingIdRef.current) {
|
||||
if (isClient && !drawingIdRef.current) setIsLoadingDrawing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchDrawing = async () => {
|
||||
if (!drawingIdRef.current) return;
|
||||
setIsLoadingDrawing(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const response = await getOneDrawing(drawingIdRef.current);
|
||||
if (response.data && response.data.data) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const drawingContent = response.data.data as { elements: any[], appState: Partial<any> };
|
||||
if (drawingContent && Array.isArray(drawingContent.elements)) {
|
||||
setInitialData({
|
||||
elements: drawingContent.elements,
|
||||
appState: drawingContent.appState || {}
|
||||
});
|
||||
} else {
|
||||
setInitialData({
|
||||
elements: [],
|
||||
appState: {}
|
||||
});
|
||||
}
|
||||
} else if (response.error) {
|
||||
setLoadError(response.error);
|
||||
console.error("Error fetching drawing:", response.error);
|
||||
} else {
|
||||
setInitialData({
|
||||
elements: [],
|
||||
appState: {}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setLoadError(t('editor.errors.loadFailedSimple') || "Failed to fetch drawing data.");
|
||||
console.error("Fetch drawing error:", err);
|
||||
} finally {
|
||||
setIsLoadingDrawing(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDrawing();
|
||||
}, [isClient, theme, t]);
|
||||
|
||||
const handleSaveDrawing = async () => {
|
||||
if (!excalidrawRef.current || !drawingIdRef.current) {
|
||||
setSaveError(t('editor.errors.cannotSave') || 'Cannot save drawing. Missing reference or ID.');
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
setSaveSuccess(false);
|
||||
|
||||
const elements = excalidrawRef.current.getSceneElements();
|
||||
const appState = excalidrawRef.current.getAppState();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const relevantAppState: Partial<any> = {
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
currentItemFontFamily: appState.currentItemFontFamily,
|
||||
currentItemRoughness: appState.currentItemRoughness,
|
||||
currentItemStrokeColor: appState.currentItemStrokeColor,
|
||||
currentItemStrokeStyle: appState.currentItemStrokeStyle,
|
||||
currentItemStrokeWidth: appState.currentItemStrokeWidth,
|
||||
currentItemTextAlign: appState.currentItemTextAlign,
|
||||
name: appState.name,
|
||||
gridSize: appState.gridSize,
|
||||
};
|
||||
|
||||
try {
|
||||
const payload: UpdateDrawingRequest = {
|
||||
drawingData: { elements, appState: relevantAppState },
|
||||
};
|
||||
const response = await updateDrawing(drawingIdRef.current, payload);
|
||||
if (response.data) {
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
} else {
|
||||
setSaveError(response.error || t('editor.errors.saveFailed') || 'Failed to save drawing.');
|
||||
}
|
||||
} catch (err) {
|
||||
setSaveError(t('editor.errors.saveFailed') || 'Failed to save drawing.');
|
||||
console.error("Save drawing error:", err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeChange = useCallback(() => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light');
|
||||
}, [theme, setTheme]);
|
||||
|
||||
const handleLanguageChange = useCallback(() => {
|
||||
const newLocale = locale === 'es' ? 'en' : 'es';
|
||||
setLocale(newLocale);
|
||||
}, [locale, setLocale]);
|
||||
|
||||
const getMenuItemText = useCallback((key: string, defaultText?: string) => {
|
||||
if (isLoading || !t) {
|
||||
return defaultText || key;
|
||||
}
|
||||
const translatedText = t(key);
|
||||
if (translatedText === key && defaultText) {
|
||||
return defaultText;
|
||||
}
|
||||
return translatedText;
|
||||
}, [t, isLoading]);
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<div className={styles.page}>
|
||||
{isLoadingDrawing && <div className={styles.loadingOverlay}><p>{getMenuItemText('editor.loadingDrawing', "Loading drawing...")}</p></div>}
|
||||
{loadError && <div className={styles.errorOverlay}><p>{loadError}</p></div>}
|
||||
|
||||
{isSaving && <div className={styles.savingIndicator}>{getMenuItemText('editor.saving', 'Saving...')}</div>}
|
||||
{saveError && <div className={styles.saveErrorIndicator}>{saveError}</div>}
|
||||
|
||||
<div ref={excalidrawWrapperRef} className={`${styles.excalidrawContainer} ${isLoadingDrawing || loadError ? styles.hidden : ''}`}>
|
||||
{isClient && initialData && (
|
||||
<ExcalidrawComponent
|
||||
excalidrawAPI={(api) => (excalidrawRef.current = api)}
|
||||
initialData={initialData}
|
||||
theme={theme}
|
||||
langCode={locale === 'es' ? 'es-ES' : 'en-US'}
|
||||
>
|
||||
<MainMenu>
|
||||
<MainMenu.DefaultItems.LoadScene />
|
||||
<MainMenu.DefaultItems.Export />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
|
||||
{drawingIdRef.current && (
|
||||
<MainMenu.Item onSelect={handleSaveDrawing} icon={<Icon name="save" />}>
|
||||
{isSaving
|
||||
? getMenuItemText('editor.saving', 'Saving...')
|
||||
: saveSuccess
|
||||
? getMenuItemText('editor.saved', 'Saved!')
|
||||
: getMenuItemText('editor.save', 'Save Drawing')}
|
||||
</MainMenu.Item>
|
||||
)}
|
||||
|
||||
<MainMenu.Item onSelect={handleThemeChange}>
|
||||
{theme === 'light'
|
||||
? <><Icon name='moon' /> {getMenuItemText('theme.dark', 'Dark mode')}</>
|
||||
: <><Icon name='sun' /> {getMenuItemText('theme.light', 'Light mode')}</>}
|
||||
</MainMenu.Item>
|
||||
|
||||
<MainMenu.Item onSelect={handleLanguageChange}>
|
||||
{locale === 'es'
|
||||
? <><Icon name='flag-en' viewBox="0 0 60 30" /> {getMenuItemText('language.en', 'English')}</>
|
||||
: <><Icon name='flag-es' viewBox="0 0 300 200" /> {getMenuItemText('language.es', 'Spanish')}</>}
|
||||
</MainMenu.Item>
|
||||
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.ChangeCanvasBackground />
|
||||
</MainMenu>
|
||||
</ExcalidrawComponent>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--background: #121212;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,15 +1,24 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
import ThemeSwitcher from '@/components/ThemeSwitcher';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher/LanguageSwitcher';
|
||||
import ThemeSwitcher from '@/components/ThemeSwitcher/ThemeSwitcher';
|
||||
import { useI18n } from '@/lib/i18n/useI18n';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
const { t, isLoading } = useI18n();
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 flex flex-col">
|
||||
<header className="py-6 bg-white dark:bg-gray-800 shadow-sm">
|
||||
|
@ -70,9 +79,28 @@ export default function Home() {
|
|||
|
||||
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
|
||||
{t('footer.copyright')}
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center space-y-4">
|
||||
<div className="flex items-center space-x-8">
|
||||
<a
|
||||
href="https://borrageiros.com"
|
||||
className="flex items-center text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-300"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">borrageiros</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/borrageiros/draw"
|
||||
className="flex items-center text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-300"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
<span className="font-medium">GitHub</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
@ -285,4 +285,133 @@
|
|||
.modalButtonConfirm:hover {
|
||||
background-color: #c9302c;
|
||||
border-color: #ac2925;
|
||||
}
|
||||
|
||||
/* Share modal */
|
||||
.shareModalContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.shareSearchInput {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
width: 100%;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.shareSearchInput:focus {
|
||||
outline: none;
|
||||
border-color: #0070f3;
|
||||
box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.2);
|
||||
}
|
||||
|
||||
:global(.dark) .shareSearchInput {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
:global(.dark) .shareSearchInput:focus {
|
||||
border-color: #0070f3;
|
||||
box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.3);
|
||||
}
|
||||
|
||||
.shareSearchInput:disabled {
|
||||
background-color: #f8f9fa;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
:global(.dark) .shareSearchInput:disabled {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.userListShare {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:global(.dark) .userListShare {
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.userListItemShare {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
border-bottom: 1px solid #eeeeee;
|
||||
}
|
||||
|
||||
.userListItemShare:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:global(.dark) .userListItemShare {
|
||||
border-bottom-color: #333;
|
||||
}
|
||||
|
||||
.userListItemShare:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
:global(.dark) .userListItemShare:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.selectedUser {
|
||||
background-color: #e8f0fe;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.selectedUser:hover {
|
||||
background-color: #d8e5fd;
|
||||
}
|
||||
|
||||
:global(.dark) .selectedUser {
|
||||
background-color: #1a3a6c;
|
||||
}
|
||||
|
||||
:global(.dark) .selectedUser:hover {
|
||||
background-color: #254b85;
|
||||
}
|
||||
|
||||
.disabledItem {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disabledItem:hover {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
:global(.dark) .disabledItem:hover {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
/* Estilo para botones deshabilitados */
|
||||
.modalButtonConfirm:disabled,
|
||||
.modalButtonCancel:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modalButtonConfirm:disabled:hover,
|
||||
.modalButtonCancel:disabled:hover {
|
||||
background-color: inherit;
|
||||
border-color: inherit;
|
||||
}
|
||||
|
||||
:global(.dark) .modalButtonConfirm:disabled:hover,
|
||||
:global(.dark) .modalButtonCancel:disabled:hover {
|
||||
background-color: inherit;
|
||||
border-color: inherit;
|
||||
}
|
|
@ -1,27 +1,34 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Drawing,
|
||||
updateDrawing as apiUpdateDrawing,
|
||||
deleteDrawing as apiDeleteDrawing,
|
||||
getUsers as apiGetUsers,
|
||||
shareDrawingWithUser as apiShareDrawing,
|
||||
} from '@/lib/api';
|
||||
import { IUser } from '@/models/User';
|
||||
import styles from './DrawingCard.module.css';
|
||||
import { useI18n } from '@/lib/i18n/useI18n';
|
||||
import Icon from '@/components/Icon';
|
||||
import Modal from '@/components/Modal';
|
||||
import Icon from '@/components/Icon/Icon';
|
||||
import Modal from '@/components/Modal/Modal';
|
||||
|
||||
interface DrawingCardProps {
|
||||
drawing: Drawing;
|
||||
owned: boolean;
|
||||
onDrawingRenamed: (updatedDrawing: Drawing) => void;
|
||||
onDrawingDeleted: (drawingId: string) => void;
|
||||
onDrawingShared?: (updatedDrawing: Drawing) => void;
|
||||
}
|
||||
|
||||
export default function DrawingCard({
|
||||
drawing,
|
||||
owned,
|
||||
onDrawingRenamed,
|
||||
onDrawingDeleted,
|
||||
onDrawingShared,
|
||||
}: DrawingCardProps) {
|
||||
const { t, isLoading: i18nCardIsLoading } = useI18n();
|
||||
|
||||
|
@ -32,6 +39,21 @@ export default function DrawingCard({
|
|||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
// States for the share modal
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [usersForSharing, setUsersForSharing] = useState<IUser[]>([]);
|
||||
const [shareSearchTerm, setShareSearchTerm] = useState('');
|
||||
const [selectedUserIdToShare, setSelectedUserIdToShare] = useState<string | null>(null);
|
||||
const [shareError, setShareError] = useState<string | null>(null);
|
||||
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setEditingTitle(drawing.title);
|
||||
setRenameError(null);
|
||||
}, [drawing.title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && editTitleInputRef.current) {
|
||||
editTitleInputRef.current.focus();
|
||||
|
@ -53,7 +75,7 @@ export default function DrawingCard({
|
|||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isEditing]);
|
||||
}, [isEditing, handleCancelEdit]);
|
||||
|
||||
const lastUpdatedText = i18nCardIsLoading
|
||||
? "Last updated:"
|
||||
|
@ -66,6 +88,10 @@ export default function DrawingCard({
|
|||
const deleteButtonAriaLabel = i18nCardIsLoading
|
||||
? "Delete drawing"
|
||||
: t('drawingCard.deleteAria') || "Delete drawing";
|
||||
|
||||
const shareButtonAriaLabel = i18nCardIsLoading
|
||||
? "Share drawing"
|
||||
: t('drawingCard.shareAria') || "Share drawing";
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditingTitle(drawing.title);
|
||||
|
@ -73,12 +99,6 @@ export default function DrawingCard({
|
|||
setRenameError(null);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
setEditingTitle(drawing.title);
|
||||
setRenameError(null);
|
||||
};
|
||||
|
||||
const handleConfirmEdit = async () => {
|
||||
if (!editingTitle.trim()) {
|
||||
setRenameError(t('drawingCard.errors.titleRequired') || "Title is required.");
|
||||
|
@ -138,6 +158,61 @@ export default function DrawingCard({
|
|||
}
|
||||
};
|
||||
|
||||
// Functions for the share modal
|
||||
const handleOpenShareModal = async () => {
|
||||
setShowShareModal(true);
|
||||
setShareError(null);
|
||||
setSelectedUserIdToShare(null);
|
||||
setShareSearchTerm('');
|
||||
if (usersForSharing.length === 0) { // Load users only if they haven't been loaded before
|
||||
setIsLoadingUsers(true);
|
||||
const response = await apiGetUsers();
|
||||
setIsLoadingUsers(false);
|
||||
if (response.data) {
|
||||
// Filter out the owner and already shared users
|
||||
const filteredUsers = response.data.filter(
|
||||
(user) => user._id !== drawing.owner_id && !drawing.shared_with.includes(user._id as string)
|
||||
);
|
||||
setUsersForSharing(filteredUsers);
|
||||
} else {
|
||||
setShareError(response.error || t('drawingCard.errors.loadUsersFailed') || "Failed to load users.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseShareModal = () => {
|
||||
setShowShareModal(false);
|
||||
setSelectedUserIdToShare(null);
|
||||
setShareSearchTerm('');
|
||||
setShareError(null);
|
||||
};
|
||||
|
||||
const handleConfirmShare = async () => {
|
||||
if (!selectedUserIdToShare) {
|
||||
setShareError(t('drawingCard.errors.userNotSelected') || "Please select a user to share with.");
|
||||
return;
|
||||
}
|
||||
setIsSharing(true);
|
||||
setShareError(null);
|
||||
const response = await apiShareDrawing(drawing._id, selectedUserIdToShare);
|
||||
setIsSharing(false);
|
||||
if (response.data) {
|
||||
setShowShareModal(false);
|
||||
if (onDrawingShared) {
|
||||
onDrawingShared(response.data);
|
||||
}
|
||||
setUsersForSharing(prevUsers => prevUsers.filter(u => u._id !== selectedUserIdToShare));
|
||||
setSelectedUserIdToShare(null);
|
||||
} else {
|
||||
setShareError(response.error || t('drawingCard.errors.shareFailed') || "Failed to share drawing.");
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsersForSharing = usersForSharing.filter((user) =>
|
||||
user.username.toLowerCase().includes(shareSearchTerm.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(shareSearchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const modalTitle = i18nCardIsLoading
|
||||
? "Confirm Deletion"
|
||||
: t('drawingCard.confirmDelete.title') || "Confirm Deletion";
|
||||
|
@ -160,6 +235,23 @@ export default function DrawingCard({
|
|||
? "Cancel"
|
||||
: t('drawingCard.confirmDelete.cancelButton') || "Cancel";
|
||||
|
||||
// Texts for the share modal
|
||||
const shareModalTitle = i18nCardIsLoading
|
||||
? "Share Drawing"
|
||||
: t('drawingCard.shareModal.title') || "Share Drawing";
|
||||
const shareModalSearchPlaceholder = i18nCardIsLoading
|
||||
? "Search user by name or email"
|
||||
: t('drawingCard.shareModal.searchPlaceholder') || "Search user by name or email";
|
||||
const shareModalConfirmButtonText = i18nCardIsLoading
|
||||
? "Share"
|
||||
: t('drawingCard.shareModal.confirmButton') || "Share";
|
||||
const shareModalCancelButtonText = i18nCardIsLoading
|
||||
? "Cancel"
|
||||
: t('drawingCard.shareModal.cancelButton') || "Cancel";
|
||||
const shareModalNoUsersFoundText = i18nCardIsLoading
|
||||
? "No users available to share with or match your search."
|
||||
: t('drawingCard.shareModal.noUsersFound') || "No users available to share with or match your search.";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.cardContainer}>
|
||||
|
@ -173,9 +265,16 @@ export default function DrawingCard({
|
|||
<button onClick={handleStartEdit} className={styles.iconButton} aria-label={editTitleAriaLabel}>
|
||||
<Icon name="edit-2" size={18} />
|
||||
</button>
|
||||
<button onClick={handleOpenDeleteModal} className={`${styles.iconButton} ${styles.deleteButton}`} aria-label={deleteButtonAriaLabel}>
|
||||
<Icon name="trash-2" size={18} />
|
||||
</button>
|
||||
{owned && (
|
||||
<>
|
||||
<button onClick={handleOpenShareModal} className={styles.iconButton} aria-label={shareButtonAriaLabel}>
|
||||
<Icon name="share-2" size={18} />
|
||||
</button>
|
||||
<button onClick={handleOpenDeleteModal} className={`${styles.iconButton} ${styles.deleteButton}`} aria-label={deleteButtonAriaLabel}>
|
||||
<Icon name="trash-2" size={18} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
@ -215,6 +314,60 @@ export default function DrawingCard({
|
|||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Share modal */}
|
||||
<Modal
|
||||
isOpen={showShareModal}
|
||||
onClose={handleCloseShareModal}
|
||||
title={shareModalTitle}
|
||||
>
|
||||
<div className={styles.shareModalContent}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={shareModalSearchPlaceholder}
|
||||
value={shareSearchTerm}
|
||||
onChange={(e) => setShareSearchTerm(e.target.value)}
|
||||
className={styles.shareSearchInput}
|
||||
disabled={isLoadingUsers || isSharing}
|
||||
/>
|
||||
{isLoadingUsers && <p>{t('drawingCard.shareModal.loadingUsers') || 'Loading users...'}</p>}
|
||||
{shareError && <p className={styles.modalErrorText}>{shareError}</p>}
|
||||
|
||||
{!isLoadingUsers && filteredUsersForSharing.length === 0 && (
|
||||
<p>{shareModalNoUsersFoundText}</p>
|
||||
)}
|
||||
|
||||
{!isLoadingUsers && filteredUsersForSharing.length > 0 && (
|
||||
<ul className={styles.userListShare}>
|
||||
{filteredUsersForSharing.map((user) => (
|
||||
<li
|
||||
key={user._id as string}
|
||||
onClick={() => !isSharing && setSelectedUserIdToShare(user._id as string)}
|
||||
className={`${styles.userListItemShare} ${selectedUserIdToShare === user._id ? styles.selectedUser : ''} ${isSharing ? styles.disabledItem : ''}`}
|
||||
>
|
||||
{user.username} ({user.email})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<button
|
||||
onClick={handleCloseShareModal}
|
||||
className={styles.modalButtonCancel}
|
||||
disabled={isSharing}
|
||||
>
|
||||
{shareModalCancelButtonText}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmShare}
|
||||
className={styles.modalButtonConfirm}
|
||||
disabled={!selectedUserIdToShare || isLoadingUsers || isSharing}
|
||||
>
|
||||
{isSharing ? (t('drawingCard.shareModal.sharingButton') || 'Sharing...') : shareModalConfirmButtonText}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { icons } from '@/lib/icons';
|
||||
import { icons } from '@/components/Icon/icons';
|
||||
|
||||
interface IconProps extends React.SVGProps<SVGSVGElement> {
|
||||
name?: string;
|
|
@ -0,0 +1,85 @@
|
|||
.loaderContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.fullScreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
:global(.dark) .fullScreen {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #5c5c5c;
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.dot:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.small .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.medium .dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.large .dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.loaderText {
|
||||
margin-top: 1rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fullScreen {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.dot {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import styles from './Loader.module.css';
|
||||
|
||||
interface LoaderProps {
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
fullScreen?: boolean;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export default function Loader({ size = 'medium', fullScreen = false, text }: LoaderProps) {
|
||||
return (
|
||||
<div className={`${styles.loaderContainer} ${fullScreen ? styles.fullScreen : ''}`}>
|
||||
<div className={`${styles.loader} ${styles[size]}`}>
|
||||
<div className={styles.dot}></div>
|
||||
<div className={styles.dot}></div>
|
||||
<div className={styles.dot}></div>
|
||||
</div>
|
||||
{text && <p className={styles.loaderText}>{text}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import React, { useEffect } from 'react';
|
||||
import styles from './Modal.module.css';
|
||||
import Icon from './Icon';
|
||||
import Icon from '../Icon/Icon';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { useI18n } from '@/lib/i18n/useI18n';
|
||||
import Icon from '@/components/Icon';
|
||||
import Icon from '@/components/Icon/Icon';
|
||||
|
||||
export default function ThemeSwitcher() {
|
||||
const { theme, toggleTheme } = useTheme();
|
|
@ -35,7 +35,9 @@ export async function createDrawing(data: {
|
|||
export async function getUserDrawings(userId: string) {
|
||||
try {
|
||||
await dbConnect();
|
||||
const drawings = await Drawing.find({ owner_id: userId }).sort({
|
||||
const drawings = await Drawing.find({
|
||||
$or: [{ owner_id: userId }, { shared_with: userId }],
|
||||
}).sort({
|
||||
updatedAt: -1,
|
||||
});
|
||||
return { data: drawings, status: 200 };
|
||||
|
@ -73,7 +75,7 @@ export async function getDrawingById(drawingId: string, userId: string) {
|
|||
export async function updateDrawing(
|
||||
drawingId: string,
|
||||
userId: string,
|
||||
updateData: Partial<Pick<IDrawing, "title" | "data" | "shared_with">>
|
||||
updateData: Partial<Pick<IDrawing, "title" | "data">> & { shared_with_users?: string[] }
|
||||
) {
|
||||
try {
|
||||
await dbConnect();
|
||||
|
@ -83,7 +85,12 @@ export async function updateDrawing(
|
|||
return { error: "Drawing not found", status: 404 };
|
||||
}
|
||||
|
||||
if (drawing.owner_id.toString() !== userId) {
|
||||
const isOwner = drawing.owner_id.toString() === userId;
|
||||
const isSharedWithUser = drawing.shared_with
|
||||
.map((id: string) => id.toString())
|
||||
.includes(userId);
|
||||
|
||||
if (!isOwner && !isSharedWithUser) {
|
||||
return { error: "Forbidden", status: 403 };
|
||||
}
|
||||
|
||||
|
@ -93,8 +100,12 @@ export async function updateDrawing(
|
|||
if (updateData.data !== undefined) {
|
||||
drawing.data = updateData.data;
|
||||
}
|
||||
if (updateData.shared_with !== undefined) {
|
||||
drawing.shared_with = updateData.shared_with;
|
||||
|
||||
if (updateData.shared_with_users !== undefined) {
|
||||
if (!isOwner) {
|
||||
return { error: "Forbidden: Only the owner can change sharing settings", status: 403 };
|
||||
}
|
||||
drawing.shared_with = updateData.shared_with_users;
|
||||
}
|
||||
|
||||
drawing.updatedAt = new Date();
|
||||
|
@ -128,3 +139,74 @@ export async function deleteDrawing(drawingId: string, userId: string) {
|
|||
return { error: "Internal server error", status: 500 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function shareDrawingWithUser(
|
||||
drawingId: string,
|
||||
ownerUserId: string,
|
||||
userIdToShareWith: string
|
||||
) {
|
||||
try {
|
||||
await dbConnect();
|
||||
const drawing = await Drawing.findById(drawingId);
|
||||
|
||||
if (!drawing) {
|
||||
return { error: "Drawing not found", status: 404 };
|
||||
}
|
||||
|
||||
if (drawing.owner_id.toString() !== ownerUserId) {
|
||||
return { error: "Forbidden: Only the owner can share this drawing", status: 403 };
|
||||
}
|
||||
|
||||
const userToShare = await User.findById(userIdToShareWith);
|
||||
if (!userToShare) {
|
||||
return { error: "User to share with not found", status: 404 };
|
||||
}
|
||||
|
||||
if (userIdToShareWith === ownerUserId) {
|
||||
return { error: "Cannot share drawing with the owner", status: 400 };
|
||||
}
|
||||
|
||||
if (!drawing.shared_with.map((id: string) => id.toString()).includes(userIdToShareWith)) {
|
||||
drawing.shared_with.push(userIdToShareWith);
|
||||
drawing.updatedAt = new Date();
|
||||
await drawing.save();
|
||||
}
|
||||
|
||||
return { data: drawing, status: 200 };
|
||||
} catch (error) {
|
||||
console.error("Error in shareDrawingWithUser:", error);
|
||||
return { error: "Internal server error", status: 500 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function unshareDrawingWithUser(
|
||||
drawingId: string,
|
||||
ownerUserId: string,
|
||||
userIdToUnshare: string
|
||||
) {
|
||||
try {
|
||||
await dbConnect();
|
||||
const drawing = await Drawing.findById(drawingId);
|
||||
|
||||
if (!drawing) {
|
||||
return { error: "Drawing not found", status: 404 };
|
||||
}
|
||||
|
||||
if (drawing.owner_id.toString() !== ownerUserId) {
|
||||
return { error: "Forbidden: Only the owner can modify sharing settings", status: 403 };
|
||||
}
|
||||
|
||||
const initialSharedCount = drawing.shared_with.length;
|
||||
drawing.shared_with = drawing.shared_with.filter((id: string) => id.toString() !== userIdToUnshare);
|
||||
|
||||
if (drawing.shared_with.length < initialSharedCount) {
|
||||
drawing.updatedAt = new Date();
|
||||
await drawing.save();
|
||||
}
|
||||
|
||||
return { data: drawing, status: 200 };
|
||||
} catch (error) {
|
||||
console.error("Error in unshareDrawingWithUser:", error);
|
||||
return { error: "Internal server error", status: 500 };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import User from "../models/User";
|
||||
import dbConnect from "@/lib/db/connection";
|
||||
|
||||
export async function getUsers() {
|
||||
try {
|
||||
await dbConnect();
|
||||
const users = await User.find();
|
||||
return users;
|
||||
} catch (error) {
|
||||
console.error("Error in getUsers:", error);
|
||||
return { error: "Internal server error", status: 500 };
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { jwtDecode } from "jwt-decode";
|
||||
import { IUser } from "@/models/User";
|
||||
|
||||
interface LoginRequest {
|
||||
email: string;
|
||||
|
@ -144,6 +145,16 @@ export async function logout(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getUsers(): Promise<ApiResponse<IUser[]>> {
|
||||
return fetchApi<undefined, IUser[]>(
|
||||
"/users",
|
||||
"GET",
|
||||
undefined,
|
||||
{},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// --- Drawing API Functions ---
|
||||
export interface Drawing {
|
||||
_id: string;
|
||||
|
@ -225,3 +236,37 @@ export async function deleteDrawing(
|
|||
true
|
||||
);
|
||||
}
|
||||
|
||||
interface ShareDrawingRequestBody {
|
||||
userIdToShareWith: string;
|
||||
}
|
||||
|
||||
export async function shareDrawingWithUser(
|
||||
drawingId: string,
|
||||
userIdToShareWith: string
|
||||
): Promise<ApiResponse<Drawing>> {
|
||||
return fetchApi<ShareDrawingRequestBody, Drawing>(
|
||||
`/drawings/${drawingId}/share`,
|
||||
"POST",
|
||||
{ userIdToShareWith },
|
||||
{},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
interface UnshareDrawingRequestBody {
|
||||
userIdToUnshare: string;
|
||||
}
|
||||
|
||||
export async function unshareDrawingWithUser(
|
||||
drawingId: string,
|
||||
userIdToUnshare: string
|
||||
): Promise<ApiResponse<Drawing>> {
|
||||
return fetchApi<UnshareDrawingRequestBody, Drawing>(
|
||||
`/drawings/${drawingId}/unshare`,
|
||||
"POST",
|
||||
{ userIdToUnshare },
|
||||
{},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { validateToken } from "@/controllers/authController";
|
||||
|
||||
export async function authMiddleware(request: NextRequest) {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return { isAuthenticated: false, user: null };
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const { user } = await validateToken(token);
|
||||
|
||||
return { isAuthenticated: !!user, user };
|
||||
}
|
|
@ -15,15 +15,11 @@ const UserSchema = new Schema<IUser>(
|
|||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
minlength: 3
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
lowercase: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
|
@ -32,7 +28,13 @@ const UserSchema = new Schema<IUser>(
|
|||
}
|
||||
},
|
||||
{
|
||||
timestamps: true
|
||||
timestamps: true,
|
||||
toJSON: {
|
||||
transform: (_, ret) => {
|
||||
delete ret.password;
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user