Add Docker Registry AIO with Nuxt Token Auth and admin UI.
Compose brings up registry:3 plus a Prisma/SQLite Nuxt app for docker login, ACL management, and image browsing with shadcn-vue. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
0fd00bfa37
120 files changed
+17094
No files matched your search
@@ -0,0 +1,21 @@
|
|||||||
|
# Public URL that docker clients use to reach the Nuxt auth endpoint
|
||||||
|
# Behind NPM/Traefik this should be the HTTPS URL, e.g. https://registry-ui.example.com
|
||||||
|
AUTH_PUBLIC_URL=http://localhost:3080
|
||||||
|
|
||||||
|
# Registry token auth (must match auth.token.service / issuer in registry config)
|
||||||
|
REGISTRY_AUTH_SERVICE=registry
|
||||||
|
REGISTRY_AUTH_ISSUER=registry-aio
|
||||||
|
|
||||||
|
# Bootstrap admin (seeded once on first start)
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
# Session secret for admin UI cookies (change in production)
|
||||||
|
SESSION_SECRET=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# Host ports (macOS often occupies 5000 via AirPlay Receiver)
|
||||||
|
REGISTRY_PORT=5050
|
||||||
|
APP_PORT=3080
|
||||||
|
|
||||||
|
# Internal registry URL used by the Nuxt app (compose service name)
|
||||||
|
REGISTRY_INTERNAL_URL=http://registry:5000
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
node_modules/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
.data/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
certs/*.crt
|
||||||
|
certs/*.key
|
||||||
|
certs/*.pem
|
||||||
|
!certs/.gitkeep
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
.DS_Store
|
||||||
|
app/data/
|
||||||
|
app/.env
|
||||||
|
app/node_modules/
|
||||||
|
app/.nuxt/
|
||||||
|
app/.output/
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Docker Registry AIO
|
||||||
|
|
||||||
|
공식 [`registry:3`](https://hub.docker.com/_/registry) + Nuxt 풀스택(Token Auth · 관리 UI)을 Docker Compose로 한 번에 올리는 로컬/내부망용 프로젝트입니다.
|
||||||
|
|
||||||
|
- htpasswd 대신 DB(Prisma 7 / SQLite)에서 사용자를 관리하며, 같은 계정이 `docker login`에도 사용됩니다.
|
||||||
|
- 관리 UI는 [shadcn-vue](https://www.shadcn-vue.com/) + Tailwind CSS 4 기반이며 라이트/다크 테마를 지원합니다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
| 서비스 | 포트 | 역할 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `registry` | `5050` | OCI Distribution Registry |
|
||||||
|
| `app` | `3080` | Nuxt UI + `/auth/token` + 관리 API |
|
||||||
|
|
||||||
|
## 빠른 시작
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Token Auth용 RSA 키/인증서(`certs/token.key`, `certs/token.crt`)는 Compose `certs` 서비스가 **없으면 자동 생성**합니다.
|
||||||
|
|
||||||
|
- 관리 UI: http://localhost:3080
|
||||||
|
- Registry: `localhost:5050` (macOS AirPlay가 5000을 쓰는 경우가 많아 기본값을 5050으로 둠)
|
||||||
|
- 기본 관리자: `.env`의 `ADMIN_USERNAME` / `ADMIN_PASSWORD` (기본 `admin` / `admin123`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login localhost:5050
|
||||||
|
docker tag alpine:latest localhost:5050/demo/alpine:latest
|
||||||
|
docker push localhost:5050/demo/alpine:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 중요한 환경 변수
|
||||||
|
|
||||||
|
- `AUTH_PUBLIC_URL` — **docker 클라이언트가 실제로 호출하는** Nuxt 공개 URL
|
||||||
|
- 로컬: `http://localhost:3080`
|
||||||
|
- 프록시 뒤: `https://registry-ui.example.com`
|
||||||
|
- Registry `auth.token.realm` = `${AUTH_PUBLIC_URL}/auth/token`
|
||||||
|
- `REGISTRY_PORT` / `APP_PORT` — 호스트 포트 (기본 `5050` / `3080`)
|
||||||
|
- `REGISTRY_AUTH_SERVICE` / `REGISTRY_AUTH_ISSUER` — Registry와 Auth JWT 설정 (기본 `registry` / `registry-aio`)
|
||||||
|
- 주의: registry 컨테이너에는 `REGISTRY_*` 환경변수를 직접 넣지 않습니다(공식 이미지가 설정 오버라이드로 해석함).
|
||||||
|
- `SESSION_SECRET` — 관리 UI 세션 서명 키 (운영에서 반드시 변경)
|
||||||
|
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` — 최초 admin 시드
|
||||||
|
|
||||||
|
`AUTH_PUBLIC_URL`을 바꾼 뒤에는 registry 컨테이너를 재시작하세요.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --force-recreate registry
|
||||||
|
```
|
||||||
|
|
||||||
|
## NPM / Traefik 연동
|
||||||
|
|
||||||
|
이 프로젝트는 TLS를 내장하지 않습니다. 이미 운영 중인 프록시에 연결하세요.
|
||||||
|
|
||||||
|
1. `registry.example.com` → 호스트 `:5050`
|
||||||
|
2. `registry-ui.example.com` → 호스트 `:3080` (UI + `/auth/token` + `/api`)
|
||||||
|
3. `.env`에 다음을 설정 후 Compose 재기동:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AUTH_PUBLIC_URL=https://registry-ui.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
프록시에서 Registry로 큰 이미지 업로드 시 body size 제한을 충분히 올리세요 (예: NPM의 client max body size).
|
||||||
|
|
||||||
|
## 사용자 · 권한
|
||||||
|
|
||||||
|
UI **사용자** 메뉴에서 계정을 만들고 ACL을 지정합니다.
|
||||||
|
|
||||||
|
| 역할 | 권한 |
|
||||||
|
|------|------|
|
||||||
|
| `ADMIN` | 전체 pull/push/delete, 사용자 관리 |
|
||||||
|
| `USER` | 할당된 ACL만 |
|
||||||
|
|
||||||
|
ACL 패턴 예:
|
||||||
|
|
||||||
|
- `demo/app` — 정확한 저장소
|
||||||
|
- `demo/*` — 한 단계 하위
|
||||||
|
- `team/**` — 하위 전체
|
||||||
|
- `*` — 모든 저장소
|
||||||
|
|
||||||
|
## 로컬 개발 (앱만)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d registry # certs 서비스가 키를 자동 생성
|
||||||
|
|
||||||
|
cd app
|
||||||
|
cp ../.env.example .env # 필요 시 TOKEN_* / REGISTRY_INTERNAL_URL 조정
|
||||||
|
# app/.env 예:
|
||||||
|
# DATABASE_URL="file:./dev.db"
|
||||||
|
# REGISTRY_INTERNAL_URL=http://localhost:5050
|
||||||
|
# TOKEN_KEY_PATH=../certs/token.key
|
||||||
|
# TOKEN_CERT_PATH=../certs/token.crt
|
||||||
|
npm install
|
||||||
|
npx prisma migrate dev
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
앱만 단독으로 띄울 때도 키가 없으면 부트스트랩/`openssl`로 자동 생성합니다(호스트에 openssl 필요).
|
||||||
|
|
||||||
|
## 인증 흐름
|
||||||
|
|
||||||
|
1. `docker login` / push / pull → Registry가 `401` + `WWW-Authenticate` 반환
|
||||||
|
2. 클라이언트가 `AUTH_PUBLIC_URL/auth/token`에 Basic Auth로 토큰 요청
|
||||||
|
3. Nuxt가 사용자·ACL을 확인해 RS256 JWT 발급
|
||||||
|
4. Registry가 `certs/token.crt`로 JWT 검증
|
||||||
|
|
||||||
|
Token Auth 키(`certs/token.key`, `certs/token.crt`)는 Compose `certs` 서비스가 없으면 자동 생성합니다. 새로 발급하려면:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -f certs/token.key certs/token.crt
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
## 디렉터리
|
||||||
|
|
||||||
|
```
|
||||||
|
docker-compose.yml
|
||||||
|
.env.example
|
||||||
|
config/registry/config.yml.template
|
||||||
|
certs/ # compose certs 서비스가 자동 생성 (gitignore)
|
||||||
|
app/ # Nuxt + Prisma
|
||||||
|
```
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
.env
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Prisma 7 generated client
|
||||||
|
/generated
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
FROM node:22-bookworm-slim AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
ENV DATABASE_URL="file:./dev.db"
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
openssl ca-certificates python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY prisma ./prisma
|
||||||
|
COPY prisma.config.ts ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
ENV DATABASE_URL="file:./dev.db"
|
||||||
|
ENV NUXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
openssl ca-certificates python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate && npm run build
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NUXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& mkdir -p /data
|
||||||
|
|
||||||
|
COPY --from=build /app/.output ./.output
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/package.json ./package.json
|
||||||
|
COPY --from=build /app/prisma ./prisma
|
||||||
|
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
|
||||||
|
COPY --from=build /app/generated ./generated
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Nuxt app for Docker Registry AIO
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npx prisma migrate dev
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
See the repository root README for full Compose usage.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<header
|
||||||
|
v-if="showNav"
|
||||||
|
class="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||||
|
>
|
||||||
|
<div class="mx-auto flex h-16 max-w-7xl items-center gap-6 px-4 sm:px-6">
|
||||||
|
<NuxtLink to="/" class="flex items-center gap-2 font-semibold tracking-tight">
|
||||||
|
<span class="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
|
<Boxes class="size-4" />
|
||||||
|
</span>
|
||||||
|
<span class="hidden sm:inline">Registry AIO</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<nav class="flex flex-1 items-center gap-1">
|
||||||
|
<Button
|
||||||
|
v-for="item in navigation"
|
||||||
|
:key="item.to"
|
||||||
|
as-child
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:class="route.path === item.to ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'"
|
||||||
|
>
|
||||||
|
<NuxtLink :to="item.to" class="gap-2">
|
||||||
|
<component :is="item.icon" class="size-4" />
|
||||||
|
<span class="hidden md:inline">{{ item.label }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon" aria-label="테마 전환" @click="toggleTheme">
|
||||||
|
<Sun v-if="isDark" class="size-4" />
|
||||||
|
<Moon v-else class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Badge variant="secondary" class="hidden sm:inline-flex">{{ username }}</Badge>
|
||||||
|
<Button variant="outline" size="sm" class="gap-2" @click="logout">
|
||||||
|
<LogOut class="size-4" />
|
||||||
|
<span class="hidden sm:inline">로그아웃</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="mx-auto max-w-7xl px-4 py-8 sm:px-6">
|
||||||
|
<NuxtPage />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Boxes, CircleHelp, Gauge, Images, LogOut, Moon, Sun, Users } from '@lucide/vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { data, refresh } = await useFetch<{ user: { username: string; role: string } | null }>(
|
||||||
|
'/api/auth/me',
|
||||||
|
{ key: 'auth-me' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const showNav = computed(() => route.path !== '/login' && !!data.value?.user)
|
||||||
|
const username = computed(() => data.value?.user?.username || '')
|
||||||
|
const isAdmin = computed(() => data.value?.user?.role === 'ADMIN')
|
||||||
|
const isDark = ref(true)
|
||||||
|
const navigation = computed(() => [
|
||||||
|
{ to: '/', label: '대시보드', icon: Gauge },
|
||||||
|
{ to: '/images', label: '이미지', icon: Images },
|
||||||
|
...(isAdmin.value ? [{ to: '/users', label: '사용자', icon: Users }] : []),
|
||||||
|
{ to: '/guide', label: '가이드', icon: CircleHelp },
|
||||||
|
])
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: (title) => (title ? `${title} · Registry AIO` : 'Registry AIO'),
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const saved = localStorage.getItem('registry-aio-theme')
|
||||||
|
isDark.value = saved ? saved === 'dark' : true
|
||||||
|
applyTheme()
|
||||||
|
})
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
document.documentElement.classList.toggle('dark', isDark.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
isDark.value = !isDark.value
|
||||||
|
localStorage.setItem('registry-aio-theme', isDark.value ? 'dark' : 'light')
|
||||||
|
applyTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||||
|
await refresh()
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f1419;
|
||||||
|
--bg-elevated: #1a222c;
|
||||||
|
--bg-soft: #243040;
|
||||||
|
--border: #334155;
|
||||||
|
--text: #e8eef5;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--accent: #3d9cf0;
|
||||||
|
--accent-hover: #62b0f5;
|
||||||
|
--danger: #f07178;
|
||||||
|
--success: #7fd962;
|
||||||
|
--warning: #e6c07b;
|
||||||
|
--radius: 10px;
|
||||||
|
--font: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||||
|
--mono: 'IBM Plex Mono', ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 10% -10%, rgba(61, 156, 240, 0.18), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at 100% 0%, rgba(127, 217, 98, 0.08), transparent 50%),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 0.95rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #041018;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary:hover {
|
||||||
|
border-color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger {
|
||||||
|
background: transparent;
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 50%, var(--border));
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus,
|
||||||
|
.select:focus {
|
||||||
|
outline: 1px solid var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.admin {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.off {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 40%, var(--border));
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.21 0.006 285.885);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.705 0.015 286.067);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.92 0.004 286.32);
|
||||||
|
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.552 0.016 285.938);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import type { AlertVariants } from '.'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { alertVariants } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
variant?: AlertVariants['variant']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
:class="cn(alertVariants({ variant }), props.class)"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
:class="cn('absolute top-2.5 right-3', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
:class="cn('font-medium group-has-[>svg]/alert:col-start-2 cn-font-heading [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export { default as Alert } from './Alert.vue'
|
||||||
|
export { default as AlertAction } from './AlertAction.vue'
|
||||||
|
export { default as AlertDescription } from './AlertDescription.vue'
|
||||||
|
export { default as AlertTitle } from './AlertTitle.vue'
|
||||||
|
|
||||||
|
export const alertVariants = cva('grid gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*=size-])]:size-4 group/alert relative w-full', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-card text-card-foreground',
|
||||||
|
destructive: 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import type { BadgeVariants } from '.'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Primitive } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { badgeVariants } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<PrimitiveProps & {
|
||||||
|
variant?: BadgeVariants['variant']
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="badge"
|
||||||
|
:data-variant="variant"
|
||||||
|
:class="cn(badgeVariants({ variant }), props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export { default as Badge } from './Badge.vue'
|
||||||
|
|
||||||
|
export const badgeVariants = cva(
|
||||||
|
'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||||
|
destructive: 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
|
||||||
|
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||||
|
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import type { ButtonVariants } from '.'
|
||||||
|
import { Primitive } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '.'
|
||||||
|
|
||||||
|
interface Props extends PrimitiveProps {
|
||||||
|
variant?: ButtonVariants['variant']
|
||||||
|
size?: ButtonVariants['size']
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
as: 'button',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="button"
|
||||||
|
:data-variant="variant"
|
||||||
|
:data-size="size"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export { default as Button } from './Button.vue'
|
||||||
|
|
||||||
|
export const buttonVariants = cva(
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||||
|
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||||
|
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
'default': 'h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
'xs': 'h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3',
|
||||||
|
'sm': 'h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5',
|
||||||
|
'lg': 'h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
'icon': 'size-9',
|
||||||
|
'icon-xs': 'size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*=size-])]:size-3',
|
||||||
|
'icon-sm': 'size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md',
|
||||||
|
'icon-lg': 'size-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
size?: 'default' | 'sm'
|
||||||
|
}>(), {
|
||||||
|
size: 'default',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
:data-size="size"
|
||||||
|
:class="cn('ring-foreground/10 bg-card text-card-foreground gap-6 overflow-hidden rounded-xl py-6 text-sm shadow-xs ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
:class="cn('px-6 group-data-[size=sm]/card:px-4', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
:class="cn('rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4 flex items-center', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
:class="cn('gap-1 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
:class="cn('text-base leading-normal font-medium group-data-[size=sm]/card:text-sm cn-font-heading', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { default as Card } from './Card.vue'
|
||||||
|
export { default as CardAction } from './CardAction.vue'
|
||||||
|
export { default as CardContent } from './CardContent.vue'
|
||||||
|
export { default as CardDescription } from './CardDescription.vue'
|
||||||
|
export { default as CardFooter } from './CardFooter.vue'
|
||||||
|
export { default as CardHeader } from './CardHeader.vue'
|
||||||
|
export { default as CardTitle } from './CardTitle.vue'
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { CheckIcon } from '@lucide/vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CheckboxRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="checkbox"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border shadow-xs transition-shadow group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<CheckIcon />
|
||||||
|
</slot>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Checkbox } from './Checkbox.vue'
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||||
|
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>()
|
||||||
|
const emits = defineEmits<DialogRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="dialog"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogCloseProps } from 'reka-ui'
|
||||||
|
import { DialogClose } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogCloseProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogClose
|
||||||
|
data-slot="dialog-close"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogClose>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { XIcon } from '@lucide/vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import DialogOverlay from './DialogOverlay.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes['class'], showCloseButton?: boolean }>(), {
|
||||||
|
showCloseButton: true,
|
||||||
|
})
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogContent
|
||||||
|
data-slot="dialog-content"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-xl p-6 text-sm ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
v-if="showCloseButton"
|
||||||
|
data-slot="dialog-close"
|
||||||
|
as-child
|
||||||
|
>
|
||||||
|
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm">
|
||||||
|
<XIcon />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogDescriptionProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogDescription, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription
|
||||||
|
data-slot="dialog-description"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { DialogClose } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}>(), {
|
||||||
|
showCloseButton: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<DialogClose v-if="showCloseButton" as-child>
|
||||||
|
<Button variant="outline">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
:class="cn('gap-2 flex flex-col', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogOverlayProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogOverlay } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogOverlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogOverlay>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { XIcon } from '@lucide/vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
@pointer-down-outside="(event) => {
|
||||||
|
const originalEvent = event.detail.originalEvent;
|
||||||
|
const target = originalEvent.target as HTMLElement;
|
||||||
|
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||||
|
>
|
||||||
|
<XIcon class="w-4 h-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogOverlay>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTitleProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogTitle, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
data-slot="dialog-title"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('leading-none font-medium cn-font-heading', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTriggerProps } from 'reka-ui'
|
||||||
|
import { DialogTrigger } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTrigger
|
||||||
|
data-slot="dialog-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { default as Dialog } from './Dialog.vue'
|
||||||
|
export { default as DialogClose } from './DialogClose.vue'
|
||||||
|
export { default as DialogContent } from './DialogContent.vue'
|
||||||
|
export { default as DialogDescription } from './DialogDescription.vue'
|
||||||
|
export { default as DialogFooter } from './DialogFooter.vue'
|
||||||
|
export { default as DialogHeader } from './DialogHeader.vue'
|
||||||
|
export { default as DialogOverlay } from './DialogOverlay.vue'
|
||||||
|
export { default as DialogScrollContent } from './DialogScrollContent.vue'
|
||||||
|
export { default as DialogTitle } from './DialogTitle.vue'
|
||||||
|
export { default as DialogTrigger } from './DialogTrigger.vue'
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { useVModel } from '@vueuse/core'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
defaultValue?: string | number
|
||||||
|
modelValue?: string | number
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'update:modelValue', payload: string | number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||||
|
passive: true,
|
||||||
|
defaultValue: props.defaultValue,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
v-model="modelValue"
|
||||||
|
data-slot="input"
|
||||||
|
:class="cn(
|
||||||
|
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-md border bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Input } from './Input.vue'
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { LabelProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Label } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Label
|
||||||
|
data-slot="label"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Label>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Label } from './Label.vue'
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
|
||||||
|
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<SelectRootProps>()
|
||||||
|
const emits = defineEmits<SelectRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="select"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</SelectRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
SelectContent,
|
||||||
|
SelectPortal,
|
||||||
|
SelectViewport,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||||
|
{
|
||||||
|
position: 'item-aligned',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const emits = defineEmits<SelectContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectPortal>
|
||||||
|
<SelectContent
|
||||||
|
data-slot="select-content"
|
||||||
|
:data-align-trigger="position === 'item-aligned'"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="cn(
|
||||||
|
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-md shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-translucent relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
|
||||||
|
position === 'popper'
|
||||||
|
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectViewport
|
||||||
|
:data-position="position"
|
||||||
|
:class="cn(
|
||||||
|
'data-[position=popper]:h-(--reka-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--reka-select-trigger-width)',
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectViewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectContent>
|
||||||
|
</SelectPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectGroupProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { SelectGroup } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectGroup
|
||||||
|
data-slot="select-group"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('scroll-my-1 p-1', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectGroup>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectItemProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { CheckIcon } from '@lucide/vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
SelectItem,
|
||||||
|
SelectItemIndicator,
|
||||||
|
SelectItemText,
|
||||||
|
useForwardProps,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectItem
|
||||||
|
data-slot="select-item"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||||
|
<SelectItemIndicator>
|
||||||
|
<slot name="indicator-icon">
|
||||||
|
<CheckIcon class="pointer-events-none" />
|
||||||
|
</slot>
|
||||||
|
</SelectItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectItemText>
|
||||||
|
<slot />
|
||||||
|
</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectItemTextProps } from 'reka-ui'
|
||||||
|
import { SelectItemText } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<SelectItemTextProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectItemText
|
||||||
|
data-slot="select-item-text"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectItemText>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectLabelProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { SelectLabel } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
:class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectLabel>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectScrollDownButtonProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { ChevronDownIcon } from '@lucide/vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</slot>
|
||||||
|
</SelectScrollDownButton>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectScrollUpButtonProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { ChevronUpIcon } from '@lucide/vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronUpIcon />
|
||||||
|
</slot>
|
||||||
|
</SelectScrollUpButton>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectSeparatorProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { SelectSeparator } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectSeparator
|
||||||
|
data-slot="select-separator"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('bg-border -mx-1 my-1 h-px pointer-events-none', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectTriggerProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { ChevronDownIcon } from '@lucide/vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'], size?: 'sm' | 'default' }>(),
|
||||||
|
{ size: 'default' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectTrigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
:data-size="size"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn(
|
||||||
|
'border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-md border bg-transparent py-2 pr-2 pl-2.5 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=size-])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<SelectIcon as-child>
|
||||||
|
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
|
||||||
|
</SelectIcon>
|
||||||
|
</SelectTrigger>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectValueProps } from 'reka-ui'
|
||||||
|
import { SelectValue } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<SelectValueProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectValue
|
||||||
|
data-slot="select-value"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectValue>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export { default as Select } from './Select.vue'
|
||||||
|
export { default as SelectContent } from './SelectContent.vue'
|
||||||
|
export { default as SelectGroup } from './SelectGroup.vue'
|
||||||
|
export { default as SelectItem } from './SelectItem.vue'
|
||||||
|
export { default as SelectItemText } from './SelectItemText.vue'
|
||||||
|
export { default as SelectLabel } from './SelectLabel.vue'
|
||||||
|
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
|
||||||
|
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
|
||||||
|
export { default as SelectSeparator } from './SelectSeparator.vue'
|
||||||
|
export { default as SelectTrigger } from './SelectTrigger.vue'
|
||||||
|
export { default as SelectValue } from './SelectValue.vue'
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SeparatorProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Separator } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<
|
||||||
|
SeparatorProps & { class?: HTMLAttributes['class'] }
|
||||||
|
>(), {
|
||||||
|
orientation: 'horizontal',
|
||||||
|
decorative: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Separator
|
||||||
|
data-slot="separator"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Separator } from './Separator.vue'
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<SkeletonProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
:class="cn('bg-muted rounded-md animate-pulse', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Skeleton } from './Skeleton.vue'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div data-slot="table-container" class="relative w-full overflow-x-auto">
|
||||||
|
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
|
||||||
|
<slot />
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
:class="cn('[&_tr:last-child]:border-0', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</caption>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
:class="cn('p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import TableCell from './TableCell.vue'
|
||||||
|
import TableRow from './TableRow.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
colspan?: number
|
||||||
|
}>(), {
|
||||||
|
colspan: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center py-10">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tfoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
:class="cn('text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
:class="cn('[&_tr]:border-b', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</thead>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors has-aria-expanded:bg-muted/50', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export { default as Table } from './Table.vue'
|
||||||
|
export { default as TableBody } from './TableBody.vue'
|
||||||
|
export { default as TableCaption } from './TableCaption.vue'
|
||||||
|
export { default as TableCell } from './TableCell.vue'
|
||||||
|
export { default as TableEmpty } from './TableEmpty.vue'
|
||||||
|
export { default as TableFooter } from './TableFooter.vue'
|
||||||
|
export { default as TableHead } from './TableHead.vue'
|
||||||
|
export { default as TableHeader } from './TableHeader.vue'
|
||||||
|
export { default as TableRow } from './TableRow.vue'
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Updater } from '@tanstack/vue-table'
|
||||||
|
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { isFunction } from '@tanstack/vue-table'
|
||||||
|
|
||||||
|
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
|
||||||
|
ref.value = isFunction(updaterOrValue)
|
||||||
|
? updaterOrValue(ref.value)
|
||||||
|
: updaterOrValue
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ClassValue } from "clsx"
|
||||||
|
import { clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
if (to.path === '/login') return
|
||||||
|
|
||||||
|
const { data } = await useFetch<{ user: { id: string; username: string; role: string } | null }>(
|
||||||
|
'/api/auth/me',
|
||||||
|
{ key: 'auth-me' },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.value?.user) {
|
||||||
|
return navigateTo('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">가이드</h1>
|
||||||
|
<p class="mt-1 text-muted-foreground">로컬 기동부터 프록시 연동까지 순서대로 안내합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<Info class="size-4" />
|
||||||
|
<AlertTitle>Token Auth 사용</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
사용자 정보는 Prisma DB에서 관리하며, UI 계정과 <code class="font-mono">docker login</code> 계정이 같습니다.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<span class="flex size-6 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">1</span>
|
||||||
|
서비스 기동
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>프로젝트 루트에서 실행합니다.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre class="overflow-x-auto rounded-lg border bg-muted/40 p-4 font-mono text-sm leading-7"><code>cp .env.example .env
|
||||||
|
docker compose up -d --build</code></pre>
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<div class="text-muted-foreground">UI / Auth</div>
|
||||||
|
<code class="mt-1 block font-mono">localhost:3080</code>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<div class="text-muted-foreground">Registry</div>
|
||||||
|
<code class="mt-1 block font-mono">localhost:5050</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<span class="flex size-6 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">2</span>
|
||||||
|
Docker 로그인
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>UI에서 생성한 계정으로 로그인합니다.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<pre class="overflow-x-auto rounded-lg border bg-muted/40 p-4 font-mono text-sm leading-7"><code>docker login localhost:5050
|
||||||
|
docker pull localhost:5050/demo/alpine:latest</code></pre>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Registry가 Nuxt의 <code class="font-mono">/auth/token</code>에서 JWT를 발급받아 요청 권한을 검증합니다.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<span class="flex size-6 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">3</span>
|
||||||
|
NPM / Traefik
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>TLS는 기존 리버스 프록시에서 종료합니다.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<code class="font-mono">registry.example.com</code>
|
||||||
|
<Badge variant="outline">:5050</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<code class="font-mono">registry-ui.example.com</code>
|
||||||
|
<Badge variant="outline">:3080</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto rounded-lg border bg-muted/40 p-4 font-mono text-sm"><code>AUTH_PUBLIC_URL=https://registry-ui.example.com</code></pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<span class="flex size-6 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">4</span>
|
||||||
|
ACL 패턴
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>사용자별 저장소 접근 범위를 설정합니다.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-for="item in aclPatterns" :key="item.pattern">
|
||||||
|
<TableCell class="font-mono">{{ item.pattern }}</TableCell>
|
||||||
|
<TableCell class="text-muted-foreground">{{ item.description }}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Info } from '@lucide/vue'
|
||||||
|
|
||||||
|
useHead({ title: '가이드' })
|
||||||
|
|
||||||
|
const aclPatterns = [
|
||||||
|
{ pattern: 'demo/app', description: '정확한 저장소' },
|
||||||
|
{ pattern: 'demo/*', description: 'demo 아래 한 단계' },
|
||||||
|
{ pattern: 'team/**', description: 'team 아래 전체' },
|
||||||
|
{ pattern: '*', description: '모든 저장소' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">이미지</h1>
|
||||||
|
<p class="mt-1 text-muted-foreground">레지스트리 저장소와 태그를 관리합니다.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" :disabled="pending" class="gap-2" @click="refreshAll">
|
||||||
|
<RefreshCw class="size-4" :class="{ 'animate-spin': pending }" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert v-if="error" variant="destructive">
|
||||||
|
<CircleAlert class="size-4" />
|
||||||
|
<AlertTitle>요청 실패</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Card v-if="pending">
|
||||||
|
<CardContent class="space-y-3 pt-6">
|
||||||
|
<Skeleton class="h-6 w-1/3" />
|
||||||
|
<Skeleton class="h-16 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-else-if="!repositories.length" class="border-dashed">
|
||||||
|
<CardContent class="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<PackageOpen class="mb-4 size-10 text-muted-foreground" />
|
||||||
|
<p class="font-medium">저장소가 없습니다</p>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">이미지를 push 하면 여기에 표시됩니다.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<Card v-for="repo in repositories" :key="repo" class="overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 px-6 py-5 text-left transition-colors hover:bg-muted/50"
|
||||||
|
@click="toggle(repo)"
|
||||||
|
>
|
||||||
|
<ChevronRight class="size-4 text-muted-foreground transition-transform" :class="{ 'rotate-90': open === repo }" />
|
||||||
|
<Package class="size-5" />
|
||||||
|
<span class="flex-1 font-mono text-sm font-medium">{{ repo }}</span>
|
||||||
|
<Badge variant="outline">repository</Badge>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<CardContent v-if="open === repo" class="border-t px-0 pb-0">
|
||||||
|
<div v-if="tagsPending" class="space-y-3 p-6">
|
||||||
|
<Skeleton class="h-10 w-full" />
|
||||||
|
<Skeleton class="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
<Table v-else-if="tags.length">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead class="pl-6">태그</TableHead>
|
||||||
|
<TableHead class="w-24 pr-6 text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-for="tag in tags" :key="tag">
|
||||||
|
<TableCell class="pl-6 font-mono">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Tag class="size-4 text-muted-foreground" />
|
||||||
|
{{ tag }}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="pr-6 text-right">
|
||||||
|
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive" @click="removeTag(repo, tag)">
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<p v-else class="p-6 text-sm text-muted-foreground">태그가 없습니다.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
CircleAlert,
|
||||||
|
Package,
|
||||||
|
PackageOpen,
|
||||||
|
RefreshCw,
|
||||||
|
Tag,
|
||||||
|
Trash2,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
|
||||||
|
useHead({ title: '이미지' })
|
||||||
|
|
||||||
|
const error = ref('')
|
||||||
|
const open = ref<string | null>(null)
|
||||||
|
const tags = ref<string[]>([])
|
||||||
|
const tagsPending = ref(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
pending,
|
||||||
|
refresh,
|
||||||
|
error: fetchError,
|
||||||
|
} = await useFetch<{ repositories: string[] }>('/api/repositories')
|
||||||
|
|
||||||
|
const repositories = computed(() => data.value?.repositories || [])
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (fetchError.value) {
|
||||||
|
error.value = fetchError.value.statusMessage || '레지스트리를 불러오지 못했습니다'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
error.value = ''
|
||||||
|
open.value = null
|
||||||
|
tags.value = []
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(repo: string) {
|
||||||
|
if (open.value === repo) {
|
||||||
|
open.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
open.value = repo
|
||||||
|
tagsPending.value = true
|
||||||
|
tags.value = []
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ tags: string[] }>('/api/repo-tags', { query: { name: repo } })
|
||||||
|
tags.value = res.tags
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.data?.statusMessage || '태그를 불러오지 못했습니다'
|
||||||
|
} finally {
|
||||||
|
tagsPending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTag(name: string, tag: string) {
|
||||||
|
if (!confirm(`${name}:${tag} 을(를) 삭제할까요?`)) return
|
||||||
|
try {
|
||||||
|
await $fetch('/api/repo-tags', { method: 'DELETE', body: { name, tag } })
|
||||||
|
tags.value = tags.value.filter((t) => t !== tag)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.data?.statusMessage || '삭제에 실패했습니다'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">대시보드</h1>
|
||||||
|
<p class="mt-1 text-muted-foreground">레지스트리와 사용자 현황을 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle class="text-sm font-medium">사용자</CardTitle>
|
||||||
|
<Users class="size-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="text-3xl font-bold">{{ stats?.users ?? '—' }}</div>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">등록된 로그인 계정</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle class="text-sm font-medium">저장소</CardTitle>
|
||||||
|
<Package class="size-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="text-3xl font-bold">{{ stats?.repositories ?? '—' }}</div>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">등록된 이미지 저장소</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2">
|
||||||
|
<Terminal class="size-5" />
|
||||||
|
빠른 시작
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Docker CLI에서 로그인한 뒤 이미지를 push 하세요.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="relative rounded-lg border bg-muted/40 p-4">
|
||||||
|
<pre class="overflow-x-auto font-mono text-sm leading-7"><code>docker login {{ registryHost }}
|
||||||
|
docker tag alpine:latest {{ registryHost }}/demo/alpine:latest
|
||||||
|
docker push {{ registryHost }}/demo/alpine:latest</code></pre>
|
||||||
|
<Button variant="ghost" size="icon" class="absolute right-2 top-2" title="명령 복사" @click="copyCommands">
|
||||||
|
<Check v-if="copied" class="size-4 text-green-500" />
|
||||||
|
<Copy v-else class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Check, Copy, Package, Terminal, Users } from '@lucide/vue'
|
||||||
|
|
||||||
|
useHead({ title: '대시보드' })
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const copied = ref(false)
|
||||||
|
const { data: stats } = await useFetch<{
|
||||||
|
users: number
|
||||||
|
repositories: number
|
||||||
|
}>('/api/stats')
|
||||||
|
|
||||||
|
const registryHost = computed(() => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
return `${window.location.hostname}:${config.public.registryPort || '5050'}`
|
||||||
|
}
|
||||||
|
return `localhost:${config.public.registryPort || '5050'}`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function copyCommands() {
|
||||||
|
const host = registryHost.value
|
||||||
|
await navigator.clipboard.writeText(
|
||||||
|
`docker login ${host}\ndocker tag alpine:latest ${host}/demo/alpine:latest\ndocker push ${host}/demo/alpine:latest`,
|
||||||
|
)
|
||||||
|
copied.value = true
|
||||||
|
window.setTimeout(() => (copied.value = false), 1500)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 grid min-h-screen place-items-center bg-muted/30 p-4">
|
||||||
|
<div class="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_top,_var(--color-muted),_transparent_45%)]" />
|
||||||
|
<Card class="w-full max-w-sm shadow-xl">
|
||||||
|
<CardHeader class="space-y-4 text-center">
|
||||||
|
<div class="mx-auto flex size-12 items-center justify-center rounded-xl bg-primary text-primary-foreground">
|
||||||
|
<Boxes class="size-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle class="text-2xl">Registry AIO</CardTitle>
|
||||||
|
<CardDescription class="mt-2">관리 콘솔에 로그인하세요</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form class="space-y-4" @submit.prevent="submit">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="username">사용자명</Label>
|
||||||
|
<Input id="username" v-model="username" autocomplete="username" placeholder="admin" required autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="password">비밀번호</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert v-if="error" variant="destructive">
|
||||||
|
<CircleAlert class="size-4" />
|
||||||
|
<AlertTitle>로그인 실패</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Button class="w-full" type="submit" :disabled="pending">
|
||||||
|
<LoaderCircle v-if="pending" class="size-4 animate-spin" />
|
||||||
|
{{ pending ? '로그인 중…' : '로그인' }}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter class="justify-center border-t py-4 text-xs text-muted-foreground">
|
||||||
|
Docker Registry Token Authentication
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Boxes, CircleAlert, LoaderCircle } from '@lucide/vue'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: '로그인', htmlAttrs: { class: 'dark' } })
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const pending = ref(false)
|
||||||
|
|
||||||
|
const { data, refresh } = await useFetch<{ user: unknown | null }>('/api/auth/me', { key: 'auth-me' })
|
||||||
|
if (data.value?.user) {
|
||||||
|
await navigateTo('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = ''
|
||||||
|
pending.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { username: username.value, password: password.value },
|
||||||
|
})
|
||||||
|
await refresh()
|
||||||
|
await navigateTo('/')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.data?.statusMessage || e?.statusMessage || '로그인에 실패했습니다'
|
||||||
|
} finally {
|
||||||
|
pending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">사용자</h1>
|
||||||
|
<p class="mt-1 text-muted-foreground">Docker 로그인 계정과 저장소 ACL을 관리합니다.</p>
|
||||||
|
</div>
|
||||||
|
<Button class="gap-2" @click="startCreate">
|
||||||
|
<UserPlus class="size-4" />
|
||||||
|
사용자 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert v-if="error" variant="destructive">
|
||||||
|
<CircleAlert class="size-4" />
|
||||||
|
<AlertTitle>요청 실패</AlertTitle>
|
||||||
|
<AlertDescription>{{ error }}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead class="pl-6">사용자</TableHead>
|
||||||
|
<TableHead>역할</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead>ACL</TableHead>
|
||||||
|
<TableHead class="w-28 pr-6 text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-for="user in users" :key="user.id">
|
||||||
|
<TableCell class="pl-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="flex size-8 items-center justify-center rounded-full bg-muted">
|
||||||
|
<UserRound class="size-4" />
|
||||||
|
</span>
|
||||||
|
<span class="font-mono font-medium">{{ user.username }}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge :variant="user.role === 'ADMIN' ? 'default' : 'secondary'">{{ user.role }}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="size-2 rounded-full" :class="user.active ? 'bg-emerald-500' : 'bg-muted-foreground'" />
|
||||||
|
{{ user.active ? '활성' : '비활성' }}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge v-if="user.role === 'ADMIN'" variant="outline">전체 권한</Badge>
|
||||||
|
<div v-else class="flex max-w-md flex-wrap gap-1.5">
|
||||||
|
<Badge v-for="acl in user.acls" :key="acl.id" variant="outline" class="font-mono font-normal">
|
||||||
|
{{ acl.repository }}
|
||||||
|
·
|
||||||
|
{{
|
||||||
|
[
|
||||||
|
acl.canPull ? 'pull' : null,
|
||||||
|
acl.canPush ? 'push' : null,
|
||||||
|
acl.canDelete ? 'delete' : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('/')
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<span v-if="!user.acls.length" class="text-sm text-muted-foreground">ACL 없음</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="pr-6 text-right">
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" title="편집" @click="startEdit(user)">
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="text-destructive hover:text-destructive"
|
||||||
|
title="삭제"
|
||||||
|
@click="removeUser(user)"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog v-model:open="editing">
|
||||||
|
<DialogContent class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||||
|
<form class="space-y-5" @submit.prevent="save">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ form.id ? '사용자 편집' : '사용자 추가' }}</DialogTitle>
|
||||||
|
<DialogDescription>이 계정은 관리 UI와 Docker CLI 로그인에 함께 사용됩니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="username">사용자명</Label>
|
||||||
|
<Input id="username" v-model="form.username" class="font-mono" :disabled="!!form.id" required />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="password">비밀번호 {{ form.id ? '(비워두면 유지)' : '' }}</Label>
|
||||||
|
<Input id="password" v-model="form.password" type="password" :required="!form.id" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>역할</Label>
|
||||||
|
<Select v-model="form.role">
|
||||||
|
<SelectTrigger class="w-full">
|
||||||
|
<SelectValue placeholder="역할 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="USER">USER</SelectItem>
|
||||||
|
<SelectItem value="ADMIN">ADMIN</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end pb-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Checkbox v-model="form.active" />
|
||||||
|
활성 계정
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div v-if="form.role === 'USER'" class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>저장소 ACL</Label>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">저장소 패턴별 허용 작업을 선택하세요.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" type="button" class="gap-2" @click="addAcl">
|
||||||
|
<Plus class="size-4" />
|
||||||
|
ACL 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Card v-for="(acl, idx) in form.acls" :key="idx" class="bg-muted/30">
|
||||||
|
<CardContent class="space-y-3 pt-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
v-model="acl.repository"
|
||||||
|
class="font-mono"
|
||||||
|
placeholder="예: demo/* 또는 library/nginx"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="icon" type="button" @click="form.acls.splice(idx, 1)">
|
||||||
|
<X class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-5">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox v-model="acl.canPull" /> pull
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox v-model="acl.canPush" /> push
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox v-model="acl.canDelete" /> delete
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" type="button" @click="editing = false">취소</Button>
|
||||||
|
<Button type="submit" :disabled="saving">
|
||||||
|
<LoaderCircle v-if="saving" class="size-4 animate-spin" />
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
CircleAlert,
|
||||||
|
LoaderCircle,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
UserPlus,
|
||||||
|
UserRound,
|
||||||
|
X,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
|
||||||
|
useHead({ title: '사용자' })
|
||||||
|
|
||||||
|
type AclForm = {
|
||||||
|
repository: string
|
||||||
|
canPull: boolean
|
||||||
|
canPush: boolean
|
||||||
|
canDelete: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserRow = {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
role: 'ADMIN' | 'USER'
|
||||||
|
active: boolean
|
||||||
|
acls: Array<AclForm & { id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = ref('')
|
||||||
|
const editing = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const form = reactive({
|
||||||
|
id: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
role: 'USER' as 'ADMIN' | 'USER',
|
||||||
|
active: true,
|
||||||
|
acls: [] as AclForm[],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data, refresh } = await useFetch<{ users: UserRow[] }>('/api/users')
|
||||||
|
const users = computed(() => data.value?.users || [])
|
||||||
|
|
||||||
|
const me = await useFetch<{ user: { role: string } | null }>('/api/auth/me', { key: 'auth-me' })
|
||||||
|
if (me.data.value?.user?.role !== 'ADMIN') {
|
||||||
|
await navigateTo('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCreate() {
|
||||||
|
form.id = ''
|
||||||
|
form.username = ''
|
||||||
|
form.password = ''
|
||||||
|
form.role = 'USER'
|
||||||
|
form.active = true
|
||||||
|
form.acls = [{ repository: '*', canPull: true, canPush: true, canDelete: false }]
|
||||||
|
editing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(user: UserRow) {
|
||||||
|
form.id = user.id
|
||||||
|
form.username = user.username
|
||||||
|
form.password = ''
|
||||||
|
form.role = user.role
|
||||||
|
form.active = user.active
|
||||||
|
form.acls = user.acls.map((a) => ({
|
||||||
|
repository: a.repository,
|
||||||
|
canPull: a.canPull,
|
||||||
|
canPush: a.canPush,
|
||||||
|
canDelete: a.canDelete,
|
||||||
|
}))
|
||||||
|
editing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAcl() {
|
||||||
|
form.acls.push({ repository: '', canPull: true, canPush: false, canDelete: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
if (form.id) {
|
||||||
|
await $fetch(`/api/users/${form.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
...(form.password ? { password: form.password } : {}),
|
||||||
|
role: form.role,
|
||||||
|
active: form.active,
|
||||||
|
acls: form.role === 'USER' ? form.acls : [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await $fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
username: form.username,
|
||||||
|
password: form.password,
|
||||||
|
role: form.role,
|
||||||
|
active: form.active,
|
||||||
|
acls: form.role === 'USER' ? form.acls : [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
editing.value = false
|
||||||
|
await refresh()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.data?.statusMessage || '저장에 실패했습니다'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUser(user: UserRow) {
|
||||||
|
if (!confirm(`${user.username} 사용자를 삭제할까요?`)) return
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/users/${user.id}`, { method: 'DELETE' })
|
||||||
|
await refresh()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.data?.statusMessage || '삭제에 실패했습니다'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { provideSSRWidth } from '@vueuse/core'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
provideSSRWidth(1024, nuxtApp.vueApp)
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-vue.com/schema.json",
|
||||||
|
"style": "reka-vega",
|
||||||
|
"font": "geist-sans",
|
||||||
|
"typescript": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/assets/css/tailwind.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"pointer": true,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"composables": "@/composables"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
mkdir -p /data
|
||||||
|
|
||||||
|
if [ -z "${DATABASE_URL:-}" ]; then
|
||||||
|
export DATABASE_URL="file:/data/registry-aio.db"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running prisma migrate deploy..."
|
||||||
|
./node_modules/.bin/prisma migrate deploy
|
||||||
|
|
||||||
|
echo "Starting Nuxt..."
|
||||||
|
exec node .output/server/index.mjs
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
devtools: { enabled: false },
|
||||||
|
css: ['~/assets/css/tailwind.css'],
|
||||||
|
modules: ['shadcn-nuxt'],
|
||||||
|
shadcn: {
|
||||||
|
prefix: '',
|
||||||
|
componentDir: './app/components/ui',
|
||||||
|
},
|
||||||
|
runtimeConfig: {
|
||||||
|
sessionSecret: process.env.SESSION_SECRET || '',
|
||||||
|
databaseUrl: process.env.DATABASE_URL || '',
|
||||||
|
registryInternalUrl: process.env.REGISTRY_INTERNAL_URL || 'http://registry:5000',
|
||||||
|
tokenKeyPath: process.env.TOKEN_KEY_PATH || '',
|
||||||
|
tokenCertPath: process.env.TOKEN_CERT_PATH || '',
|
||||||
|
public: {
|
||||||
|
authPublicUrl: process.env.AUTH_PUBLIC_URL || 'http://localhost:3000',
|
||||||
|
registryPort: process.env.REGISTRY_PORT || '5050',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nitro: {
|
||||||
|
esbuild: {
|
||||||
|
options: {
|
||||||
|
target: 'esnext',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moduleSideEffects: ['better-sqlite3', '@prisma/adapter-better-sqlite3'],
|
||||||
|
externals: {
|
||||||
|
external: ['better-sqlite3', '@prisma/client', '@prisma/adapter-better-sqlite3'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
},
|
||||||
|
})
|
||||||
Generated
+13081
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "registry-aio-app",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare && prisma generate",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate deploy",
|
||||||
|
"db:migrate": "prisma migrate deploy"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@lucide/vue": "^1.27.0",
|
||||||
|
"@prisma/adapter-better-sqlite3": "^7.9.1",
|
||||||
|
"@prisma/client": "^7.9.1",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
"@vueuse/core": "^14.4.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^13.0.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"jose": "^6.2.5",
|
||||||
|
"nuxt": "^4.5.1",
|
||||||
|
"prisma": "^7.9.1",
|
||||||
|
"reka-ui": "^2.10.1",
|
||||||
|
"shadcn-nuxt": "^2.8.1",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"vue": "^3.5.40",
|
||||||
|
"vue-router": "^5.2.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^26.1.2",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "5.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'prisma/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
migrations: {
|
||||||
|
path: 'prisma/migrations',
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env.DATABASE_URL || 'file:./dev.db',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL DEFAULT 'USER',
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Acl" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"repository" TEXT NOT NULL,
|
||||||
|
"canPull" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"canPush" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"canDelete" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
CONSTRAINT "Acl_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Acl_userId_idx" ON "Acl"("userId");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
username String @unique
|
||||||
|
passwordHash String
|
||||||
|
role Role @default(USER)
|
||||||
|
active Boolean @default(true)
|
||||||
|
acls Acl[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Acl {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
repository String
|
||||||
|
canPull Boolean @default(true)
|
||||||
|
canPush Boolean @default(false)
|
||||||
|
canDelete Boolean @default(false)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { PrismaClient } from '../../generated/prisma/client'
|
||||||
|
import { hashPassword } from '../server/utils/password'
|
||||||
|
|
||||||
|
type SeedPrisma = Pick<PrismaClient, 'user'>
|
||||||
|
|
||||||
|
export async function seedAdmin(prisma: SeedPrisma) {
|
||||||
|
const username = process.env.ADMIN_USERNAME || 'admin'
|
||||||
|
const password = process.env.ADMIN_PASSWORD || 'admin123'
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { username } })
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
passwordHash: await hashPassword(password),
|
||||||
|
role: 'ADMIN',
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await ensureBootstrap()
|
||||||
|
const body = bodySchema.parse(await readBody(event))
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { username: body.username } })
|
||||||
|
if (!user || !user.active) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await verifyPassword(body.password, user.passwordHash)
|
||||||
|
if (!ok) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await createSessionToken({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
})
|
||||||
|
setSessionCookie(event, token)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
clearSessionCookie(event)
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await readSessionUser(event)
|
||||||
|
if (!user) {
|
||||||
|
return { user: null }
|
||||||
|
}
|
||||||
|
return { user }
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default defineEventHandler(() => {
|
||||||
|
return { ok: true, service: 'registry-aio' }
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
tag: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await requireSession(event)
|
||||||
|
await ensureBootstrap()
|
||||||
|
|
||||||
|
const body = bodySchema.parse(await readBody(event))
|
||||||
|
|
||||||
|
if (session.role !== 'ADMIN') {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.id },
|
||||||
|
include: { acls: true },
|
||||||
|
})
|
||||||
|
if (!user) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
const allowed = allowedActionsForRepo(user, body.name)
|
||||||
|
if (!allowed.includes('delete') && !allowed.includes('*')) {
|
||||||
|
throw createError({ statusCode: 403, statusMessage: 'Delete not allowed' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = await getManifestDigest(body.name, body.tag)
|
||||||
|
if (!digest) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Tag not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteManifest(body.name, digest)
|
||||||
|
return { ok: true, name: body.name, tag: body.tag, digest }
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireSession(event)
|
||||||
|
await ensureBootstrap()
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
const name = typeof query.name === 'string' ? query.name : ''
|
||||||
|
if (!name) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Missing name query parameter' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = await listTags(name)
|
||||||
|
return { name, tags }
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireSession(event)
|
||||||
|
await ensureBootstrap()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repositories = await listRepositories()
|
||||||
|
return { repositories }
|
||||||
|
} catch (err: any) {
|
||||||
|
// Registry may be unreachable during local UI-only boot
|
||||||
|
if (err?.statusCode) throw err
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
statusMessage: err?.message || 'Failed to reach registry',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireSession(event)
|
||||||
|
await ensureBootstrap()
|
||||||
|
|
||||||
|
const [userCount, repositories] = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
listRepositories().catch(() => [] as string[]),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: userCount,
|
||||||
|
repositories: repositories.length,
|
||||||
|
authPublicUrl: process.env.AUTH_PUBLIC_URL || '',
|
||||||
|
registryInternalUrl: process.env.REGISTRY_INTERNAL_URL || '',
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await requireAdmin(event)
|
||||||
|
await ensureBootstrap()
|
||||||
|
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
if (!id) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Missing id' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.id === id) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Cannot delete yourself' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { id } })
|
||||||
|
if (!existing) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'User not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.username === (process.env.ADMIN_USERNAME || 'admin')) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Cannot delete bootstrap admin' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.delete({ where: { id } })
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user