diff --git a/.repos/.avatars/3 b/.repos/.avatars/3 new file mode 100644 index 0000000..62a5f8f Binary files /dev/null and b/.repos/.avatars/3 differ diff --git a/.repos/.avatars/7 b/.repos/.avatars/7 new file mode 100644 index 0000000..1c54c27 Binary files /dev/null and b/.repos/.avatars/7 differ diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9d6384b..15e38c2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -12,7 +12,7 @@ export async function bootstrapCSRF(): Promise { } } -async function getCSRFToken(): Promise { +export async function getCSRFToken(): Promise { if (csrfToken) return csrfToken await bootstrapCSRF() return csrfToken ?? '' diff --git a/frontend/src/api/queries/repos.ts b/frontend/src/api/queries/repos.ts index 71c9513..d68a966 100644 --- a/frontend/src/api/queries/repos.ts +++ b/frontend/src/api/queries/repos.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { z } from 'zod' -import { api } from '../client' +import { api, getCSRFToken } from '../client' import type { Repository, TreeEntry } from '../../types/api' const fileDiffSchema = z.object({ @@ -19,6 +19,7 @@ const repositorySchema = z.object({ ownerName: z.string(), isEmpty: z.boolean(), size: z.number().default(0), + avatarUrl: z.string().default(''), name: z.string(), description: z.string(), isPrivate: z.boolean(), @@ -91,7 +92,7 @@ export function useRepoDiff(owner: string, name: string, base: string, head: str export function useUpdateRepo(owner: string, name: string) { const queryClient = useQueryClient() return useMutation({ - mutationFn: (data: { description?: string; isPrivate?: boolean; defaultBranch?: string }) => + mutationFn: (data: { name?: string; description?: string; isPrivate?: boolean; defaultBranch?: string }) => api.patch( `/api/v1/repos/${owner}/${name}`, repositorySchema, @@ -99,7 +100,32 @@ export function useUpdateRepo(owner: string, name: string) { ), onSuccess: (updated) => { queryClient.invalidateQueries({ queryKey: ['repos'] }) - queryClient.setQueryData(['repos', owner, name], updated) + queryClient.setQueryData(['repos', updated.ownerName, updated.name], updated) + }, + }) +} + +export function useUploadRepoAvatar(owner: string, name: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (file: File) => { + const token = await getCSRFToken() + const formData = new FormData() + formData.append('avatar', file) + const res = await fetch(`/api/v1/repos/${owner}/${name}/avatar`, { + method: 'POST', + credentials: 'include', + headers: { 'X-CSRF-Token': token }, + body: formData, + }) + if (!res.ok) { + const body = await res.json().catch(() => ({ error: 'Upload failed' })) + throw new Error(body.error ?? 'Upload failed') + } + return res.json() as Promise<{ avatarUrl: string }> + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['repos', owner, name] }) }, }) } diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 2062f5c..69e1403 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -4,6 +4,7 @@ import { cn } from '../../lib/utils' import { useAuth } from '../../contexts/AuthContext' import { useRecentRepos } from '../../hooks/useRecentRepos' import { useStarredRepos } from '../../hooks/useStarredRepos' +import { RepoAvatar } from '../../ui/RepoAvatar' interface SidebarProps { className?: string @@ -71,9 +72,12 @@ export function Sidebar({ className }: SidebarProps) { {/* ── Repo context sub-nav ────────────────────────────────────── */} {currentOwner && currentRepo && (
-

- {currentOwner}/{currentRepo} -

+
+ +

+ {currentRepo} +

+
)} @@ -124,11 +128,7 @@ function RecentRepoItem({ ownerName, name, isActive, isStarred, onStar }: { )}> -
- - - -
+ {name} ))} @@ -185,23 +120,13 @@ export default function RepoSettingsPage() { - {/* ── Main content ── */} + {/* Content */}
- {/* Mobile section selector */}
- setSearchParams({ section: e.target.value })} className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none"> + {SIDEBAR.map(g => g.items.map(i => ))}
- {section === 'repository-details' ? : @@ -211,38 +136,60 @@ export default function RepoSettingsPage() { ) } -// ─── Repository details section ─────────────────────────────────────────────── +// ─── Repository details ─────────────────────────────────────────────────────── function formatSize(bytes: number): string { - if (bytes === 0) return '0 B' + if (!bytes) return '—' if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB` if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB` return `${(bytes / 1024 ** 3).toFixed(2)} GB` } function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string }) { + const navigate = useNavigate() const { data: repoData, isLoading } = useRepo(owner, repo) const updateRepo = useUpdateRepo(owner, repo) const deleteRepo = useDeleteRepo(owner, repo) - const navigate = useNavigate() + const uploadAvatar = useUploadRepoAvatar(owner, repo) + // Form state + const [name, setName] = useState('') const [description, setDescription] = useState('') const [isPrivate, setIsPrivate] = useState(false) const [defaultBranch, setDefaultBranch] = useState('') const [showAdvanced, setShowAdvanced] = useState(false) + const [showManage, setShowManage] = useState(false) const [confirmDelete, setConfirmDelete] = useState('') const [saved, setSaved] = useState(false) + const [nameError, setNameError] = useState('') + // Avatar state + const fileInputRef = useRef(null) + const [avatarPreview, setAvatarPreview] = useState(null) + const manageRef = useRef(null) + + // Initialize form from API data useEffect(() => { if (repoData) { + setName(repoData.name) setDescription(repoData.description ?? '') setIsPrivate(repoData.isPrivate) setDefaultBranch(repoData.defaultBranch) } }, [repoData]) + // Close manage dropdown on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (manageRef.current && !manageRef.current.contains(e.target as Node)) setShowManage(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, []) + const isDirty = repoData != null && ( + name !== repoData.name || description !== (repoData.description ?? '') || isPrivate !== repoData.isPrivate || defaultBranch !== repoData.defaultBranch @@ -250,24 +197,50 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string function handleDiscard() { if (!repoData) return + setName(repoData.name) setDescription(repoData.description ?? '') setIsPrivate(repoData.isPrivate) setDefaultBranch(repoData.defaultBranch) + setNameError('') + } + + function handleNameChange(val: string) { + setName(val) + if (val && !/^[a-zA-Z0-9._-]+$/.test(val)) { + setNameError('Only letters, numbers, hyphens, underscores, and dots are allowed.') + } else { + setNameError('') + } } async function handleSave(e: React.FormEvent) { e.preventDefault() - await updateRepo.mutateAsync({ description, isPrivate, defaultBranch }) - setSaved(true) - setTimeout(() => setSaved(false), 3000) + if (nameError) return + const payload: Record = { description, isPrivate, defaultBranch } + if (name !== repoData?.name) payload.name = name + const updated = await updateRepo.mutateAsync(payload) + if (updated.name !== repo) { + // Renamed — navigate to new URL + navigate(`/repos/${owner}/${updated.name}/settings?section=repository-details`, { replace: true }) + } else { + setSaved(true) + setTimeout(() => setSaved(false), 3000) + } } async function handleDelete() { - if (confirmDelete !== repo || !repoData) return + if (confirmDelete !== repo) return await deleteRepo.mutateAsync() navigate('/repos') } + const handleAvatarFile = useCallback((file: File) => { + const reader = new FileReader() + reader.onload = () => setAvatarPreview(reader.result as string) + reader.readAsDataURL(file) + uploadAvatar.mutate(file) + }, [uploadAvatar]) + if (isLoading || !repoData) { return (
@@ -278,14 +251,11 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string ) } - // Initials avatar colour based on repo name - const avatarColor = '#0052CC' - return (
{/* Page header */} -
+
{owner} @@ -295,13 +265,30 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string

Repository details

-
- + {showManage && ( +
+ +
+ )}
@@ -313,34 +300,70 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
-
- {repoData.name[0]?.toUpperCase()} -
+ {/* Clickable avatar */} +
+ +

JPEG, PNG, GIF or WebP · max 5 MB

+ {uploadAvatar.isError && ( +

{(uploadAvatar.error as Error).message}

+ )} + {uploadAvatar.isSuccess && !avatarPreview && ( +

Avatar updated.

+ )} +
+ { const f = e.target.files?.[0]; if (f) handleAvatarFile(f) }} + />
- {/* Repository name (read-only) */} + {/* Repository name */}
handleNameChange(e.target.value)} + className={`w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 ${nameError ? 'border-[#DE350B] focus:border-[#DE350B] focus:ring-[#DE350B]' : 'border-[#DFE1E6] focus:border-[#4C9AFF] focus:ring-[#4C9AFF]'}`} /> -

- Renaming would require all collaborators to update their git remotes. Coming soon. -

+ {nameError + ?

{nameError}

+ : name !== repoData.name + ?

+ + Renaming will change the clone URL — all existing git remotes will need to be updated. +

+ :

Letters, numbers, hyphens, underscores, and dots only.

+ }
{/* Size (read-only) */} @@ -357,7 +380,7 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string onChange={e => setDescription(e.target.value)} rows={4} placeholder="Describe this repository…" - className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] resize-y" + className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] focus:ring-1 focus:ring-[#4C9AFF] resize-y" />
@@ -372,8 +395,8 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string className="mt-0.5 w-4 h-4 accent-[#0052CC]" />
- - {isPrivate ? '✓ This is a private repository' : 'Make this repository private'} + + {isPrivate ? 'This is a private repository' : 'This is a public repository'}

{isPrivate @@ -391,10 +414,8 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string onClick={() => setShowAdvanced(s => !s)} className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-[#172B4D] hover:bg-[#F4F5F7] text-left" > - + Advanced @@ -402,30 +423,29 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string {showAdvanced && (

- {/* Default branch */}
setDefaultBranch(e.target.value)} - className="w-full max-w-xs border border-[#DFE1E6] bg-white rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]" placeholder="main" + className="w-full max-w-xs bg-white border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]" /> -

Used as the base branch for new pull requests.

+

The default branch used as the base for new pull requests.

{/* Danger zone */} -
-
+
+

Delete repository

- This is permanent and cannot be undone. All commits, branches, pull requests, issues, and settings will be lost. + This is permanent and cannot be undone. All commits, branches, pull requests, issues, and settings will be permanently deleted.

- Type {repo} to confirm. + Type {repo} to confirm.

- {/* Footer */} + {/* Footer: error / saved / Discard / Save */}
{updateRepo.isError && (

{(updateRepo.error as Error).message}

)} - {saved && ( + {saved && !updateRepo.isError && ( @@ -463,19 +483,12 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string Changes saved )} - -
@@ -484,46 +497,20 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string ) } -// ─── Coming soon section ────────────────────────────────────────────────────── - -const COMING_SOON_ICONS: Partial> = { - 'access-keys': ( - - - - ), - 'webhooks': ( - - - - ), - 'branch-restrictions': ( - - - - ), -} +// ─── Coming soon ────────────────────────────────────────────────────────────── function ComingSoon({ sectionId }: { sectionId: SectionId }) { const meta = SECTION_META[sectionId] - return (

{meta.title}

-
-
- {COMING_SOON_ICONS[sectionId] ?? ( - - - - )} -
+ + +

{meta.title}

{meta.description}

- - Coming soon - + Coming soon
) diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 5ce2a11..02646d3 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -21,6 +21,7 @@ export interface Repository { defaultBranch: string isEmpty: boolean size: number + avatarUrl: string createdAt: string updatedAt: string } diff --git a/frontend/src/ui/RepoAvatar.tsx b/frontend/src/ui/RepoAvatar.tsx new file mode 100644 index 0000000..0932eb1 --- /dev/null +++ b/frontend/src/ui/RepoAvatar.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react' + +interface RepoAvatarProps { + ownerName: string + name: string + avatarUrl?: string + size?: number + className?: string +} + +// Consistent color per repo name (not random on each render) +function hashColor(name: string): string { + const palette = [ + '#0052CC', '#00875A', '#FF5630', '#FF8B00', + '#6554C0', '#00B8D9', '#36B37E', '#253858', + ] + let hash = 0 + for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0 + return palette[Math.abs(hash) % palette.length] +} + +export function RepoAvatar({ ownerName, name, avatarUrl, size = 32, className = '' }: RepoAvatarProps) { + const [imgError, setImgError] = useState(false) + + // Use provided avatarUrl; fall back to the API endpoint (which 404s if not set → onError fires) + const src = avatarUrl || `/api/v1/repos/${ownerName}/${name}/avatar` + const letter = (name[0] ?? '?').toUpperCase() + const bg = hashColor(name) + const fontSize = Math.round(size * 0.42) + + const style = { width: size, height: size, minWidth: size, minHeight: size, fontSize } + + if (!imgError) { + return ( + {name} setImgError(true)} + /> + ) + } + + return ( +
+ {letter} +
+ ) +} diff --git a/internal/api/handlers/repos.go b/internal/api/handlers/repos.go index 6845936..af49b23 100644 --- a/internal/api/handlers/repos.go +++ b/internal/api/handlers/repos.go @@ -2,10 +2,13 @@ package handlers import ( "encoding/json" + "io" "net/http" "net/url" + "os" "path/filepath" "strconv" + "strings" "github.com/go-chi/chi/v5" "xorm.io/xorm" @@ -21,15 +24,40 @@ type repoResponse struct { models.Repository OwnerName string `json:"ownerName"` IsEmpty bool `json:"isEmpty"` + AvatarURL string `json:"avatarUrl"` Size int64 `json:"size"` } +func avatarPath(repoRoot string, repoID int64) string { + return filepath.Join(repoRoot, ".avatars", strconv.FormatInt(repoID, 10)) +} + +func isValidRepoName(name string) bool { + if len(name) == 0 || len(name) > 100 { + return false + } + for _, c := range name { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-') { + return false + } + } + return true +} + func (h *RepoHandler) withOwnerName(repo *models.Repository) repoResponse { var owner models.User h.db.ID(repo.OwnerID).Get(&owner) gitdomain.SetRepoRoot(h.cfg.RepoRoot) + + avURL := "" + if _, err := os.Stat(avatarPath(h.cfg.RepoRoot, repo.ID)); err == nil { + avURL = "/api/v1/repos/" + owner.Username + "/" + repo.Name + "/avatar" + } + return repoResponse{ Repository: *repo, + AvatarURL: avURL, OwnerName: owner.Username, IsEmpty: gitdomain.IsEmpty(repo.DiskPath), } @@ -354,10 +382,16 @@ func (h *RepoHandler) Update(w http.ResponseWriter, r *http.Request) { if !ok { return } + callerID, _ := middleware.UserIDFromContext(r.Context()) + if callerID != repo.OwnerID { + jsonError(w, "only the owner can update a repository", http.StatusForbidden) + return + } var body struct { - Description *string `json:"description"` - IsPrivate *bool `json:"isPrivate"` + Name *string `json:"name"` + Description *string `json:"description"` + IsPrivate *bool `json:"isPrivate"` DefaultBranch *string `json:"defaultBranch"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -366,6 +400,27 @@ func (h *RepoHandler) Update(w http.ResponseWriter, r *http.Request) { } cols := []string{} + + // Rename: update disk path and DB name atomically. + if body.Name != nil && *body.Name != "" && *body.Name != repo.Name { + newName := strings.TrimSpace(*body.Name) + if !isValidRepoName(newName) { + jsonError(w, "invalid repository name: use letters, numbers, hyphens, underscores, and dots only", http.StatusBadRequest) + return + } + newDiskPath := filepath.Join(filepath.Dir(repo.DiskPath), newName+".git") + if _, err := os.Stat(newDiskPath); !os.IsNotExist(err) { + jsonError(w, "a repository with that name already exists", http.StatusConflict) + return + } + if err := os.Rename(repo.DiskPath, newDiskPath); err != nil { + jsonError(w, "could not rename repository on disk", http.StatusInternalServerError) + return + } + repo.DiskPath = newDiskPath + repo.Name = newName + cols = append(cols, "name", "disk_path") + } if body.Description != nil { repo.Description = *body.Description cols = append(cols, "description") @@ -388,6 +443,78 @@ func (h *RepoHandler) Update(w http.ResponseWriter, r *http.Request) { jsonOK(w, h.withOwnerName(repo)) } +// GetAvatar serves the repository avatar image stored on disk. +func (h *RepoHandler) GetAvatar(w http.ResponseWriter, r *http.Request) { + repo, ok := h.lookupRepo(w, r) + if !ok { + return + } + data, err := os.ReadFile(avatarPath(h.cfg.RepoRoot, repo.ID)) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", http.DetectContentType(data)) + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Write(data) +} + +// UploadAvatar accepts a multipart image upload and stores it as the repo avatar. +func (h *RepoHandler) UploadAvatar(w http.ResponseWriter, r *http.Request) { + repo, ok := h.lookupRepo(w, r) + if !ok { + return + } + callerID, _ := middleware.UserIDFromContext(r.Context()) + if callerID != repo.OwnerID { + jsonError(w, "only the owner can change the avatar", http.StatusForbidden) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 5<<20) + if err := r.ParseMultipartForm(5 << 20); err != nil { + jsonError(w, "file too large (max 5 MB)", http.StatusBadRequest) + return + } + file, _, err := r.FormFile("avatar") + if err != nil { + jsonError(w, "avatar file is required", http.StatusBadRequest) + return + } + defer file.Close() + + // Sniff content type from first 512 bytes, then read the rest. + sniff := make([]byte, 512) + n, _ := file.Read(sniff) + ct := http.DetectContentType(sniff[:n]) + if ct != "image/jpeg" && ct != "image/png" && ct != "image/gif" && ct != "image/webp" { + jsonError(w, "unsupported image type; use JPEG, PNG, GIF, or WebP", http.StatusBadRequest) + return + } + rest, err := io.ReadAll(file) + if err != nil { + jsonError(w, "could not read file", http.StatusInternalServerError) + return + } + data := append(sniff[:n], rest...) + + avatarDir := filepath.Join(h.cfg.RepoRoot, ".avatars") + if err := os.MkdirAll(avatarDir, 0755); err != nil { + jsonError(w, "could not create avatar directory", http.StatusInternalServerError) + return + } + if err := os.WriteFile(avatarPath(h.cfg.RepoRoot, repo.ID), data, 0644); err != nil { + jsonError(w, "could not save avatar", http.StatusInternalServerError) + return + } + + var ownerUser models.User + h.db.ID(repo.OwnerID).Get(&ownerUser) + jsonOK(w, map[string]string{ + "avatarUrl": "/api/v1/repos/" + ownerUser.Username + "/" + repo.Name + "/avatar", + }) +} + func (h *RepoHandler) Delete(w http.ResponseWriter, r *http.Request) { repo, ok := h.lookupRepo(w, r) if !ok { diff --git a/internal/api/router.go b/internal/api/router.go index 9020bd1..f7e83fd 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -109,6 +109,8 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi r.With(csrf).Patch("/", repoH.Update) r.With(csrf).Delete("/", repoH.Delete) r.Get("/tree", repoH.Tree) + r.Get("/avatar", repoH.GetAvatar) + r.With(csrf).Post("/avatar", repoH.UploadAvatar) r.Get("/blob", repoH.Blob) r.With(csrf).Put("/blob", repoH.UpdateBlob) r.Get("/commits", repoH.Commits)