feat: add context menu component and update dropdown, select styles
- Implemented a new ContextMenu component with various subcomponents for better UI interaction. - Updated DropdownMenu and Select components to include hover effects for improved user experience. - Enhanced global styles with new CSS variables for better theming and consistency across components.
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -28,6 +28,7 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -18950,6 +18951,15 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tw-animate-css": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/Wombosvideo"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,13 @@ const projectsRouter = router({
|
|||||||
db.delete(projects).where(eq(projects.id, input.id)).run();
|
db.delete(projects).where(eq(projects.id, input.id)).run();
|
||||||
return { success: true as const };
|
return { success: true as const };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
archiveByClient: publicProcedure
|
||||||
|
.input(z.object({ clientId: z.string(), status: z.enum(['active', 'archived']) }))
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
getDb().update(projects).set({ status: input.status }).where(eq(projects.clientId, input.clientId)).run();
|
||||||
|
return { success: true as const };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const tasksRouter = router({
|
const tasksRouter = router({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
ChevronDown,
|
ChevronUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import {
|
import {
|
||||||
@@ -61,15 +61,15 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
{children}
|
{children}
|
||||||
|
|
||||||
{/* Right-edge vertical 'keep scrolling for AI' affordance (non-interactive) */}
|
{/* Right-edge vertical 'keep scrolling for AI' affordance (non-interactive) */}
|
||||||
<div className="absolute right-0 top-0 bottom-0 flex items-end justify-center pb-8 pointer-events-none select-none">
|
<div className="absolute right-0 top-0 flex items-end justify-center pt-8 pointer-events-none select-none">
|
||||||
<div className="flex flex-col items-center gap-1.5 pr-2">
|
<div className="flex flex-col items-center gap-1.5 pr-2">
|
||||||
|
<ChevronUp size={10} className="text-muted-foreground/30" />
|
||||||
<span
|
<span
|
||||||
className="text-[9px] text-muted-foreground/30 tracking-widest uppercase font-medium"
|
className="text-[9px] text-muted-foreground/30 tracking-widest uppercase font-medium"
|
||||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
||||||
>
|
>
|
||||||
keep scrolling for AI
|
keep scrolling up for AI
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown size={10} className="text-muted-foreground/30" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Plus,
|
Plus,
|
||||||
MoreHorizontal,
|
Pencil,
|
||||||
Edit2,
|
Edit2,
|
||||||
Archive,
|
Archive,
|
||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
@@ -18,12 +18,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
ContextMenu,
|
||||||
DropdownMenuContent,
|
ContextMenuContent,
|
||||||
DropdownMenuItem,
|
ContextMenuItem,
|
||||||
DropdownMenuSeparator,
|
ContextMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/context-menu';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -77,8 +77,20 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [deleteProjectId, setDeleteProjectId] = useState<{ id: string; name: string } | null>(null);
|
const [deleteProjectId, setDeleteProjectId] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
const [renameProject, setRenameProject] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
const [renameProjectValue, setRenameProjectValue] = useState('');
|
||||||
const [editDialog, setEditDialog] = useState<{ id: string; name: string; clientId: string | null } | null>(null);
|
const [editDialog, setEditDialog] = useState<{ id: string; name: string; clientId: string | null } | null>(null);
|
||||||
|
|
||||||
|
// Client action state
|
||||||
|
const [renameClient, setRenameClient] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
const [renameClientValue, setRenameClientValue] = useState('');
|
||||||
|
const [deleteClient, setDeleteClient] = useState<{ id: string; name: string } | null>(null);
|
||||||
const [editClientValue, setEditClientValue] = useState<string>(NO_CLIENT_KEY);
|
const [editClientValue, setEditClientValue] = useState<string>(NO_CLIENT_KEY);
|
||||||
|
const [editSubClientValue, setEditSubClientValue] = useState<string>(NO_CLIENT_KEY);
|
||||||
|
const [editCreatingClient, setEditCreatingClient] = useState(false);
|
||||||
|
const [editNewClientName, setEditNewClientName] = useState('');
|
||||||
|
const [editCreatingSubClient, setEditCreatingSubClient] = useState(false);
|
||||||
|
const [editNewSubClientName, setEditNewSubClientName] = useState('');
|
||||||
|
|
||||||
// New-project dialog state
|
// New-project dialog state
|
||||||
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
||||||
@@ -141,6 +153,25 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateClientMutation = trpc.clients.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setRenameClient(null);
|
||||||
|
void utils.clients.list.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveByClientMutation = trpc.projects.archiveByClient.useMutation({
|
||||||
|
onSuccess: () => { void utils.projects.list.invalidate(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteClientMutation = trpc.clients.deleteWithCascade.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteClient(null);
|
||||||
|
void utils.clients.list.invalidate();
|
||||||
|
void utils.projects.list.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Build a client lookup map
|
// Build a client lookup map
|
||||||
const clientMap = useMemo(() => {
|
const clientMap = useMemo(() => {
|
||||||
const m = new Map<string, string>();
|
const m = new Map<string, string>();
|
||||||
@@ -156,6 +187,12 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const groups = new Map<string, typeof filtered>();
|
const groups = new Map<string, typeof filtered>();
|
||||||
|
|
||||||
|
// Ensure every client appears even with 0 projects
|
||||||
|
for (const c of clientList) {
|
||||||
|
groups.set(c.id, []);
|
||||||
|
}
|
||||||
|
|
||||||
for (const p of filtered) {
|
for (const p of filtered) {
|
||||||
const key = p.clientId ?? NO_CLIENT_KEY;
|
const key = p.clientId ?? NO_CLIENT_KEY;
|
||||||
const arr = groups.get(key);
|
const arr = groups.get(key);
|
||||||
@@ -163,7 +200,20 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
|||||||
else groups.set(key, [p]);
|
else groups.set(key, [p]);
|
||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
}, [projectList, searchQuery]);
|
}, [projectList, clientList, searchQuery]);
|
||||||
|
|
||||||
|
// Count projects for a top-level client including its sub-clients
|
||||||
|
const totalProjectCount = useMemo(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const c of topLevelClients) {
|
||||||
|
let count = (grouped.get(c.id) ?? []).length;
|
||||||
|
for (const sc of subClientsByParent.get(c.id) ?? []) {
|
||||||
|
count += (grouped.get(sc.id) ?? []).length;
|
||||||
|
}
|
||||||
|
counts.set(c.id, count);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [topLevelClients, subClientsByParent, grouped]);
|
||||||
|
|
||||||
// Auto-expand all groups when searching
|
// Auto-expand all groups when searching
|
||||||
const effectiveExpanded = useMemo(() => {
|
const effectiveExpanded = useMemo(() => {
|
||||||
@@ -242,22 +292,76 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
|||||||
|
|
||||||
function handleEditOpen(project: { id: string; name: string; clientId: string | null }) {
|
function handleEditOpen(project: { id: string; name: string; clientId: string | null }) {
|
||||||
setEditDialog(project);
|
setEditDialog(project);
|
||||||
setEditClientValue(project.clientId ?? NO_CLIENT_KEY);
|
setEditCreatingClient(false);
|
||||||
|
setEditNewClientName('');
|
||||||
|
setEditCreatingSubClient(false);
|
||||||
|
setEditNewSubClientName('');
|
||||||
|
if (!project.clientId) {
|
||||||
|
setEditClientValue(NO_CLIENT_KEY);
|
||||||
|
setEditSubClientValue(NO_CLIENT_KEY);
|
||||||
|
} else {
|
||||||
|
// Check if clientId is a sub-client
|
||||||
|
const client = clientList.find((c) => c.id === project.clientId);
|
||||||
|
if (client?.parentId) {
|
||||||
|
// It's a sub-client — set parent as client, this as sub-client
|
||||||
|
setEditClientValue(client.parentId);
|
||||||
|
setEditSubClientValue(client.id);
|
||||||
|
} else {
|
||||||
|
setEditClientValue(project.clientId);
|
||||||
|
setEditSubClientValue(NO_CLIENT_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEditSave() {
|
async function handleEditSave() {
|
||||||
if (!editDialog) return;
|
if (!editDialog) return;
|
||||||
const newClientId = editClientValue === NO_CLIENT_KEY ? null : editClientValue;
|
|
||||||
|
let resolvedClientId: string | null;
|
||||||
|
|
||||||
|
if (editCreatingClient && editNewClientName.trim()) {
|
||||||
|
// Create a new client
|
||||||
|
const result = await createClientMutation.mutateAsync({ name: editNewClientName.trim() });
|
||||||
|
let parentId = result.id;
|
||||||
|
|
||||||
|
if (editCreatingSubClient && editNewSubClientName.trim()) {
|
||||||
|
// Also create a sub-client under the new client
|
||||||
|
const subResult = await createClientMutation.mutateAsync({
|
||||||
|
name: editNewSubClientName.trim(),
|
||||||
|
parentId,
|
||||||
|
});
|
||||||
|
resolvedClientId = subResult.id;
|
||||||
|
} else {
|
||||||
|
resolvedClientId = parentId;
|
||||||
|
}
|
||||||
|
} else if (editCreatingSubClient && editNewSubClientName.trim() && editClientValue !== NO_CLIENT_KEY) {
|
||||||
|
// Create a new sub-client under the selected parent
|
||||||
|
const result = await createClientMutation.mutateAsync({
|
||||||
|
name: editNewSubClientName.trim(),
|
||||||
|
parentId: editClientValue,
|
||||||
|
});
|
||||||
|
resolvedClientId = result.id;
|
||||||
|
} else if (editSubClientValue !== NO_CLIENT_KEY) {
|
||||||
|
resolvedClientId = editSubClientValue;
|
||||||
|
} else if (editClientValue !== NO_CLIENT_KEY) {
|
||||||
|
resolvedClientId = editClientValue;
|
||||||
|
} else {
|
||||||
|
resolvedClientId = null;
|
||||||
|
}
|
||||||
|
|
||||||
updateMutation.mutate(
|
updateMutation.mutate(
|
||||||
{ id: editDialog.id, clientId: newClientId },
|
{ id: editDialog.id, clientId: resolvedClientId },
|
||||||
{ onSuccess: () => setEditDialog(null) },
|
{ onSuccess: () => setEditDialog(null) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Sort groups: client groups first (alphabetically), then "Internal / No Client" last
|
// Sort groups: only top-level clients + NO_CLIENT_KEY, sub-clients rendered nested
|
||||||
const sortedGroupKeys = useMemo(() => {
|
const sortedGroupKeys = useMemo(() => {
|
||||||
const keys = [...grouped.keys()];
|
// Only include top-level clients and the NO_CLIENT_KEY group
|
||||||
|
const topLevelIds = new Set(topLevelClients.map((c) => c.id));
|
||||||
|
const keys = [...grouped.keys()].filter(
|
||||||
|
(k) => k === NO_CLIENT_KEY || topLevelIds.has(k),
|
||||||
|
);
|
||||||
return keys.sort((a, b) => {
|
return keys.sort((a, b) => {
|
||||||
if (a === NO_CLIENT_KEY) return 1;
|
if (a === NO_CLIENT_KEY) return 1;
|
||||||
if (b === NO_CLIENT_KEY) return -1;
|
if (b === NO_CLIENT_KEY) return -1;
|
||||||
@@ -265,7 +369,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
|||||||
const nameB = clientMap.get(b) ?? '';
|
const nameB = clientMap.get(b) ?? '';
|
||||||
return nameA.localeCompare(nameB);
|
return nameA.localeCompare(nameB);
|
||||||
});
|
});
|
||||||
}, [grouped, clientMap]);
|
}, [grouped, clientMap, topLevelClients]);
|
||||||
|
|
||||||
const totalProjects = projectList.length;
|
const totalProjects = projectList.length;
|
||||||
|
|
||||||
@@ -336,16 +440,16 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
|||||||
</Button>
|
</Button>
|
||||||
</EmptyContent>
|
</EmptyContent>
|
||||||
</Empty>
|
</Empty>
|
||||||
) : sortedGroupKeys.length === 0 ? (
|
) : sortedGroupKeys.length === 0 && clientList.length === 0 ? (
|
||||||
<div className="text-xs text-muted-foreground px-3 py-4 text-center">
|
<div className="text-xs text-muted-foreground px-3 py-4 text-center">
|
||||||
No projects match your search.
|
No projects match your search.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
sortedGroupKeys.map((groupKey) => {
|
<>
|
||||||
|
{/* Client groups */}
|
||||||
|
{sortedGroupKeys.filter((k) => k !== NO_CLIENT_KEY).map((groupKey) => {
|
||||||
const groupProjects = grouped.get(groupKey) ?? [];
|
const groupProjects = grouped.get(groupKey) ?? [];
|
||||||
const groupName = groupKey === NO_CLIENT_KEY
|
const groupName = clientMap.get(groupKey) ?? 'Unknown Client';
|
||||||
? 'Internal / No Client'
|
|
||||||
: clientMap.get(groupKey) ?? 'Unknown Client';
|
|
||||||
const isOpen = effectiveExpanded.has(groupKey);
|
const isOpen = effectiveExpanded.has(groupKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -354,110 +458,415 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
|||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={() => toggleExpanded(groupKey)}
|
onOpenChange={() => toggleExpanded(groupKey)}
|
||||||
>
|
>
|
||||||
{/* Client group header */}
|
<ContextMenu>
|
||||||
<CollapsibleTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div className="flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors px-2">
|
<CollapsibleTrigger asChild>
|
||||||
{isOpen ? (
|
<div className="flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors px-2">
|
||||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground mr-1" />
|
{isOpen ? (
|
||||||
) : (
|
<ChevronDown className="size-3 shrink-0 text-muted-foreground mr-1" />
|
||||||
<ChevronRight className="size-3 shrink-0 text-muted-foreground mr-1" />
|
) : (
|
||||||
)}
|
<ChevronRight className="size-3 shrink-0 text-muted-foreground mr-1" />
|
||||||
<Folder className="size-3.5 shrink-0 text-muted-foreground mr-1.5" />
|
)}
|
||||||
<span className="flex-1 min-w-0 truncate font-medium text-foreground">
|
<Folder className="size-3.5 shrink-0 text-muted-foreground mr-1.5" />
|
||||||
{groupName}
|
<span className="flex-1 min-w-0 truncate font-medium text-foreground">
|
||||||
</span>
|
{groupName}
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
</span>
|
||||||
{groupProjects.length}
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
</span>
|
{totalProjectCount.get(groupKey) ?? groupProjects.length}
|
||||||
</div>
|
</span>
|
||||||
</CollapsibleTrigger>
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="min-w-[160px]">
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setRenameClient({ id: groupKey, name: groupName });
|
||||||
|
setRenameClientValue(groupName);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
Rename
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const allArchived = groupProjects.every((p) => p.status === 'archived');
|
||||||
|
archiveByClientMutation.mutate({
|
||||||
|
clientId: groupKey,
|
||||||
|
status: allArchived ? 'active' : 'archived',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{groupProjects.every((p) => p.status === 'archived') ? (
|
||||||
|
<>
|
||||||
|
<ArchiveRestore />
|
||||||
|
Unarchive All
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Archive />
|
||||||
|
Archive All
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteClient({ id: groupKey, name: groupName });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
{groupProjects.map((project) => (
|
{/* Sub-clients nested under this parent */}
|
||||||
<div
|
{(subClientsByParent.get(groupKey) ?? []).map((subClient) => {
|
||||||
key={project.id}
|
const subProjects = grouped.get(subClient.id) ?? [];
|
||||||
className={cn(
|
const subIsOpen = effectiveExpanded.has(subClient.id);
|
||||||
'group flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors',
|
const subName = subClient.name;
|
||||||
selectedProjectId === project.id && 'bg-sidebar-accent',
|
|
||||||
project.status === 'archived' && 'opacity-60',
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: '28px', paddingRight: '4px' }}
|
|
||||||
onClick={() => onSelectProject(project.id)}
|
|
||||||
>
|
|
||||||
<Circle className="size-2.5 shrink-0 text-muted-foreground mr-1.5" />
|
|
||||||
<span className="flex-1 min-w-0 truncate text-foreground">
|
|
||||||
{project.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Context menu */}
|
return (
|
||||||
<DropdownMenu>
|
<Collapsible
|
||||||
<DropdownMenuTrigger asChild>
|
key={subClient.id}
|
||||||
<Button
|
open={subIsOpen}
|
||||||
variant="ghost"
|
onOpenChange={() => toggleExpanded(subClient.id)}
|
||||||
className={cn(
|
>
|
||||||
'relative z-10 h-5 w-5 min-h-0 min-w-0 p-0 ml-1 text-muted-foreground hover:bg-muted shrink-0',
|
<ContextMenu>
|
||||||
'opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100',
|
<ContextMenuTrigger asChild>
|
||||||
)}
|
<CollapsibleTrigger asChild>
|
||||||
tabIndex={-1}
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors"
|
||||||
>
|
style={{ paddingLeft: '20px', paddingRight: '8px' }}
|
||||||
<MoreHorizontal className="size-3" />
|
>
|
||||||
</Button>
|
{subIsOpen ? (
|
||||||
</DropdownMenuTrigger>
|
<ChevronDown className="size-3 shrink-0 text-muted-foreground mr-1" />
|
||||||
<DropdownMenuContent align="end" className="min-w-[152px]">
|
) : (
|
||||||
<DropdownMenuItem
|
<ChevronRight className="size-3 shrink-0 text-muted-foreground mr-1" />
|
||||||
onClick={(e) => {
|
)}
|
||||||
e.stopPropagation();
|
<Folder className="size-3 shrink-0 text-muted-foreground mr-1.5" />
|
||||||
handleEditOpen({
|
<span className="flex-1 min-w-0 truncate font-medium text-foreground">
|
||||||
id: project.id,
|
{subName}
|
||||||
name: project.name,
|
</span>
|
||||||
clientId: project.clientId,
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
});
|
{subProjects.length}
|
||||||
}}
|
</span>
|
||||||
>
|
</div>
|
||||||
<Edit2 />
|
</CollapsibleTrigger>
|
||||||
Edit Client
|
</ContextMenuTrigger>
|
||||||
</DropdownMenuItem>
|
<ContextMenuContent className="min-w-[160px]">
|
||||||
<DropdownMenuItem
|
<ContextMenuItem
|
||||||
onClick={(e) => {
|
onClick={() => {
|
||||||
e.stopPropagation();
|
setRenameClient({ id: subClient.id, name: subName });
|
||||||
handleArchiveToggle(project.id, project.status);
|
setRenameClientValue(subName);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{project.status === 'archived' ? (
|
<Pencil />
|
||||||
<>
|
Rename
|
||||||
<ArchiveRestore />
|
</ContextMenuItem>
|
||||||
Unarchive
|
<ContextMenuItem
|
||||||
</>
|
onClick={() => {
|
||||||
) : (
|
const allArchived = subProjects.every((p) => p.status === 'archived');
|
||||||
<>
|
archiveByClientMutation.mutate({
|
||||||
<Archive />
|
clientId: subClient.id,
|
||||||
Archive
|
status: allArchived ? 'active' : 'archived',
|
||||||
</>
|
});
|
||||||
)}
|
}}
|
||||||
</DropdownMenuItem>
|
>
|
||||||
<DropdownMenuSeparator />
|
{subProjects.every((p) => p.status === 'archived') ? (
|
||||||
<DropdownMenuItem
|
<>
|
||||||
variant="destructive"
|
<ArchiveRestore />
|
||||||
onClick={(e) => {
|
Unarchive All
|
||||||
e.stopPropagation();
|
</>
|
||||||
setDeleteProjectId({ id: project.id, name: project.name });
|
) : (
|
||||||
}}
|
<>
|
||||||
>
|
<Archive />
|
||||||
<Trash2 />
|
Archive All
|
||||||
Delete
|
</>
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
</DropdownMenuContent>
|
</ContextMenuItem>
|
||||||
</DropdownMenu>
|
<ContextMenuSeparator />
|
||||||
</div>
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteClient({ id: subClient.id, name: subName });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
{subProjects.map((project) => (
|
||||||
|
<ContextMenu key={project.id}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors',
|
||||||
|
selectedProjectId === project.id && 'bg-sidebar-accent',
|
||||||
|
project.status === 'archived' && 'opacity-60',
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: '40px', paddingRight: '4px' }}
|
||||||
|
onClick={() => onSelectProject(project.id)}
|
||||||
|
>
|
||||||
|
<Circle className="size-2.5 shrink-0 text-muted-foreground mr-1.5" />
|
||||||
|
<span className="flex-1 min-w-0 truncate text-foreground">
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="min-w-[152px]">
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setRenameProject({ id: project.id, name: project.name });
|
||||||
|
setRenameProjectValue(project.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
Rename
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleEditOpen({
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
clientId: project.clientId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit2 />
|
||||||
|
Edit Client
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleArchiveToggle(project.id, project.status);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.status === 'archived' ? (
|
||||||
|
<>
|
||||||
|
<ArchiveRestore />
|
||||||
|
Unarchive
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Archive />
|
||||||
|
Archive
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteProjectId({ id: project.id, name: project.name });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Direct projects under this client */}
|
||||||
|
{groupProjects.map((project) => (
|
||||||
|
<ContextMenu key={project.id}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors',
|
||||||
|
selectedProjectId === project.id && 'bg-sidebar-accent',
|
||||||
|
project.status === 'archived' && 'opacity-60',
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: '28px', paddingRight: '4px' }}
|
||||||
|
onClick={() => onSelectProject(project.id)}
|
||||||
|
>
|
||||||
|
<Circle className="size-2.5 shrink-0 text-muted-foreground mr-1.5" />
|
||||||
|
<span className="flex-1 min-w-0 truncate text-foreground">
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="min-w-[152px]">
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setRenameProject({ id: project.id, name: project.name });
|
||||||
|
setRenameProjectValue(project.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
Rename
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleEditOpen({
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
clientId: project.clientId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit2 />
|
||||||
|
Edit Client
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleArchiveToggle(project.id, project.status);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.status === 'archived' ? (
|
||||||
|
<>
|
||||||
|
<ArchiveRestore />
|
||||||
|
Unarchive
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Archive />
|
||||||
|
Archive
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteProjectId({ id: project.id, name: project.name });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
))}
|
))}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
|
||||||
|
{/* Unassigned projects (no client) — rendered flat at root level */}
|
||||||
|
{(grouped.get(NO_CLIENT_KEY) ?? []).map((project) => (
|
||||||
|
<ContextMenu key={project.id}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors px-2',
|
||||||
|
selectedProjectId === project.id && 'bg-sidebar-accent',
|
||||||
|
project.status === 'archived' && 'opacity-60',
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectProject(project.id)}
|
||||||
|
>
|
||||||
|
<Circle className="size-2.5 shrink-0 text-muted-foreground mr-1.5" />
|
||||||
|
<span className="flex-1 min-w-0 truncate text-foreground">
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="min-w-[152px]">
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setRenameProject({ id: project.id, name: project.name });
|
||||||
|
setRenameProjectValue(project.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
Rename
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleEditOpen({
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
clientId: project.clientId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit2 />
|
||||||
|
Edit Client
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleArchiveToggle(project.id, project.status);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.status === 'archived' ? (
|
||||||
|
<>
|
||||||
|
<ArchiveRestore />
|
||||||
|
Unarchive
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Archive />
|
||||||
|
Archive
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteProjectId({ id: project.id, name: project.name });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rename project dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!renameProject}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setRenameProject(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rename Project</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter a new name for “{renameProject?.name}”.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
value={renameProjectValue}
|
||||||
|
onChange={(e) => setRenameProjectValue(e.target.value)}
|
||||||
|
placeholder="Project name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRenameProject(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (renameProject && renameProjectValue.trim()) {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ id: renameProject.id, name: renameProjectValue.trim() },
|
||||||
|
{ onSuccess: () => setRenameProject(null) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!renameProjectValue.trim() || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Saving\u2026' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
{/* Delete confirmation */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={!!deleteProjectId}
|
open={!!deleteProjectId}
|
||||||
@@ -642,6 +1051,75 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete client confirmation */}
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deleteClient}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !deleteClientMutation.isPending) setDeleteClient(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Delete “{deleteClient?.name}”?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will delete the client and all its projects. Tasks assigned to those projects will become unassigned. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleteClientMutation.isPending}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={() => {
|
||||||
|
if (deleteClient) deleteClientMutation.mutate({ id: deleteClient.id });
|
||||||
|
}}
|
||||||
|
disabled={deleteClientMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteClientMutation.isPending ? 'Deleting\u2026' : 'Delete'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Rename client dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!renameClient}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setRenameClient(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rename Client</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter a new name for “{renameClient?.name}”.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
value={renameClientValue}
|
||||||
|
onChange={(e) => setRenameClientValue(e.target.value)}
|
||||||
|
placeholder="Client name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRenameClient(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (renameClient && renameClientValue.trim()) {
|
||||||
|
updateClientMutation.mutate({ id: renameClient.id, name: renameClientValue.trim() });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!renameClientValue.trim() || updateClientMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateClientMutation.isPending ? 'Saving\u2026' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Edit client dialog */}
|
{/* Edit client dialog */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={!!editDialog}
|
open={!!editDialog}
|
||||||
@@ -656,25 +1134,131 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
|||||||
Assign “{editDialog?.name}” to a client or leave as internal.
|
Assign “{editDialog?.name}” to a client or leave as internal.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Select value={editClientValue} onValueChange={setEditClientValue}>
|
<div className="flex flex-col gap-4 py-2">
|
||||||
<SelectTrigger>
|
<div className="flex flex-col gap-1.5">
|
||||||
<SelectValue placeholder="Select a client" />
|
<label className="text-sm font-medium">Client</label>
|
||||||
</SelectTrigger>
|
{editCreatingClient ? (
|
||||||
<SelectContent>
|
<div className="flex items-center gap-2">
|
||||||
<SelectItem value={NO_CLIENT_KEY}>No Client (Internal)</SelectItem>
|
<Input
|
||||||
{clientList.map((c) => (
|
placeholder="New client name"
|
||||||
<SelectItem key={c.id} value={c.id}>
|
value={editNewClientName}
|
||||||
{c.name}
|
onChange={(e) => setEditNewClientName(e.target.value)}
|
||||||
</SelectItem>
|
className="flex-1"
|
||||||
))}
|
/>
|
||||||
</SelectContent>
|
<Button
|
||||||
</Select>
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEditCreatingClient(false);
|
||||||
|
setEditNewClientName('');
|
||||||
|
setEditCreatingSubClient(false);
|
||||||
|
setEditNewSubClientName('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={editClientValue}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setEditClientValue(v);
|
||||||
|
setEditSubClientValue(NO_CLIENT_KEY);
|
||||||
|
setEditCreatingSubClient(false);
|
||||||
|
setEditNewSubClientName('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="Select a client" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_CLIENT_KEY}>No Client (Internal)</SelectItem>
|
||||||
|
{topLevelClients.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditCreatingClient(true)}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5 mr-1" />New
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(editClientValue !== NO_CLIENT_KEY || (editCreatingClient && editNewClientName.trim())) && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium">Sub-client <span className="text-muted-foreground font-normal">(optional)</span></label>
|
||||||
|
{editCreatingSubClient ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="New sub-client name"
|
||||||
|
value={editNewSubClientName}
|
||||||
|
onChange={(e) => setEditNewSubClientName(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEditCreatingSubClient(false);
|
||||||
|
setEditNewSubClientName('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : editCreatingClient ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
onClick={() => setEditCreatingSubClient(true)}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5 mr-1" />New Sub-client
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={editSubClientValue}
|
||||||
|
onValueChange={setEditSubClientValue}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="Select a sub-client" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_CLIENT_KEY}>None</SelectItem>
|
||||||
|
{(subClientsByParent.get(editClientValue) ?? []).map((sc) => (
|
||||||
|
<SelectItem key={sc.id} value={sc.id}>
|
||||||
|
{sc.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditCreatingSubClient(true)}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5 mr-1" />New
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setEditDialog(null)}>
|
<Button variant="outline" onClick={() => setEditDialog(null)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleEditSave} disabled={updateMutation.isPending}>
|
<Button onClick={handleEditSave} disabled={updateMutation.isPending || createClientMutation.isPending}>
|
||||||
{updateMutation.isPending ? 'Saving\u2026' : 'Save'}
|
{updateMutation.isPending || createClientMutation.isPending ? 'Saving\u2026' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
250
src/renderer/components/ui/context-menu.tsx
Normal file
250
src/renderer/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ContextMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||||
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||||
|
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioGroup
|
||||||
|
data-slot="context-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:hover:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 dark:data-[variant=destructive]:hover:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:hover:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ function DropdownMenuItem({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:hover:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 dark:data-[variant=destructive]:hover:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:hover:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
|
|||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
|
|||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ function SelectItem({
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -3,10 +3,16 @@
|
|||||||
@import '@fontsource/geist/600.css';
|
@import '@fontsource/geist/600.css';
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
@@ -39,82 +45,86 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
--font-sans: 'Geist', 'Inter', system-ui, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.21 0.006 285.885);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.92 0.004 286.32);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.92 0.004 286.32);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.705 0.015 286.067);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.269 0 0);
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.92 0.004 286.32);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
--accent: oklch(0.371 0 0);
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.552 0.016 285.938);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.439 0 0);
|
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
Reference in New Issue
Block a user