diff --git a/src/api/workspaces.ts b/src/api/workspaces.ts index 519f61e..ee2c11f 100644 --- a/src/api/workspaces.ts +++ b/src/api/workspaces.ts @@ -2,12 +2,13 @@ import { authFetch } from "./client"; export interface Workspace { id: string; - name: string; - description?: string; - owner?: string; - my_role?: 'owner' | 'admin' | 'member' | 'guest'; - [key: string]: any; -} + name: string; + description?: string; + thumbnail?: string | null; + owner?: string; + my_role?: 'owner' | 'admin' | 'member' | 'guest'; + [key: string]: any; +} export interface PaginatedResponse { count: number; @@ -77,11 +78,36 @@ export const getWorkspace = async (id: string): Promise => { return await response.json(); }; -export const createWorkspace = async (data: { name: string; description: string; members?: any[] }): Promise => { - const response = await authFetch('/api/workspaces/', { - method: 'POST', - body: JSON.stringify(data), - }); +export const createWorkspace = async (data: { + name: string; + description: string; + members?: any[]; + thumbnail?: File | null; +}): Promise => { + const hasFile = data.thumbnail instanceof File; + const body = hasFile + ? (() => { + const formData = new FormData(); + formData.append("name", data.name); + formData.append("description", data.description); + if (Array.isArray(data.members)) { + formData.append("members", JSON.stringify(data.members)); + } + if (data.thumbnail) { + formData.append("thumbnail", data.thumbnail); + } + return formData; + })() + : JSON.stringify({ + name: data.name, + description: data.description, + members: data.members, + }); + + const response = await authFetch('/api/workspaces/', { + method: 'POST', + body, + }); if (!response.ok) { const errorData = await response.json(); @@ -90,11 +116,33 @@ export const createWorkspace = async (data: { name: string; description: string; return await response.json(); }; -export const updateWorkspace = async (id: string, data: { name?: string; description?: string }): Promise => { - const response = await authFetch(`/api/workspaces/${id}/`, { - method: 'PATCH', - body: JSON.stringify(data), - }); +export const updateWorkspace = async ( + id: string, + data: { + name?: string; + description?: string; + thumbnail?: File | null; + clear_thumbnail?: boolean; + }, +): Promise => { + const hasFile = data.thumbnail instanceof File; + const shouldClear = Boolean(data.clear_thumbnail); + const useForm = hasFile || shouldClear; + const body = useForm + ? (() => { + const formData = new FormData(); + if (data.name !== undefined) formData.append("name", data.name); + if (data.description !== undefined) formData.append("description", data.description); + if (data.thumbnail) formData.append("thumbnail", data.thumbnail); + if (shouldClear) formData.append("clear_thumbnail", "true"); + return formData; + })() + : JSON.stringify(data); + + const response = await authFetch(`/api/workspaces/${id}/`, { + method: 'PATCH', + body, + }); if (!response.ok) { const errorData = await response.json(); diff --git a/src/components/WorkspaceSelector.tsx b/src/components/WorkspaceSelector.tsx index 4c58b8d..9a99bb7 100644 --- a/src/components/WorkspaceSelector.tsx +++ b/src/components/WorkspaceSelector.tsx @@ -50,10 +50,9 @@ export const WorkspaceSelector: React.FC = () => { const handleWorkspaceEdited = ((e: CustomEvent) => { // آپدیت نام کارتابل در نوبار در صورتی که کارتابل فعال ویرایش شده باشد if (activeWorkspace?.id === e.detail?.id) { - setActiveWorkspace({ - ...activeWorkspace, - name: e.detail.name, - description: e.detail.description + setActiveWorkspace({ + ...activeWorkspace, + ...e.detail, } as Workspace); } refreshWorkspacesList(); @@ -123,9 +122,13 @@ export const WorkspaceSelector: React.FC = () => { onClick={() => setIsOpen(!isOpen)} className="flex items-center gap-2 px-3 py-2 text-base font-medium text-slate-700 dark:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" > -
- {activeWorkspace?.name?.charAt(0) || } -
+
+ {activeWorkspace?.thumbnail ? ( + {activeWorkspace?.name + ) : ( + activeWorkspace?.name?.charAt(0)?.toUpperCase() || + )} +
{activeWorkspace?.name || t.workspace?.title || "Workspaces"} @@ -159,9 +162,13 @@ export const WorkspaceSelector: React.FC = () => { className="w-full flex items-center justify-between px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" >
-
- {ws.name.charAt(0)} -
+
+ {ws.thumbnail ? ( + {ws.name} + ) : ( + ws.name.charAt(0).toUpperCase() + )} +
{ws.name}
{activeWorkspace?.id === ws.id && ( diff --git a/src/pages/WorkspaceCreate.tsx b/src/pages/WorkspaceCreate.tsx index aad124a..b21dd4e 100644 --- a/src/pages/WorkspaceCreate.tsx +++ b/src/pages/WorkspaceCreate.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, Fragment } from 'react'; import { useBlocker, useNavigate } from 'react-router-dom'; import { useTranslation } from '../hooks/useTranslation'; -import { AlertCircle, UserPlus, Trash2, Shield, Loader2 } from 'lucide-react'; +import { AlertCircle, UserPlus, Trash2, Shield, Loader2, UploadCloud } from 'lucide-react'; import { Dialog, Transition } from '@headlessui/react'; import { toast } from 'sonner'; import { createWorkspace } from '../api/workspaces'; @@ -40,6 +40,8 @@ export default function WorkspaceCreate() { // Workspace Info States const [name, setName] = useState(''); const [description, setDescription] = useState(''); + const [thumbnailFile, setThumbnailFile] = useState(null); + const [thumbnailPreview, setThumbnailPreview] = useState(null); const [isSaving, setIsSaving] = useState(false); // Members States (Local) @@ -54,8 +56,36 @@ export default function WorkspaceCreate() { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [memberIdToDelete, setMemberIdToDelete] = useState(null); - const searchTimeoutRef = useRef | null>(null); - const hasUnsavedChanges = name.trim() !== '' || description.trim() !== '' || members.length > 0; + const searchTimeoutRef = useRef | null>(null); + const hasUnsavedChanges = name.trim() !== '' || description.trim() !== '' || members.length > 0 || !!thumbnailFile; + + useEffect(() => { + if (!thumbnailFile) { + setThumbnailPreview(null); + return; + } + const objectUrl = URL.createObjectURL(thumbnailFile); + setThumbnailPreview(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [thumbnailFile]); + + const handleThumbnailChange = (file: File | null) => { + if (!file) { + setThumbnailFile(null); + return; + } + const allowedTypes = ["image/jpeg", "image/png", "image/webp"]; + if (!allowedTypes.includes(file.type)) { + toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP."); + return; + } + const maxBytes = 2 * 1024 * 1024; + if (file.size > maxBytes) { + toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less."); + return; + } + setThumbnailFile(file); + }; useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { @@ -121,7 +151,8 @@ export default function WorkspaceCreate() { const payload = { name, description, - members: members.map(m => ({ user_id: m.user.id, role: m.role })) + members: members.map(m => ({ user_id: m.user.id, role: m.role })), + thumbnail: thumbnailFile, }; const newWorkspace = await createWorkspace(payload); @@ -178,13 +209,13 @@ export default function WorkspaceCreate() { const isFirstOwner = true; return ( -
+

{t.workspace?.createTitle || "Create Workspace"}

-
-
+
+
+
+ + + + + {thumbnailFile && ( + + )} +
-
+

{ t.workspace?.members || "Members" } @@ -322,7 +392,7 @@ export default function WorkspaceCreate() {

{/* لیست اعضا (با قابلیت اسکرول) */} -
+
{members.map((m) => { return (
diff --git a/src/pages/WorkspaceEdit.tsx b/src/pages/WorkspaceEdit.tsx index fa85136..7befe65 100644 --- a/src/pages/WorkspaceEdit.tsx +++ b/src/pages/WorkspaceEdit.tsx @@ -1,11 +1,11 @@ import React, { useState, useEffect, useRef, Fragment, useMemo, useCallback } from 'react'; -import { useBlocker, useNavigate, useParams } from 'react-router-dom'; -import { useTranslation } from '../hooks/useTranslation'; -import { AlertCircle, UserPlus, Trash2, Shield } from 'lucide-react'; -import { Dialog, Transition } from '@headlessui/react'; -import { toast } from 'sonner'; -import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates'; -import { +import { useBlocker, useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from '../hooks/useTranslation'; +import { AlertCircle, UserPlus, Trash2, Shield, UploadCloud } from 'lucide-react'; +import { Dialog, Transition } from '@headlessui/react'; +import { toast } from 'sonner'; +import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates'; +import { updateWorkspace, addWorkspaceMembership, removeWorkspaceMembership, @@ -14,21 +14,21 @@ import { getWorkspace } from '../api/workspaces'; import { searchUserByExactMobile, type SearchedUser } from '../api/users'; -import { useAppContext } from '../context/AppContext'; -import { - WORKSPACE_EDIT, - WORKSPACE_MEMBERS_ADD, - WORKSPACE_MEMBERS_CHANGE_ROLE, - canChangeWorkspaceMember, - canWorkspace, - type WorkspaceRole, -} from '../lib/permissions'; +import { useAppContext } from '../context/AppContext'; +import { + WORKSPACE_EDIT, + WORKSPACE_MEMBERS_ADD, + WORKSPACE_MEMBERS_CHANGE_ROLE, + canChangeWorkspaceMember, + canWorkspace, + type WorkspaceRole, +} from '../lib/permissions'; import { Button } from '../components/ui/button'; -import { InfiniteScroll } from '../components/InfiniteScroll'; -import { Select } from '../components/ui/Select'; -import { Input } from '../components/ui/input'; -import { TextAreaInput } from '../components/ui/TextAreaInput'; -import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields'; +import { InfiniteScroll } from '../components/InfiniteScroll'; +import { Select } from '../components/ui/Select'; +import { Input } from '../components/ui/input'; +import { TextAreaInput } from '../components/ui/TextAreaInput'; +import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields'; const toEnglishDigits = (str: string) => { return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString()) @@ -55,12 +55,16 @@ export default function EditWorkspace() { // Workspace Info States const [name, setName] = useState(''); const [description, setDescription] = useState(''); - const [myRole, setMyRole] = useState('member'); + const [thumbnailUrl, setThumbnailUrl] = useState(null); + const [thumbnailFile, setThumbnailFile] = useState(null); + const [clearThumbnail, setClearThumbnail] = useState(false); + const [thumbnailPreview, setThumbnailPreview] = useState(null); + const [myRole, setMyRole] = useState('member'); const [workspaceOwnerId, setWorkspaceOwnerId] = useState(''); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [workspaceRates, setWorkspaceRates] = useState([]); - const [priceUnits, setPriceUnits] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [workspaceRates, setWorkspaceRates] = useState([]); + const [priceUnits, setPriceUnits] = useState([]); // Members States const [members, setMembers] = useState([]); @@ -79,7 +83,7 @@ export default function EditWorkspace() { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [memberIdToDelete, setMemberIdToDelete] = useState(null); - const searchTimeoutRef = useRef | null>(null); + const searchTimeoutRef = useRef | null>(null); const [initialData, setInitialData] = useState({ name: '', @@ -91,9 +95,39 @@ export default function EditWorkspace() { const isNameChanged = name.trim() !== (initialData.name || '').trim(); const isDescChanged = description.trim() !== (initialData.description || '').trim(); + const isImageChanged = !!thumbnailFile || clearThumbnail; - return isNameChanged || isDescChanged; - }, [name, description, initialData, isLoading]); + return isNameChanged || isDescChanged || isImageChanged; + }, [name, description, initialData, isLoading, thumbnailFile, clearThumbnail]); + + useEffect(() => { + if (!thumbnailFile) { + setThumbnailPreview(null); + return; + } + const objectUrl = URL.createObjectURL(thumbnailFile); + setThumbnailPreview(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [thumbnailFile]); + + const handleThumbnailChange = (file: File | null) => { + if (!file) { + setThumbnailFile(null); + return; + } + const allowedTypes = ["image/jpeg", "image/png", "image/webp"]; + if (!allowedTypes.includes(file.type)) { + toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP."); + return; + } + const maxBytes = 2 * 1024 * 1024; + if (file.size > maxBytes) { + toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less."); + return; + } + setThumbnailFile(file); + setClearThumbnail(false); + }; useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { @@ -113,16 +147,16 @@ export default function EditWorkspace() { return false; }); - useEffect(() => { - if (id) loadData(); - }, [id]); - - useEffect(() => { - if (!isLoading && id && !canWorkspace(myRole, WORKSPACE_EDIT)) { - toast.error("You do not have permission to edit this workspace."); - navigate(`/workspaces/${id}`); - } - }, [id, isLoading, myRole, navigate]); + useEffect(() => { + if (id) loadData(); + }, [id]); + + useEffect(() => { + if (!isLoading && id && !canWorkspace(myRole, WORKSPACE_EDIT)) { + toast.error("You do not have permission to edit this workspace."); + navigate(`/workspaces/${id}`); + } + }, [id, isLoading, myRole, navigate]); const loadData = async () => { try { @@ -130,20 +164,23 @@ export default function EditWorkspace() { const workspaceData = await getWorkspace(id!); setName(workspaceData.name); setDescription(workspaceData.description || ''); + setThumbnailUrl(workspaceData.thumbnail || null); + setThumbnailFile(null); + setClearThumbnail(false); setMyRole(workspaceData.my_role || 'member'); setWorkspaceOwnerId(workspaceData.owner || ''); - const [membersData, ratesData, unitsData] = await Promise.all([ - fetchWorkspaceMemberships({ workspace: id!, limit: LIMIT, offset: 0 }), - getWorkspaceUserRates(id!), - getPriceUnits(), - ]); - const results = membersData.results || (Array.isArray(membersData) ? membersData : []); - - setMembers(results); - setWorkspaceRates(ratesData.results || []); - setPriceUnits(unitsData.results || []); - setOffset(LIMIT); + const [membersData, ratesData, unitsData] = await Promise.all([ + fetchWorkspaceMemberships({ workspace: id!, limit: LIMIT, offset: 0 }), + getWorkspaceUserRates(id!), + getPriceUnits(), + ]); + const results = membersData.results || (Array.isArray(membersData) ? membersData : []); + + setMembers(results); + setWorkspaceRates(ratesData.results || []); + setPriceUnits(unitsData.results || []); + setOffset(LIMIT); // Robust hasMore check: use `.next` if available, otherwise check if array filled the limit setHasMore(membersData.next ? true : results.length >= LIMIT); @@ -225,10 +262,15 @@ export default function EditWorkspace() { if (!name.trim() || !id) return; try { setIsSaving(true); - await updateWorkspace(id, { name, description }); + const updatedWorkspace = await updateWorkspace(id, { + name, + description, + thumbnail: thumbnailFile, + clear_thumbnail: clearThumbnail, + }); toast.success(t.workspace?.toast?.successUpdate || "Workspace updated successfully."); window.dispatchEvent(new CustomEvent('workspace_edited', { - detail: { id, name, description } + detail: updatedWorkspace })); navigate('/workspaces'); } catch (error) { @@ -241,11 +283,11 @@ export default function EditWorkspace() { const handleAddMember = async () => { if (!searchResult || !id) return; try { - const newMembership = await addWorkspaceMembership({ - workspace: id, - user: String(searchResult.id), - role: newMemberRole - }); + const newMembership = await addWorkspaceMembership({ + workspace: id, + user: String(searchResult.id), + role: newMemberRole + }); setMembers([newMembership, ...members]); toast.success(t.workspace?.toast?.successAdd || "Member added successfully."); setSearchQuery(''); @@ -283,27 +325,27 @@ export default function EditWorkspace() { } }; - const canManageMembers = canWorkspace(myRole, WORKSPACE_MEMBERS_CHANGE_ROLE); - const isFirstOwner = currentUserId === workspaceOwnerId; - const isOwner = myRole === "owner"; - - const roleOptions = (allowOwnerRole: boolean, allowAdminRole: boolean) => [ - ...(allowOwnerRole ? [{ value: "owner", label: t.workspace?.roles?.owner || "Owner" }] : []), - ...(allowAdminRole ? [{ value: "admin", label: t.workspace?.roles?.admin || "Admin" }] : []), - { value: "member", label: t.workspace?.roles?.member || "Member" }, - { value: "guest", label: t.workspace?.roles?.guest || "Guest" }, - ]; - - if (isLoading) return
{t.workspace?.loading || "Loading..."}
; + const canManageMembers = canWorkspace(myRole, WORKSPACE_MEMBERS_CHANGE_ROLE); + const isFirstOwner = currentUserId === workspaceOwnerId; + const isOwner = myRole === "owner"; + + const roleOptions = (allowOwnerRole: boolean, allowAdminRole: boolean) => [ + ...(allowOwnerRole ? [{ value: "owner", label: t.workspace?.roles?.owner || "Owner" }] : []), + ...(allowAdminRole ? [{ value: "admin", label: t.workspace?.roles?.admin || "Admin" }] : []), + { value: "member", label: t.workspace?.roles?.member || "Member" }, + { value: "guest", label: t.workspace?.roles?.guest || "Guest" }, + ]; + + if (isLoading) return
{t.workspace?.loading || "Loading..."}
; return ( -
+

{t.workspace?.editTitle || "Edit Workspace"}

-
-
+
+
+
+ + + + + {(thumbnailUrl || thumbnailFile) && ( + + )} +
-
+

{ t.workspace?.members || "Members" }

- {canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && ( -
+ {canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && ( +
handleChangeRole(m.id, val)} - options={roleOptions(isFirstOwner, isOwner)} - buttonClassName="w-[110px] px-3 py-1.5 text-sm" - /> - ) : ( - - {m.role === 'owner' && } - {m.role && m.role in t.workspace.roles - ? t.workspace.roles[m.role as keyof typeof t.workspace.roles] - : m.role || "-"} - - )} - - {canChangeThisUserRole && ( - - )} -
-
- -
-
- {t.rates?.workspaceRate || "Workspace rate"} -
- item.user === m.user.id)} - priceUnits={priceUnits} - onRatesChanged={(updater) => setWorkspaceRates((current) => updater(current))} - /> -
-
- ); - })} +

{toPersianNum(m.user?.mobile)}

+
+
+ +
+ {canChangeThisUserRole ? ( +