diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 9969cf6..a31052b 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -61,6 +61,8 @@ "newDrawingButton": "Create New Drawing", "newDrawingPlaceholder": "Enter drawing title...", "noDrawings": "You don't have any drawings yet. Create one!", + "myDrawingsTitle": "My Drawings", + "sharedDrawingsTitle": "Shared with me", "errors": { "createFailed": "Failed to create new drawing.", "titleRequired": "Title is required." @@ -70,6 +72,7 @@ "lastUpdatedPrefix": "Last updated:", "editTitleAria": "Edit title", "deleteAria": "Delete drawing", + "shareAria": "Share drawing", "confirmDelete": { "title": "Confirm Deletion", "message": "Are you sure you want to delete \"{{title}}\"? This action cannot be undone.", @@ -79,7 +82,19 @@ "errors": { "titleRequired": "Title is required.", "renameFailed": "Failed to rename drawing.", - "deleteFailed": "Failed to delete drawing." + "deleteFailed": "Failed to delete drawing.", + "loadUsersFailed": "Failed to load users.", + "userNotSelected": "Please select a user to share with.", + "shareFailed": "Failed to share drawing." + }, + "shareModal": { + "title": "Share Drawing", + "searchPlaceholder": "Search user by name or email", + "confirmButton": "Share", + "cancelButton": "Cancel", + "noUsersFound": "No users available to share with or match your search.", + "loadingUsers": "Loading users...", + "sharingButton": "Sharing..." } }, "editor": { diff --git a/public/locales/es/common.json b/public/locales/es/common.json index 43018bd..13d9f19 100644 --- a/public/locales/es/common.json +++ b/public/locales/es/common.json @@ -61,6 +61,8 @@ "newDrawingButton": "Crear Nuevo Dibujo", "newDrawingPlaceholder": "Introduce el título del dibujo...", "noDrawings": "Aún no tienes dibujos. ¡Crea uno!", + "myDrawingsTitle": "Mis Dibujos", + "sharedDrawingsTitle": "Compartidos conmigo", "errors": { "createFailed": "Error al crear nuevo dibujo.", "titleRequired": "El título es obligatorio." @@ -70,6 +72,7 @@ "lastUpdatedPrefix": "Última act.:", "editTitleAria": "Editar título", "deleteAria": "Eliminar dibujo", + "shareAria": "Compartir dibujo", "confirmDelete": { "title": "Confirmar Eliminación", "message": "¿Estás seguro de que quieres eliminar \"{{title}}\"? Esta acción no se puede deshacer.", @@ -79,7 +82,19 @@ "errors": { "titleRequired": "El título es obligatorio.", "renameFailed": "Error al renombrar el dibujo.", - "deleteFailed": "Error al eliminar el dibujo." + "deleteFailed": "Error al eliminar el dibujo.", + "loadUsersFailed": "Error al cargar usuarios.", + "userNotSelected": "Por favor, selecciona un usuario para compartir.", + "shareFailed": "Error al compartir el dibujo." + }, + "shareModal": { + "title": "Compartir Dibujo", + "searchPlaceholder": "Buscar usuario por nombre o email", + "confirmButton": "Compartir", + "cancelButton": "Cancelar", + "noUsersFound": "No hay usuarios disponibles para compartir o que coincidan con tu búsqueda.", + "loadingUsers": "Cargando usuarios...", + "sharingButton": "Compartiendo..." } }, "editor": { diff --git a/src/app/api/drawings/[id]/share/route.ts b/src/app/api/drawings/[id]/share/route.ts new file mode 100644 index 0000000..c3fa992 --- /dev/null +++ b/src/app/api/drawings/[id]/share/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { shareDrawingWithUser } from "@/controllers/drawingsController"; +import { authMiddleware } from "@/lib/helpers/api-middlewares/auth"; + +interface ShareRequestBody { + userIdToShareWith: string; +} + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + const authResult = await authMiddleware(request); + if (!authResult.isAuthenticated || !authResult.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const drawingId = params.id; + const ownerUserId = authResult.user.id; + + try { + const body = await request.json() as ShareRequestBody; + const { userIdToShareWith } = body; + + if (!userIdToShareWith) { + return NextResponse.json( + { error: "userIdToShareWith is required" }, + { status: 400 } + ); + } + + const result = await shareDrawingWithUser( + drawingId, + ownerUserId, + userIdToShareWith + ); + + if (result.error) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + + return NextResponse.json(result.data, { status: result.status }); + } catch (error) { + console.error("Error in POST /api/drawings/[id]/share:", error); + if (error instanceof SyntaxError) { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/drawings/[id]/unshare/route.ts b/src/app/api/drawings/[id]/unshare/route.ts new file mode 100644 index 0000000..411b99f --- /dev/null +++ b/src/app/api/drawings/[id]/unshare/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { unshareDrawingWithUser } from "@/controllers/drawingsController"; +import { authMiddleware } from "@/lib/helpers/api-middlewares/auth"; + +interface UnshareRequestBody { + userIdToUnshare: string; +} + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + const authResult = await authMiddleware(request); + if (!authResult.isAuthenticated || !authResult.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const drawingId = params.id; + const ownerUserId = authResult.user.id; + + try { + const body = await request.json() as UnshareRequestBody; + const { userIdToUnshare } = body; + + if (!userIdToUnshare) { + return NextResponse.json( + { error: "userIdToUnshare is required" }, + { status: 400 } + ); + } + + const result = await unshareDrawingWithUser( + drawingId, + ownerUserId, + userIdToUnshare + ); + + if (result.error) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + + return NextResponse.json(result.data, { status: result.status }); + } catch (error) { + console.error("Error in POST /api/drawings/[id]/unshare:", error); + if (error instanceof SyntaxError) { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..d2ccb7d --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUsers } from "@/controllers/usersController"; +import { authMiddleware } from "@/lib/helpers/api-middlewares/auth"; + +export async function GET(request: NextRequest) { + const { isAuthenticated } = await authMiddleware(request); + + if (!isAuthenticated) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const result = await getUsers(); + return NextResponse.json(result, { status: 200 }); + } catch (error) { + console.error("Error in GET /api/users:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + diff --git a/src/app/dashboard/page.module.css b/src/app/dashboard/page.module.css index 719c44e..270789d 100644 --- a/src/app/dashboard/page.module.css +++ b/src/app/dashboard/page.module.css @@ -93,6 +93,10 @@ --text-primary: #fff; } +.logoutButtonText { + display: inline; +} + .createButton { display: flex; align-items: center; @@ -241,6 +245,10 @@ font-weight: 500; cursor: pointer; transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out, transform 0.1s ease; + display: flex; + align-items: center; + justify-content: center; + min-width: 40px; } .actionButton:active { @@ -276,4 +284,107 @@ justify-content: center; height: 100%; width: 100%; +} + +.drawingsSection { + margin-bottom: 2.5rem; +} + +.sectionTitle { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 1.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #e0e0e0; + color: #333; +} + +:global(.dark) .sectionTitle { + color: #f0f0f0; + border-bottom: 1px solid #333; +} + +@media (max-width: 768px) { + .page { + padding: 1rem; + } + + .header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .headerLeft { + width: 100%; + justify-content: space-between; + flex-direction: row; + align-items: center; + } + + .headerRight { + width: 100%; + justify-content: space-between; + } + + .createButton { + flex: 1; + justify-content: center; + } + + .logoutButton { + padding: 0.5rem; + justify-content: center; + } + + .logoutButtonText { + display: none; + } + + .drawingsGrid { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1rem; + } + + .inlineCreateForm { + width: 100%; + display: flex; + gap: 0.5rem; + } + + .inlineInput { + flex: 1; + } + + .actionButton { + padding: 0.6rem; + } +} + +@media (max-width: 480px) { + .headerSwitchers { + gap: 0.5rem; + } + + .headerLeft { + flex-direction: row; + align-items: center; + gap: 0.75rem; + } + + .header h1 { + font-size: 1.5rem; + } + + .drawingsGrid { + grid-template-columns: 1fr; + } + + .inlineCreateForm { + width: 100%; + } + + .inlineInput { + width: 100%; + } } \ No newline at end of file diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index cc69de2..b560024 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -10,11 +10,12 @@ import ThemeSwitcher from "@/components/ThemeSwitcher/ThemeSwitcher"; import LanguageSwitcher from "@/components/LanguageSwitcher/LanguageSwitcher"; import Icon from "@/components/Icon/Icon"; import Loader from "@/components/Loader/Loader"; - +import { jwtDecode } from "jwt-decode"; export default function Dashboard() { const { t, isLoading: i18nIsLoading } = useI18n(); const router = useRouter(); - const [drawings, setDrawings] = useState([]); + const [myDrawings, setMyDrawings] = useState([]); + const [sharedDrawings, setSharedDrawings] = useState([]); const [pageLoading, setPageLoading] = useState(true); const [error, setError] = useState(null); @@ -29,7 +30,14 @@ export default function Dashboard() { setError(null); const response = await getAllDrawings(); if (response.data) { - setDrawings(response.data); + const token = localStorage.getItem('token'); + const decodedToken = jwtDecode(token || "") as { userId: string }; + const userId = decodedToken.userId; + const own = response.data.filter(drawing => drawing.owner_id === userId); + const shared = response.data.filter(drawing => drawing.owner_id !== userId); + + setMyDrawings(own); + setSharedDrawings(shared); } else if (response.error) { setError(response.error); } @@ -91,10 +99,9 @@ export default function Dashboard() { }; const response = await createDrawing(newDrawingData); if (response.data) { - router.push(`/editor?id=${response.data._id}`); + router.push(`/editor/${response.data._id}`); setIsCreating(false); setNewDrawingTitle(""); - // fetchDrawings(); } else if (response.error) { setError(response.error); } @@ -115,15 +122,31 @@ export default function Dashboard() { }; const handleDrawingRenamed = (updatedDrawing: Drawing) => { - setDrawings(prevDrawings => - prevDrawings.map(d => d._id === updatedDrawing._id ? updatedDrawing : d) - ); + const userId = localStorage.getItem('userId'); + if (updatedDrawing.owner_id === userId) { + setMyDrawings(prevDrawings => + prevDrawings.map(d => d._id === updatedDrawing._id ? updatedDrawing : d) + ); + } else { + setSharedDrawings(prevDrawings => + prevDrawings.map(d => d._id === updatedDrawing._id ? updatedDrawing : d) + ); + } }; const handleDrawingDeleted = (drawingId: string) => { - setDrawings(prevDrawings => + setMyDrawings(prevDrawings => prevDrawings.filter(d => d._id !== drawingId) ); + setSharedDrawings(prevDrawings => + prevDrawings.filter(d => d._id !== drawingId) + ); + }; + + const handleDrawingShared = (updatedDrawing: Drawing) => { + setMyDrawings(prevDrawings => + prevDrawings.map(d => d._id === updatedDrawing._id ? updatedDrawing : d) + ); }; const handleLogout = async () => { @@ -165,7 +188,7 @@ export default function Dashboard() {
-

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

+

Draw

@@ -188,6 +211,20 @@ export default function Dashboard() { placeholder={t('dashboard.newDrawingPlaceholder') || "Enter drawing title..."} className={styles.inlineInput} /> + +
)}
); diff --git a/src/app/page.tsx b/src/app/page.tsx index a2a0934..c161eb3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,12 +4,21 @@ import Link from 'next/link'; import LanguageSwitcher from '@/components/LanguageSwitcher/LanguageSwitcher'; import ThemeSwitcher from '@/components/ThemeSwitcher/ThemeSwitcher'; import { useI18n } from '@/lib/i18n/useI18n'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; export default function Home() { const { t, isLoading } = useI18n(); + const router = useRouter(); + useEffect(() => { + const token = localStorage.getItem('token'); + if (token) { + router.push('/dashboard'); + } + }, [router]); if (isLoading) return null; - + return (
diff --git a/src/components/DrawingCard/DrawingCard.module.css b/src/components/DrawingCard/DrawingCard.module.css index 40f3762..5e69987 100644 --- a/src/components/DrawingCard/DrawingCard.module.css +++ b/src/components/DrawingCard/DrawingCard.module.css @@ -285,4 +285,133 @@ .modalButtonConfirm:hover { background-color: #c9302c; border-color: #ac2925; +} + +/* Share modal */ +.shareModalContent { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.shareSearchInput { + padding: 0.75rem; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 0.95rem; + width: 100%; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.shareSearchInput:focus { + outline: none; + border-color: #0070f3; + box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.2); +} + +:global(.dark) .shareSearchInput { + background-color: #2a2a2a; + border-color: #444; + color: #e0e0e0; +} + +:global(.dark) .shareSearchInput:focus { + border-color: #0070f3; + box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.3); +} + +.shareSearchInput:disabled { + background-color: #f8f9fa; + cursor: not-allowed; + opacity: 0.7; +} + +:global(.dark) .shareSearchInput:disabled { + background-color: #333; +} + +.userListShare { + list-style: none; + padding: 0; + margin: 0; + max-height: 250px; + overflow-y: auto; + border: 1px solid #ced4da; + border-radius: 6px; +} + +:global(.dark) .userListShare { + border-color: #444; +} + +.userListItemShare { + padding: 0.75rem 1rem; + cursor: pointer; + transition: background-color 0.2s ease; + border-bottom: 1px solid #eeeeee; +} + +.userListItemShare:last-child { + border-bottom: none; +} + +:global(.dark) .userListItemShare { + border-bottom-color: #333; +} + +.userListItemShare:hover { + background-color: #f8f9fa; +} + +:global(.dark) .userListItemShare:hover { + background-color: #333; +} + +.selectedUser { + background-color: #e8f0fe; + font-weight: 500; +} + +.selectedUser:hover { + background-color: #d8e5fd; +} + +:global(.dark) .selectedUser { + background-color: #1a3a6c; +} + +:global(.dark) .selectedUser:hover { + background-color: #254b85; +} + +.disabledItem { + opacity: 0.6; + cursor: not-allowed; +} + +.disabledItem:hover { + background-color: inherit; +} + +:global(.dark) .disabledItem:hover { + background-color: inherit; +} + +/* Estilo para botones deshabilitados */ +.modalButtonConfirm:disabled, +.modalButtonCancel:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.modalButtonConfirm:disabled:hover, +.modalButtonCancel:disabled:hover { + background-color: inherit; + border-color: inherit; +} + +:global(.dark) .modalButtonConfirm:disabled:hover, +:global(.dark) .modalButtonCancel:disabled:hover { + background-color: inherit; + border-color: inherit; } \ No newline at end of file diff --git a/src/components/DrawingCard/DrawingCard.tsx b/src/components/DrawingCard/DrawingCard.tsx index b819a02..bee63c1 100644 --- a/src/components/DrawingCard/DrawingCard.tsx +++ b/src/components/DrawingCard/DrawingCard.tsx @@ -1,12 +1,15 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import Link from 'next/link'; import { Drawing, updateDrawing as apiUpdateDrawing, deleteDrawing as apiDeleteDrawing, + getUsers as apiGetUsers, + shareDrawingWithUser as apiShareDrawing, } from '@/lib/api'; +import { IUser } from '@/models/User'; import styles from './DrawingCard.module.css'; import { useI18n } from '@/lib/i18n/useI18n'; import Icon from '@/components/Icon/Icon'; @@ -14,14 +17,18 @@ import Modal from '@/components/Modal/Modal'; interface DrawingCardProps { drawing: Drawing; + owned: boolean; onDrawingRenamed: (updatedDrawing: Drawing) => void; onDrawingDeleted: (drawingId: string) => void; + onDrawingShared?: (updatedDrawing: Drawing) => void; } export default function DrawingCard({ drawing, + owned, onDrawingRenamed, onDrawingDeleted, + onDrawingShared, }: DrawingCardProps) { const { t, isLoading: i18nCardIsLoading } = useI18n(); @@ -32,6 +39,21 @@ export default function DrawingCard({ const [showDeleteModal, setShowDeleteModal] = useState(false); const [deleteError, setDeleteError] = useState(null); + // States for the share modal + const [showShareModal, setShowShareModal] = useState(false); + const [usersForSharing, setUsersForSharing] = useState([]); + const [shareSearchTerm, setShareSearchTerm] = useState(''); + const [selectedUserIdToShare, setSelectedUserIdToShare] = useState(null); + const [shareError, setShareError] = useState(null); + const [isLoadingUsers, setIsLoadingUsers] = useState(false); + const [isSharing, setIsSharing] = useState(false); + + const handleCancelEdit = useCallback(() => { + setIsEditing(false); + setEditingTitle(drawing.title); + setRenameError(null); + }, [drawing.title]); + useEffect(() => { if (isEditing && editTitleInputRef.current) { editTitleInputRef.current.focus(); @@ -53,7 +75,7 @@ export default function DrawingCard({ return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [isEditing]); + }, [isEditing, handleCancelEdit]); const lastUpdatedText = i18nCardIsLoading ? "Last updated:" @@ -66,6 +88,10 @@ export default function DrawingCard({ const deleteButtonAriaLabel = i18nCardIsLoading ? "Delete drawing" : t('drawingCard.deleteAria') || "Delete drawing"; + + const shareButtonAriaLabel = i18nCardIsLoading + ? "Share drawing" + : t('drawingCard.shareAria') || "Share drawing"; const handleStartEdit = () => { setEditingTitle(drawing.title); @@ -73,12 +99,6 @@ export default function DrawingCard({ setRenameError(null); }; - const handleCancelEdit = () => { - setIsEditing(false); - setEditingTitle(drawing.title); - setRenameError(null); - }; - const handleConfirmEdit = async () => { if (!editingTitle.trim()) { setRenameError(t('drawingCard.errors.titleRequired') || "Title is required."); @@ -138,6 +158,61 @@ export default function DrawingCard({ } }; + // Functions for the share modal + const handleOpenShareModal = async () => { + setShowShareModal(true); + setShareError(null); + setSelectedUserIdToShare(null); + setShareSearchTerm(''); + if (usersForSharing.length === 0) { // Load users only if they haven't been loaded before + setIsLoadingUsers(true); + const response = await apiGetUsers(); + setIsLoadingUsers(false); + if (response.data) { + // Filter out the owner and already shared users + const filteredUsers = response.data.filter( + (user) => user._id !== drawing.owner_id && !drawing.shared_with.includes(user._id as string) + ); + setUsersForSharing(filteredUsers); + } else { + setShareError(response.error || t('drawingCard.errors.loadUsersFailed') || "Failed to load users."); + } + } + }; + + const handleCloseShareModal = () => { + setShowShareModal(false); + setSelectedUserIdToShare(null); + setShareSearchTerm(''); + setShareError(null); + }; + + const handleConfirmShare = async () => { + if (!selectedUserIdToShare) { + setShareError(t('drawingCard.errors.userNotSelected') || "Please select a user to share with."); + return; + } + setIsSharing(true); + setShareError(null); + const response = await apiShareDrawing(drawing._id, selectedUserIdToShare); + setIsSharing(false); + if (response.data) { + setShowShareModal(false); + if (onDrawingShared) { + onDrawingShared(response.data); + } + setUsersForSharing(prevUsers => prevUsers.filter(u => u._id !== selectedUserIdToShare)); + setSelectedUserIdToShare(null); + } else { + setShareError(response.error || t('drawingCard.errors.shareFailed') || "Failed to share drawing."); + } + }; + + const filteredUsersForSharing = usersForSharing.filter((user) => + user.username.toLowerCase().includes(shareSearchTerm.toLowerCase()) || + user.email.toLowerCase().includes(shareSearchTerm.toLowerCase()) + ); + const modalTitle = i18nCardIsLoading ? "Confirm Deletion" : t('drawingCard.confirmDelete.title') || "Confirm Deletion"; @@ -160,6 +235,23 @@ export default function DrawingCard({ ? "Cancel" : t('drawingCard.confirmDelete.cancelButton') || "Cancel"; + // Texts for the share modal + const shareModalTitle = i18nCardIsLoading + ? "Share Drawing" + : t('drawingCard.shareModal.title') || "Share Drawing"; + const shareModalSearchPlaceholder = i18nCardIsLoading + ? "Search user by name or email" + : t('drawingCard.shareModal.searchPlaceholder') || "Search user by name or email"; + const shareModalConfirmButtonText = i18nCardIsLoading + ? "Share" + : t('drawingCard.shareModal.confirmButton') || "Share"; + const shareModalCancelButtonText = i18nCardIsLoading + ? "Cancel" + : t('drawingCard.shareModal.cancelButton') || "Cancel"; + const shareModalNoUsersFoundText = i18nCardIsLoading + ? "No users available to share with or match your search." + : t('drawingCard.shareModal.noUsersFound') || "No users available to share with or match your search."; + return ( <>
@@ -173,9 +265,16 @@ export default function DrawingCard({ - + {owned && ( + <> + + + + )}
) : ( @@ -215,6 +314,60 @@ export default function DrawingCard({
+ + {/* Share modal */} + +
+ setShareSearchTerm(e.target.value)} + className={styles.shareSearchInput} + disabled={isLoadingUsers || isSharing} + /> + {isLoadingUsers &&

{t('drawingCard.shareModal.loadingUsers') || 'Loading users...'}

} + {shareError &&

{shareError}

} + + {!isLoadingUsers && filteredUsersForSharing.length === 0 && ( +

{shareModalNoUsersFoundText}

+ )} + + {!isLoadingUsers && filteredUsersForSharing.length > 0 && ( +
    + {filteredUsersForSharing.map((user) => ( +
  • !isSharing && setSelectedUserIdToShare(user._id as string)} + className={`${styles.userListItemShare} ${selectedUserIdToShare === user._id ? styles.selectedUser : ''} ${isSharing ? styles.disabledItem : ''}`} + > + {user.username} ({user.email}) +
  • + ))} +
+ )} +
+
+ + +
+
); } \ No newline at end of file diff --git a/src/controllers/drawingsController.ts b/src/controllers/drawingsController.ts index a2afb05..cf78b31 100644 --- a/src/controllers/drawingsController.ts +++ b/src/controllers/drawingsController.ts @@ -35,7 +35,9 @@ export async function createDrawing(data: { export async function getUserDrawings(userId: string) { try { await dbConnect(); - const drawings = await Drawing.find({ owner_id: userId }).sort({ + const drawings = await Drawing.find({ + $or: [{ owner_id: userId }, { shared_with: userId }], + }).sort({ updatedAt: -1, }); return { data: drawings, status: 200 }; @@ -73,7 +75,7 @@ export async function getDrawingById(drawingId: string, userId: string) { export async function updateDrawing( drawingId: string, userId: string, - updateData: Partial> + updateData: Partial> & { shared_with_users?: string[] } ) { try { await dbConnect(); @@ -83,7 +85,12 @@ export async function updateDrawing( return { error: "Drawing not found", status: 404 }; } - if (drawing.owner_id.toString() !== userId) { + const isOwner = drawing.owner_id.toString() === userId; + const isSharedWithUser = drawing.shared_with + .map((id: string) => id.toString()) + .includes(userId); + + if (!isOwner && !isSharedWithUser) { return { error: "Forbidden", status: 403 }; } @@ -93,8 +100,12 @@ export async function updateDrawing( if (updateData.data !== undefined) { drawing.data = updateData.data; } - if (updateData.shared_with !== undefined) { - drawing.shared_with = updateData.shared_with; + + if (updateData.shared_with_users !== undefined) { + if (!isOwner) { + return { error: "Forbidden: Only the owner can change sharing settings", status: 403 }; + } + drawing.shared_with = updateData.shared_with_users; } drawing.updatedAt = new Date(); @@ -128,3 +139,74 @@ export async function deleteDrawing(drawingId: string, userId: string) { return { error: "Internal server error", status: 500 }; } } + +export async function shareDrawingWithUser( + drawingId: string, + ownerUserId: string, + userIdToShareWith: string +) { + try { + await dbConnect(); + const drawing = await Drawing.findById(drawingId); + + if (!drawing) { + return { error: "Drawing not found", status: 404 }; + } + + if (drawing.owner_id.toString() !== ownerUserId) { + return { error: "Forbidden: Only the owner can share this drawing", status: 403 }; + } + + const userToShare = await User.findById(userIdToShareWith); + if (!userToShare) { + return { error: "User to share with not found", status: 404 }; + } + + if (userIdToShareWith === ownerUserId) { + return { error: "Cannot share drawing with the owner", status: 400 }; + } + + if (!drawing.shared_with.map((id: string) => id.toString()).includes(userIdToShareWith)) { + drawing.shared_with.push(userIdToShareWith); + drawing.updatedAt = new Date(); + await drawing.save(); + } + + return { data: drawing, status: 200 }; + } catch (error) { + console.error("Error in shareDrawingWithUser:", error); + return { error: "Internal server error", status: 500 }; + } +} + +export async function unshareDrawingWithUser( + drawingId: string, + ownerUserId: string, + userIdToUnshare: string +) { + try { + await dbConnect(); + const drawing = await Drawing.findById(drawingId); + + if (!drawing) { + return { error: "Drawing not found", status: 404 }; + } + + if (drawing.owner_id.toString() !== ownerUserId) { + return { error: "Forbidden: Only the owner can modify sharing settings", status: 403 }; + } + + const initialSharedCount = drawing.shared_with.length; + drawing.shared_with = drawing.shared_with.filter((id: string) => id.toString() !== userIdToUnshare); + + if (drawing.shared_with.length < initialSharedCount) { + drawing.updatedAt = new Date(); + await drawing.save(); + } + + return { data: drawing, status: 200 }; + } catch (error) { + console.error("Error in unshareDrawingWithUser:", error); + return { error: "Internal server error", status: 500 }; + } +} diff --git a/src/controllers/usersController.ts b/src/controllers/usersController.ts new file mode 100644 index 0000000..583b4ff --- /dev/null +++ b/src/controllers/usersController.ts @@ -0,0 +1,13 @@ +import User from "../models/User"; +import dbConnect from "@/lib/db/connection"; + +export async function getUsers() { + try { + await dbConnect(); + const users = await User.find(); + return users; + } catch (error) { + console.error("Error in getUsers:", error); + return { error: "Internal server error", status: 500 }; + } +} \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 6928d03..73ffc09 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,5 @@ import { jwtDecode } from "jwt-decode"; +import { IUser } from "@/models/User"; interface LoginRequest { email: string; @@ -144,6 +145,16 @@ export async function logout(): Promise { } } +export async function getUsers(): Promise> { + return fetchApi( + "/users", + "GET", + undefined, + {}, + true + ); +} + // --- Drawing API Functions --- export interface Drawing { _id: string; @@ -225,3 +236,37 @@ export async function deleteDrawing( true ); } + +interface ShareDrawingRequestBody { + userIdToShareWith: string; +} + +export async function shareDrawingWithUser( + drawingId: string, + userIdToShareWith: string +): Promise> { + return fetchApi( + `/drawings/${drawingId}/share`, + "POST", + { userIdToShareWith }, + {}, + true + ); +} + +interface UnshareDrawingRequestBody { + userIdToUnshare: string; +} + +export async function unshareDrawingWithUser( + drawingId: string, + userIdToUnshare: string +): Promise> { + return fetchApi( + `/drawings/${drawingId}/unshare`, + "POST", + { userIdToUnshare }, + {}, + true + ); +} diff --git a/src/lib/helpers/api-middlewares/auth.tsx b/src/lib/helpers/api-middlewares/auth.tsx new file mode 100644 index 0000000..f927744 --- /dev/null +++ b/src/lib/helpers/api-middlewares/auth.tsx @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; +import { validateToken } from "@/controllers/authController"; + +export async function authMiddleware(request: NextRequest) { + const authHeader = request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return { isAuthenticated: false, user: null }; + } + + const token = authHeader.substring(7); + const { user } = await validateToken(token); + + return { isAuthenticated: !!user, user }; +} \ No newline at end of file diff --git a/src/models/User.ts b/src/models/User.ts index 8851c19..6f1eb50 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -15,15 +15,11 @@ const UserSchema = new Schema( type: String, required: true, unique: true, - trim: true, - minlength: 3 }, email: { type: String, required: true, unique: true, - trim: true, - lowercase: true }, password: { type: String, @@ -32,7 +28,13 @@ const UserSchema = new Schema( } }, { - timestamps: true + timestamps: true, + toJSON: { + transform: (_, ret) => { + delete ret.password; + return ret; + } + } } );