Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { Button } from "@unkey/ui";
import { cn } from "@unkey/ui/src/lib/utils";
import { Search, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useProjectsFilters } from "../../../hooks/use-projects-filters";

type Props = {
placeholder?: string;
debounceTime?: number;
className?: string;
};

const MAX_QUERY_LENGTH = 120;
const DEFAULT_DEBOUNCE = 300;
const DEFAULT_PLACEHOLDER = "Search projects...";

export const ProjectsSearchInput = ({
placeholder = DEFAULT_PLACEHOLDER,
debounceTime = DEFAULT_DEBOUNCE,
className,
}: Props) => {
const { filters, updateFilters } = useProjectsFilters();
const [searchText, setSearchText] = useState("");
const [isInitialized, setIsInitialized] = useState(false);
const debounceRef = useRef<NodeJS.Timeout>();
const inputRef = useRef<HTMLInputElement>(null);
const previousFilterValueRef = useRef<string>("");

// Get current query filter value from URL on mount and when filters change
useEffect(() => {
const queryFilter = filters.find((f) => f.field === "query");
const currentValue = typeof queryFilter?.value === "string" ? queryFilter.value : "";

// Only update if the filter value actually changed (not from our own input)
if (currentValue !== previousFilterValueRef.current) {
previousFilterValueRef.current = currentValue;
setSearchText(currentValue);
}

// Mark as initialized after first effect run
if (!isInitialized) {
setIsInitialized(true);
}
}, [filters, isInitialized]);

// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);

const updateQuery = (value: string) => {
// Remove existing filters for query field
const filtersWithoutCurrent = filters.filter((f) => f.field !== "query");

if (value.trim()) {
// Add new filter
updateFilters([
...filtersWithoutCurrent,
{
field: "query",
id: crypto.randomUUID(),
operator: "contains",
value: value.trim(),
},
]);
} else {
// Just remove query filters if empty
updateFilters(filtersWithoutCurrent);
}
};

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchText(value);

// Clear existing debounce
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}

// Set new debounce
debounceRef.current = setTimeout(() => {
updateQuery(value);
}, debounceTime);
};

const handleClear = () => {
setSearchText("");

// Clear debounce
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}

// Immediately update filters
updateQuery("");
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
handleClear();
inputRef.current?.blur();
}

if (e.key === "Enter") {
// Clear debounce and immediately update
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
updateQuery(searchText);
}
};

// Show loading state while initializing
if (!isInitialized) {
return (
<div className={cn("relative flex-1", className)}>
<div
className={cn(
"px-2 flex items-center flex-1 md:w-80 gap-2 border rounded-lg py-1 h-8 border-none cursor-pointer",
"bg-gray-3 opacity-50",
)}
>
<div className="flex items-center gap-2 w-full flex-1 md:w-80">
<div className="flex-shrink-0">
<Search className="text-accent-9 size-4" />
</div>
<div className="flex-1">
<div className="text-accent-11 text-[13px] animate-pulse">Loading...</div>
</div>
</div>
</div>
</div>
);
}

return (
<div className={cn("relative flex-1", className)}>
<div
className={cn(
"px-2 flex items-center flex-1 md:w-80 gap-2 border rounded-lg py-1 h-8 border-none cursor-pointer hover:bg-gray-3",
"focus-within:bg-gray-4",
"transition-all duration-200",
searchText.length > 0 ? "bg-gray-4" : "",
)}
>
<div className="flex items-center gap-2 w-full flex-1 md:w-80">
<div className="flex-shrink-0">
<Search className="text-accent-9 size-4" />
</div>

<div className="flex-1">
<input
ref={inputRef}
type="text"
value={searchText}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
maxLength={MAX_QUERY_LENGTH}
placeholder={placeholder}
className="truncate text-accent-12 font-medium text-[13px] bg-transparent border-none outline-none focus:ring-0 focus:outline-none placeholder:text-accent-12 selection:bg-gray-6 w-full"
/>
</div>
</div>

{searchText && (
<Button
variant="ghost"
onClick={handleClear}
className="text-accent-9 hover:text-accent-12 rounded transition-colors flex-shrink-0"
size="icon"
aria-label="Clear search"
>
<X className="!size-3" />
</Button>
)}
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ControlsContainer, ControlsLeft } from "@/components/logs/controls-container";
import { ProjectsSearchInput } from "./components/projects-list-search";

export function ProjectsListControls() {
return (
<ControlsContainer>
<ControlsLeft>
<ProjectsSearchInput />
</ControlsLeft>
</ControlsContainer>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"use client";

import { NavbarActionButton } from "@/components/navigation/action-button";
import { Navbar } from "@/components/navigation/navbar";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus } from "@unkey/icons";
import { Button, DialogContainer, FormInput, toast } from "@unkey/ui";
import { useState } from "react";
import { useForm } from "react-hook-form";
import type { z } from "zod";
import { createProjectSchema } from "./create-project.schema";
import { useCreateProject } from "./use-create-project";

type FormValues = z.infer<typeof createProjectSchema>;

export const CreateProjectDialog = () => {
const [isModalOpen, setIsModalOpen] = useState(false);

const {
register,
handleSubmit,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(createProjectSchema),
defaultValues: {
name: "",
slug: "",
gitRepositoryUrl: "",
},
});

const createProject = useCreateProject((data) => {
toast.success("Project has been created", {
description: `${data.name} is ready to use`,
});
reset();
setIsModalOpen(false);
});

const onSubmitForm = async (values: FormValues) => {
try {
await createProject.mutateAsync({
name: values.name,
slug: values.slug,
gitRepositoryUrl: values.gitRepositoryUrl || undefined,
});
} catch (error) {
console.error("Form submission error:", error);
}
};

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const name = e.target.value;
const slug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");

setValue("slug", slug);
};

const handleModalClose = (open: boolean) => {
if (!open) {
reset();
}
setIsModalOpen(open);
};

return (
<>
<Navbar.Actions>
<NavbarActionButton title="Create new project" onClick={() => setIsModalOpen(true)}>
<Plus />
Create new project
</NavbarActionButton>
</Navbar.Actions>

<DialogContainer
isOpen={isModalOpen}
onOpenChange={handleModalClose}
title="Create New Project"
subTitle="Set up a new project with a unique name and optional Git repository"
footer={
<div className="flex flex-col items-center justify-center w-full gap-2">
<Button
type="submit"
form="project-form"
variant="primary"
size="xlg"
disabled={isSubmitting || createProject.isLoading}
loading={isSubmitting || createProject.isLoading}
className="w-full rounded-lg"
>
Create Project
</Button>
<div className="text-xs text-gray-9">
Project will be available immediately after creation
</div>
</div>
}
>
<form
id="project-form"
onSubmit={handleSubmit(onSubmitForm)}
className="flex flex-col gap-4"
>
<FormInput
required
label="Project Name"
className="[&_input:first-of-type]:h-[36px]"
description="A descriptive name for your project."
error={errors.name?.message}
{...register("name", {
onChange: handleNameChange,
})}
placeholder="My Awesome Project"
/>
<FormInput
required
label="Slug"
className="[&_input:first-of-type]:h-[36px]"
description="URL-friendly identifier for your project (auto-generated from name)."
error={errors.slug?.message}
{...register("slug")}
placeholder="my-awesome-project"
/>
<FormInput
label="Git Repository URL"
className="[&_input:first-of-type]:h-[36px]"
description="Optional: Link to your project's Git repository."
error={errors.gitRepositoryUrl?.message}
{...register("gitRepositoryUrl")}
placeholder="https://github.com/username/repo"
/>
</form>
</DialogContainer>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from "zod";

export const createProjectSchema = z.object({
name: z.string().trim().min(1, "Project name is required").max(256, "Project name too long"),
slug: z
.string()
.trim()
.min(1, "Project slug is required")
.max(256, "Project slug too long")
.regex(
/^[a-z0-9-]+$/,
"Project slug must contain only lowercase letters, numbers, and hyphens",
),
gitRepositoryUrl: z.string().trim().url("Must be a valid URL").optional().or(z.literal("")),
});
Loading
Loading