replace modals with a centralized component
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { X, Search, Trash2, UserPlus, Loader2, AlertCircle } from "lucide-react";
|
import { Search, Trash2, UserPlus, Loader2, AlertCircle } from "lucide-react";
|
||||||
import { searchUserByExactMobile, type SearchedUser } from "../api/users";
|
import { searchUserByExactMobile, type SearchedUser } from "../api/users";
|
||||||
// Adjust the import path for your useLanguage hook if necessary
|
|
||||||
import { useTranslation } from "../hooks/useTranslation";
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
|
||||||
export interface WorkspaceMemberInput extends SearchedUser {
|
export interface WorkspaceMemberInput extends SearchedUser {
|
||||||
role: "admin" | "member";
|
role: "admin" | "member";
|
||||||
@@ -87,156 +87,13 @@ export const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm transition-opacity" dir={isFa ? "rtl" : "ltr"}>
|
<Modal
|
||||||
<div className="w-full max-w-lg bg-white dark:bg-slate-900 rounded-xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-800">
|
title={t.workspace?.createNew || "Create New Workspace"}
|
||||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-100">
|
isFa={isFa}
|
||||||
{t.workspace?.createNew || "Create New Workspace"}
|
footer={
|
||||||
</h2>
|
<>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1.5 rounded-md text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-5 space-y-5">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
||||||
{t.workspace?.nameLabel || "Workspace Name"} <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg bg-transparent text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder={t.workspace?.namePlaceholder || "e.g. Design Team"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
||||||
{t.workspace?.descriptionLabel || "Description"}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg bg-transparent text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
|
||||||
placeholder={t.workspace?.descriptionPlaceholder || "What is this workspace for?"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 pt-4 border-t border-slate-200 dark:border-slate-800">
|
|
||||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
||||||
{t.workspace?.inviteMembers || "Invite Members (Exact Mobile Number)"}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className={`absolute inset-y-0 ${isFa ? "right-0 pr-3" : "left-0 pl-3"} flex items-center pointer-events-none`}>
|
|
||||||
{isSearching ? (
|
|
||||||
<Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Search className="w-4 h-4 text-slate-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder={t.workspace?.searchMemberPlaceholder || "e.g. 09123456789 or +989123456789"}
|
|
||||||
className={`w-full ${isFa ? "pr-9 pl-3" : "pl-9 pr-3"} py-2 border border-slate-300 dark:border-slate-700 rounded-lg bg-slate-50 dark:bg-slate-800/50 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error State */}
|
|
||||||
{searchError && (
|
|
||||||
<div className="flex items-center gap-2 p-3 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10 rounded-lg border border-red-100 dark:border-red-500/20">
|
|
||||||
<AlertCircle className="w-4 h-4" />
|
|
||||||
{t.workspace?.userNotFound || "No user found with this exact number."}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Success State - Single Result */}
|
|
||||||
{searchResult && (
|
|
||||||
<div className="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg">
|
|
||||||
{searchResult.profile_picture ? (
|
|
||||||
<img src={searchResult.profile_picture} alt={searchResult.first_name} className="w-10 h-10 rounded-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-200 dark:bg-blue-900/50 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm">
|
|
||||||
{searchResult.first_name?.[0] || "U"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
|
||||||
{searchResult.first_name} {searchResult.last_name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate" dir="ltr">
|
|
||||||
{searchResult.mobile}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddMember}
|
|
||||||
disabled={members.some(m => m.id === searchResult.id)}
|
|
||||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-400 text-white text-sm font-medium rounded-md transition-colors flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<UserPlus className="w-4 h-4" />
|
|
||||||
{members.some(m => m.id === searchResult.id)
|
|
||||||
? (t.workspace?.userAlreadyAdded || "Added")
|
|
||||||
: (t.workspace?.addMember || "Add")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Added Members List */}
|
|
||||||
{members.length > 0 && (
|
|
||||||
<div className="space-y-2 mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
|
|
||||||
<h4 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
|
||||||
{t.workspace?.selectedMembers || "Selected Members"}
|
|
||||||
</h4>
|
|
||||||
{members.map((member) => (
|
|
||||||
<div key={member.id} className="flex items-center gap-3 p-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50">
|
|
||||||
{member.profile_picture ? (
|
|
||||||
<img src={member.profile_picture} alt={member.first_name} className="w-8 h-8 rounded-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="w-8 h-8 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-300 font-medium text-sm">
|
|
||||||
{member.first_name?.[0] || "U"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
|
||||||
{member.first_name} {member.last_name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={member.role}
|
|
||||||
onChange={(e) => handleRoleChange(member.id, e.target.value as "admin" | "member")}
|
|
||||||
className="px-2 py-1.5 border border-slate-300 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
|
|
||||||
>
|
|
||||||
<option value="member">{t.workspace?.roleMember || "Member"}</option>
|
|
||||||
<option value="admin">{t.workspace?.roleAdmin || "Admin"}</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRemoveMember(member.id)}
|
|
||||||
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="p-4 border-t border-slate-200 dark:border-slate-800 flex justify-end gap-3 bg-slate-50 dark:bg-slate-800/50">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -252,13 +109,144 @@ export const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
|
|||||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
{isLoading
|
{isLoading ? (t.workspace?.creating || "Creating...") : (t.workspace?.submit || "Create")}
|
||||||
? (t.workspace?.creating || "Creating...")
|
|
||||||
: (t.workspace?.submit || "Create Workspace")}
|
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-5 space-y-5">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{t.workspace?.nameLabel || "Workspace Name"} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg bg-transparent text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder={t.workspace?.namePlaceholder || "e.g. Design Team"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
<div className="space-y-1.5">
|
||||||
</div>
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
);
|
{t.workspace?.descriptionLabel || "Description"}
|
||||||
};
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg bg-transparent text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
placeholder={t.workspace?.descriptionPlaceholder || "What is this workspace for?"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 pt-4 border-t border-slate-200 dark:border-slate-800">
|
||||||
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{t.workspace?.inviteMembers || "Invite Members (Exact Mobile Number)"}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className={`absolute inset-y-0 ${isFa ? "right-0 pr-3" : "left-0 pl-3"} flex items-center pointer-events-none`}>
|
||||||
|
{isSearching ? (
|
||||||
|
<Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="w-4 h-4 text-slate-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t.workspace?.searchMemberPlaceholder || "e.g. 09123456789 or +989123456789"}
|
||||||
|
className={`w-full ${isFa ? "pr-9 pl-3" : "pl-9 pr-3"} py-2 border border-slate-300 dark:border-slate-700 rounded-lg bg-slate-50 dark:bg-slate-800/50 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{searchError && (
|
||||||
|
<div className="flex items-center gap-2 p-3 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10 rounded-lg border border-red-100 dark:border-red-500/20">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
{t.workspace?.userNotFound || "No user found with this exact number."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success State - Single Result */}
|
||||||
|
{searchResult && (
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg">
|
||||||
|
{searchResult.profile_picture ? (
|
||||||
|
<img src={searchResult.profile_picture} alt={searchResult.first_name} className="w-10 h-10 rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-200 dark:bg-blue-900/50 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm">
|
||||||
|
{searchResult.first_name?.[0] || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
||||||
|
{searchResult.first_name} {searchResult.last_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 truncate" dir="ltr">
|
||||||
|
{searchResult.mobile}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddMember}
|
||||||
|
disabled={members.some(m => m.id === searchResult.id)}
|
||||||
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-400 text-white text-sm font-medium rounded-md transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
{members.some(m => m.id === searchResult.id)
|
||||||
|
? (t.workspace?.userAlreadyAdded || "Added")
|
||||||
|
: (t.workspace?.addMember || "Add")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Added Members List */}
|
||||||
|
{members.length > 0 && (
|
||||||
|
<div className="space-y-2 mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
|
||||||
|
<h4 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||||
|
{t.workspace?.selectedMembers || "Selected Members"}
|
||||||
|
</h4>
|
||||||
|
{members.map((member) => (
|
||||||
|
<div key={member.id} className="flex items-center gap-3 p-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50">
|
||||||
|
{member.profile_picture ? (
|
||||||
|
<img src={member.profile_picture} alt={member.first_name} className="w-8 h-8 rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-300 font-medium text-sm">
|
||||||
|
{member.first_name?.[0] || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
||||||
|
{member.first_name} {member.last_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={member.role}
|
||||||
|
onChange={(e) => handleRoleChange(member.id, e.target.value as "admin" | "member")}
|
||||||
|
className="px-2 py-1.5 border border-slate-300 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="member">{t.workspace?.roleMember || "Member"}</option>
|
||||||
|
<option value="admin">{t.workspace?.roleAdmin || "Admin"}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveMember(member.id)}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/Modal.tsx
Normal file
67
src/components/Modal.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
maxWidth?: string;
|
||||||
|
isFa?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Modal: React.FC<ModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
maxWidth = "max-w-lg",
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "unset";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "unset";
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`bg-white dark:bg-slate-900 rounded-2xl shadow-xl w-full ${maxWidth} overflow-hidden`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-100">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-md text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">{children}</div>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<div className="p-4 border-t border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-800/50 shrink-0 flex justify-end gap-3">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,12 +15,21 @@ export function Navbar() {
|
|||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||||
const [user, setUser] = useState<any>(null)
|
const [user, setUser] = useState<any>(null)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isFa = lang === 'fa';
|
||||||
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(() =>
|
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||||
document.documentElement.classList.contains('dark')
|
const savedTheme = localStorage.getItem('theme');
|
||||||
)
|
if (savedTheme) return savedTheme === 'dark';
|
||||||
|
return document.documentElement.classList.contains('dark');
|
||||||
|
});
|
||||||
|
|
||||||
const isFa = lang === 'fa'
|
useEffect(() => {
|
||||||
|
if (isDarkMode) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}, [isDarkMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
@@ -67,10 +76,10 @@ export function Navbar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
const isDark = document.documentElement.classList.toggle('dark')
|
const newThemeState = !isDarkMode;
|
||||||
setIsDarkMode(isDark)
|
setIsDarkMode(newThemeState);
|
||||||
localStorage.setItem('theme', isDark ? 'dark' : 'light')
|
localStorage.setItem('theme', newThemeState ? 'dark' : 'light');
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
const toggleLanguage = () => {
|
||||||
const newLang = isFa ? 'en' : 'fa'
|
const newLang = isFa ? 'en' : 'fa'
|
||||||
@@ -84,13 +93,13 @@ export function Navbar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-6 py-4 flex items-center justify-between transition-colors">
|
<header className="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-8 py-6 flex items-center justify-between transition-colors">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 cursor-pointer"
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
>
|
>
|
||||||
<span className="relative z-20 flex items-center gap-2 font-bold text-xl tracking-tight text-slate-900 dark:text-slate-50">
|
<span className="relative z-20 flex items-center gap-2 font-bold text-xl tracking-tight text-slate-900 dark:text-slate-50">
|
||||||
<Command className="h-6 w-6" />
|
<Command className="h-7 w-7" />
|
||||||
Qlockify
|
Qlockify
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +110,7 @@ export function Navbar() {
|
|||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
className="w-10 h-10 rounded-full overflow-hidden border-2 border-slate-200 dark:border-slate-700 hover:border-blue-500 dark:hover:border-blue-500 transition-all focus:outline-none"
|
className="w-12 h-12 rounded-full overflow-hidden border-2 border-slate-200 dark:border-slate-700 hover:border-blue-500 dark:hover:border-blue-500 transition-all focus:outline-none"
|
||||||
>
|
>
|
||||||
{user.profile_picture ? (
|
{user.profile_picture ? (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Button } from "../components/ui/button"
|
|||||||
import { Camera, Edit2, Trash2, User as UserIcon, UploadCloud } from "lucide-react"
|
import { Camera, Edit2, Trash2, User as UserIcon, UploadCloud } from "lucide-react"
|
||||||
import JalaliDatePicker from "../components/ui/JalaliDatePicker"
|
import JalaliDatePicker from "../components/ui/JalaliDatePicker"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { Modal } from "../components/Modal"
|
||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -261,18 +262,33 @@ export default function Profile() {
|
|||||||
|
|
||||||
{/* Edit Profile Modal */}
|
{/* Edit Profile Modal */}
|
||||||
{isEditModalOpen && (
|
{isEditModalOpen && (
|
||||||
<div
|
<Modal
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm px-4"
|
isOpen={isEditModalOpen}
|
||||||
onClick={() => !isSaving && setIsEditModalOpen(false)}
|
isFa={isFa}
|
||||||
|
onClose={() => setIsEditModalOpen(false)}
|
||||||
|
title={t.profile?.title || "Edit Profile"}
|
||||||
|
maxWidth="max-w-lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditModalOpen(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg"
|
||||||
|
>
|
||||||
|
{t.cancel || "Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveProfile}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : t.profile?.save || "Save"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div className="space-y-4">
|
||||||
className="w-full max-w-lg rounded-xl bg-white p-6 shadow-lg dark:bg-slate-900 border dark:border-slate-800"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h2 className="mb-4 text-xl font-bold text-slate-900 dark:text-white">
|
|
||||||
{t.profile?.editInfo || 'Edit Profile'}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -323,98 +339,94 @@ export default function Profile() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end gap-3">
|
|
||||||
<Button variant="outline" onClick={() => setIsEditModalOpen(false)} disabled={isSaving}>
|
|
||||||
{t.profile?.cancel || t.cancel || 'Cancel'}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSaveProfile} disabled={isSaving}>
|
|
||||||
{isSaving ? '...' : (t.profile?.save || 'Save')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Profile Picture Modal */}
|
{/* Profile Picture Modal */}
|
||||||
|
|
||||||
{isPicModalOpen && (
|
{isPicModalOpen && (
|
||||||
<div
|
<Modal
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm px-4"
|
isOpen={isPicModalOpen}
|
||||||
onClick={() => !isSaving && setIsPicModalOpen(false)}
|
onClose={() => !isSaving && setIsPicModalOpen(false)}
|
||||||
>
|
title={t.profile?.changePicture || 'Profile Picture'}
|
||||||
<div
|
maxWidth="max-w-sm"
|
||||||
className="w-full max-w-sm rounded-xl bg-white p-6 shadow-lg dark:bg-slate-900 border dark:border-slate-800"
|
footer={
|
||||||
onClick={(e) => e.stopPropagation()}
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsPicModalOpen(false)}
|
||||||
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
<h2 className="mb-4 text-xl font-bold text-slate-900 dark:text-white">
|
{t.profile?.cancel || t.cancel || 'Cancel'}
|
||||||
{t.profile?.changePicture || 'Profile Picture'}
|
</Button>
|
||||||
</h2>
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Drag and Drop Zone */}
|
||||||
|
<div
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={`relative flex flex-col items-center justify-center w-full h-48 border-2 border-dashed rounded-xl cursor-pointer transition-colors ${
|
||||||
|
dragActive
|
||||||
|
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
|
||||||
|
: "border-slate-300 dark:border-slate-700 bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-800/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{selectedFile ? (
|
||||||
|
<div className="absolute inset-0 p-2">
|
||||||
{/* Drag and Drop Zone */}
|
<img
|
||||||
<div
|
src={URL.createObjectURL(selectedFile)}
|
||||||
onDragEnter={handleDrag}
|
alt="Preview"
|
||||||
onDragLeave={handleDrag}
|
className="w-full h-full object-contain rounded-lg"
|
||||||
onDragOver={handleDrag}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className={`relative flex flex-col items-center justify-center w-full h-48 border-2 border-dashed rounded-xl cursor-pointer transition-colors ${
|
|
||||||
dragActive
|
|
||||||
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
|
|
||||||
: "border-slate-300 dark:border-slate-700 bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-800/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedFile ? (
|
|
||||||
<div className="absolute inset-0 p-2">
|
|
||||||
<img
|
|
||||||
src={URL.createObjectURL(selectedFile)}
|
|
||||||
alt="Preview"
|
|
||||||
className="w-full h-full object-contain rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center text-center p-4">
|
|
||||||
<UploadCloud className="h-10 w-10 text-slate-400 mb-2" />
|
|
||||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
||||||
{ t.profile?.imageInput }
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
|
||||||
SVG, PNG, JPG (MAX. 800x400px)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex flex-col gap-2 mt-4">
|
<div className="flex flex-col items-center text-center p-4">
|
||||||
<Button onClick={handlePictureUpload} disabled={!selectedFile || isSaving} className="w-full">
|
<UploadCloud className="h-10 w-10 text-slate-400 mb-2" />
|
||||||
{t.profile?.upload || 'Upload'}
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
</Button>
|
{ t.profile?.imageInput }
|
||||||
|
</p>
|
||||||
{user.profile_picture && (
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
<Button variant="destructive" onClick={handleDeletePicture} disabled={isSaving} className="w-full flex items-center justify-center gap-2">
|
SVG, PNG, JPG (MAX. 800x400px)
|
||||||
<Trash2 className="h-4 w-4" />
|
</p>
|
||||||
{t.profile?.remove || "Remove"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
<div className="flex flex-col gap-2 mt-4">
|
||||||
<Button variant="outline" onClick={() => setIsPicModalOpen(false)} disabled={isSaving}>
|
<Button
|
||||||
{t.profile?.cancel || t.cancel || 'Cancel'}
|
onClick={handlePictureUpload}
|
||||||
|
disabled={!selectedFile || isSaving}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t.profile?.upload || 'Upload'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{user?.profile_picture && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeletePicture}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="w-full flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{t.profile?.remove || "Remove"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user