From ddd0eba390ccc7a346a0c5fa91375b41e0d1429c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Borrageiros=20Mourelos?= Date: Tue, 13 May 2025 23:01:28 +0200 Subject: [PATCH] SAVE --- .env.example | 1 + package.json | 1 + public/locales/en/common.json | 33 +- public/locales/es/common.json | 33 +- src/app/api/auth/login/route.ts | 4 +- src/app/api/auth/register/route.ts | 4 +- src/app/api/drawings/[id]/route.ts | 142 +++++++ src/app/api/drawings/route.ts | 67 ++++ src/app/auth/login/page.tsx | 8 +- src/app/dashboard/page.module.css | 257 +++++++++++++ src/app/dashboard/page.tsx | 213 +++++++++++ src/app/editor/page.tsx | 72 +++- src/components/AuthGuard.tsx | 51 +++ src/components/DrawingCard.module.css | 288 ++++++++++++++ src/components/DrawingCard.tsx | 220 +++++++++++ src/components/Icon.tsx | 59 +++ src/components/LanguageSwitcher.tsx | 63 +--- src/components/Modal.module.css | 154 ++++++++ src/components/Modal.tsx | 60 +++ src/components/ThemeSwitcher.tsx | 68 +--- src/controllers/authController.ts | 43 ++- src/controllers/drawingsController.ts | 130 +++++++ src/lib/api.ts | 161 +++++++- src/lib/i18n/language-context.tsx | 1 - src/lib/icons.ts | 516 ++++++++++++++++++++++++++ src/models/Drawing.ts | 38 ++ yarn.lock | 5 + 27 files changed, 2532 insertions(+), 160 deletions(-) create mode 100644 src/app/api/drawings/[id]/route.ts create mode 100644 src/app/api/drawings/route.ts create mode 100644 src/app/dashboard/page.module.css create mode 100644 src/app/dashboard/page.tsx create mode 100644 src/components/AuthGuard.tsx create mode 100644 src/components/DrawingCard.module.css create mode 100644 src/components/DrawingCard.tsx create mode 100644 src/components/Icon.tsx create mode 100644 src/components/Modal.module.css create mode 100644 src/components/Modal.tsx create mode 100644 src/controllers/drawingsController.ts create mode 100644 src/lib/icons.ts create mode 100644 src/models/Drawing.ts diff --git a/.env.example b/.env.example index 808bc8e..086d9bc 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ +JWT_SECRET=146acd46a3932b75db1cf64b21753ee1b933dd73e2fdff3eab8f000f6a7493e7 MONGODB_URI=mongodb+srv://adrianborrageirosmourelos:FOcz19ZnmULvFZeY@cluster0.gf3secu.mongodb.net/draw_dev?retryWrites=true&w=majority&appName=Cluster0 \ No newline at end of file diff --git a/package.json b/package.json index b593485..842988a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "i18next": "^25.1.2", "i18next-resources-to-backend": "^1.2.1", "jsonwebtoken": "^9.0.2", + "jwt-decode": "^4.0.0", "mongoose": "^8.14.2", "next": "15.3.2", "react": "^19.0.0", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 25095c4..44bde1b 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1,7 +1,8 @@ { "header": { "login": "Log in", - "register": "Sign up" + "register": "Sign up", + "logout": "Log out" }, "home": { "title": "Draw what you imagine", @@ -16,7 +17,8 @@ "login": { "title": "Log in", "subtitle": "Access your account to start drawing", - "email": "Email address", + "emailOrUsername": "Email or Username", + "emailOrUsernamePlaceholder": "you@example.com or your_username", "password": "Password", "forgotPassword": "Forgot your password?", "rememberMe": "Remember me", @@ -55,5 +57,32 @@ "light": "Light theme", "dark": "Dark theme", "select": "Select theme" + }, + "dashboard": { + "title": "My Drawings", + "loading": "Loading dashboard...", + "newDrawingButton": "Create New Drawing", + "newDrawingPlaceholder": "Enter drawing title...", + "noDrawings": "You don't have any drawings yet. Create one!", + "errors": { + "createFailed": "Failed to create new drawing.", + "titleRequired": "Title is required." + } + }, + "drawingCard": { + "lastUpdatedPrefix": "Last updated:", + "editTitleAria": "Edit title", + "deleteAria": "Delete drawing", + "confirmDelete": { + "title": "Confirm Deletion", + "message": "Are you sure you want to delete \"{{title}}\"? This action cannot be undone.", + "confirmButton": "Delete", + "cancelButton": "Cancel" + }, + "errors": { + "titleRequired": "Title is required.", + "renameFailed": "Failed to rename drawing.", + "deleteFailed": "Failed to delete drawing." + } } } \ No newline at end of file diff --git a/public/locales/es/common.json b/public/locales/es/common.json index a120bc4..3175f22 100644 --- a/public/locales/es/common.json +++ b/public/locales/es/common.json @@ -1,7 +1,8 @@ { "header": { "login": "Iniciar sesión", - "register": "Registrarse" + "register": "Registrarse", + "logout": "Cerrar sesión" }, "home": { "title": "Dibuja lo que imagines", @@ -16,7 +17,8 @@ "login": { "title": "Iniciar sesión", "subtitle": "Accede a tu cuenta para comenzar a dibujar", - "email": "Correo electrónico", + "emailOrUsername": "Email o Nombre de usuario", + "emailOrUsernamePlaceholder": "tu@ejemplo.com o tu_usuario", "password": "Contraseña", "forgotPassword": "¿Olvidaste tu contraseña?", "rememberMe": "Recordarme", @@ -55,5 +57,32 @@ "light": "Tema claro", "dark": "Tema oscuro", "select": "Seleccionar tema" + }, + "dashboard": { + "title": "Mis Dibujos", + "loading": "Cargando dashboard...", + "newDrawingButton": "Crear Nuevo Dibujo", + "newDrawingPlaceholder": "Introduce el título del dibujo...", + "noDrawings": "Aún no tienes dibujos. ¡Crea uno!", + "errors": { + "createFailed": "Error al crear nuevo dibujo.", + "titleRequired": "El título es obligatorio." + } + }, + "drawingCard": { + "lastUpdatedPrefix": "Última act.:", + "editTitleAria": "Editar título", + "deleteAria": "Eliminar dibujo", + "confirmDelete": { + "title": "Confirmar Eliminación", + "message": "¿Estás seguro de que quieres eliminar \"{{title}}\"? Esta acción no se puede deshacer.", + "confirmButton": "Eliminar", + "cancelButton": "Cancelar" + }, + "errors": { + "titleRequired": "El título es obligatorio.", + "renameFailed": "Error al renombrar el dibujo.", + "deleteFailed": "Error al eliminar el dibujo." + } } } \ No newline at end of file diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 60fbae3..d153b3b 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -18,9 +18,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ token, user }, { status: 200 }); } catch (error) { - console.error("Error en el login:", error); + console.error("Error in login:", error); return NextResponse.json( - { error: "Error interno del servidor" }, + { error: "Internal server error" }, { status: 500 } ); } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 072f4a5..8c8e129 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -13,9 +13,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ token, user }, { status: 201 }); } catch (error) { - console.error("Error en el registro:", error); + console.error("Error in register:", error); return NextResponse.json( - { error: "Error interno del servidor" }, + { error: "Internal server error" }, { status: 500 } ); } diff --git a/src/app/api/drawings/[id]/route.ts b/src/app/api/drawings/[id]/route.ts new file mode 100644 index 0000000..bbde532 --- /dev/null +++ b/src/app/api/drawings/[id]/route.ts @@ -0,0 +1,142 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getDrawingById, + updateDrawing, + deleteDrawing, +} from "@/controllers/drawingsController"; +import { validateToken } from "@/controllers/authController"; + +interface RouteParams { + params: { + id: string; + }; +} + +async function getUserIdFromRequest( + request: NextRequest +): Promise { + const authHeader = request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return null; + } + const token = authHeader.substring(7); + const { user, error } = await validateToken(token); + if (error || !user) { + return null; + } + return user.id; +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + const userId = await getUserIdFromRequest(request); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const drawingId = params.id; + if (!drawingId) { + return NextResponse.json( + { error: "Drawing ID is required" }, + { status: 400 } + ); + } + + try { + const result = await getDrawingById(drawingId, userId); + 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 GET /api/drawings/${drawingId}:`, error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function PUT(request: NextRequest, { params }: RouteParams) { + const userId = await getUserIdFromRequest(request); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const drawingId = params.id; + if (!drawingId) { + return NextResponse.json( + { error: "Drawing ID is required" }, + { status: 400 } + ); + } + + try { + const body = await request.json(); + const { title, drawingData, shared_with } = body; + + if ( + title === undefined && + drawingData === undefined && + shared_with === undefined + ) { + return NextResponse.json( + { error: "No update data provided" }, + { status: 400 } + ); + } + + const result = await updateDrawing(drawingId, userId, { + title, + data: drawingData, + shared_with, + }); + 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 PUT /api/drawings/${drawingId}:`, error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest, { params }: RouteParams) { + const userId = await getUserIdFromRequest(request); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const drawingId = params.id; + if (!drawingId) { + return NextResponse.json( + { error: "Drawing ID is required" }, + { status: 400 } + ); + } + + try { + const result = await deleteDrawing(drawingId, userId); + 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 DELETE /api/drawings/${drawingId}:`, error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/drawings/route.ts b/src/app/api/drawings/route.ts new file mode 100644 index 0000000..7ea6c24 --- /dev/null +++ b/src/app/api/drawings/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + createDrawing, + getUserDrawings, +} from "@/controllers/drawingsController"; +import { validateToken } from "@/controllers/authController"; + +async function getUserIdFromRequest( + request: NextRequest +): Promise { + const authHeader = request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return null; + } + const token = authHeader.substring(7); // Remove "Bearer " + const { user, error } = await validateToken(token); + if (error || !user) { + return null; + } + return user.id; +} + +export async function POST(request: NextRequest) { + const userId = await getUserIdFromRequest(request); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await request.json(); + const { title, drawingData } = body; + + if (!title || drawingData === undefined) { + return NextResponse.json( + { error: "Missing title or drawingData" }, + { status: 400 } + ); + } + + const result = await createDrawing({ title, drawingData, userId }); + return NextResponse.json(result.data, { status: result.status }); + } catch (error) { + console.error("Error in POST /api/drawings:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function GET(request: NextRequest) { + const userId = await getUserIdFromRequest(request); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const result = await getUserDrawings(userId); + return NextResponse.json(result.data, { status: result.status }); + } catch (error) { + console.error("Error in GET /api/drawings:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 2b4f4e3..3cc114d 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -83,18 +83,18 @@ export default function Login() {
diff --git a/src/app/dashboard/page.module.css b/src/app/dashboard/page.module.css new file mode 100644 index 0000000..a06adb3 --- /dev/null +++ b/src/app/dashboard/page.module.css @@ -0,0 +1,257 @@ +.page { + height: 100%; + padding: 2rem; + max-width: 1200px; + margin: 0 auto; + font-family: sans-serif; + color: #333; + background-color: #f9f9f9; + transition: background-color 0.3s ease, color 0.3s ease; +} + +:global(.dark) .page { + background-color: #121212; + color: #e0e0e0; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e0e0e0; +} + +:global(.dark) .header { + border-bottom: 1px solid #333; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.headerSwitchers { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.headerRight { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.header h1 { + font-size: 2rem; + font-weight: 600; + color: #333; +} + +:global(.dark) .header h1 { + color: #f0f0f0; +} + +.logoutButton { + display: flex; + align-items: center; + gap: 0.5rem; + background-color: transparent; + border: 1px solid var(--border-color, #e0e0e0); + color: var(--text-secondary, #555); + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} + +:global(.dark) .logoutButton { + --border-color: #444; + --text-secondary: #bbb; +} + +.logoutButton:hover { + background-color: var(--hover-bg, #f0f0f0); + border-color: var(--border-color-hover, #ccc); + color: var(--text-primary, #111); +} + +:global(.dark) .logoutButton:hover { + --hover-bg: #333; + --border-color-hover: #555; + --text-primary: #fff; +} + +.createButton { + background-color: #0070f3; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 6px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease-in-out, transform 0.1s ease; +} + +.createButton:hover { + background-color: #005bb5; + transform: translateY(-2px); +} + +.createButton:active { + transform: translateY(0); +} + +.errorText { + color: #ff4d4f; + background-color: #fff1f0; + border: 1px solid #ffccc7; + padding: 1rem; + border-radius: 6px; + margin-bottom: 1.5rem; + text-align: center; +} + +:global(.dark) .errorText { + color: #ff7875; + background-color: rgba(255, 77, 79, 0.2); + border: 1px solid rgba(255, 77, 79, 0.3); +} + +.drawingsGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.drawingCardPlaceholder { + border: 1px solid #ccc; + padding: 1rem; + border-radius: 8px; + background-color: #f9f9f9; +} + +:global(.dark) .drawingCardPlaceholder { + border: 1px solid #333; + background-color: #1e1e1e; +} + +.drawingCardPlaceholder h2 { + margin-top: 0; + margin-bottom: 0.5rem; +} + +.drawingCardPlaceholder p { + font-size: 0.9rem; + color: #555; + margin-bottom: 1rem; +} + +:global(.dark) .drawingCardPlaceholder p { + color: #aaa; +} + +.drawingCardLink { + display: inline-block; + padding: 0.5rem 1rem; + background-color: #e9e9e9; + color: #333; + text-decoration: none; + border-radius: 4px; + transition: background-color 0.2s ease; +} + +:global(.dark) .drawingCardLink { + background-color: #333; + color: #e0e0e0; +} + +.drawingCardLink:hover { + background-color: #dcdcdc; +} + +:global(.dark) .drawingCardLink:hover { + background-color: #444; +} + +.page p { + text-align: center; + font-size: 1.1rem; + color: #555; + padding: 2rem 0; +} + +:global(.dark) .page p { + color: #bbb; +} + +.inlineCreateForm { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.inlineInput { + padding: 0.6rem 0.8rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.9rem; + flex-grow: 1; + background-color: #fff; + color: #333; +} + +:global(.dark) .inlineInput { + background-color: #2a2a2a; + border-color: #444; + color: #e0e0e0; +} + +.inlineInput:focus { + outline: none; + border-color: #0070f3; + box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.2); +} + +:global(.dark) .inlineInput:focus { + border-color: #0070f3; + box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.3); +} + +.actionButton { + padding: 0.6rem 1rem; + border: none; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out, transform 0.1s ease; +} + +.actionButton:active { + transform: translateY(1px); +} + +.confirmButton { + background-color: #28a745; + color: white; +} + +.confirmButton:hover { + background-color: #218838; +} + +.cancelButton { + background-color: #6c757d; + color: white; +} + +.cancelButton:hover { + background-color: #5a6268; +} \ No newline at end of file diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..43c6085 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,213 @@ +"use client"; +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 { 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"; + +export default function Dashboard() { + const { t, isLoading: i18nIsLoading } = useI18n(); + const router = useRouter(); + const [drawings, setDrawings] = useState([]); + const [pageLoading, setPageLoading] = useState(true); + const [error, setError] = useState(null); + + const [isCreating, setIsCreating] = useState(false); + const [newDrawingTitle, setNewDrawingTitle] = useState(""); + const newTitleInputRef = useRef(null); + + useEffect(() => { + const fetchDrawings = async () => { + try { + setPageLoading(true); + setError(null); + const response = await getAllDrawings(); + if (response.data) { + setDrawings(response.data); + } else if (response.error) { + setError(response.error); + } + } catch (err) { + setError("Failed to fetch drawings."); + console.error(err); + } finally { + setPageLoading(false); + } + }; + + if (!i18nIsLoading) { + fetchDrawings(); + } + }, [i18nIsLoading]); + + useEffect(() => { + if (isCreating && newTitleInputRef.current) { + newTitleInputRef.current.focus(); + } + + const handleClickOutside = (event: MouseEvent) => { + if (isCreating && newTitleInputRef.current && !newTitleInputRef.current.contains(event.target as Node)) { + handleCancelCreate(); + } + }; + + if (isCreating) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isCreating]); + + const handleStartCreate = () => { + setIsCreating(true); + }; + + const handleCancelCreate = () => { + setIsCreating(false); + setNewDrawingTitle(""); + setError(null); + }; + + const handleConfirmCreate = async () => { + if (!newDrawingTitle.trim()) { + setError(t('dashboard.errors.titleRequired') || "Title is required."); + return; + } + try { + setError(null); + const newDrawingData = { + title: newDrawingTitle.trim(), + drawingData: { elements: [], appState: {} } + }; + const response = await createDrawing(newDrawingData); + if (response.data) { + router.push(`/editor?id=${response.data._id}`); + setIsCreating(false); + setNewDrawingTitle(""); + // fetchDrawings(); + } else if (response.error) { + setError(response.error); + } + } catch (err) { + const errorText = i18nIsLoading ? "Failed to create new drawing." : (t('dashboard.errors.createFailed') || "Failed to create new drawing."); + setError(errorText); + console.error(err); + } + }; + + const handleNewTitleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleConfirmCreate(); + } + if (event.key === 'Escape') { + handleCancelCreate(); + } + }; + + const handleDrawingRenamed = (updatedDrawing: Drawing) => { + setDrawings(prevDrawings => + prevDrawings.map(d => d._id === updatedDrawing._id ? updatedDrawing : d) + ); + }; + + const handleDrawingDeleted = (drawingId: string) => { + setDrawings(prevDrawings => + prevDrawings.filter(d => d._id !== drawingId) + ); + }; + + const handleLogout = async () => { + try { + await logout(); + router.push('/auth/login'); + } catch (error) { + console.error("Failed to logout:", error); + setError(t('auth.errors.logoutError') || "Logout failed. Please try again."); + } + }; + + if (i18nIsLoading) { + return ( + +
+

Loading translations...

+
+
+ ); + } + + if (pageLoading) { + return ( + +
+

{t('dashboard.loading') || "Loading dashboard..."}

+
+
+ ); + } + + return ( + +
+
+
+

{t('dashboard.title') || "My Drawings"}

+
+ + +
+
+
+ {!isCreating ? ( + + ) : ( +
+ setNewDrawingTitle(e.target.value)} + onKeyDown={handleNewTitleKeyDown} + placeholder={t('dashboard.newDrawingPlaceholder') || "Enter drawing title..."} + className={styles.inlineInput} + /> +
+ )} + +
+
+ + {error &&

{error}

} + + {drawings.length === 0 && !error && !isCreating && ( +

{t('dashboard.noDrawings') || "You don\'t have any drawings yet. Create one!"}

+ )} + +
+ {drawings.map((drawing) => ( + + ))} +
+
+
+ ); +} diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index 656468f..b570b8b 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -1,33 +1,79 @@ "use client"; import styles from "./page.module.css"; import dynamic from "next/dynamic"; -import { useRef } from "react"; +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'; const ExcalidrawComponent = dynamic( () => import("@excalidraw/excalidraw").then((mod) => mod.Excalidraw), { ssr: false } ); -export default function Home() { +export default function Editor() { + const { t, isLoading } = useI18n(); const excalidrawWrapperRef = useRef(null); + const { theme, setTheme } = useTheme(); + const { locale, setLocale } = useLanguage(); + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + 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; + return t(key); + }, [t, isLoading]); return ( +
- - - - - - - - - - - + {isClient && ( + + + + + + + + + {theme === 'light' + ? <> {getMenuItemText('theme.dark', 'Dark mode')} + : <> {getMenuItemText('theme.light', 'Light mode')}} + + + + {locale === 'es' + ? <> {getMenuItemText('language.en', 'English')} + : <> {getMenuItemText('language.es', 'Spanish')}} + + + + + + + )}
+
); } diff --git a/src/components/AuthGuard.tsx b/src/components/AuthGuard.tsx new file mode 100644 index 0000000..fc11033 --- /dev/null +++ b/src/components/AuthGuard.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { jwtDecode } from 'jwt-decode'; + +interface AuthGuardProps { + children: React.ReactNode; +} + +interface DecodedToken { + exp: number; +} + +const AuthGuard: React.FC = ({ children }) => { + const router = useRouter(); + const [isVerified, setIsVerified] = useState(false); + + useEffect(() => { + const token = localStorage.getItem('token'); + + if (!token) { + router.replace('/auth/login'); + return; + } + + try { + const decodedToken = jwtDecode(token); + const currentTime = Date.now() / 1000; + + if (decodedToken.exp < currentTime) { + localStorage.removeItem('token'); + router.replace('/auth/login'); + } else { + setIsVerified(true); + } + } catch (error) { + console.error('Invalid token:', error); + localStorage.removeItem('token'); + router.replace('/auth/login'); + } + }, [router]); + + if (!isVerified) { + return
Loading...
; + } + + return <>{children}; +}; + +export default AuthGuard; \ No newline at end of file diff --git a/src/components/DrawingCard.module.css b/src/components/DrawingCard.module.css new file mode 100644 index 0000000..40f3762 --- /dev/null +++ b/src/components/DrawingCard.module.css @@ -0,0 +1,288 @@ +.cardContainer { + border: 1px solid #e0e0e0; + border-radius: 8px; + background-color: #ffffff; + padding: 1rem; + transition: box-shadow 0.2s ease-in-out, transform 0.2s ease-in-out, background-color 0.3s ease, border-color 0.3s ease; + display: flex; + flex-direction: column; +} + +:global(.dark) .cardContainer { + background-color: #1e1e1e; + border-color: #333; +} + +.cardContainer:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +:global(.dark) .cardContainer:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.titleContainer { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.titleLink { + text-decoration: none; + color: inherit; + flex-grow: 1; + margin-right: 0.5rem; +} + +.cardTitle { + font-size: 1.2rem; + font-weight: 600; + color: #333; + margin: 0; + word-break: break-word; +} + +:global(.dark) .cardTitle { + color: #e0e0e0; +} + +.editButton { + background: none; + border: none; + padding: 0.25rem; + cursor: pointer; + color: #555; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.editButton:hover { + color: #000; + background-color: #f0f0f0; +} + +.actionButtonsContainer { + display: flex; + gap: 0.25rem; +} + +.iconButton { + background: none; + border: none; + padding: 0.25rem; + cursor: pointer; + color: #555; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s ease, color 0.2s ease; +} + +:global(.dark) .iconButton { + color: #aaa; +} + +.iconButton:hover { + color: #000; + background-color: #f0f0f0; +} + +:global(.dark) .iconButton:hover { + color: #fff; + background-color: #333; +} + +.deleteButton:hover { + color: #d9534f; +} + +:global(.dark) .deleteButton:hover { + color: #ff7875; +} + +.inlineEditForm { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.inlineInput { + padding: 0.5rem 0.7rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 1rem; + flex-grow: 1; + background-color: #fff; + color: #333; +} + +:global(.dark) .inlineInput { + background-color: #2a2a2a; + border-color: #444; + color: #e0e0e0; +} + +.inlineInput:focus { + outline: none; + border-color: #0070f3; + box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.2); +} + +:global(.dark) .inlineInput:focus { + border-color: #0070f3; + box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.3); +} + +.formActionButton { + padding: 0.5rem 0.9rem; + border: none; + border-radius: 4px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out, transform 0.1s ease; +} + +.formActionButton:active { + transform: translateY(1px); +} + +.confirmButton { + background-color: #28a745; + color: white; +} + +.confirmButton:hover { + background-color: #218838; +} + +.cancelButton { + background-color: #6c757d; + color: white; +} + +.cancelButton:hover { + background-color: #5a6268; +} + +.cardLinkUnderTitle { + text-decoration: none; + color: inherit; +} + +.cardContent { + margin-top: auto; +} + +.cardDate { + font-size: 0.8rem; + color: #777; + margin-top: 0.5rem; +} + +:global(.dark) .cardDate { + color: #aaa; +} + +.renameErrorText { + color: #d9534f; + font-size: 0.8rem; + margin-bottom: 0.5rem; +} + +:global(.dark) .renameErrorText { + color: #ff7875; +} + +.thumbnailPlaceholder { + width: 100%; + height: 150px; + background-color: #f0f0f0; + display: flex; + align-items: center; + justify-content: center; + color: #999; + border-radius: 4px; + margin-bottom: 12px; +} + +:global(.dark) .thumbnailPlaceholder { + background-color: #2a2a2a; + color: #aaa; +} + +.cardActions { + margin-top: 16px; + display: flex; + justify-content: flex-end; +} + +.modalErrorText { + color: #d9534f; + font-size: 0.9rem; + margin: 0.5rem 0; + padding: 0.5rem; + background-color: rgba(217, 83, 79, 0.1); + border-radius: 4px; +} + +:global(.dark) .modalErrorText { + color: #ff7875; + background-color: rgba(255, 120, 117, 0.15); +} + +.modalFooter { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1rem; +} + +.modalButtonCancel { + padding: 0.6rem 1.2rem; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + background-color: #f8f9fa; + color: #333; + border: 1px solid #ced4da; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +: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; +} + +.modalButtonConfirm { + padding: 0.6rem 1.2rem; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + background-color: #d9534f; + color: white; + border: 1px solid #d9534f; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.modalButtonConfirm:hover { + background-color: #c9302c; + border-color: #ac2925; +} \ No newline at end of file diff --git a/src/components/DrawingCard.tsx b/src/components/DrawingCard.tsx new file mode 100644 index 0000000..312d966 --- /dev/null +++ b/src/components/DrawingCard.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import Link from 'next/link'; +import { + Drawing, + updateDrawing as apiUpdateDrawing, + deleteDrawing as apiDeleteDrawing, +} from '@/lib/api'; +import styles from './DrawingCard.module.css'; +import { useI18n } from '@/lib/i18n/useI18n'; +import Icon from '@/components/Icon'; +import Modal from '@/components/Modal'; + +interface DrawingCardProps { + drawing: Drawing; + onDrawingRenamed: (updatedDrawing: Drawing) => void; + onDrawingDeleted: (drawingId: string) => void; +} + +export default function DrawingCard({ + drawing, + onDrawingRenamed, + onDrawingDeleted, +}: DrawingCardProps) { + const { t, isLoading: i18nCardIsLoading } = useI18n(); + + const [isEditing, setIsEditing] = useState(false); + const [editingTitle, setEditingTitle] = useState(drawing.title); + const editTitleInputRef = useRef(null); + const [renameError, setRenameError] = useState(null); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + useEffect(() => { + if (isEditing && editTitleInputRef.current) { + editTitleInputRef.current.focus(); + editTitleInputRef.current.select(); + } + + const handleClickOutside = (event: MouseEvent) => { + if (isEditing && editTitleInputRef.current && !editTitleInputRef.current.contains(event.target as Node)) { + handleCancelEdit(); + } + }; + + if (isEditing) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isEditing]); + + const lastUpdatedText = i18nCardIsLoading + ? "Last updated:" + : t('drawingCard.lastUpdatedPrefix') || "Last updated:"; + + const editTitleAriaLabel = i18nCardIsLoading + ? "Edit title" + : t('drawingCard.editTitleAria') || "Edit title"; + + const deleteButtonAriaLabel = i18nCardIsLoading + ? "Delete drawing" + : t('drawingCard.deleteAria') || "Delete drawing"; + + const handleStartEdit = () => { + setEditingTitle(drawing.title); + setIsEditing(true); + 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."); + return; + } + if (editingTitle.trim() === drawing.title) { + setIsEditing(false); + setRenameError(null); + return; + } + try { + setRenameError(null); + const response = await apiUpdateDrawing(drawing._id, { title: editingTitle.trim() }); + if (response.data) { + onDrawingRenamed(response.data); + setIsEditing(false); + } else { + setRenameError(response.error || t('drawingCard.errors.renameFailed') || "Failed to rename drawing."); + } + } catch (err) { + setRenameError(t('drawingCard.errors.renameFailed') || "Failed to rename drawing."); + console.error("Rename error:", err); + } + }; + + const handleEditTitleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleConfirmEdit(); + } + if (event.key === 'Escape') { + handleCancelEdit(); + } + }; + + const handleOpenDeleteModal = () => { + setShowDeleteModal(true); + setDeleteError(null); + }; + + const handleCloseDeleteModal = () => { + setShowDeleteModal(false); + }; + + const handleConfirmDelete = async () => { + try { + setDeleteError(null); + const response = await apiDeleteDrawing(drawing._id); + if (response.data) { + onDrawingDeleted(drawing._id); + setShowDeleteModal(false); + } else { + setDeleteError(response.error || t('drawingCard.errors.deleteFailed') || "Failed to delete drawing."); + } + } catch (err) { + setDeleteError(t('drawingCard.errors.deleteFailed') || "Failed to delete drawing."); + console.error("Delete error:", err); + } + }; + + const modalTitle = i18nCardIsLoading + ? "Confirm Deletion" + : t('drawingCard.confirmDelete.title') || "Confirm Deletion"; + + let modalMessage = `Are you sure you want to delete "${drawing.title}"? This action cannot be undone.`; + if (!i18nCardIsLoading) { + const messageTemplate = t('drawingCard.confirmDelete.message'); + if (messageTemplate) { + modalMessage = messageTemplate.replace('{{title}}', drawing.title); + } else { + modalMessage = `Are you sure you want to delete "${drawing.title}"? This action cannot be undone.`; + } + } + + const modalConfirmButtonText = i18nCardIsLoading + ? "Delete" + : t('drawingCard.confirmDelete.confirmButton') || "Delete"; + + const modalCancelButtonText = i18nCardIsLoading + ? "Cancel" + : t('drawingCard.confirmDelete.cancelButton') || "Cancel"; + + return ( + <> +
+ {renameError &&

{renameError}

} + {!isEditing ? ( +
+ +

{drawing.title}

+ +
+ + +
+
+ ) : ( +
+ setEditingTitle(e.target.value)} + onKeyDown={handleEditTitleKeyDown} + className={styles.inlineInput} + /> +
+ )} + +
+

+ {`${lastUpdatedText} ${new Date(drawing.updatedAt).toLocaleDateString()}`} +

+
+ +
+ + +

{modalMessage}

+ {deleteError &&

{deleteError}

} +
+ + +
+
+ + ); +} \ No newline at end of file diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx new file mode 100644 index 0000000..1418f4a --- /dev/null +++ b/src/components/Icon.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { icons } from '@/lib/icons'; + +interface IconProps extends React.SVGProps { + name?: string; + size?: number | string; + strokeWidth?: number | string; + color?: string; + className?: string; +} + +const Icon: React.FC = ({ + name = '', + size = 24, + strokeWidth = 2, + color = 'currentColor', + className = '', + ...restProps +}) => { + const iconHtml = name && icons[name] ? icons[name] : null; + + if (iconHtml) { + return ( + + ); + } + return ( + + + + + + ); +}; + +export default Icon; diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 16fd634..a41d9c7 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -2,63 +2,28 @@ import { useLanguage } from '@/lib/i18n/language-context'; import { locales } from '@/lib/i18n/i18n-config'; -import { useState, useRef, useEffect } from 'react'; import { useI18n } from '@/lib/i18n/useI18n'; export default function LanguageSwitcher() { const { locale, setLocale } = useLanguage(); - const { t, isLoading } = useI18n(); - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); + const { isLoading } = useI18n(); if (isLoading) return null; - return ( -
- + const toggleLanguage = () => { + const currentIndex = locales.indexOf(locale); + const nextIndex = (currentIndex + 1) % locales.length; + setLocale(locales[nextIndex]); + }; - {isOpen && ( -
-
- {t('language.select')} -
- {locales.map((lang) => ( - - ))} -
- )} + return ( +
+
); } \ No newline at end of file diff --git a/src/components/Modal.module.css b/src/components/Modal.module.css new file mode 100644 index 0000000..e2610d2 --- /dev/null +++ b/src/components/Modal.module.css @@ -0,0 +1,154 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(3px); +} + +:global(.dark) .overlay { + background-color: rgba(0, 0, 0, 0.7); +} + +.modal { + background-color: white; + padding: 1.5rem; + border-radius: 8px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); + width: 90%; + max-width: 500px; + display: flex; + flex-direction: column; + transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease; +} + +:global(.dark) .modal { + background-color: #1e1e1e; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #e9ecef; +} + +:global(.dark) .header { + border-bottom: 1px solid #333; +} + +.title { + font-size: 1.25rem; + font-weight: 600; + color: #333; + margin: 0; +} + +:global(.dark) .title { + color: #f0f0f0; +} + +.closeButton { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + padding: 0.25rem; + line-height: 1; + color: #666; + border-radius: 4px; + transition: color 0.2s ease, background-color 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +:global(.dark) .closeButton { + color: #aaa; +} + +.closeButton:hover { + color: #000; + background-color: #f0f0f0; +} + +:global(.dark) .closeButton:hover { + color: #fff; + background-color: #333; +} + +.content { + margin-bottom: 1.5rem; + font-size: 1rem; + color: #555; + line-height: 1.6; +} + +:global(.dark) .content { + color: #bbb; +} + +.footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding-top: 1rem; + border-top: 1px solid #e9ecef; +} + +:global(.dark) .footer { + border-top: 1px solid #333; +} + +.footer button { + padding: 0.6rem 1.2rem; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.1s ease; +} + +.footer button:active { + transform: translateY(1px); +} + +.footer .confirm { + background-color: #d9534f; + color: white; + border: 1px solid #d9534f; +} +.footer .confirm:hover { + background-color: #c9302c; + border-color: #ac2925; +} + +.footer .cancel { + background-color: #f8f9fa; + color: #333; + border: 1px solid #ced4da; +} + +:global(.dark) .footer .cancel { + background-color: #333; + color: #e0e0e0; + border-color: #555; +} + +.footer .cancel:hover { + background-color: #e2e6ea; + border-color: #dae0e5; +} + +:global(.dark) .footer .cancel:hover { + background-color: #444; + border-color: #666; +} \ No newline at end of file diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..9c6ea60 --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,60 @@ +'use client'; + +import React, { useEffect } from 'react'; +import styles from './Modal.module.css'; +import Icon from './Icon'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + footer?: React.ReactNode; +} + +export default function Modal({ + isOpen, + onClose, + title, + children, + footer, +}: ModalProps) { + useEffect(() => { + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscapeKey); + } else { + document.removeEventListener('keydown', handleEscapeKey); + } + + return () => { + document.removeEventListener('keydown', handleEscapeKey); + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+ + +
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ThemeSwitcher.tsx b/src/components/ThemeSwitcher.tsx index 2b05401..6fe52aa 100644 --- a/src/components/ThemeSwitcher.tsx +++ b/src/components/ThemeSwitcher.tsx @@ -1,84 +1,28 @@ 'use client'; import { useTheme } from '@/lib/theme-context'; -import { useState, useRef, useEffect } from 'react'; import { useI18n } from '@/lib/i18n/useI18n'; +import Icon from '@/components/Icon'; export default function ThemeSwitcher() { const { theme, toggleTheme } = useTheme(); - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); const { t, isLoading } = useI18n(); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - if (isLoading) return null; return ( -
+
- - {isOpen && ( -
- - -
- )}
); } \ No newline at end of file diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts index 65e2e2e..82aaf07 100644 --- a/src/controllers/authController.ts +++ b/src/controllers/authController.ts @@ -3,7 +3,7 @@ import jwt from "jsonwebtoken"; import User from "../models/User"; import dbConnect from "@/lib/db/connection"; -const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; +const JWT_SECRET = process.env.JWT_SECRET || ""; export async function register(data: { username: string; @@ -40,7 +40,7 @@ export async function register(data: { }, }; } catch (error) { - console.error("Error en el registro:", error); + console.error("Error in register:", error); return { error: "Internal server error", status: 500 }; } } @@ -52,9 +52,11 @@ export async function loginUser(data: { }) { try { await dbConnect(); - const { email, password, rememberMe } = data; + const { email: identifier, password, rememberMe } = data; - const user = await User.findOne({ email }); + const user = await User.findOne({ + $or: [{ email: identifier }, { username: identifier }], + }); if (!user) { return { error: "Invalid credentials", status: 401 }; } @@ -81,7 +83,38 @@ export async function loginUser(data: { }, }; } catch (error) { - console.error("Error en el login:", error); + console.error("Error in login:", error); + return { error: "Internal server error", status: 500 }; + } +} + +export async function validateToken(token: string): Promise<{ + user?: { id: string; username: string; email: string }; + error?: string; + status: number; +}> { + try { + await dbConnect(); + const decoded = jwt.verify(token, JWT_SECRET) as { userId: string }; + const user = await User.findById(decoded.userId).select("-password"); + + if (!user) { + return { error: "Invalid token - user not found", status: 401 }; + } + + return { + user: { + id: user._id.toString(), + username: user.username, + email: user.email, + }, + status: 200, + }; + } catch (error) { + console.error("Error in validateToken:", error); + if (error instanceof jwt.JsonWebTokenError) { + return { error: "Invalid token", status: 401 }; + } return { error: "Internal server error", status: 500 }; } } diff --git a/src/controllers/drawingsController.ts b/src/controllers/drawingsController.ts new file mode 100644 index 0000000..a2afb05 --- /dev/null +++ b/src/controllers/drawingsController.ts @@ -0,0 +1,130 @@ +import Drawing, { IDrawing } from "@/models/Drawing"; +import dbConnect from "@/lib/db/connection"; +import User from "@/models/User"; + +export async function createDrawing(data: { + title: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + drawingData: any; + userId: string; +}) { + try { + await dbConnect(); + const { title, drawingData, userId } = data; + + // Verificar que el usuario exista (opcional, pero buena práctica) + const owner = await User.findById(userId); + if (!owner) { + return { error: "Owner not found", status: 404 }; + } + + const newDrawing = await Drawing.create({ + owner_id: userId, + title, + data: drawingData, + shared_with: [], // Inicialmente no compartido + }); + + return { data: newDrawing, status: 201 }; + } catch (error) { + console.error("Error in createDrawing:", error); + return { error: "Internal server error", status: 500 }; + } +} + +export async function getUserDrawings(userId: string) { + try { + await dbConnect(); + const drawings = await Drawing.find({ owner_id: userId }).sort({ + updatedAt: -1, + }); + return { data: drawings, status: 200 }; + } catch (error) { + console.error("Error in getUserDrawings:", error); + return { error: "Internal server error", status: 500 }; + } +} + +export async function getDrawingById(drawingId: string, userId: string) { + try { + await dbConnect(); + const drawing = await Drawing.findById(drawingId); + + if (!drawing) { + return { error: "Drawing not found", status: 404 }; + } + + 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 }; + } + + return { data: drawing, status: 200 }; + } catch (error) { + console.error("Error in getDrawingById:", error); + return { error: "Internal server error", status: 500 }; + } +} + +export async function updateDrawing( + drawingId: string, + userId: string, + updateData: Partial> +) { + try { + await dbConnect(); + const drawing = await Drawing.findById(drawingId); + + if (!drawing) { + return { error: "Drawing not found", status: 404 }; + } + + if (drawing.owner_id.toString() !== userId) { + return { error: "Forbidden", status: 403 }; + } + + if (updateData.title !== undefined) { + drawing.title = updateData.title; + } + if (updateData.data !== undefined) { + drawing.data = updateData.data; + } + if (updateData.shared_with !== undefined) { + drawing.shared_with = updateData.shared_with; + } + + drawing.updatedAt = new Date(); + const updatedDrawing = await drawing.save(); + + return { data: updatedDrawing, status: 200 }; + } catch (error) { + console.error("Error in updateDrawing:", error); + return { error: "Internal server error", status: 500 }; + } +} + +export async function deleteDrawing(drawingId: string, userId: string) { + try { + await dbConnect(); + const drawing = await Drawing.findById(drawingId); + + if (!drawing) { + return { error: "Drawing not found", status: 404 }; + } + + if (drawing.owner_id.toString() !== userId) { + return { error: "Forbidden", status: 403 }; + } + + await Drawing.findByIdAndDelete(drawingId); + + return { data: { message: "Drawing deleted successfully" }, status: 200 }; + } catch (error) { + console.error("Error in deleteDrawing:", error); + return { error: "Internal server error", status: 500 }; + } +} diff --git a/src/lib/api.ts b/src/lib/api.ts index b32b49e..3d311c5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,3 +1,5 @@ +import { jwtDecode } from "jwt-decode"; + interface LoginRequest { email: string; password: string; @@ -27,12 +29,56 @@ interface AuthResponse { user: UserProfile; } +interface DecodedToken { + exp: number; +} + async function fetchApi( endpoint: string, method: string = "GET", body?: T, - headers?: Record + headers?: Record, + requiresAuth: boolean = false ): Promise> { + if (requiresAuth) { + const token = localStorage.getItem("token"); + + if (!token) { + logout(); + return { + error: "Authentication required. No token found.", + status: 401, + }; + } + + try { + const decodedToken = jwtDecode(token); + const currentTime = Date.now() / 1000; + + if (decodedToken.exp < currentTime) { + logout(); + return { + error: "Authentication required. Token expired.", + status: 401, + }; + } + headers = { + ...headers, + Authorization: `Bearer ${token}`, + }; + } catch (error) { + console.error("Token validation error:", error); + localStorage.removeItem("token"); + if (typeof window !== "undefined") { + window.location.href = "/auth/login"; + } + return { + error: "Authentication required. Invalid token.", + status: 401, + }; + } + } + try { const requestHeaders = { "Content-Type": "application/json", @@ -48,9 +94,18 @@ async function fetchApi( const response = await fetch(`/api${endpoint}`, config); const data = await response.json(); + if (!response.ok) { + if (response.status === 401 && typeof window !== "undefined") { + logout(); + } + return { + error: data.error || "Error en la petición", + status: response.status, + }; + } + return { - data: response.ok ? data : undefined, - error: !response.ok ? data.error || "Error en la petición" : undefined, + data: data, status: response.status, }; } catch (error) { @@ -82,21 +137,91 @@ export async function register( ); } -export async function getProfile(): Promise> { - const token = localStorage.getItem("token"); - - if (!token) { - return { - error: "No hay token de autenticación", - status: 401, - }; - } - - return fetchApi("/user/profile", "GET", undefined, { - Authorization: `Bearer ${token}`, - }); -} - export async function logout(): Promise { localStorage.removeItem("token"); + if (typeof window !== "undefined") { + window.location.href = "/auth/login"; + } +} + +// --- Drawing API Functions --- +export interface Drawing { + _id: string; + owner_id: string; + title: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; + shared_with: string[]; + createdAt: string; + updatedAt: string; +} + +interface CreateDrawingRequest { + title: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + drawingData: any; +} + +interface UpdateDrawingRequest { + title?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + drawingData?: any; + shared_with?: string[]; +} + +export async function createDrawing( + drawingData: CreateDrawingRequest +): Promise> { + return fetchApi( + "/drawings", + "POST", + drawingData, + {}, + true + ); +} + +export async function getAllDrawings(): Promise> { + return fetchApi( + "/drawings", + "GET", + undefined, + {}, + true + ); +} + +export async function getOneDrawing(id: string): Promise> { + return fetchApi( + `/drawings/${id}`, + "GET", + undefined, + {}, + true + ); +} + +export async function updateDrawing( + id: string, + updateData: UpdateDrawingRequest +): Promise> { + return fetchApi( + `/drawings/${id}`, + "PUT", + updateData, + {}, + true + ); +} + +export async function deleteDrawing( + id: string +): Promise> { + return fetchApi( + `/drawings/${id}`, + "DELETE", + undefined, + {}, + true + ); } diff --git a/src/lib/i18n/language-context.tsx b/src/lib/i18n/language-context.tsx index 20e5cc3..50f2892 100644 --- a/src/lib/i18n/language-context.tsx +++ b/src/lib/i18n/language-context.tsx @@ -30,7 +30,6 @@ export function LanguageProvider({ children }: { children: ReactNode }) { if (locales.includes(newLocale)) { setLocale(newLocale); localStorage.setItem('locale', newLocale); - window.location.reload(); } }; diff --git a/src/lib/icons.ts b/src/lib/icons.ts new file mode 100644 index 0000000..956d461 --- /dev/null +++ b/src/lib/icons.ts @@ -0,0 +1,516 @@ +export const icons: Record = { + activity: '', + airplay: + '', + "alert-circle": + '', + "alert-octagon": + '', + "alert-triangle": + '', + "align-center": + '', + "align-justify": + '', + "align-left": + '', + "align-right": + '', + anchor: + '', + aperture: + '', + archive: + '', + "arrow-down": + '', + "arrow-down-circle": + '', + "arrow-down-left": + '', + "arrow-down-right": + '', + "arrow-left": + '', + "arrow-left-circle": + '', + "arrow-right": + '', + "arrow-right-circle": + '', + "arrow-up": + '', + "arrow-up-circle": + '', + "arrow-up-left": + '', + "arrow-up-right": + '', + "at-sign": + '', + award: + '', + "bar-chart": + '', + "bar-chart-2": + '', + battery: + '', + "battery-charging": + '', + bell: '', + "bell-off": + '', + bluetooth: + '', + bold: '', + book: '', + "book-open": + '', + bookmark: + '', + box: '', + briefcase: + '', + calendar: + '', + camera: + '', + "camera-off": + '', + cast: '', + check: '', + "check-circle": + '', + "check-square": + '', + "chevron-down": '', + "chevron-left": '', + "chevron-right": '', + "chevron-up": '', + "chevrons-down": + '', + "chevrons-left": + '', + "chevrons-right": + '', + "chevrons-up": + '', + chrome: + '', + circle: '', + clipboard: + '', + clock: + '', + cloud: '', + "cloud-drizzle": + '', + "cloud-lightning": + '', + "cloud-off": + '', + "cloud-rain": + '', + "cloud-snow": + '', + code: '', + codepen: + '', + codesandbox: + '', + coffee: + '', + columns: + '', + command: + '', + compass: + '', + copy: '', + "corner-down-left": + '', + "corner-down-right": + '', + "corner-left-down": + '', + "corner-left-up": + '', + "corner-right-down": + '', + "corner-right-up": + '', + "corner-up-left": + '', + "corner-up-right": + '', + cpu: '', + "credit-card": + '', + crop: '', + crosshair: + '', + database: + '', + delete: + '', + disc: '', + divide: + '', + "divide-circle": + '', + "divide-square": + '', + "dollar-sign": + '', + download: + '', + "download-cloud": + '', + dribbble: + '', + droplet: '', + edit: '', + "edit-2": + '', + "edit-3": + '', + "external-link": + '', + eye: '', + "eye-off": + '', + facebook: + '', + "fast-forward": + '', + feather: + '', + figma: + '', + file: '', + "file-minus": + '', + "file-plus": + '', + "file-text": + '', + film: '', + filter: + '', + flag: '', + folder: + '', + "folder-minus": + '', + "folder-plus": + '', + framer: '', + frown: + '', + gift: '', + "git-branch": + '', + "git-commit": + '', + "git-merge": + '', + "git-pull-request": + '', + github: + '', + gitlab: + '', + globe: + '', + grid: '', + "hard-drive": + '', + hash: '', + headphones: + '', + heart: + '', + "help-circle": + '', + hexagon: + '', + home: '', + image: + '', + inbox: + '', + info: '', + instagram: + '', + italic: + '', + key: '', + layers: + '', + layout: + '', + "life-buoy": + '', + link: '', + "link-2": + '', + linkedin: + '', + list: '', + loader: + '', + lock: '', + "log-in": + '', + "log-out": + '', + mail: '', + map: '', + "map-pin": + '', + maximize: + '', + "maximize-2": + '', + meh: '', + menu: '', + "message-circle": + '', + "message-square": + '', + mic: '', + "mic-off": + '', + minimize: + '', + "minimize-2": + '', + minus: '', + "minus-circle": + '', + "minus-square": + '', + monitor: + '', + moon: '', + "more-horizontal": + '', + "more-vertical": + '', + "mouse-pointer": + '', + move: '', + music: + '', + navigation: '', + "navigation-2": '', + octagon: + '', + package: + '', + paperclip: + '', + pause: + '', + "pause-circle": + '', + "pen-tool": + '', + percent: + '', + phone: + '', + "phone-call": + '', + "phone-forwarded": + '', + "phone-incoming": + '', + "phone-missed": + '', + "phone-off": + '', + "phone-outgoing": + '', + "pie-chart": + '', + play: '', + "play-circle": + '', + plus: '', + "plus-circle": + '', + "plus-square": + '', + pocket: + '', + power: + '', + printer: + '', + radio: + '', + "refresh-ccw": + '', + "refresh-cw": + '', + repeat: + '', + rewind: + '', + "rotate-ccw": + '', + "rotate-cw": + '', + rss: '', + save: '', + scissors: + '', + search: + '', + send: '', + server: + '', + settings: + '', + share: + '', + "share-2": + '', + shield: '', + "shield-off": + '', + "shopping-bag": + '', + "shopping-cart": + '', + shuffle: + '', + sidebar: + '', + "skip-back": + '', + "skip-forward": + '', + slack: + '', + slash: + '', + sliders: + '', + smartphone: + '', + smile: + '', + speaker: + '', + square: '', + star: '', + "stop-circle": + '', + sun: '', + sunrise: + '', + sunset: + '', + table: + '', + tablet: + '', + tag: '', + target: + '', + terminal: + '', + thermometer: + '', + "thumbs-down": + '', + "thumbs-up": + '', + "toggle-left": + '', + "toggle-right": + '', + tool: '', + trash: + '', + "trash-2": + '', + trello: + '', + "trending-down": + '', + "trending-up": + '', + triangle: + '', + truck: + '', + tv: '', + twitch: '', + twitter: + '', + type: '', + umbrella: + '', + underline: + '', + unlock: + '', + upload: + '', + "upload-cloud": + '', + user: '', + "user-check": + '', + "user-minus": + '', + "user-plus": + '', + "user-x": + '', + users: + '', + video: + '', + "video-off": + '', + voicemail: + '', + volume: '', + "volume-1": + '', + "volume-2": + '', + "volume-x": + '', + watch: + '', + wifi: '', + "wifi-off": + '', + wind: '', + x: '', + "x-circle": + '', + "x-octagon": + '', + "x-square": + '', + youtube: + '', + zap: '', + "zap-off": + '', + "zoom-in": + '', + "zoom-out": + '', + "flag-es": + '', + "flag-en": + '', +}; diff --git a/src/models/Drawing.ts b/src/models/Drawing.ts new file mode 100644 index 0000000..64774d8 --- /dev/null +++ b/src/models/Drawing.ts @@ -0,0 +1,38 @@ +import mongoose, { Document, Schema } from "mongoose"; + +export interface IDrawing extends Document { + id: string; + owner_id: string; + title: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; + shared_with: string[]; + createdAt: Date; + updatedAt: Date; +} + +const DrawingSchema = new Schema( + { + owner_id: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + data: { + type: Schema.Types.Mixed, + required: true, + }, + shared_with: { + type: [String], + }, + }, + { + timestamps: true, + } +); + +export default mongoose.models.Drawing || + mongoose.model("Drawing", DrawingSchema); diff --git a/yarn.lock b/yarn.lock index 8d66891..99af9f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3368,6 +3368,11 @@ jws@^3.2.2: jwa "^1.4.1" safe-buffer "^5.0.1" +jwt-decode@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b" + integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA== + kareem@2.6.3: version "2.6.3" resolved "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz"