add workspace navbar status + creation modal
This commit is contained in:
21
src/App.tsx
21
src/App.tsx
@@ -3,6 +3,7 @@ import { ThemeProvider } from "./components/ThemeProvider"
|
|||||||
import { LanguageProvider } from "./components/LanguageProvider"
|
import { LanguageProvider } from "./components/LanguageProvider"
|
||||||
import { Toaster } from "./components/ui/toaster"
|
import { Toaster } from "./components/ui/toaster"
|
||||||
import { Navbar } from "./components/Navbar"
|
import { Navbar } from "./components/Navbar"
|
||||||
|
import { WorkspaceProvider } from "./context/WorkspaceContext"
|
||||||
import Auth from "./pages/Auth"
|
import Auth from "./pages/Auth"
|
||||||
import Profile from "./pages/Profile"
|
import Profile from "./pages/Profile"
|
||||||
import Terms from "./pages/Terms"
|
import Terms from "./pages/Terms"
|
||||||
@@ -23,15 +24,17 @@ function App() {
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<WorkspaceProvider>
|
||||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
<Routes>
|
||||||
<Route path="/login" element={<Auth />} />
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
<Route path="/terms" element={<Terms />} />
|
<Route path="/login" element={<Auth />} />
|
||||||
|
<Route path="/terms" element={<Terms />} />
|
||||||
<Route element={<MainLayout />}>
|
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route element={<MainLayout />}>
|
||||||
</Route>
|
<Route path="/profile" element={<Profile />} />
|
||||||
</Routes>
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</WorkspaceProvider>
|
||||||
</Router>
|
</Router>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { API_BASE_URL } from "../config/constants";
|
|||||||
|
|
||||||
export const authFetch = async (endpoint: string, options: RequestInit = {}) => {
|
export const authFetch = async (endpoint: string, options: RequestInit = {}) => {
|
||||||
const token = localStorage.getItem("accessToken");
|
const token = localStorage.getItem("accessToken");
|
||||||
|
|
||||||
const isFormData = options.body instanceof FormData;
|
const isFormData = options.body instanceof FormData;
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
@@ -11,7 +10,12 @@ export const authFetch = async (endpoint: string, options: RequestInit = {}) =>
|
|||||||
...options.headers,
|
...options.headers,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
// Safely join URLs preventing double slashes (e.g., "http://api.com//api/..." -> "http://api.com/api/...")
|
||||||
|
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "");
|
||||||
|
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||||||
|
const url = `${cleanBaseUrl}${cleanEndpoint}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,3 +78,22 @@ export const removeProfilePicture = async () => {
|
|||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export interface SearchedUser {
|
||||||
|
id: number | string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
mobile: string;
|
||||||
|
profile_picture: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchUserByExactMobile = async (mobile: string): Promise<SearchedUser | null> => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`/api/users/search/?mobile=${encodeURIComponent(mobile)}`);
|
||||||
|
if (!response.ok) return null; // Returns null on 404 or other errors
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
42
src/api/workspaces.ts
Normal file
42
src/api/workspaces.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { authFetch } from "./client"
|
||||||
|
|
||||||
|
export interface Workspace {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchWorkspaces = async (): Promise<Workspace[]> => {
|
||||||
|
const response = await authFetch("/api/workspaces/")
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch workspaces")
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
// Handles paginated responses if the API returns { count, next, previous, results: [...] }
|
||||||
|
return data.results || data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createWorkspace = async (data: { name: string; description: string; members?: any[] }) => {
|
||||||
|
// 1. Only send name and description to the workspaces endpoint
|
||||||
|
const payload = {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await authFetch('/api/workspaces/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json', // CRITICAL for DRF to parse the string correctly
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Failed to create workspace');
|
||||||
|
}
|
||||||
|
const newWorkspace = await response.json();
|
||||||
|
return newWorkspace;
|
||||||
|
};
|
||||||
264
src/components/CreateWorkspaceModal.tsx
Normal file
264
src/components/CreateWorkspaceModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,8 +3,9 @@ import { useNavigate } from "react-router-dom"
|
|||||||
import { useTranslation } from "../hooks/useTranslation"
|
import { useTranslation } from "../hooks/useTranslation"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { SettingsMenu } from "./SettingsMenu"
|
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 { logoutUser, getUserProfile } from "../api/users"
|
||||||
|
import { WorkspaceSelector } from "./WorkspaceSelector"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
@@ -88,15 +89,14 @@ export function Navbar() {
|
|||||||
className="flex items-center gap-2 cursor-pointer"
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded bg-blue-600 flex items-center justify-center text-white font-bold">
|
<span className="relative z-20 flex items-center gap-2 font-bold text-xl tracking-tight text-slate-900 dark:text-slate-50">
|
||||||
Q
|
<Command className="h-6 w-6" />
|
||||||
</div>
|
|
||||||
<span className="font-bold text-xl tracking-tight text-slate-900 dark:text-slate-50">
|
|
||||||
Qlockify
|
Qlockify
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{user && <WorkspaceSelector />}
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
107
src/components/WorkspaceSelector.tsx
Normal file
107
src/components/WorkspaceSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
129
src/context/WorkspaceContext.tsx
Normal file
129
src/context/WorkspaceContext.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from "react"
|
||||||
|
import { fetchWorkspaces, createWorkspace, type Workspace } from "../api/workspaces"
|
||||||
|
import { useTranslation } from "../hooks/useTranslation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Button } from "../components/ui/button"
|
||||||
|
|
||||||
|
interface WorkspaceContextType {
|
||||||
|
workspaces: Workspace[]
|
||||||
|
activeWorkspace: Workspace | null
|
||||||
|
setActiveWorkspace: (workspace: Workspace) => void
|
||||||
|
addWorkspace: (name: string) => Promise<void>
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceContext = createContext<WorkspaceContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
export const useWorkspace = () => {
|
||||||
|
const context = useContext(WorkspaceContext)
|
||||||
|
if (!context) throw new Error("useWorkspace must be used within a WorkspaceProvider")
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
||||||
|
const [activeWorkspace, setActiveWorkspaceState] = useState<Workspace | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [newWorkspaceName, setNewWorkspaceName] = useState("")
|
||||||
|
const [isCreatingFirst, setIsCreatingFirst] = useState(false)
|
||||||
|
|
||||||
|
const isAuthenticated = !!localStorage.getItem("accessToken")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadWorkspaces = async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchWorkspaces()
|
||||||
|
setWorkspaces(data)
|
||||||
|
|
||||||
|
if (data.length > 0) {
|
||||||
|
const storedId = localStorage.getItem("activeWorkspaceId")
|
||||||
|
const stored = data.find((w) => w.id === storedId)
|
||||||
|
if (stored) {
|
||||||
|
setActiveWorkspaceState(stored)
|
||||||
|
} else {
|
||||||
|
setActiveWorkspaceState(data[0])
|
||||||
|
localStorage.setItem("activeWorkspaceId", data[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadWorkspaces()
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
const setActiveWorkspace = (workspace: Workspace) => {
|
||||||
|
setActiveWorkspaceState(workspace)
|
||||||
|
localStorage.setItem("activeWorkspaceId", workspace.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addWorkspace = async (name: string) => {
|
||||||
|
try {
|
||||||
|
setIsCreatingFirst(true)
|
||||||
|
const newWs = await createWorkspace(name)
|
||||||
|
setWorkspaces((prev) => [...prev, newWs])
|
||||||
|
setActiveWorkspace(newWs)
|
||||||
|
toast.success(t.workspace?.createSuccess || "Workspace created!")
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t.workspace?.createError || "Failed to create workspace")
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
setIsCreatingFirst(false)
|
||||||
|
setNewWorkspaceName("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force workspace creation if authenticated but none exist
|
||||||
|
if (!isLoading && isAuthenticated && workspaces.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4">
|
||||||
|
<div className="w-full max-w-md bg-white dark:bg-slate-800 p-8 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
|
||||||
|
{t.workspace?.noWorkspaceTitle || "Welcome!"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
{t.workspace?.noWorkspaceDesc || "Please create your first workspace."}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
{t.workspace?.nameLabel || "Workspace Name"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newWorkspaceName}
|
||||||
|
onChange={(e) => setNewWorkspaceName(e.target.value)}
|
||||||
|
placeholder={t.workspace?.namePlaceholder || "e.g. My Company"}
|
||||||
|
className="w-full px-4 py-2 rounded-md border border-slate-300 dark:border-slate-600 bg-transparent text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => addWorkspace(newWorkspaceName)}
|
||||||
|
disabled={!newWorkspaceName.trim() || isCreatingFirst}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
>
|
||||||
|
{isCreatingFirst ? "..." : t.workspace?.submit || "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceContext.Provider
|
||||||
|
value={{ workspaces, activeWorkspace, setActiveWorkspace, addWorkspace, isLoading }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</WorkspaceContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,3 +33,24 @@
|
|||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.text-xs {
|
||||||
|
font-size: 0.875rem !important; /* Bumps 12px to 14px */
|
||||||
|
line-height: 1.25rem !important;
|
||||||
|
}
|
||||||
|
.text-sm {
|
||||||
|
font-size: 1rem !important; /* Bumps 14px to 16px */
|
||||||
|
line-height: 1.5rem !important;
|
||||||
|
}
|
||||||
|
.text-base {
|
||||||
|
font-size: 1.125rem !important; /* Bumps 16px to 18px */
|
||||||
|
line-height: 1.75rem !important;
|
||||||
|
}
|
||||||
|
.text-lg {
|
||||||
|
font-size: 1.25rem !important; /* Bumps 18px to 20px */
|
||||||
|
line-height: 1.75rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,4 +102,26 @@ export const en = {
|
|||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
lightMode: "Light Mode",
|
lightMode: "Light Mode",
|
||||||
darkMode: "Dark Mode",
|
darkMode: "Dark Mode",
|
||||||
|
|
||||||
|
workspace: {
|
||||||
|
title: "Workspaces",
|
||||||
|
createNew: "Create New Workspace",
|
||||||
|
nameLabel: "Workspace Name",
|
||||||
|
namePlaceholder: "Enter workspace name",
|
||||||
|
descriptionLabel: "Description",
|
||||||
|
descriptionPlaceholder: "Enter description (optional)",
|
||||||
|
searchMemberPlaceholder: "Search exact mobile (e.g. 09123456789)",
|
||||||
|
addMember: "Add Member",
|
||||||
|
roleAdmin: "Admin",
|
||||||
|
roleMember: "Member",
|
||||||
|
userNotFound: "User not found",
|
||||||
|
userAlreadyAdded: "User already added",
|
||||||
|
inviteMembers: "Invite Members (Exact Mobile Number)",
|
||||||
|
selectedMembers: "Selected Members",
|
||||||
|
creating: "Creating...",
|
||||||
|
submit: "Create",
|
||||||
|
cancel: "Cancel",
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,4 +104,25 @@ export const fa = {
|
|||||||
lightMode: "حالت روشن",
|
lightMode: "حالت روشن",
|
||||||
darkMode: "حالت تاریک",
|
darkMode: "حالت تاریک",
|
||||||
|
|
||||||
|
workspace: {
|
||||||
|
title: "فضاهای کاری",
|
||||||
|
createNew: "ایجاد فضای کاری جدید",
|
||||||
|
nameLabel: "نام فضای کاری",
|
||||||
|
namePlaceholder: "نام فضای کاری را وارد کنید",
|
||||||
|
descriptionLabel: "توضیحات",
|
||||||
|
descriptionPlaceholder: "توضیحات (اختیاری)",
|
||||||
|
searchMemberPlaceholder: "جستجو با موبایل دقیق (مثلا 09123456789)",
|
||||||
|
addMember: "افزودن عضو",
|
||||||
|
roleAdmin: "مدیر",
|
||||||
|
roleMember: "عضو",
|
||||||
|
userNotFound: "کاربر یافت نشد",
|
||||||
|
userAlreadyAdded: "کاربر قبلا اضافه شده است",
|
||||||
|
inviteMembers: "دعوت اعضا (شماره موبایل دقیق)",
|
||||||
|
selectedMembers: "اعضای انتخاب شده",
|
||||||
|
creating: "در حال ایجاد...",
|
||||||
|
submit: "ایجاد",
|
||||||
|
cancel: "انصراف",
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user