|
|
@@ -0,0 +1,324 @@
|
|
|
+import { useState } from 'react'
|
|
|
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
+import { FolderKanban, Plus, Trash2, UserPlus, X, Lock, Users } from 'lucide-react'
|
|
|
+import { projectsApi, usersApi, Project } from '../api/users'
|
|
|
+import { useAuthStore } from '../stores/authStore'
|
|
|
+
|
|
|
+/**
|
|
|
+ * Projects: who work belongs to, and who may touch it.
|
|
|
+ *
|
|
|
+ * Everybody has a personal project - it is where their own work lives, and it
|
|
|
+ * cannot be shared or deleted. Team projects are the ones with members, and a
|
|
|
+ * member's role there decides what they can do to everything inside it.
|
|
|
+ */
|
|
|
+
|
|
|
+const ROLE_HELP: Record<string, string> = {
|
|
|
+ admin: 'The project itself, its members, and everything in it',
|
|
|
+ editor: 'Create, change, run and delete the work in it',
|
|
|
+ viewer: 'Look, and watch it run',
|
|
|
+}
|
|
|
+
|
|
|
+export default function ProjectsPage() {
|
|
|
+ const queryClient = useQueryClient()
|
|
|
+ const me = useAuthStore((s) => s.user)
|
|
|
+
|
|
|
+ const [showNew, setShowNew] = useState(false)
|
|
|
+ const [newProject, setNewProject] = useState({ name: '', description: '' })
|
|
|
+ const [managing, setManaging] = useState<Project | null>(null)
|
|
|
+ const [addUserId, setAddUserId] = useState('')
|
|
|
+ const [addRole, setAddRole] = useState('editor')
|
|
|
+ const [error, setError] = useState<string | null>(null)
|
|
|
+
|
|
|
+ const { data: projects = [], isLoading } = useQuery({
|
|
|
+ queryKey: ['projects'],
|
|
|
+ queryFn: () => projectsApi.list(),
|
|
|
+ })
|
|
|
+
|
|
|
+ // Only somebody who can manage users can read the whole list, so this is
|
|
|
+ // allowed to fail quietly - it only fills the "add a member" dropdown.
|
|
|
+ const { data: users = [] } = useQuery({
|
|
|
+ queryKey: ['users'],
|
|
|
+ queryFn: () => usersApi.list(),
|
|
|
+ retry: false,
|
|
|
+ })
|
|
|
+
|
|
|
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ['projects'] })
|
|
|
+ const fail = (e: any) => setError(e?.response?.data?.error || e.message || 'That did not work')
|
|
|
+
|
|
|
+ const createMutation = useMutation({
|
|
|
+ mutationFn: () => projectsApi.create(newProject),
|
|
|
+ onSuccess: () => { refresh(); setShowNew(false); setNewProject({ name: '', description: '' }); setError(null) },
|
|
|
+ onError: fail,
|
|
|
+ })
|
|
|
+
|
|
|
+ const deleteMutation = useMutation({
|
|
|
+ mutationFn: (id: string) => projectsApi.remove(id),
|
|
|
+ onSuccess: () => { refresh(); setError(null) },
|
|
|
+ onError: (e: any) => {
|
|
|
+ // The refusal carries what the project still holds, which is the thing
|
|
|
+ // somebody needs in order to act on it.
|
|
|
+ const holding = e?.response?.data?.holding
|
|
|
+ const detail = holding
|
|
|
+ ? ' It still holds ' + Object.entries(holding).map(([k, v]) => `${v} ${k}`).join(', ') + '.'
|
|
|
+ : ''
|
|
|
+ setError((e?.response?.data?.error || 'Could not delete it') + detail)
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ const memberMutation = useMutation({
|
|
|
+ mutationFn: ({ action, project, userId, role }: any) => {
|
|
|
+ if (action === 'add') return projectsApi.addMember(project, userId, role)
|
|
|
+ if (action === 'role') return projectsApi.setMemberRole(project, userId, role)
|
|
|
+ return projectsApi.removeMember(project, userId)
|
|
|
+ },
|
|
|
+ onSuccess: (updated) => {
|
|
|
+ refresh()
|
|
|
+ setManaging(updated)
|
|
|
+ setAddUserId('')
|
|
|
+ setError(null)
|
|
|
+ },
|
|
|
+ onError: fail,
|
|
|
+ })
|
|
|
+
|
|
|
+ const personal = projects.filter((p) => p.type === 'personal')
|
|
|
+ const teams = projects.filter((p) => p.type !== 'personal')
|
|
|
+
|
|
|
+ const card = (project: Project) => {
|
|
|
+ const isPersonal = project.type === 'personal'
|
|
|
+ const canManage = project.myRole === 'admin'
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={project._id}
|
|
|
+ className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg p-4"
|
|
|
+ >
|
|
|
+ <div className="flex items-start justify-between mb-2">
|
|
|
+ <div className="flex items-center gap-2 min-w-0">
|
|
|
+ {isPersonal ? <Lock className="w-4 h-4 text-gray-400 shrink-0" /> : <Users className="w-4 h-4 text-primary-500 shrink-0" />}
|
|
|
+ <h3 className="font-medium text-gray-900 dark:text-gray-100 truncate">{project.name}</h3>
|
|
|
+ </div>
|
|
|
+ <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-300 shrink-0">
|
|
|
+ {project.myRole}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {project.description && (
|
|
|
+ <p className="text-sm text-gray-500 dark:text-gray-400 mb-2">{project.description}</p>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
|
|
+ {project.workflowCount} workflow{project.workflowCount === 1 ? '' : 's'}
|
|
|
+ {!isPersonal && ` · ${project.members.length} member${project.members.length === 1 ? '' : 's'}`}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ {isPersonal ? (
|
|
|
+ <span className="text-xs text-gray-400">Your own - not shared</span>
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ <button
|
|
|
+ onClick={() => { setManaging(project); setError(null) }}
|
|
|
+ className="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700"
|
|
|
+ >
|
|
|
+ Members
|
|
|
+ </button>
|
|
|
+ {canManage && (
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ if (confirm(`Delete "${project.name}"? It has to be empty first.`)) {
|
|
|
+ deleteMutation.mutate(project._id)
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ className="p-1 text-gray-500 hover:text-red-600 dark:hover:text-red-400 rounded"
|
|
|
+ title="Delete this project"
|
|
|
+ >
|
|
|
+ <Trash2 className="w-4 h-4" />
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="h-full flex flex-col">
|
|
|
+ <div className="h-14 px-6 flex items-center justify-between bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <FolderKanban className="w-5 h-5 text-gray-400" />
|
|
|
+ <h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Projects</h1>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ onClick={() => { setShowNew(true); setError(null) }}
|
|
|
+ className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
|
|
+ >
|
|
|
+ <Plus className="w-4 h-4" />
|
|
|
+ New project
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex-1 overflow-auto p-6">
|
|
|
+ {error && (
|
|
|
+ <div className="mb-4 px-3 py-2 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm flex items-center justify-between">
|
|
|
+ <span>{error}</span>
|
|
|
+ <button onClick={() => setError(null)}><X className="w-4 h-4" /></button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {isLoading && <p className="text-gray-500 dark:text-gray-400">Reading the projects...</p>}
|
|
|
+
|
|
|
+ {teams.length > 0 && (
|
|
|
+ <>
|
|
|
+ <h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
|
|
+ Team projects
|
|
|
+ </h2>
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
|
|
+ {teams.map(card)}
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
|
|
+ Personal
|
|
|
+ </h2>
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
|
+ {personal.map(card)}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {!isLoading && projects.length === 0 && (
|
|
|
+ <p className="text-gray-500 dark:text-gray-400">
|
|
|
+ No projects yet. Yours should be here - if it is not, something went wrong creating your account.
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {showNew && (
|
|
|
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setShowNew(false)}>
|
|
|
+ <div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
|
|
|
+ <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">New project</h3>
|
|
|
+ <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
|
|
+ You will be its admin. Add people to it afterwards.
|
|
|
+ </p>
|
|
|
+ {error && (
|
|
|
+ <div className="mb-3 px-3 py-2 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
|
|
|
+ {error}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <input
|
|
|
+ autoFocus
|
|
|
+ placeholder="Name"
|
|
|
+ value={newProject.name}
|
|
|
+ onChange={(e) => setNewProject({ ...newProject, name: e.target.value })}
|
|
|
+ className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 mb-2"
|
|
|
+ />
|
|
|
+ <input
|
|
|
+ placeholder="What is it for? (optional)"
|
|
|
+ value={newProject.description}
|
|
|
+ onChange={(e) => setNewProject({ ...newProject, description: e.target.value })}
|
|
|
+ className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
|
|
|
+ />
|
|
|
+ <div className="flex justify-end gap-2 mt-5">
|
|
|
+ <button onClick={() => setShowNew(false)} className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400">
|
|
|
+ Cancel
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => createMutation.mutate()}
|
|
|
+ disabled={!newProject.name.trim() || createMutation.isPending}
|
|
|
+ className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-40"
|
|
|
+ >
|
|
|
+ {createMutation.isPending ? 'Creating...' : 'Create'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {managing && (
|
|
|
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setManaging(null)}>
|
|
|
+ <div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-5" onClick={(e) => e.stopPropagation()}>
|
|
|
+ <div className="flex items-center justify-between mb-4">
|
|
|
+ <h3 className="font-semibold text-gray-900 dark:text-gray-100">{managing.name} · members</h3>
|
|
|
+ <button onClick={() => setManaging(null)}><X className="w-5 h-5 text-gray-400" /></button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {error && (
|
|
|
+ <div className="mb-3 px-3 py-2 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
|
|
|
+ {error}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <div className="divide-y divide-gray-100 dark:divide-slate-700 mb-4">
|
|
|
+ {managing.members.map((member) => (
|
|
|
+ <div key={member.userId} className="py-2 flex items-center gap-3 text-sm">
|
|
|
+ <span className="flex-1 text-gray-900 dark:text-gray-100">
|
|
|
+ {member.username}
|
|
|
+ {member.userId === me?.id && <span className="text-xs text-gray-400 ml-1">you</span>}
|
|
|
+ </span>
|
|
|
+ <select
|
|
|
+ value={member.role}
|
|
|
+ disabled={managing.myRole !== 'admin' || memberMutation.isPending}
|
|
|
+ onChange={(e) => memberMutation.mutate({
|
|
|
+ action: 'role', project: managing._id, userId: member.userId, role: e.target.value,
|
|
|
+ })}
|
|
|
+ className="px-2 py-1 text-xs border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 disabled:opacity-50"
|
|
|
+ title={ROLE_HELP[member.role]}
|
|
|
+ >
|
|
|
+ <option value="admin">Admin</option>
|
|
|
+ <option value="editor">Editor</option>
|
|
|
+ <option value="viewer">Viewer</option>
|
|
|
+ </select>
|
|
|
+ <button
|
|
|
+ onClick={() => memberMutation.mutate({ action: 'remove', project: managing._id, userId: member.userId })}
|
|
|
+ disabled={managing.myRole !== 'admin' && member.userId !== me?.id}
|
|
|
+ className="p-1 text-gray-500 hover:text-red-600 dark:hover:text-red-400 rounded disabled:opacity-40"
|
|
|
+ title={member.userId === me?.id ? 'Leave this project' : 'Remove from the project'}
|
|
|
+ >
|
|
|
+ <Trash2 className="w-4 h-4" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {managing.myRole === 'admin' && (
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <select
|
|
|
+ value={addUserId}
|
|
|
+ onChange={(e) => setAddUserId(e.target.value)}
|
|
|
+ className="flex-1 px-2 py-1.5 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
|
|
|
+ >
|
|
|
+ <option value="">Add somebody...</option>
|
|
|
+ {users
|
|
|
+ .filter((u) => !managing.members.some((m) => m.userId === u.id))
|
|
|
+ .map((u) => (
|
|
|
+ <option key={u.id} value={u.id}>{u.username}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ <select
|
|
|
+ value={addRole}
|
|
|
+ onChange={(e) => setAddRole(e.target.value)}
|
|
|
+ className="px-2 py-1.5 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
|
|
|
+ >
|
|
|
+ <option value="admin">Admin</option>
|
|
|
+ <option value="editor">Editor</option>
|
|
|
+ <option value="viewer">Viewer</option>
|
|
|
+ </select>
|
|
|
+ <button
|
|
|
+ onClick={() => memberMutation.mutate({ action: 'add', project: managing._id, userId: addUserId, role: addRole })}
|
|
|
+ disabled={!addUserId || memberMutation.isPending}
|
|
|
+ className="flex items-center gap-1 px-2 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-40"
|
|
|
+ >
|
|
|
+ <UserPlus className="w-4 h-4" />
|
|
|
+ Add
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
|
|
+ {ROLE_HELP[addRole]}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|