import { A, useNavigate } from '@solidjs/router';
import { createQuery, useQueryClient } from '@tanstack/solid-query';
import { BookPlus, LoaderCircle } from 'lucide-solid';
import { For, Show, createEffect, createSignal, type Component } from 'solid-js';
import { Avatar } from '../../components/common';
import { createRepo, listUserKnots, listRepoRecords } from '../../lib/api/repos';
import { resolveActor } from '../../lib/api/identity';
import { useAuth } from '../../lib/auth';
import { getErrorMessage } from '../../lib/repo-utils';
export const validateRepoName = (name: string): string | null => {
if (name.length === 0) {
return 'repository name cannot be empty';
}
if (name.length > 100) {
return 'repository name must be 100 characters or fewer';
}
if (name.includes('/') || name.includes('\\')) {
return 'repository name contains invalid path characters';
}
if (name.startsWith('.') || name.endsWith('.')) {
return 'repository name contains invalid path sequence';
}
if (name.includes('..')) {
return 'repository name cannot contain sequential dots';
}
const validChars = /^[a-zA-Z0-9\-_.]+$/;
if (!validChars.test(name)) {
return 'repository name can only contain alphanumeric characters, periods, hyphens, and underscores';
}
if (name.toLowerCase() === 'self') {
return 'repository name "self" is reserved';
}
return null;
};
export const NewRepoPage: Component = () => {
const auth = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [name, setName] = createSignal('');
const [description, setDescription] = createSignal('');
const [branch, setBranch] = createSignal('main');
const [selectedKnot, setSelectedKnot] = createSignal('');
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const viewerQuery = createQuery(() => ({
queryKey: ['viewer', auth.currentDid()],
enabled: Boolean(auth.currentDid()),
queryFn: async () => resolveActor(auth.currentDid()!),
}));
const knotsQuery = createQuery(() => ({
queryKey: ['knots', auth.currentDid()],
enabled: Boolean(auth.currentDid()),
queryFn: async () => listUserKnots(auth.currentDid()!),
}));
// Automatically select the knot if there is only one
createEffect(() => {
const knots = knotsQuery.data;
if (knots && knots.length === 1) {
setSelectedKnot(knots[0]);
}
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
const agent = auth.agent();
if (!agent) return;
const repoName = name().trim();
const nameError = validateRepoName(repoName);
if (nameError) {
setError(nameError);
return;
}
const knot = selectedKnot();
if (!knot) {
setError('please select a knot.');
return;
}
setSaving(true);
setError(null);
try {
// First check if the repository already exists for this user
const existingRecords = await queryClient.fetchQuery({
queryKey: ['profile-repos', auth.currentDid()],
queryFn: () => listRepoRecords(auth.currentDid()!),
});
const cleanedName = repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName;
const rkey = cleanedName.toLowerCase();
const exists = existingRecords.some(
(r) => r.rkey === rkey || (r.value.name && r.value.name.toLowerCase() === rkey)
);
if (exists) {
setError(`you already have a repository named "${cleanedName}".`);
setSaving(false);
return;
}
const result = await createRepo(agent, {
name: repoName,
description: description(),
defaultBranch: branch() || 'main',
knot: knot,
});
// Invalidate repos query for the current user
await queryClient.invalidateQueries({
queryKey: ['profile-repos', auth.currentDid()],
});
// Navigate to the newly created repository
const handle = viewerQuery.data?.handle || auth.currentDid();
navigate(`/${handle}/${result.rkey}`);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setSaving(false);
}
};
return (
<Show
when={auth.currentDid()}
fallback={
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded p-4 relative flex gap-2 items-center dark:text-white">
please log in to create a new repository.
</div>
}
>
<div class="grid grid-cols-12 dark:text-white">
<div class="col-span-full md:col-start-3 md:col-span-8 px-4 py-1 mb-2">
<h1 class="text-xl font-bold dark:text-white mb-1">create a new repository</h1>
<p class="text-gray-600 dark:text-gray-400">
repositories contain a project's files and version history. all repositories are publicly accessible.
</p>
</div>
<div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-4 md:px-6 border border-gray-200 dark:border-gray-700">
<form onSubmit={handleSubmit}>
{error() && <p class="text-red-500 dark:text-red-400 text-sm mb-3">{error()}</p>}
{/* Step 1 */}
<div class="untangled-timeline-step untangled-timeline-step-1">
<div class="untangled-timeline-circle">
<div class="untangled-timeline-circle-inner">
1
</div>
</div>
<div class="flex-1 pb-6 pt-1">
<h2 class="text-lg font-medium dark:text-white">general</h2>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-3">basic repository information.</div>
<div class="space-y-3">
{/* Repository Name */}
<div>
<label class="block text-sm font-bold dark:text-white mb-1">
REPOSITORY NAME
</label>
<div class="flex flex-col md:flex-row md:items-center w-full">
<div class="shrink-0 hidden md:flex items-center px-3 py-2 gap-1.5 text-sm text-gray-700 dark:text-gray-300 border border-r-0 border-gray-300 dark:border-gray-600 rounded-l bg-gray-50 dark:bg-gray-700 h-[38px]">
<Avatar did={auth.currentDid()!} size="size-5" />
<span>{viewerQuery.data?.handle || auth.currentDid()}</span>
</div>
<input
type="text"
id="name"
name="name"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
required
class="flex-1 md:rounded-r md:rounded-l-none rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 h-[38px]"
placeholder="repository-name"
/>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
choose a unique, descriptive name for your repository. use letters, numbers, and hyphens.
</p>
</div>
{/* Description */}
<div class="pt-2">
<label for="description" class="block text-sm font-bold dark:text-white mb-1">
DESCRIPTION
</label>
<input
type="text"
id="description"
name="description"
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
maxlength="140"
class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 h-[38px]"
placeholder="a brief description of your project..."
/>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
optional. a short description to help others understand what your project does (max 140 characters).
</p>
</div>
</div>
</div>
</div>
{/* Step 2 */}
<div class="untangled-timeline-step untangled-timeline-step-2">
<div class="untangled-timeline-circle">
<div class="untangled-timeline-circle-inner">
2
</div>
</div>
<div class="flex-1 pt-1">
<h2 class="text-lg font-medium dark:text-white">configuration</h2>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-3">repository settings and hosting.</div>
<div class="space-y-3">
{/* Default Branch */}
<div>
<label for="branch" class="block text-sm font-bold dark:text-white mb-1">
DEFAULT BRANCH
</label>
<input
type="text"
id="branch"
name="branch"
value={branch()}
onInput={(e) => setBranch(e.currentTarget.value)}
required
class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 h-[38px]"
/>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
the primary branch where development happens. common choices are "main" or "master".
</p>
</div>
{/* Knot Selection */}
<div class="pt-2">
<label class="block text-sm font-bold dark:text-white mb-1">
SELECT A KNOT
</label>
<div class="w-full space-y-1 py-0">
<Show
when={!knotsQuery.isLoading}
fallback={<div class="text-sm text-gray-500 dark:text-gray-400">loading knots...</div>}
>
<Show
when={knotsQuery.data && knotsQuery.data.length > 0}
fallback={<p class="dark:text-white">no knots available.</p>}
>
<For each={knotsQuery.data}>
{(knot) => (
<div class="flex items-center leading-tight">
<input
type="radio"
name="domain"
value={knot}
checked={selectedKnot() === knot}
onChange={() => setSelectedKnot(knot)}
class="mr-2 cursor-pointer"
id={`domain-${knot}`}
required
/>
<label for={`domain-${knot}`} class="dark:text-white lowercase cursor-pointer">
{knot}
</label>
</div>
)}
</For>
</Show>
</Show>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
a knot hosts repository data and handles git operations. you can also{' '}
<A href="/settings/knots" class="underline">
register your own knot
</A>
.
</p>
</div>
</div>
</div>
</div>
<div class="untangled-repo-form-submit flex justify-end">
<button
type="submit"
disabled={saving() || !selectedKnot()}
class="btn-create flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Show when={saving()} fallback={<BookPlus class="w-4 h-4" />}>
<LoaderCircle class="w-4 h-4 animate-spin" />
</Show>
create repository
</button>
</div>
</form>
</div>
</div>
</Show>
);
};