CoolFace
Apppublic

Leon4gr45/builder

sourceHugging Facemitupdated 2d agoView on Hugging Face
0likes
workspaces-view.tsx679 linesDownload Raw Back to views
1'use client';2 3import React, { useState, useEffect, useMemo } from 'react';4import { getLoginUrl } from '@/lib/config/storage';5import {6  Building2,7  Plus,8  Search,9  MoreHorizontal,10  Trash2,11  Pencil,12  ChevronRight,13  ChevronDown,14  UserPlus,15  UserMinus,16} from 'lucide-react';17import { Button } from '@/components/ui/button';18import { Spinner } from '@/components/ui/spinner';19import { Input } from '@/components/ui/input';20import { Badge } from '@/components/ui/badge';21import { Label } from '@/components/ui/label';22import {23  Dialog,24  DialogContent,25  DialogHeader,26  DialogTitle,27  DialogFooter,28} from '@/components/ui/dialog';29import {30  DropdownMenu,31  DropdownMenuContent,32  DropdownMenuItem,33  DropdownMenuTrigger,34} from '@/components/ui/dropdown-menu';35import { toast } from 'sonner';36import { logger } from '@/lib/utils';37 38interface WorkspaceMember {39  userId: string;40  email: string;41  displayName: string | null;42  role: string;43  joinedAt: string;44}45 46interface WorkspaceInfo {47  id: string;48  name: string;49  ownerId: string;50  ownerEmail: string | null;51  maxProjects: number;52  maxDeployments: number;53  maxStorageMb: number;54  memberCount: number;55  projectCount: number;56  deploymentCount: number;57  createdAt: string;58  updatedAt: string;59}60 61interface WorkspaceDetail extends WorkspaceInfo {62  members: WorkspaceMember[];63}64 65export function WorkspacesView() {66  const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([]);67  const [loading, setLoading] = useState(true);68  const [searchQuery, setSearchQuery] = useState('');69  const [saving, setSaving] = useState(false);70 71  // Edit dialog72  const [editWorkspace, setEditWorkspace] = useState<WorkspaceInfo | null>(null);73  const [showEditDialog, setShowEditDialog] = useState(false);74  const [editForm, setEditForm] = useState({75    name: '',76    maxProjects: 3,77    maxDeployments: 1,78    maxStorageMb: 100,79  });80 81  // Create dialog82  const [showCreateDialog, setShowCreateDialog] = useState(false);83  const [createForm, setCreateForm] = useState({84    name: '',85    ownerEmail: '',86  });87 88  // Expand / member detail89  const [expandedId, setExpandedId] = useState<string | null>(null);90  const [workspaceDetail, setWorkspaceDetail] = useState<WorkspaceDetail | null>(null);91  const [loadingDetail, setLoadingDetail] = useState(false);92 93  // Add member dialog94  const [showAddMemberDialog, setShowAddMemberDialog] = useState(false);95  const [addMemberForm, setAddMemberForm] = useState({ email: '', role: 'editor' });96  const [addingMember, setAddingMember] = useState(false);97 98  const isServerMode = process.env.NEXT_PUBLIC_SERVER_MODE === 'true';99 100  useEffect(() => {101    loadWorkspaces();102  }, []);103 104  const loadWorkspaces = async () => {105    setLoading(true);106    try {107      const res = await fetch('/api/admin/workspaces');108      if (res.status === 401) {109        window.location.href = getLoginUrl();110        return;111      }112      if (!res.ok) throw new Error('Failed to load workspaces');113      const data = await res.json();114      setWorkspaces(data.workspaces);115    } catch (err) {116      logger.error('[WorkspacesView] Failed to load workspaces:', err);117      toast.error('Failed to load workspaces');118    } finally {119      setLoading(false);120    }121  };122 123  const handleOpenEdit = (ws: WorkspaceInfo) => {124    setEditWorkspace(ws);125    setEditForm({126      name: ws.name,127      maxProjects: ws.maxProjects,128      maxDeployments: ws.maxDeployments,129      maxStorageMb: ws.maxStorageMb,130    });131    setShowEditDialog(true);132  };133 134  const handleSaveEdit = async () => {135    if (!editWorkspace) return;136    setSaving(true);137    try {138      const res = await fetch(`/api/admin/workspaces/${editWorkspace.id}`, {139        method: 'PUT',140        headers: { 'Content-Type': 'application/json' },141        body: JSON.stringify({142          name: editForm.name || undefined,143          maxProjects: editForm.maxProjects,144          maxDeployments: editForm.maxDeployments,145          maxStorageMb: editForm.maxStorageMb,146        }),147      });148      if (!res.ok) {149        const error = await res.json();150        throw new Error(error.error || 'Failed to update workspace');151      }152      toast.success('Workspace updated');153      setShowEditDialog(false);154      setEditWorkspace(null);155      await loadWorkspaces();156      // Refresh detail if expanded157      if (expandedId === editWorkspace.id) {158        await loadDetail(editWorkspace.id);159      }160    } catch (err) {161      logger.error('[WorkspacesView] Failed to update workspace:', err);162      toast.error(err instanceof Error ? err.message : 'Failed to update workspace');163    } finally {164      setSaving(false);165    }166  };167 168  const handleCreate = async () => {169    if (!createForm.name || !createForm.ownerEmail) {170      toast.error('Workspace name and owner email are required');171      return;172    }173    setSaving(true);174    try {175      const res = await fetch('/api/admin/workspaces', {176        method: 'POST',177        headers: { 'Content-Type': 'application/json' },178        body: JSON.stringify({179          name: createForm.name,180          ownerEmail: createForm.ownerEmail,181        }),182      });183      if (!res.ok) {184        const error = await res.json();185        throw new Error(error.error || 'Failed to create workspace');186      }187      toast.success('Workspace created');188      setShowCreateDialog(false);189      setCreateForm({ name: '', ownerEmail: '' });190      await loadWorkspaces();191    } catch (err) {192      logger.error('[WorkspacesView] Failed to create workspace:', err);193      toast.error(err instanceof Error ? err.message : 'Failed to create workspace');194    } finally {195      setSaving(false);196    }197  };198 199  const handleDelete = async (ws: WorkspaceInfo) => {200    if (!confirm(`Delete workspace "${ws.name}"? This cannot be undone.`)) return;201    try {202      const res = await fetch(`/api/admin/workspaces/${ws.id}`, { method: 'DELETE' });203      if (!res.ok) {204        const error = await res.json();205        throw new Error(error.error || 'Failed to delete workspace');206      }207      toast.success('Workspace deleted');208      if (expandedId === ws.id) {209        setExpandedId(null);210        setWorkspaceDetail(null);211      }212      await loadWorkspaces();213    } catch (err) {214      logger.error('[WorkspacesView] Failed to delete workspace:', err);215      toast.error(err instanceof Error ? err.message : 'Failed to delete workspace');216    }217  };218 219  const loadDetail = async (wsId: string) => {220    setLoadingDetail(true);221    try {222      const res = await fetch(`/api/admin/workspaces/${wsId}`);223      if (!res.ok) throw new Error('Failed to load workspace details');224      const data = await res.json();225      setWorkspaceDetail(data);226    } catch (err) {227      logger.error('[WorkspacesView] Failed to load workspace details:', err);228      toast.error('Failed to load workspace details');229      setExpandedId(null);230    } finally {231      setLoadingDetail(false);232    }233  };234 235  const handleToggleExpand = async (wsId: string) => {236    if (expandedId === wsId) {237      setExpandedId(null);238      setWorkspaceDetail(null);239      return;240    }241    setExpandedId(wsId);242    await loadDetail(wsId);243  };244 245  const handleAddMember = async () => {246    if (!expandedId || !addMemberForm.email) {247      toast.error('Email is required');248      return;249    }250    setAddingMember(true);251    try {252      const res = await fetch(`/api/admin/workspaces/${expandedId}/access`, {253        method: 'POST',254        headers: { 'Content-Type': 'application/json' },255        body: JSON.stringify({ email: addMemberForm.email, role: addMemberForm.role }),256      });257      if (!res.ok) {258        const error = await res.json();259        throw new Error(error.error || 'Failed to add member');260      }261      toast.success('Member added');262      setShowAddMemberDialog(false);263      setAddMemberForm({ email: '', role: 'editor' });264      await loadDetail(expandedId);265      await loadWorkspaces();266    } catch (err) {267      logger.error('[WorkspacesView] Failed to add member:', err);268      toast.error(err instanceof Error ? err.message : 'Failed to add member');269    } finally {270      setAddingMember(false);271    }272  };273 274  const handleRemoveMember = async (wsId: string, userId: string, memberEmail: string) => {275    if (!confirm(`Remove ${memberEmail} from this workspace?`)) return;276    try {277      const res = await fetch(`/api/admin/workspaces/${wsId}/access`, {278        method: 'DELETE',279        headers: { 'Content-Type': 'application/json' },280        body: JSON.stringify({ userId }),281      });282      if (!res.ok) {283        const error = await res.json();284        throw new Error(error.error || 'Failed to remove member');285      }286      toast.success('Member removed');287      await loadDetail(wsId);288      await loadWorkspaces();289    } catch (err) {290      logger.error('[WorkspacesView] Failed to remove member:', err);291      toast.error(err instanceof Error ? err.message : 'Failed to remove member');292    }293  };294 295  const filteredWorkspaces = useMemo(() => {296    if (!searchQuery) return workspaces;297    const query = searchQuery.toLowerCase();298    return workspaces.filter(299      ws =>300        ws.name.toLowerCase().includes(query) ||301        ws.ownerEmail?.toLowerCase().includes(query)302    );303  }, [workspaces, searchQuery]);304 305  const formatDate = (dateStr: string) => {306    try {307      return new Date(dateStr).toLocaleDateString(undefined, {308        year: 'numeric',309        month: 'short',310        day: 'numeric',311      });312    } catch {313      return dateStr;314    }315  };316 317  const roleBadgeVariant = (role: string): 'default' | 'secondary' | 'outline' => {318    switch (role) {319      case 'owner': return 'default';320      case 'editor': return 'secondary';321      default: return 'outline';322    }323  };324 325  if (!isServerMode) {326    return (327      <div className="h-full flex items-center justify-center">328        <div className="text-center text-muted-foreground">329          <p>Workspace management is only available in Server Mode</p>330        </div>331      </div>332    );333  }334 335  if (loading) {336    return (337      <div className="h-full flex items-center justify-center">338        <div className="text-center">339          <Spinner size={48} color="#f97316" className="mx-auto" />340          <p className="mt-4">Loading workspaces...</p>341        </div>342      </div>343    );344  }345 346  return (347    <>348      <div className="h-full flex flex-col">349        {/* Toolbar */}350        <div className="pt-4 px-4 pb-3 sm:pt-6 sm:px-6 sm:pb-3 shrink-0">351          <div className="mx-auto max-w-7xl flex flex-col sm:flex-row gap-3">352            <div className="relative flex-1">353              <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />354              <Input355                placeholder="Search workspaces..."356                value={searchQuery}357                onChange={(e) => setSearchQuery(e.target.value)}358                className="pl-9"359              />360            </div>361            <div className="flex items-center gap-2">362              <Button onClick={() => setShowCreateDialog(true)} size="sm" className="gap-2">363                <Plus className="h-4 w-4" />364                <span>New Workspace</span>365              </Button>366            </div>367          </div>368        </div>369 370        {/* Workspace List */}371        <div className="flex-1 px-4 pt-3 pb-4 sm:px-6 sm:pt-3 sm:pb-6 overflow-auto">372          <div className="mx-auto max-w-7xl">373            {filteredWorkspaces.length === 0 ? (374              <div className="flex flex-col items-center justify-center py-16 text-center">375                <Building2 className="h-16 w-16 text-muted-foreground mb-4" />376                {workspaces.length === 0 ? (377                  <>378                    <h2 className="text-xl font-semibold mb-2">No Workspaces Yet</h2>379                    <p className="text-muted-foreground mb-4 max-w-md">380                      Create your first workspace by clicking the &quot;New Workspace&quot; button above.381                    </p>382                  </>383                ) : (384                  <>385                    <h2 className="text-xl font-semibold mb-2">No workspaces found</h2>386                    <p className="text-muted-foreground mb-4 max-w-md">387                      Try adjusting your search criteria388                    </p>389                  </>390                )}391              </div>392            ) : (393              <div className="space-y-2">394                {filteredWorkspaces.map((ws) => {395                  const isExpanded = expandedId === ws.id;396                  return (397                    <div key={ws.id} className="rounded-lg border bg-card overflow-hidden">398                      <div399                        className="flex items-center gap-4 p-4 hover:bg-accent/50 transition-colors cursor-pointer"400                        onClick={() => handleToggleExpand(ws.id)}401                      >402                        {/* Expand chevron */}403                        <div className="shrink-0 text-muted-foreground">404                          {isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}405                        </div>406 407                        {/* Workspace Info */}408                        <div className="flex-1 min-w-0">409                          <div className="flex items-center gap-2 flex-wrap">410                            <span className="font-medium truncate">{ws.name}</span>411                          </div>412                          <div className="text-sm text-muted-foreground mt-1 flex items-center gap-3 flex-wrap">413                            {ws.ownerEmail && (414                              <span>Owner: {ws.ownerEmail}</span>415                            )}416                            <span>{ws.memberCount} {ws.memberCount === 1 ? 'member' : 'members'}</span>417                            <span>{ws.projectCount} {ws.projectCount === 1 ? 'project' : 'projects'}</span>418                            <span>Created {formatDate(ws.createdAt)}</span>419                          </div>420                        </div>421 422                        {/* Actions */}423                        <DropdownMenu>424                          <DropdownMenuTrigger asChild>425                            <Button426                              variant="ghost"427                              size="sm"428                              className="h-8 w-8 p-0"429                              onClick={(e) => e.stopPropagation()}430                            >431                              <MoreHorizontal className="h-4 w-4" />432                            </Button>433                          </DropdownMenuTrigger>434                          <DropdownMenuContent align="end">435                            <DropdownMenuItem onClick={() => handleOpenEdit(ws)}>436                              <Pencil className="h-4 w-4 mr-2" />437                              Edit438                            </DropdownMenuItem>439                            <DropdownMenuItem440                              onClick={() => handleDelete(ws)}441                              className="text-destructive focus:text-destructive"442                            >443                              <Trash2 className="h-4 w-4 mr-2" />444                              Delete445                            </DropdownMenuItem>446                          </DropdownMenuContent>447                        </DropdownMenu>448                      </div>449 450                      {/* Expanded members section */}451                      {isExpanded && (452                        <div className="border-t bg-muted/30 px-4 py-3 pl-12">453                          {loadingDetail ? (454                            <div className="flex items-center gap-2 text-sm text-muted-foreground py-2">455                              <Spinner size={16} color="#f97316" />456                              <span>Loading members...</span>457                            </div>458                          ) : workspaceDetail ? (459                            <div className="space-y-3">460                              {/* Stats row */}461                              <div className="flex items-center gap-4 text-xs text-muted-foreground">462                                <span>Max projects: {workspaceDetail.maxProjects}</span>463                                <span>Max deployments: {workspaceDetail.maxDeployments}</span>464                                <span>Max storage: {workspaceDetail.maxStorageMb} MB</span>465                                <span>Deployments: {workspaceDetail.deploymentCount}</span>466                              </div>467 468                              {/* Members */}469                              <div>470                                <div className="flex items-center justify-between mb-2">471                                  <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">472                                    Members ({workspaceDetail.members.length})473                                  </div>474                                  <Button475                                    size="sm"476                                    variant="outline"477                                    className="h-7 text-xs gap-1"478                                    onClick={(e) => {479                                      e.stopPropagation();480                                      setShowAddMemberDialog(true);481                                    }}482                                  >483                                    <UserPlus className="h-3 w-3" />484                                    Add Member485                                  </Button>486                                </div>487                                {workspaceDetail.members.length > 0 ? (488                                  <div className="space-y-1">489                                    {workspaceDetail.members.map((member) => (490                                      <div491                                        key={member.userId}492                                        className="flex items-center gap-3 text-sm p-2 rounded bg-background/60"493                                        onClick={(e) => e.stopPropagation()}494                                      >495                                        <div className="flex-1 min-w-0">496                                          <div className="flex items-center gap-2 flex-wrap">497                                            <span className="font-medium truncate">{member.email}</span>498                                            <Badge variant={roleBadgeVariant(member.role)} className="text-xs">499                                              {member.role}500                                            </Badge>501                                          </div>502                                          {member.displayName && (503                                            <div className="text-xs text-muted-foreground">{member.displayName}</div>504                                          )}505                                        </div>506                                        <div className="text-xs text-muted-foreground shrink-0">507                                          {formatDate(member.joinedAt)}508                                        </div>509                                        {member.userId !== workspaceDetail.ownerId && (510                                          <Button511                                            variant="ghost"512                                            size="sm"513                                            className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"514                                            onClick={(e) => {515                                              e.stopPropagation();516                                              handleRemoveMember(ws.id, member.userId, member.email);517                                            }}518                                            title="Remove member"519                                          >520                                            <UserMinus className="h-3.5 w-3.5" />521                                          </Button>522                                        )}523                                      </div>524                                    ))}525                                  </div>526                                ) : (527                                  <div className="text-sm text-muted-foreground py-1">No members</div>528                                )}529                              </div>530                            </div>531                          ) : null}532                        </div>533                      )}534                    </div>535                  );536                })}537              </div>538            )}539          </div>540        </div>541      </div>542 543      {/* Edit Workspace Dialog */}544      <Dialog open={showEditDialog} onOpenChange={(open) => { setShowEditDialog(open); if (!open) setEditWorkspace(null); }}>545        <DialogContent className="sm:max-w-md">546          <DialogHeader>547            <DialogTitle>Edit Workspace</DialogTitle>548          </DialogHeader>549          <div className="space-y-4 py-4">550            <div className="space-y-2">551              <Label htmlFor="edit-name">Name</Label>552              <Input553                id="edit-name"554                value={editForm.name}555                onChange={(e) => setEditForm(f => ({ ...f, name: e.target.value }))}556                placeholder="Workspace name"557              />558            </div>559            <div className="grid grid-cols-3 gap-3">560              <div className="space-y-2">561                <Label htmlFor="edit-maxProjects">Max Projects</Label>562                <Input563                  id="edit-maxProjects"564                  type="number"565                  min={0}566                  value={editForm.maxProjects}567                  onChange={(e) => setEditForm(f => ({ ...f, maxProjects: Number(e.target.value) }))}568                />569              </div>570              <div className="space-y-2">571                <Label htmlFor="edit-maxDeployments">Max Deploys</Label>572                <Input573                  id="edit-maxDeployments"574                  type="number"575                  min={0}576                  value={editForm.maxDeployments}577                  onChange={(e) => setEditForm(f => ({ ...f, maxDeployments: Number(e.target.value) }))}578                />579              </div>580              <div className="space-y-2">581                <Label htmlFor="edit-maxStorage">Storage (MB)</Label>582                <Input583                  id="edit-maxStorage"584                  type="number"585                  min={0}586                  value={editForm.maxStorageMb}587                  onChange={(e) => setEditForm(f => ({ ...f, maxStorageMb: Number(e.target.value) }))}588                />589              </div>590            </div>591          </div>592          <DialogFooter>593            <Button variant="outline" onClick={() => { setShowEditDialog(false); setEditWorkspace(null); }}>Cancel</Button>594            <Button onClick={handleSaveEdit} disabled={saving}>595              {saving ? 'Saving...' : 'Save Changes'}596            </Button>597          </DialogFooter>598        </DialogContent>599      </Dialog>600 601      {/* Create Workspace Dialog */}602      <Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>603        <DialogContent className="sm:max-w-md">604          <DialogHeader>605            <DialogTitle>Create Workspace</DialogTitle>606          </DialogHeader>607          <div className="space-y-4 py-4">608            <div className="space-y-2">609              <Label htmlFor="create-name">Name</Label>610              <Input611                id="create-name"612                value={createForm.name}613                onChange={(e) => setCreateForm(f => ({ ...f, name: e.target.value }))}614                placeholder="Workspace name"615              />616            </div>617            <div className="space-y-2">618              <Label htmlFor="create-ownerEmail">Owner Email</Label>619              <Input620                id="create-ownerEmail"621                type="email"622                value={createForm.ownerEmail}623                onChange={(e) => setCreateForm(f => ({ ...f, ownerEmail: e.target.value }))}624                placeholder="owner@example.com"625              />626            </div>627          </div>628          <DialogFooter>629            <Button variant="outline" onClick={() => setShowCreateDialog(false)}>Cancel</Button>630            <Button onClick={handleCreate} disabled={saving}>631              {saving ? 'Creating...' : 'Create Workspace'}632            </Button>633          </DialogFooter>634        </DialogContent>635      </Dialog>636 637      {/* Add Member Dialog */}638      <Dialog open={showAddMemberDialog} onOpenChange={setShowAddMemberDialog}>639        <DialogContent className="sm:max-w-md">640          <DialogHeader>641            <DialogTitle>Add Member</DialogTitle>642          </DialogHeader>643          <div className="space-y-4 py-4">644            <div className="space-y-2">645              <Label htmlFor="add-member-email">User Email</Label>646              <Input647                id="add-member-email"648                type="email"649                value={addMemberForm.email}650                onChange={(e) => setAddMemberForm(f => ({ ...f, email: e.target.value }))}651                placeholder="user@example.com"652              />653            </div>654            <div className="space-y-2">655              <Label htmlFor="add-member-role">Role</Label>656              <select657                id="add-member-role"658                className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"659                value={addMemberForm.role}660                onChange={(e) => setAddMemberForm(f => ({ ...f, role: e.target.value }))}661              >662                <option value="viewer">viewer</option>663                <option value="editor">editor</option>664                <option value="owner">owner</option>665              </select>666            </div>667          </div>668          <DialogFooter>669            <Button variant="outline" onClick={() => setShowAddMemberDialog(false)}>Cancel</Button>670            <Button onClick={handleAddMember} disabled={addingMember}>671              {addingMember ? 'Adding...' : 'Add Member'}672            </Button>673          </DialogFooter>674        </DialogContent>675      </Dialog>676    </>677  );678}679