feat: 저장소 생성 이름 실시간 가용성 체크 + 소유자 아바타 제거
- GET /repos/name-available?slug= 추가(:repoId 앞 선언, Relay slug 중복만 검사) - 생성 폼: 형식 즉시 검사 + 350ms 디바운스 서버 조회로 상태별(빈칸/형식위반/확인중/사용가능/중복) 색·아이콘 표기 - 항상 떠 있던 정적 "사용 가능한 이름입니다" 표시 제거 - 조직 이니셜 소유자 아바타 제거 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,14 @@ export class RepoController {
|
||||
return this.repoService.getCreateMeta();
|
||||
}
|
||||
|
||||
// ':repoId' 보다 먼저 선언 — 생성 폼의 이름(slug) 실시간 가용성 체크
|
||||
@Get('name-available')
|
||||
@ApiOperation({ summary: '저장소 이름(slug) 사용 가능 여부' })
|
||||
@ApiResponse({ status: 200, description: '확인 성공' })
|
||||
checkName(@Query('slug') slug: string): Promise<{ available: boolean }> {
|
||||
return this.repoService.isNameAvailable(slug ?? '');
|
||||
}
|
||||
|
||||
@Get(':repoId')
|
||||
@ApiOperation({ summary: '저장소 단건 조회' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
|
||||
@@ -116,6 +116,17 @@ export class RepoService {
|
||||
});
|
||||
}
|
||||
|
||||
// 저장소 이름(slug) 사용 가능 여부 — 생성 폼의 실시간 체크용(Relay 중복만 검사).
|
||||
// 빈 값/형식은 프론트가 거르고, Gitea 이름 충돌은 생성 시 최종 확인한다.
|
||||
async isNameAvailable(slug: string): Promise<{ available: boolean }> {
|
||||
const trimmed = (slug ?? '').trim();
|
||||
if (!trimmed) return { available: false };
|
||||
const existing = await this.repoRepo.findOne({
|
||||
where: { slugName: trimmed },
|
||||
});
|
||||
return { available: !existing };
|
||||
}
|
||||
|
||||
// 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드).
|
||||
// 응답에 현재 사용자의 권한(canManage/isOwner)을 함께 내려, 프론트가 설정 탭 노출·진입을 결정한다.
|
||||
async findOne(slugName: string, userId: string): Promise<RepoResponse> {
|
||||
|
||||
@@ -22,6 +22,14 @@ export function useRepo() {
|
||||
return (await api.get('/repos/meta')) as unknown as RepoCreateMeta
|
||||
}
|
||||
|
||||
// 이름(slug) 사용 가능 여부 — 생성 폼 실시간 체크 (실패해도 전역 알림 X)
|
||||
async function checkName(slug: string): Promise<{ available: boolean }> {
|
||||
return (await api.get('/repos/name-available', {
|
||||
params: { slug },
|
||||
silent: true,
|
||||
})) as unknown as { available: boolean }
|
||||
}
|
||||
|
||||
async function get(id: string): Promise<ApiRepo> {
|
||||
return (await api.get(`/repos/${id}`)) as unknown as ApiRepo
|
||||
}
|
||||
@@ -38,5 +46,5 @@ export function useRepo() {
|
||||
await api.delete(`/repos/${id}`)
|
||||
}
|
||||
|
||||
return { list, meta, get, create, update, remove }
|
||||
return { list, meta, checkName, get, create, update, remove }
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
// 새 저장소 만들기 — 저장소 생성 폼
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useRepo } from '@/composables/useRepo'
|
||||
import type { Visibility } from '@/mock/relay.mock'
|
||||
|
||||
const router = useRouter()
|
||||
const repoStore = useRepoStore()
|
||||
const repoApi = useRepo()
|
||||
|
||||
// 생성 폼 메타 — 소유자(GITEA_ORG) 고정 표시 + 템플릿 가용 정보
|
||||
const { owner, templateAvailable, templateSlug } = storeToRefs(repoStore)
|
||||
const ownerInitial = computed(() => (owner.value.trim().charAt(0) || '?').toUpperCase())
|
||||
onMounted(() => {
|
||||
void repoStore.fetchCreateMeta()
|
||||
})
|
||||
@@ -27,6 +28,55 @@ const useTemplate = ref(false)
|
||||
const branch = ref('main')
|
||||
const submitting = ref(false)
|
||||
|
||||
// 이름(slug) 실시간 가용성 — 입력 형식 검사(즉시) + 중복 검사(디바운스 서버 조회)
|
||||
const SLUG_RE = /^[a-z0-9-_]+$/
|
||||
type NameStatus = 'empty' | 'invalid' | 'checking' | 'available' | 'taken'
|
||||
const nameStatus = ref<NameStatus>('empty')
|
||||
let nameTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const availMessage = computed(() => {
|
||||
switch (nameStatus.value) {
|
||||
case 'invalid':
|
||||
return '영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다.'
|
||||
case 'checking':
|
||||
return '사용 가능 여부 확인 중…'
|
||||
case 'available':
|
||||
return '사용 가능한 이름입니다'
|
||||
case 'taken':
|
||||
return '이미 사용 중인 이름입니다'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// 입력 변경 시: 빈 값/형식 위반은 즉시 판정, 형식 OK 면 디바운스 후 서버 중복 조회
|
||||
watch(repoName, (val) => {
|
||||
const slug = val.trim()
|
||||
if (nameTimer) clearTimeout(nameTimer)
|
||||
if (!slug) {
|
||||
nameStatus.value = 'empty'
|
||||
return
|
||||
}
|
||||
if (!SLUG_RE.test(slug)) {
|
||||
nameStatus.value = 'invalid'
|
||||
return
|
||||
}
|
||||
nameStatus.value = 'checking'
|
||||
nameTimer = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const { available } = await repoApi.checkName(slug)
|
||||
// 그 사이 입력이 또 바뀌었으면 결과 무시(경합 방지)
|
||||
if (repoName.value.trim() !== slug) return
|
||||
nameStatus.value = available ? 'available' : 'taken'
|
||||
} catch {
|
||||
// 확인 실패 시 표시는 감춤 — 최종 검증은 제출 시 서버가 수행
|
||||
if (repoName.value.trim() === slug) nameStatus.value = 'empty'
|
||||
}
|
||||
})()
|
||||
}, 350)
|
||||
})
|
||||
|
||||
// 생성 — 성공 시 새 저장소 상세로 이동
|
||||
async function createRepo() {
|
||||
if (submitting.value) return
|
||||
@@ -119,7 +169,6 @@ async function createRepo() {
|
||||
class="owner-fixed"
|
||||
title="소유자는 변경할 수 없습니다"
|
||||
>
|
||||
<span class="owner-avatar">{{ ownerInitial }}</span>
|
||||
{{ owner || '조직 불러오는 중…' }}
|
||||
<span class="lock">
|
||||
<svg
|
||||
@@ -150,8 +199,13 @@ async function createRepo() {
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avail">
|
||||
<div
|
||||
v-if="nameStatus !== 'empty'"
|
||||
class="avail"
|
||||
:class="nameStatus"
|
||||
>
|
||||
<svg
|
||||
v-if="nameStatus === 'available'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -161,7 +215,22 @@ async function createRepo() {
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
사용 가능한 이름입니다
|
||||
<svg
|
||||
v-else-if="nameStatus === 'invalid' || nameStatus === 'taken'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/><path d="M15 9l-6 6M9 9l6 6" />
|
||||
</svg>
|
||||
{{ availMessage }}
|
||||
</div>
|
||||
<div class="fhint below">
|
||||
영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다. 한글·공백은 입력할 수 없습니다.
|
||||
@@ -508,17 +577,6 @@ textarea.tarea {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.owner-avatar {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 0.3125rem;
|
||||
background: #0ea5a3;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.656rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.name-wrap {
|
||||
flex: 1;
|
||||
height: 2.5rem;
|
||||
@@ -561,12 +619,23 @@ textarea.tarea {
|
||||
gap: 0.375rem;
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
color: var(--green);
|
||||
margin-top: 0.5625rem;
|
||||
}
|
||||
/* 상태별 색상 — 사용 가능(초록) / 형식 위반·중복(빨강) / 확인 중(회색) */
|
||||
.avail.available {
|
||||
color: var(--green);
|
||||
}
|
||||
.avail.invalid,
|
||||
.avail.taken {
|
||||
color: var(--red);
|
||||
}
|
||||
.avail.checking {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.avail svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fixed-pill {
|
||||
font-size: 0.6875rem;
|
||||
|
||||
Reference in New Issue
Block a user