This commit is contained in:
borrageiros 2025-05-14 10:19:03 +02:00
parent 29d3322682
commit c583dd0b72
21 changed files with 495 additions and 350 deletions

View File

@ -10,9 +10,6 @@
"startFree": "Start for free", "startFree": "Start for free",
"learnMore": "Learn more" "learnMore": "Learn more"
}, },
"footer": {
"copyright": "© 2023 Draw. All rights reserved."
},
"auth": { "auth": {
"login": { "login": {
"title": "Log in", "title": "Log in",
@ -90,10 +87,18 @@
"saving": "Saving...", "saving": "Saving...",
"saved": "Saved!", "saved": "Saved!",
"loadingDrawing": "Loading drawing...", "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": { "errors": {
"cannotSave": "Cannot save drawing. Missing reference or ID.", "cannotSave": "Cannot save drawing. Missing reference or ID.",
"saveFailed": "Failed to save drawing.", "saveFailed": "Failed to save drawing.",
"loadFailedSimple": "Error loading drawing." "loadFailedSimple": "Error loading drawing."
} }
} }
} }

View File

@ -10,9 +10,6 @@
"startFree": "Comenzar gratis", "startFree": "Comenzar gratis",
"learnMore": "Conocer más" "learnMore": "Conocer más"
}, },
"footer": {
"copyright": "© 2023 Draw. Todos los derechos reservados."
},
"auth": { "auth": {
"login": { "login": {
"title": "Iniciar sesión", "title": "Iniciar sesión",
@ -90,10 +87,18 @@
"saving": "Guardando...", "saving": "Guardando...",
"saved": "¡Guardado!", "saved": "¡Guardado!",
"loadingDrawing": "Cargando dibujo...", "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": { "errors": {
"cannotSave": "No se puede guardar el dibujo. Falta referencia o ID.", "cannotSave": "No se puede guardar el dibujo. Falta referencia o ID.",
"saveFailed": "Error al guardar el dibujo.", "saveFailed": "Error al guardar el dibujo.",
"loadFailedSimple": "Error al cargar el dibujo." "loadFailedSimple": "Error al cargar el dibujo."
} }
} }
} }

View File

@ -1,12 +1,18 @@
.page { .page {
height: 100%; height: 100vh;
width: 100%;
padding: 2rem; padding: 2rem;
max-width: 1200px; max-width: 100%;
margin: 0 auto; margin: 0 auto;
font-family: sans-serif; font-family: sans-serif;
color: #333; color: #333;
background-color: #f9f9f9; background-color: #f9f9f9;
transition: background-color 0.3s ease, color 0.3s ease; 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 { :global(.dark) .page {
@ -88,6 +94,9 @@
} }
.createButton { .createButton {
display: flex;
align-items: center;
gap: 0.5rem;
background-color: #0070f3; background-color: #0070f3;
color: white; color: white;
border: none; border: none;
@ -254,4 +263,17 @@
.cancelButton:hover { .cancelButton:hover {
background-color: #5a6268; 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%;
} }

View File

@ -3,12 +3,13 @@ import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import styles from "./page.module.css"; import styles from "./page.module.css";
import { useI18n } from '@/lib/i18n/useI18n'; 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 { getAllDrawings, createDrawing, Drawing, logout } from '@/lib/api';
import DrawingCard from '@/components/DrawingCard'; import DrawingCard from '@/components/DrawingCard/DrawingCard';
import ThemeSwitcher from "@/components/ThemeSwitcher"; import ThemeSwitcher from "@/components/ThemeSwitcher/ThemeSwitcher";
import LanguageSwitcher from "@/components/LanguageSwitcher"; import LanguageSwitcher from "@/components/LanguageSwitcher/LanguageSwitcher";
import Icon from "@/components/Icon"; import Icon from "@/components/Icon/Icon";
import Loader from "@/components/Loader/Loader";
export default function Dashboard() { export default function Dashboard() {
const { t, isLoading: i18nIsLoading } = useI18n(); const { t, isLoading: i18nIsLoading } = useI18n();
@ -139,7 +140,9 @@ export default function Dashboard() {
return ( return (
<AuthGuard> <AuthGuard>
<div className={styles.page}> <div className={styles.page}>
<p>Loading translations...</p> <div className={styles.loaderWrapper}>
<Loader size="medium" text="Loading translations..." />
</div>
</div> </div>
</AuthGuard> </AuthGuard>
); );
@ -149,7 +152,9 @@ export default function Dashboard() {
return ( return (
<AuthGuard> <AuthGuard>
<div className={styles.page}> <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> </div>
</AuthGuard> </AuthGuard>
); );
@ -169,6 +174,7 @@ export default function Dashboard() {
<div className={styles.headerRight}> <div className={styles.headerRight}>
{!isCreating ? ( {!isCreating ? (
<button onClick={handleStartCreate} className={styles.createButton}> <button onClick={handleStartCreate} className={styles.createButton}>
<Icon name="plus" size={20} />
{t('dashboard.newDrawingButton') || "Create New Drawing"} {t('dashboard.newDrawingButton') || "Create New Drawing"}
</button> </button>
) : ( ) : (

View File

@ -18,7 +18,19 @@
display: none; /* O visibility: hidden; */ 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 { .errorOverlay {
position: absolute; position: absolute;
top: 0; top: 0;
@ -46,32 +58,134 @@
background-color: var(--color-danger-dark, #a94442); 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 { .saveErrorIndicator {
position: fixed; position: fixed;
top: 20px; top: 1rem;
left: 50%; right: 1rem;
transform: translateX(-50%); z-index: 100;
padding: 0.75rem 1.25rem; padding: 0.75rem 1.25rem;
border-radius: 6px; border-radius: 8px;
color: white; color: white;
font-size: 1rem; font-size: 1rem;
z-index: 20; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
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); background-color: var(--color-danger, #d9534f);
} }
:global(.dark) .saveErrorIndicator { :global(.dark) .saveErrorIndicator {
background-color: var(--color-danger-dark, #a94442); 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;
}

View File

@ -1,23 +1,27 @@
"use client"; "use client";
import styles from "./page.module.css"; import styles from "./page.module.css";
import dynamic from "next/dynamic"; 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 "@excalidraw/excalidraw/index.css";
import { MainMenu } from "@excalidraw/excalidraw"; import { MainMenu } from "@excalidraw/excalidraw";
import { useRouter } from "next/navigation";
import { useTheme } from "@/lib/theme-context"; import { useTheme } from "@/lib/theme-context";
import { useLanguage } from "@/lib/i18n/language-context"; import { useLanguage } from "@/lib/i18n/language-context";
import { useI18n } from '@/lib/i18n/useI18n'; import { useI18n } from '@/lib/i18n/useI18n';
import Icon from '@/components/Icon'; import Icon from '@/components/Icon/Icon';
import AuthGuard from '@/components/AuthGuard'; import AuthGuard from '@/components/AuthGuard/AuthGuard';
import { getOneDrawing, updateDrawing, UpdateDrawingRequest } from '@/lib/api'; import { getOneDrawing, updateDrawing, UpdateDrawingRequest } from '@/lib/api';
import Modal from '@/components/Modal/Modal';
import Loader from '@/components/Loader/Loader';
const ExcalidrawComponent = dynamic( const ExcalidrawComponent = dynamic(
() => import("@excalidraw/excalidraw").then((mod) => mod.Excalidraw), () => import("@excalidraw/excalidraw").then((mod) => mod.Excalidraw),
{ ssr: false } { 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 { t, isLoading } = useI18n();
const excalidrawWrapperRef = useRef<HTMLDivElement>(null); const excalidrawWrapperRef = useRef<HTMLDivElement>(null);
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@ -26,6 +30,7 @@ export default function Editor({ params }: { params: { id: string } }) {
const drawingIdRef = useRef<string | null>(null); const drawingIdRef = useRef<string | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const excalidrawRef = useRef<any | null>(null); const excalidrawRef = useRef<any | null>(null);
const router = useRouter();
const [initialData, setInitialData] = useState<{ const [initialData, setInitialData] = useState<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false); 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(() => { useEffect(() => {
setIsClient(true); setIsClient(true);
if (params.id) { if (resolvedParams.id) {
drawingIdRef.current = params.id; drawingIdRef.current = resolvedParams.id;
} else { } else {
console.warn("No drawing ID found in route params."); console.warn("No drawing ID found in route params.");
setIsLoadingDrawing(false); setIsLoadingDrawing(false);
} }
}, [params.id]); }, [resolvedParams.id]);
useEffect(() => { useEffect(() => {
if (!isClient || !drawingIdRef.current) { 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const drawingContent = response.data.data as { elements: any[], appState: Partial<any> }; const drawingContent = response.data.data as { elements: any[], appState: Partial<any> };
if (drawingContent && Array.isArray(drawingContent.elements)) { if (drawingContent && Array.isArray(drawingContent.elements)) {
initialElementsRef.current = drawingContent.elements;
setInitialData({ setInitialData({
elements: drawingContent.elements, elements: drawingContent.elements,
appState: drawingContent.appState || {} appState: drawingContent.appState || {}
}); });
} else { } else {
initialElementsRef.current = [];
setInitialData({ setInitialData({
elements: [], elements: [],
appState: {} appState: {}
@ -80,6 +92,7 @@ export default function Editor({ params }: { params: { id: string } }) {
setLoadError(response.error); setLoadError(response.error);
console.error("Error fetching drawing:", response.error); console.error("Error fetching drawing:", response.error);
} else { } else {
initialElementsRef.current = [];
setInitialData({ setInitialData({
elements: [], elements: [],
appState: {} appState: {}
@ -96,6 +109,63 @@ export default function Editor({ params }: { params: { id: string } }) {
fetchDrawing(); fetchDrawing();
}, [isClient, theme, t]); }, [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 () => { const handleSaveDrawing = async () => {
if (!excalidrawRef.current || !drawingIdRef.current) { if (!excalidrawRef.current || !drawingIdRef.current) {
setSaveError(t('editor.errors.cannotSave') || 'Cannot save drawing. Missing reference or ID.'); 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); const response = await updateDrawing(drawingIdRef.current, payload);
if (response.data) { if (response.data) {
setSaveSuccess(true); setSaveSuccess(true);
setHasUnsavedChanges(false);
initialElementsRef.current = elements;
setTimeout(() => setSaveSuccess(false), 3000); 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 { } else {
setSaveError(response.error || t('editor.errors.saveFailed') || 'Failed to save drawing.'); 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'; const newLocale = locale === 'es' ? 'en' : 'es';
setLocale(newLocale); setLocale(newLocale);
}, [locale, setLocale]); }, [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) => { const getMenuItemText = useCallback((key: string, defaultText?: string) => {
if (isLoading || !t) { if (isLoading || !t) {
@ -160,13 +271,30 @@ export default function Editor({ params }: { params: { id: string } }) {
return translatedText; return translatedText;
}, [t, isLoading]); }, [t, isLoading]);
// Track changes to the drawing
const handleExcalidrawChange = useCallback(() => {
if (!isLoadingDrawing && initialData) {
if (hasActualChanges()) {
setHasUnsavedChanges(true);
}
}
}, [isLoadingDrawing, initialData, hasActualChanges]);
return ( return (
<AuthGuard> <AuthGuard>
<div className={styles.page}> <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>} {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>} {saveError && <div className={styles.saveErrorIndicator}>{saveError}</div>}
<div ref={excalidrawWrapperRef} className={`${styles.excalidrawContainer} ${isLoadingDrawing || loadError ? styles.hidden : ''}`}> <div ref={excalidrawWrapperRef} className={`${styles.excalidrawContainer} ${isLoadingDrawing || loadError ? styles.hidden : ''}`}>
@ -176,6 +304,16 @@ export default function Editor({ params }: { params: { id: string } }) {
initialData={initialData} initialData={initialData}
theme={theme} theme={theme}
langCode={locale === 'es' ? 'es-ES' : 'en-US'} 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>
<MainMenu.DefaultItems.LoadScene /> <MainMenu.DefaultItems.LoadScene />
@ -211,6 +349,28 @@ export default function Editor({ params }: { params: { id: string } }) {
</ExcalidrawComponent> </ExcalidrawComponent>
)} )}
</div> </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> </div>
</AuthGuard> </AuthGuard>
); );

View File

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

View File

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

View File

@ -8,7 +8,7 @@
} }
.dark { .dark {
--background: #0a0a0a; --background: #121212;
--foreground: #ededed; --foreground: #ededed;
} }

View File

@ -1,8 +1,8 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import LanguageSwitcher from '@/components/LanguageSwitcher'; import LanguageSwitcher from '@/components/LanguageSwitcher/LanguageSwitcher';
import ThemeSwitcher from '@/components/ThemeSwitcher'; import ThemeSwitcher from '@/components/ThemeSwitcher/ThemeSwitcher';
import { useI18n } from '@/lib/i18n/useI18n'; import { useI18n } from '@/lib/i18n/useI18n';
export default function Home() { export default function Home() {
@ -70,9 +70,28 @@ export default function Home() {
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 py-8"> <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"> <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"> <div className="flex flex-col items-center justify-center space-y-4">
{t('footer.copyright')} <div className="flex items-center space-x-8">
</p> <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> </div>
</footer> </footer>
</div> </div>

View File

@ -9,8 +9,8 @@ import {
} from '@/lib/api'; } from '@/lib/api';
import styles from './DrawingCard.module.css'; import styles from './DrawingCard.module.css';
import { useI18n } from '@/lib/i18n/useI18n'; import { useI18n } from '@/lib/i18n/useI18n';
import Icon from '@/components/Icon'; import Icon from '@/components/Icon/Icon';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal/Modal';
interface DrawingCardProps { interface DrawingCardProps {
drawing: Drawing; drawing: Drawing;

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { icons } from '@/lib/icons'; import { icons } from '@/components/Icon/icons';
interface IconProps extends React.SVGProps<SVGSVGElement> { interface IconProps extends React.SVGProps<SVGSVGElement> {
name?: string; name?: string;

View File

@ -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;
}
}

View File

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

View File

@ -2,7 +2,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import styles from './Modal.module.css'; import styles from './Modal.module.css';
import Icon from './Icon'; import Icon from '../Icon/Icon';
interface ModalProps { interface ModalProps {
isOpen: boolean; isOpen: boolean;

View File

@ -2,7 +2,7 @@
import { useTheme } from '@/lib/theme-context'; import { useTheme } from '@/lib/theme-context';
import { useI18n } from '@/lib/i18n/useI18n'; import { useI18n } from '@/lib/i18n/useI18n';
import Icon from '@/components/Icon'; import Icon from '@/components/Icon/Icon';
export default function ThemeSwitcher() { export default function ThemeSwitcher() {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();