diff --git a/pygitweb/api/plugins/project_zip.py b/pygitweb/api/plugins/project_zip.py
new file mode 100644
index 0000000..f5c4335
--- /dev/null
+++ b/pygitweb/api/plugins/project_zip.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+import os
+import tempfile
+import zipfile
+from pathlib import Path
+
+from pygitweb.api.project import Project
+from pygitweb.tasks import create_board_for_project
+
+
+class ProjectZip(Project):
+	def __init__(
+		self,
+		path: os.PathLike[str] | str,
+		zip_content: bytes,
+		create_task_board: bool = False,
+		do_maintenance: bool = False,
+	) -> None:
+		self.path: str = str(Path(path))
+		with tempfile.TemporaryDirectory() as tmpdir:
+			zip_path = os.path.join(tmpdir, "repo.zip")
+			with open(zip_path, "wb") as file_obj:
+				file_obj.write(zip_content)
+			with zipfile.ZipFile(zip_path, "r") as zip_file:
+				zip_file.extractall(tmpdir)
+			repo_root: str = self._discover_repo_root(tmpdir)
+			super().__init__(
+				path=self.path,
+				remote_url=repo_root,
+				create_task_board=False,
+				do_maintenance=do_maintenance,
+			)
+		if create_task_board:
+			create_board_for_project(Path(self.path).name, name="Tasks", description="")
+
+	@staticmethod
+	def _discover_repo_root(tmpdir: str) -> str:
+		for name in os.listdir(tmpdir):
+			if name == "repo.zip":
+				continue
+			candidate = os.path.join(tmpdir, name)
+			if os.path.isdir(candidate) and os.path.isdir(os.path.join(candidate, ".git")):
+				return candidate
+		if os.path.isdir(os.path.join(tmpdir, ".git")):
+			return tmpdir
+		subdirs: list[str] = [
+			name for name in os.listdir(tmpdir) if name != "repo.zip" and os.path.isdir(os.path.join(tmpdir, name))
+		]
+		if len(subdirs) == 1:
+			return os.path.join(tmpdir, subdirs[0])
+		for name in os.listdir(tmpdir):
+			if name != "repo.zip":
+				candidate = os.path.join(tmpdir, name)
+				if os.path.isdir(candidate):
+					return candidate
+		return tmpdir
+
+	@classmethod
+	def form_content(cls) -> str:
+		return (
+			'<label class="form-label" for="repo_zip">Upload .zip (git repo)</label>'
+			'<div class="dropzone border rounded p-4 text-center" id="dropzone" style="min-height: 120px;">'
+			'<input type="file" class="form-control d-none" id="repo_zip" name="repo_zip" accept=".zip" '
+			"data-dropzone-target>"
+			'<div class="dropzone-placeholder text-muted" data-dropzone-placeholder>'
+			"Drag a .zip file here or click to choose."
+			"</div>"
+			'<div class="dropzone-filename d-none text-start small mt-2" data-dropzone-filename></div>'
+			"</div>"
+		)
diff --git a/pygitweb/api/project.py b/pygitweb/api/project.py
new file mode 100644
index 0000000..9600cbe
--- /dev/null
+++ b/pygitweb/api/project.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+import contextlib
+import os
+import subprocess
+from pathlib import Path
+from types import TracebackType
+
+import pygit2
+
+from pygitweb.config import settings
+from pygitweb.tasks import create_board_for_project
+
+
+class Project:
+	"""
+	Plugin API: Project - the root class for a project which is always a directory, and _may_ contain a git repository.
+	"""
+
+	def __init__(
+		self,
+		path: os.PathLike[str] | str,
+		remote_url: str | None = None,
+		create_task_board: bool = False,
+		do_maintenance: bool = False,
+	) -> None:
+		self.path: str = str(Path(path))
+		self.remote_url: str | None = remote_url
+		os.makedirs(self.path)
+		if self.remote_url:
+			pygit2.clone_repository(self.remote_url, self.path, bare=True)
+		else:
+			pygit2.init_repository(self.path, bare=True)
+		self.repo: pygit2.Repository = pygit2.Repository(self.path)
+		if create_task_board:
+			create_board_for_project(Path(self.path).name, name="Tasks", description="")
+		if do_maintenance:
+			with contextlib.suppress(subprocess.SubprocessError, FileNotFoundError):
+				subprocess.run(
+					[settings.GIT, "-C", self.path, "maintenance", "start"],
+					capture_output=True,
+					timeout=60,
+				)
+
+	def __enter__(self) -> Project:
+		return self
+
+	def __exit__(
+		self,
+		exc_type: type[BaseException] | None,
+		exc_value: BaseException | None,
+		traceback: TracebackType | None,
+	) -> None:
+		self.repo.free()
+
+	def __del__(self) -> None:
+		self.repo.free()
+
+	@classmethod
+	def form_content(cls) -> str:
+		return (
+			'<label class="form-label" for="pull_from_remote">Pull from remote</label>'
+			'<input type="url" class="form-control" id="pull_from_remote" name="pull_from_remote" '
+			'placeholder="https://example.com/repo.git or ssh://git@host/repo.git or git://host/repo.git">'
+			'<small class="form-hint">Optional. Use an <code>ssh://</code>, <code>git://</code>, '
+			"or <code>https://</code> URL to clone an existing repository.</small>"
+		)
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 5b91819..489a534 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -6,10 +6,6 @@ Ported from gitweb/gitweb.perl dispatch and action handlers.
 from __future__ import annotations
 
 import os
-import subprocess
-import tempfile
-import zipfile
-from contextlib import suppress
 from datetime import UTC, datetime
 from pathlib import Path
 from typing import Annotated
@@ -43,6 +39,8 @@ from pygitweb.actions import (
 	summary_ref_options,
 	summary_ref_state,
 )
+from pygitweb.api.plugins.project_zip import ProjectZip
+from pygitweb.api.project import Project
 from pygitweb.config import ACTIONS, check_loadavg, settings
 from pygitweb.formatting import age_string, esc_html
 from pygitweb.git_helpers import git_get_references, git_get_type
@@ -377,7 +375,11 @@ def addproject_page(request: Request):
 		site_name=settings.SITE_NAME,
 	)
 	tpl = env.get_template("addproject.html")
-	body = tpl.render(site_name=settings.SITE_NAME)
+	body = tpl.render(
+		site_name=settings.SITE_NAME,
+		empty_repo_form_content=Project.form_content(),
+		upload_zip_form_content=ProjectZip.form_content(),
+	)
 	return HTMLResponse(status_code=200, content=f"{pre}{body}{POSTAMBLE}")
 
 
@@ -413,58 +415,24 @@ async def addproject_submit(
 	os.makedirs(settings.PROJECTROOT, exist_ok=True)
 
 	try:
-		if remote_url and remote_url != "":
-			pygit2.clone_repository(remote_url, dest_path, bare=True)
-		elif repo_zip and repo_zip.filename and repo_zip.filename.lower().endswith(".zip"):
-			with tempfile.TemporaryDirectory() as tmpdir:
-				zip_path = os.path.join(tmpdir, "repo.zip")
-				content = await repo_zip.read()
-				with open(zip_path, "wb") as f:
-					f.write(content)
-				with zipfile.ZipFile(zip_path, "r") as zf:
-					zf.extractall(tmpdir)
-				# Find .git: either at root or inside a single top-level dir
-				repo_root = None
-				for name in os.listdir(tmpdir):
-					if name == "repo.zip":
-						continue
-					p = os.path.join(tmpdir, name)
-					if os.path.isdir(p):
-						if os.path.isdir(os.path.join(p, ".git")):
-							repo_root = p
-							break
-						if name == ".git":
-							repo_root = tmpdir
-							break
-				if repo_root is None:
-					if os.path.isdir(os.path.join(tmpdir, ".git")):
-						repo_root = tmpdir
-					else:
-						# Single subdir that might be the repo
-						subs = [
-							x for x in os.listdir(tmpdir) if x != "repo.zip" and os.path.isdir(os.path.join(tmpdir, x))
-						]
-						if len(subs) == 1:
-							repo_root = os.path.join(tmpdir, subs[0])
-				if repo_root is None or not pygit2.discover_repository(repo_root):
-					raise HTTPException(
-						status_code=400,
-						detail="ZIP must contain a git repository (directory with .git)",
-					)
-				pygit2.clone_repository(repo_root, dest_path, bare=True)
+		if repo_zip and repo_zip.filename and repo_zip.filename.lower().endswith(".zip"):
+			content = await repo_zip.read()
+			with ProjectZip(
+				path=dest_path,
+				zip_content=content,
+				create_task_board=do_task_board,
+				do_maintenance=do_maintenance,
+			):
+				pass
 		else:
-			pygit2.init_repository(dest_path, bare=True)
-
-		if do_maintenance:
-			with suppress(subprocess.SubprocessError, FileNotFoundError):
-				subprocess.run(
-					[settings.GIT, "-C", dest_path, "maintenance", "start"],
-					capture_output=True,
-					timeout=60,
-				)
-
-		if do_task_board:
-			pass  # stub
+			with Project(
+				path=dest_path,
+				remote_url=remote_url or None,
+				create_task_board=do_task_board,
+				do_maintenance=do_maintenance,
+			):
+				pass
+
 	except pygit2.GitError as e:
 		raise HTTPException(status_code=400, detail=f"Git error: {e}") from e
 	except HTTPException:
diff --git a/pygitweb/static/main.css b/pygitweb/static/main.css
index 502b3d3..50c5ce1 100644
--- a/pygitweb/static/main.css
+++ b/pygitweb/static/main.css
@@ -298,6 +298,39 @@ body {
 	}
 }
 
+radio-picker {
+	display: flex;
+
+	label {
+		box-shadow: inset 0 0 1.2px 0 #000;
+		padding: 0.5rem;
+		cursor: pointer;
+		background: #0002;
+
+		&:has(input:checked) {
+			box-shadow: inset 0 0 0.2rem 0 #888;
+		}
+
+		&:has(input:focus-visible) {
+			outline: 2px solid #000;
+		}
+
+		&:hover {
+			background: #0004;
+		}
+
+		&:active {
+			background: #0006;
+		}
+	}
+
+	input {
+		opacity: 0;
+		position: absolute;
+		pointer-events: none;
+	}
+}
+
 /* Fluid vertical layout: fixed sidebar + main content offset */
 .page {
 	.navbar-vertical.position-fixed {
diff --git a/pygitweb/templates/addproject.html b/pygitweb/templates/addproject.html
index 6fc5040..34752bc 100644
--- a/pygitweb/templates/addproject.html
+++ b/pygitweb/templates/addproject.html
@@ -3,29 +3,26 @@
   <div class="card-body">
     <form action="/addproject" method="post" enctype="multipart/form-data" class="needs-validation" novalidate>
       <div class="mb-3" id="project_name_wrapper">
-        <label class="form-label required" for="project_name">Project name</label>
+        <label class="form-label required" for="project_name">Project Name</label>
         <input type="text" class="form-control" id="project_name" name="project_name" required
                placeholder="my-repo" autocomplete="off">
         <small class="form-hint">Unique name for the project (no path segments).</small>
       </div>
 
       <div class="mb-3">
-        <label class="form-label" for="pull_from_remote">Pull from remote</label>
-        <input type="url" class="form-control" id="pull_from_remote" name="pull_from_remote"
-               placeholder="https://example.com/repo.git or ssh://git@host/repo.git or git://host/repo.git">
-        <small class="form-hint">Optional. Use an <code>ssh://</code>, <code>git://</code>, or <code>https://</code> URL to clone an existing repository.</small>
-      </div>
-
-      <div class="mb-3">
-        <label class="form-label" for="repo_zip">Upload .zip (git repo)</label>
-        <div class="dropzone border rounded p-4 text-center" id="dropzone" style="min-height: 120px;">
-          <input type="file" class="form-control d-none" id="repo_zip" name="repo_zip" accept=".zip"
-                 data-dropzone-target>
-          <div class="dropzone-placeholder text-muted" data-dropzone-placeholder>
-            Drag a .zip file here or click to choose. The archive should contain a git repository (e.g. a directory with a <code>.git</code> folder).
+        <label class="form-label">Project Type</label>
+        <radio-picker aria-label="Project source" role="radiogroup">
+          <label><input type="radio" name="project_source_type" id="empty-repo" value="empty" checked>Empty Repo</label>
+          <label><input type="radio" name="project_source_type" id="upload-zip" value="zip">Upload Zip</label>
+        </radio-picker>
+        <radio-tabs>
+          <div id="tab-empty-repo" tabindex="0" class="mt-3">
+            {{ empty_repo_form_content | safe }}
           </div>
-          <div class="dropzone-filename d-none text-start small mt-2" data-dropzone-filename></div>
-        </div>
+          <div id="tab-upload-zip" tabindex="0" class="mt-3 d-none">
+            {{ upload_zip_form_content | safe }}
+          </div>
+        </radio-tabs>
       </div>
 
       <div class="mb-3">
@@ -100,6 +97,41 @@
 })();
 
 (function() {
+  var sourceRadios = Array.prototype.slice.call(document.querySelectorAll('input[name="project_source_type"]'));
+  var emptyTab = document.getElementById('tab-empty-repo');
+  var zipTab = document.getElementById('tab-upload-zip');
+  var remoteInput = document.getElementById('pull_from_remote');
+  var zipInput = document.getElementById('repo_zip');
+
+  function getSelectedSource() {
+    for (var i = 0; i < sourceRadios.length; i++) {
+      if (sourceRadios[i].checked) {
+        return sourceRadios[i].value;
+      }
+    }
+    return 'empty';
+  }
+
+  function updateSourceTabs() {
+    var selected = getSelectedSource();
+    var isZip = selected === 'zip';
+    emptyTab.classList.toggle('d-none', isZip);
+    zipTab.classList.toggle('d-none', !isZip);
+    if (isZip) {
+      remoteInput.value = '';
+    } else {
+      zipInput.value = '';
+      zipInput.dispatchEvent(new Event('change'));
+    }
+  }
+
+  sourceRadios.forEach(function(radio) {
+    radio.addEventListener('change', updateSourceTabs);
+  });
+  updateSourceTabs();
+})();
+
+(function() {
   var dropzone = document.getElementById('dropzone');
   var input = document.getElementById('repo_zip');
   var placeholder = dropzone.querySelector('[data-dropzone-placeholder]');
