add workspace navbar status + creation modal

This commit is contained in:
2026-03-12 09:20:25 +08:00
parent bc099512db
commit 94489a7769
11 changed files with 648 additions and 16 deletions

View File

@@ -0,0 +1,264 @@
import React, { useState, useEffect } from "react";
import { X, Search, Trash2, UserPlus, Loader2, AlertCircle } from "lucide-react";
import { searchUserByExactMobile, type SearchedUser } from "../api/users";
// Adjust the import path for your useLanguage hook if necessary
import { useTranslation } from "../hooks/useTranslation";
export interface WorkspaceMemberInput extends SearchedUser {
role: "admin" | "member";
}
interface CreateWorkspaceModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: { name: string; description: string; members: { user_id: string | number; role: string }[] }) => void;
isLoading?: boolean;
}
export const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
isOpen,
onClose,
onSubmit,
isLoading = false,
}) => {
const { t, lang } = useTranslation();
const isFa = lang === "fa";
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [members, setMembers] = useState<WorkspaceMemberInput[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [searchResult, setSearchResult] = useState<SearchedUser | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [searchError, setSearchError] = useState(false);
useEffect(() => {
if (searchQuery.trim().length < 11) {
setSearchResult(null);
setSearchError(false);
return;
}
const delayDebounceFn = setTimeout(async () => {
setIsSearching(true);
setSearchError(false);
const user = await searchUserByExactMobile(searchQuery.trim());
if (user) {
setSearchResult(user);
} else {
setSearchResult(null);
setSearchError(true);
}
setIsSearching(false);
}, 500);
return () => clearTimeout(delayDebounceFn);
}, [searchQuery]);
const handleAddMember = () => {
if (!searchResult || members.some((m) => m.id === searchResult.id)) return;
setMembers([...members, { ...searchResult, role: "member" }]);
setSearchQuery("");
setSearchResult(null);
setSearchError(false);
};
const handleRemoveMember = (id: string | number) => {
setMembers(members.filter((m) => m.id !== id));
};
const handleRoleChange = (id: string | number, role: "admin" | "member") => {
setMembers(members.map((m) => (m.id === id ? { ...m, role } : m)));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
name,
description,
members: members.map((m) => ({ user_id: m.id, role: m.role })),
});
};
if (!isOpen) return null;
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"}>
<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]">
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-800">
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-100">
{t.workspace?.createNew || "Create New Workspace"}
</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
type="button"
onClick={onClose}
disabled={isLoading}
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 transition-colors disabled:opacity-50"
>
{t.workspace?.cancel || "Cancel"}
</button>
<button
type="submit"
onClick={handleSubmit}
disabled={isLoading || !name.trim()}
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
? (t.workspace?.creating || "Creating...")
: (t.workspace?.submit || "Create Workspace")}
</button>
</div>
</div>
</div>
);
};

View File

@@ -3,8 +3,9 @@ import { useNavigate } from "react-router-dom"
import { useTranslation } from "../hooks/useTranslation"
import { Button } from "./ui/button"
import { SettingsMenu } from "./SettingsMenu"
import { LogOut, User, Moon, Sun, Globe } from "lucide-react"
import { LogOut, User, Moon, Sun, Globe, Command } from "lucide-react"
import { logoutUser, getUserProfile } from "../api/users"
import { WorkspaceSelector } from "./WorkspaceSelector"
import { toast } from "sonner"
export function Navbar() {
@@ -88,15 +89,14 @@ export function Navbar() {
className="flex items-center gap-2 cursor-pointer"
onClick={() => navigate("/")}
>
<div className="w-8 h-8 rounded bg-blue-600 flex items-center justify-center text-white font-bold">
Q
</div>
<span className="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" />
Qlockify
</span>
</div>
<div className="flex items-center gap-4">
{user && <WorkspaceSelector />}
{user ? (
<div className="relative" ref={dropdownRef}>
<button

View File

@@ -0,0 +1,107 @@
import React, { useState, useRef, useEffect } from "react";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import { Check, ChevronDown, Plus, Briefcase } from "lucide-react";
import { CreateWorkspaceModal } from "./CreateWorkspaceModal"; // Adjust path if needed
export const WorkspaceSelector: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { workspaces, activeWorkspace, setActiveWorkspace, addWorkspace } = useWorkspace();
const { t, lang } = useTranslation();
const isFa = lang === "fa";
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleCreateWorkspace = async (data: { name: string; description: string; members: any[] }) => {
await addWorkspace(data);
setIsCreateModalOpen(false);
};
return (
<div className="relative" ref={dropdownRef}>
{/* Selector Button */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-slate-700 dark:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<div className="w-6 h-6 flex items-center justify-center bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 rounded-md">
{activeWorkspace?.name?.charAt(0) || <Briefcase className="w-4 h-4" />}
</div>
<span className="max-w-30 truncate">
{activeWorkspace?.name || t.workspace?.title || "Workspaces"}
</span>
<ChevronDown className="w-4 h-4 text-slate-400" />
</button>
{/* Dropdown Menu */}
{isOpen && (
<div
className={`absolute top-full mt-2 w-64 bg-white dark:bg-slate-900 rounded-xl shadow-lg border border-slate-200 dark:border-slate-800 py-2 z-40 ${
isFa ? "left-0" : "right-0"
}`}
>
<div className="px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
{t.workspace?.title || "Workspaces"}
</div>
<div className="max-h-60 overflow-y-auto">
{workspaces.map((ws) => (
<button
key={ws.id}
type="button"
onClick={() => {
setActiveWorkspace(ws);
setIsOpen(false);
}}
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"
>
<div className="flex items-center gap-2 truncate">
<div className="w-6 h-6 flex items-center justify-center bg-slate-100 dark:bg-slate-800 rounded-md font-medium">
{ws.name.charAt(0)}
</div>
<span className="truncate">{ws.name}</span>
</div>
{activeWorkspace?.id === ws.id && (
<Check className="w-4 h-4 text-blue-500 shrink-0" />
)}
</button>
))}
</div>
<div className="h-px bg-slate-200 dark:bg-slate-800 my-2" />
<button
type="button"
onClick={() => {
setIsOpen(false);
setIsCreateModalOpen(true);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors"
>
<Plus className="w-4 h-4" />
{t.workspace?.createNew || "Create New Workspace"}
</button>
</div>
)}
{/* Advanced Create Workspace Modal */}
<CreateWorkspaceModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSubmit={handleCreateWorkspace}
/>
</div>
);
};