Refine admin workflows and team access
This commit is contained in:
@@ -5,13 +5,13 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-xl text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'inline-flex items-center justify-center rounded-xl border text-sm font-semibold shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg active:translate-y-0 active:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:translate-y-0 disabled:opacity-50 disabled:shadow-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-violet-600 text-white shadow-lg shadow-violet-500/20 hover:bg-violet-500',
|
||||
secondary: 'border border-amber-300/60 bg-white text-amber-600 hover:bg-amber-50',
|
||||
ghost: 'bg-white/70 text-slate-700 hover:bg-white',
|
||||
default: 'border-violet-600 bg-violet-600 text-white shadow-violet-500/20 hover:border-violet-500 hover:bg-violet-500 hover:shadow-violet-500/30',
|
||||
secondary: 'border-amber-300/70 bg-amber-50 text-amber-700 shadow-amber-200/30 hover:border-amber-400 hover:bg-amber-100 hover:text-amber-800 hover:shadow-amber-200/60',
|
||||
ghost: 'border-violet-200 bg-white text-violet-700 shadow-violet-100/50 hover:border-violet-300 hover:bg-violet-50 hover:text-violet-800 hover:shadow-violet-200/70',
|
||||
},
|
||||
size: {
|
||||
default: 'h-11 px-5',
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { X } from '@lucide/vue'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
size?: 'md' | 'lg' | 'xl'
|
||||
}>(), {
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const panelSizeClass = computed(() => ({
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-3xl',
|
||||
xl: 'max-w-6xl',
|
||||
}[props.size]))
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && props.open) emit('close')
|
||||
}
|
||||
@@ -38,7 +47,7 @@ onBeforeUnmount(() => {
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div class="absolute inset-0 bg-violet-950/30 backdrop-blur-sm" @click="emit('close')" />
|
||||
<div class="modal-panel relative z-10 w-full max-w-lg overflow-hidden rounded-[28px] border border-violet-200/70 bg-white shadow-[0_40px_90px_rgba(76,40,160,0.28)]">
|
||||
<div class="modal-panel relative z-10 w-full overflow-hidden rounded-[28px] border border-violet-200/70 bg-white shadow-[0_40px_90px_rgba(76,40,160,0.28)]" :class="panelSizeClass">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-violet-100 bg-[linear-gradient(135deg,#f3edff,#fff4e6)] px-6 py-5">
|
||||
<div>
|
||||
<h3 v-if="title" class="font-[Cormorant_Garamond] text-3xl text-violet-800">{{ title }}</h3>
|
||||
|
||||
@@ -1,43 +1,390 @@
|
||||
<template>
|
||||
<select
|
||||
:value="modelValue ?? ''"
|
||||
class="h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
@change="onChange"
|
||||
>
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="`${option.value}`"
|
||||
:value="stringifyValue(option.value)"
|
||||
<div ref="rootEl" class="native-select" :class="{ 'native-select--open': open, 'native-select--disabled': disabled }">
|
||||
<select
|
||||
class="native-select__native"
|
||||
:value="selectedKey"
|
||||
:disabled="disabled"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="optionKey(option.value)"
|
||||
:value="optionKey(option.value)"
|
||||
:disabled="option.disabled"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="native-select__trigger"
|
||||
:disabled="disabled"
|
||||
:aria-expanded="open"
|
||||
aria-haspopup="listbox"
|
||||
:aria-controls="listboxId"
|
||||
@click="toggleDropdown"
|
||||
@keydown="onButtonKeydown"
|
||||
>
|
||||
<span class="native-select__value">{{ selectedOption?.label ?? placeholder }}</span>
|
||||
<span class="native-select__chevron" aria-hidden="true">
|
||||
<ChevronDown :size="20" :stroke-width="2.8" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="open" :id="listboxId" class="native-select__menu" role="listbox">
|
||||
<template v-for="(option, index) in options" :key="optionKey(option.value)">
|
||||
<p v-if="option.group && option.group !== options[index - 1]?.group" class="native-select__group">
|
||||
{{ option.group }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="native-select__option"
|
||||
:class="{
|
||||
'native-select__option--active': index === activeIndex,
|
||||
'native-select__option--selected': isSelected(option),
|
||||
}"
|
||||
:disabled="option.disabled"
|
||||
role="option"
|
||||
:aria-selected="isSelected(option)"
|
||||
@mouseenter="setActiveIndex(index)"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<span class="native-select__option-label">{{ option.label }}</span>
|
||||
<Check v-if="isSelected(option)" class="native-select__check" :size="18" :stroke-width="3" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
type SelectOptionValue = string | number | null
|
||||
|
||||
type SelectOption = {
|
||||
label: string
|
||||
value: SelectOptionValue
|
||||
disabled?: boolean
|
||||
group?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: SelectOptionValue
|
||||
options: SelectOption[]
|
||||
}>()
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
placeholder: 'Auswählen',
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: SelectOptionValue]
|
||||
change: [value: SelectOptionValue, option: SelectOption]
|
||||
}>()
|
||||
|
||||
function stringifyValue(value: SelectOptionValue) {
|
||||
return value === null ? '' : String(value)
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
const open = ref(false)
|
||||
const activeIndex = ref(0)
|
||||
const listboxId = `native-select-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
const selectedOption = computed(() =>
|
||||
props.options.find((option) => isSameValue(option.value, props.modelValue)) ?? null,
|
||||
)
|
||||
|
||||
const selectedKey = computed(() => optionKey(selectedOption.value?.value ?? null))
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.options] as const,
|
||||
() => {
|
||||
const selectedIndex = props.options.findIndex((option) => isSameValue(option.value, props.modelValue))
|
||||
activeIndex.value = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function optionKey(value: SelectOptionValue) {
|
||||
return value === null ? 'null:' : `${typeof value}:${value}`
|
||||
}
|
||||
|
||||
function onChange(event: Event) {
|
||||
const rawValue = (event.target as HTMLSelectElement).value
|
||||
const selectedOption = props.options.find((option) => stringifyValue(option.value) === rawValue)
|
||||
emit('update:modelValue', selectedOption?.value ?? null)
|
||||
function isSameValue(left: SelectOptionValue, right: SelectOptionValue) {
|
||||
return optionKey(left) === optionKey(right)
|
||||
}
|
||||
|
||||
function isSelected(option: SelectOption) {
|
||||
return isSameValue(option.value, props.modelValue)
|
||||
}
|
||||
|
||||
function firstEnabledIndex() {
|
||||
return Math.max(0, props.options.findIndex((option) => !option.disabled))
|
||||
}
|
||||
|
||||
function setActiveIndex(index: number) {
|
||||
if (!props.options[index]?.disabled) {
|
||||
activeIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
if (props.disabled || props.options.length === 0) return
|
||||
open.value = !open.value
|
||||
if (open.value && props.options[activeIndex.value]?.disabled) {
|
||||
activeIndex.value = firstEnabledIndex()
|
||||
}
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function selectOption(option: SelectOption) {
|
||||
if (props.disabled || option.disabled) return
|
||||
emit('update:modelValue', option.value)
|
||||
emit('change', option.value, option)
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
function selectActiveOption() {
|
||||
const option = props.options[activeIndex.value]
|
||||
if (option) selectOption(option)
|
||||
}
|
||||
|
||||
function moveActive(delta: number) {
|
||||
if (!props.options.length || props.disabled) return
|
||||
if (!open.value) {
|
||||
open.value = true
|
||||
}
|
||||
|
||||
let nextIndex = activeIndex.value
|
||||
for (let step = 0; step < props.options.length; step += 1) {
|
||||
nextIndex = (nextIndex + delta + props.options.length) % props.options.length
|
||||
if (!props.options[nextIndex]?.disabled) {
|
||||
activeIndex.value = nextIndex
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onButtonKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
open.value ? selectActiveOption() : toggleDropdown()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (!rootEl.value?.contains(event.target as Node)) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.native-select{
|
||||
position:relative;
|
||||
width:100%;
|
||||
min-width:0;
|
||||
z-index:1;
|
||||
}
|
||||
|
||||
.native-select--open{
|
||||
z-index:45;
|
||||
}
|
||||
|
||||
.native-select__native{
|
||||
position:absolute;
|
||||
width:1px;
|
||||
height:1px;
|
||||
opacity:0;
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
.native-select__trigger{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:14px;
|
||||
width:100%;
|
||||
min-height:48px;
|
||||
padding:8px 9px 8px 15px;
|
||||
border:2px solid #e8def8;
|
||||
border-radius:16px;
|
||||
background:linear-gradient(180deg,#fff 0%,#fbf8ff 100%);
|
||||
box-shadow:0 8px 22px rgba(139,108,219,.08), inset 0 1px 0 rgba(255,255,255,.9);
|
||||
color:#1f2337;
|
||||
cursor:pointer;
|
||||
font-family:'Outfit',sans-serif;
|
||||
font-size:14px;
|
||||
font-weight:750;
|
||||
line-height:1.2;
|
||||
outline:none;
|
||||
text-align:left;
|
||||
transition:border-color .18s ease, box-shadow .18s ease, transform .18s ease, background .18s ease;
|
||||
}
|
||||
|
||||
.native-select__trigger:hover,
|
||||
.native-select__trigger:focus-visible,
|
||||
.native-select--open .native-select__trigger{
|
||||
border-color:#9b7ce4;
|
||||
background:#fff;
|
||||
transform:translateY(-2px);
|
||||
box-shadow:0 12px 30px rgba(139,108,219,.16), 0 0 0 4px rgba(139,108,219,.12);
|
||||
}
|
||||
|
||||
.native-select__trigger:active{
|
||||
transform:translateY(0);
|
||||
}
|
||||
|
||||
.native-select__trigger:disabled{
|
||||
cursor:not-allowed;
|
||||
border-color:#e5e7eb;
|
||||
background:#f8fafc;
|
||||
color:#94a3b8;
|
||||
box-shadow:none;
|
||||
}
|
||||
|
||||
.native-select__value{
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
.native-select__chevron{
|
||||
display:grid;
|
||||
place-items:center;
|
||||
flex:none;
|
||||
width:32px;
|
||||
height:32px;
|
||||
border-radius:12px;
|
||||
background:#f1eafd;
|
||||
color:#7c5bd2;
|
||||
box-shadow:inset 0 0 0 1px rgba(139,108,219,.09);
|
||||
transition:transform .18s ease, background .18s ease, color .18s ease;
|
||||
}
|
||||
|
||||
.native-select--open .native-select__chevron{
|
||||
transform:rotate(180deg);
|
||||
background:#835fd8;
|
||||
color:#fff;
|
||||
}
|
||||
|
||||
.native-select--disabled .native-select__chevron{
|
||||
background:#eef2f7;
|
||||
color:#94a3b8;
|
||||
}
|
||||
|
||||
.native-select__menu{
|
||||
position:absolute;
|
||||
top:calc(100% + 8px);
|
||||
left:0;
|
||||
right:0;
|
||||
display:grid;
|
||||
gap:4px;
|
||||
max-height:286px;
|
||||
padding:8px;
|
||||
overflow:auto;
|
||||
border:1px solid rgba(139,108,219,.18);
|
||||
border-radius:18px;
|
||||
background:rgba(255,255,255,.98);
|
||||
box-shadow:0 24px 54px rgba(63,53,86,.22);
|
||||
backdrop-filter:blur(14px);
|
||||
-webkit-backdrop-filter:blur(14px);
|
||||
}
|
||||
|
||||
.native-select__group{
|
||||
margin:8px 8px 2px;
|
||||
color:#7f728f;
|
||||
font-size:10px;
|
||||
font-weight:800;
|
||||
letter-spacing:.16em;
|
||||
line-height:1.2;
|
||||
text-transform:uppercase;
|
||||
}
|
||||
|
||||
.native-select__group:first-child{
|
||||
margin-top:2px;
|
||||
}
|
||||
|
||||
.native-select__option{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
width:100%;
|
||||
min-height:42px;
|
||||
padding:9px 12px;
|
||||
border:0;
|
||||
border-radius:13px;
|
||||
background:transparent;
|
||||
color:#514765;
|
||||
cursor:pointer;
|
||||
font-family:'Outfit',sans-serif;
|
||||
font-size:14px;
|
||||
font-weight:650;
|
||||
line-height:1.2;
|
||||
text-align:left;
|
||||
transition:background .16s ease, color .16s ease, transform .16s ease;
|
||||
}
|
||||
|
||||
.native-select__option:hover,
|
||||
.native-select__option--active{
|
||||
background:#f6f0ff;
|
||||
color:#4f3a8a;
|
||||
transform:translateY(-1px);
|
||||
}
|
||||
|
||||
.native-select__option--selected{
|
||||
background:linear-gradient(135deg,#efe6ff,#e7dcfb);
|
||||
color:#3f2f70;
|
||||
font-weight:800;
|
||||
}
|
||||
|
||||
.native-select__option:disabled{
|
||||
cursor:not-allowed;
|
||||
color:#a8a0b5;
|
||||
background:#f8fafc;
|
||||
}
|
||||
|
||||
.native-select__option-label{
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
.native-select__check{
|
||||
flex:none;
|
||||
color:#7c5bd2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div v-if="filteredCount > 0" class="flex items-center justify-between gap-4 border-t border-violet-100 px-6 py-4 text-sm text-slate-500">
|
||||
<span><strong class="text-violet-800">{{ rangeStart }}–{{ rangeEnd }}</strong> von {{ filteredCount }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
:disabled="page <= 1"
|
||||
@click="$emit('update:page', page - 1)"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
<span class="min-w-[72px] text-center font-semibold text-slate-700">Seite {{ page }}/{{ totalPages }}</span>
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
:disabled="page >= totalPages"
|
||||
@click="$emit('update:page', page + 1)"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
page: number
|
||||
totalPages: number
|
||||
rangeStart: number
|
||||
rangeEnd: number
|
||||
filteredCount: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:page': [page: number]
|
||||
}>()
|
||||
</script>
|
||||
Reference in New Issue
Block a user