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:
ysj7705andCursor committed 2026-07-30 16:12:12 +09:00
commit 0fd00bfa37
120 files changed
+17094

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
node_modules
.nuxt
.output
.data
*.db
*.db-journal
.env
README.md
+27
View File
@@ -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
+43
View File
@@ -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"]
+9
View File
@@ -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.
+95
View File
@@ -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>
+175
View File
@@ -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;
}
+124
View File
@@ -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;
}
}
+21
View File
@@ -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>
+21
View File
@@ -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>
+27
View File
@@ -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>
+24
View File
@@ -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>
+31
View File
@@ -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>
+35
View File
@@ -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>
+21
View File
@@ -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>
+17
View File
@@ -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>
+17
View File
@@ -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>
+17
View File
@@ -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>
+17
View File
@@ -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>
+7
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export { default as Checkbox } from './Checkbox.vue'
+19
View File
@@ -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>
+10
View File
@@ -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'
+31
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export { default as Input } from './Input.vue'
+26
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export { default as Label } from './Label.vue'
+19
View File
@@ -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>
+11
View File
@@ -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>
+1
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export { default as Skeleton } from './Skeleton.vue'
+16
View File
@@ -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>
+17
View File
@@ -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>
+17
View File
@@ -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>
+17
View File
@@ -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>
+17
View File
@@ -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>
+9
View File
@@ -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'
+10
View File
@@ -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
}
+7
View File
@@ -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))
}
+12
View File
@@ -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')
}
})
+115
View File
@@ -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>
+150
View File
@@ -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>
+81
View File
@@ -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>
+86
View File
@@ -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>
+303
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import { provideSSRWidth } from '@vueuse/core'
export default defineNuxtPlugin((nuxtApp) => {
provideSSRWidth(1024, nuxtApp.vueApp)
})
+24
View File
@@ -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": {}
}
+14
View File
@@ -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
+38
View File
@@ -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()],
},
})
+13081
View File
File diff suppressed because it is too large. Load diff
+45
View File
@@ -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"
}
}
+11
View File
@@ -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"
+36
View File
@@ -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])
}
+23
View File
@@ -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

+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
+34
View File
@@ -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,
}
})
+4
View File
@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
clearSessionCookie(event)
return { ok: true }
})
+7
View File
@@ -0,0 +1,7 @@
export default defineEventHandler(async (event) => {
const user = await readSessionUser(event)
if (!user) {
return { user: null }
}
return { user }
})
+3
View File
@@ -0,0 +1,3 @@
export default defineEventHandler(() => {
return { ok: true, service: 'registry-aio' }
})
+35
View File
@@ -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 }
})
+13
View File
@@ -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 }
})
+16
View File
@@ -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',
})
}
})
+16
View File
@@ -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 || '',
}
})
+25
View File
@@ -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 }
})
+29
View File
@@ -0,0 +1,29 @@
export default defineEventHandler(async (event) => {
await requireAdmin(event)
await ensureBootstrap()
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({ statusCode: 400, statusMessage: 'Missing id' })
}
const user = await prisma.user.findUnique({
where: { id },
include: { acls: true },
})
if (!user) {
throw createError({ statusCode: 404, statusMessage: 'User not found' })
}
return {
user: {
id: user.id,
username: user.username,
role: user.role,
active: user.active,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
acls: user.acls,
},
}
})
+77
View File
@@ -0,0 +1,77 @@
import { z } from 'zod'
const aclSchema = z.object({
repository: z.string().min(1),
canPull: z.boolean().default(true),
canPush: z.boolean().default(false),
canDelete: z.boolean().default(false),
})
const bodySchema = z.object({
password: z.string().min(6).optional(),
role: z.enum(['ADMIN', 'USER']).optional(),
active: z.boolean().optional(),
acls: z.array(aclSchema).optional(),
})
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' })
}
const existing = await prisma.user.findUnique({ where: { id } })
if (!existing) {
throw createError({ statusCode: 404, statusMessage: 'User not found' })
}
const body = bodySchema.parse(await readBody(event))
if (existing.username === (process.env.ADMIN_USERNAME || 'admin') && body.active === false) {
throw createError({ statusCode: 400, statusMessage: 'Cannot deactivate bootstrap admin' })
}
if (session.id === id && body.role && body.role !== 'ADMIN') {
throw createError({ statusCode: 400, statusMessage: 'Cannot demote yourself' })
}
const user = await prisma.$transaction(async (tx) => {
if (body.acls) {
await tx.acl.deleteMany({ where: { userId: id } })
await tx.acl.createMany({
data: body.acls.map((acl) => ({
userId: id,
repository: acl.repository,
canPull: acl.canPull,
canPush: acl.canPush,
canDelete: acl.canDelete,
})),
})
}
return tx.user.update({
where: { id },
data: {
...(body.role ? { role: body.role } : {}),
...(typeof body.active === 'boolean' ? { active: body.active } : {}),
...(body.password ? { passwordHash: await hashPassword(body.password) } : {}),
},
include: { acls: true },
})
})
return {
user: {
id: user.id,
username: user.username,
role: user.role,
active: user.active,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
acls: user.acls,
},
}
})
+21
View File
@@ -0,0 +1,21 @@
export default defineEventHandler(async (event) => {
await requireAdmin(event)
await ensureBootstrap()
const users = await prisma.user.findMany({
orderBy: { username: 'asc' },
include: { acls: true },
})
return {
users: users.map((u) => ({
id: u.id,
username: u.username,
role: u.role,
active: u.active,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
acls: u.acls,
})),
}
})
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more