big milestone!

This commit is contained in:
padreug 2025-02-11 14:43:08 +01:00
parent 2b35d6f39b
commit ac906ca6c9
28 changed files with 1332 additions and 16 deletions

278
src/components/ChatBox.vue Normal file
View file

@ -0,0 +1,278 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, watch } from 'vue'
import { Send, Copy, ExternalLink, Check, AlertCircle } from 'lucide-vue-next'
import { useNostrStore } from '@/stores/nostr'
import type { DirectMessage } from '@/types/nostr'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
const nostrStore = useNostrStore()
const input = ref('')
const inputLength = computed(() => input.value.trim().length)
const messagesEndRef = ref<HTMLDivElement | null>(null)
const isSending = ref(false)
// Group messages by sender and time
interface MessageGroup {
sent: boolean
messages: DirectMessage[]
timestamp: number
}
const groupedMessages = computed<MessageGroup[]>(() => {
const groups: MessageGroup[] = []
let currentGroup: MessageGroup | null = null
// Sort messages by timestamp first
const sortedMessages = [...nostrStore.currentMessages].sort((a, b) => a.created_at - b.created_at)
for (const message of sortedMessages) {
// Start a new group if:
// 1. No current group
// 2. Different sender than last message
// 3. More than 2 minutes since last message
if (!currentGroup ||
currentGroup.sent !== message.sent ||
message.created_at - currentGroup.messages[currentGroup.messages.length - 1].created_at > 120) {
currentGroup = {
sent: message.sent,
messages: [],
timestamp: message.created_at
}
groups.push(currentGroup)
}
currentGroup.messages.push(message)
}
return groups
})
// Scroll to bottom when new messages arrive
watch(() => nostrStore.currentMessages.length, () => {
nextTick(() => {
scrollToBottom()
})
})
onMounted(() => {
scrollToBottom()
})
function scrollToBottom() {
if (messagesEndRef.value) {
messagesEndRef.value.scrollIntoView({ behavior: 'smooth' })
}
}
const sendMessage = async (event: Event) => {
event.preventDefault()
if (inputLength.value === 0 || !nostrStore.activeChat || isSending.value) return
try {
isSending.value = true
await nostrStore.sendMessage(nostrStore.activeChat, input.value)
input.value = ''
} finally {
isSending.value = false
}
}
const formatTime = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
}
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000)
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === today.toDateString()) {
return 'Today'
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Yesterday'
}
return date.toLocaleDateString()
}
const getMessageGroupClasses = (sent: boolean) => {
return [
'group flex flex-col gap-0.5 animate-in slide-in-from-bottom-2',
sent ? 'items-end' : 'items-start'
]
}
const getMessageBubbleClasses = (sent: boolean, isFirst: boolean, isLast: boolean) => {
return [
'relative flex flex-col break-words min-w-[120px] max-w-[85%] md:max-w-[65%] text-sm',
sent
? 'bg-gradient-to-br from-[#cba6f7] to-[#b4befe] text-[#11111b] hover:brightness-105' // Gradient from Mauve to Lavender
: 'bg-gradient-to-br from-[#313244] to-[#45475a] text-[#cdd6f4] hover:brightness-105', // Gradient from Surface0 to Surface1
// First message in group
isFirst && (sent ? 'rounded-t-2xl rounded-l-2xl' : 'rounded-t-2xl rounded-r-2xl'),
// Last message in group
isLast && (sent ? 'rounded-b-2xl rounded-l-2xl' : 'rounded-b-2xl rounded-r-2xl'),
// Single message
isFirst && isLast && 'rounded-2xl',
// Middle messages
!isFirst && !isLast && (sent ? 'rounded-l-2xl' : 'rounded-r-2xl'),
// Add shadow and hover effect
'shadow-lg hover:shadow-xl transition-all duration-300 ease-out'
]
}
</script>
<template>
<Card
class="flex flex-col h-[calc(100vh-2rem)] bg-gradient-to-b from-[#1e1e2e] to-[#181825] border-[#313244] shadow-2xl overflow-hidden relative z-0">
<CardHeader
class="flex-shrink-0 flex flex-row items-center justify-between px-6 py-4 border-b border-[#313244]/50 bg-[#181825]/95 backdrop-blur-md relative z-50">
<!-- Left side with avatar and name -->
<div class="flex items-center gap-5 flex-shrink-0">
<div class="relative group">
<div
class="absolute -inset-0.5 bg-gradient-to-r from-[#cba6f7] to-[#89b4fa] rounded-full opacity-75 group-hover:opacity-100 blur transition duration-200">
</div>
<Avatar
class="relative h-11 w-11 bg-[#313244] ring-2 ring-[#cba6f7] ring-offset-2 ring-offset-[#181825] shadow-md transition-all duration-200 hover:shadow-lg group-hover:scale-105">
<AvatarFallback class="text-base font-semibold text-[#cdd6f4]">SA</AvatarFallback>
</Avatar>
</div>
<div class="hidden sm:block">
<p class="font-semibold leading-none text-[#cdd6f4] tracking-tight">Support Agent</p>
<div class="flex items-center gap-1.5 mt-1.5">
<div class="w-2 h-2 rounded-full bg-[#a6e3a1] animate-pulse"></div>
<p class="text-sm text-[#a6adc8]">Online</p>
</div>
</div>
</div>
<!-- Center badge -->
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div
class="text-xs font-medium text-[#cdd6f4] bg-gradient-to-r from-[#313244]/80 to-[#45475a]/80 backdrop-blur-sm px-4 py-2 rounded-full whitespace-nowrap shadow-lg border border-[#45475a]/50">
Customer Support
</div>
</div>
<!-- Right side spacer to maintain layout balance -->
<div class="flex items-center gap-5 flex-shrink-0 invisible">
<div class="h-11 w-11"></div>
<div class="hidden sm:block">
<div class="h-5"></div>
<div class="h-5 mt-1.5"></div>
</div>
</div>
</CardHeader>
<CardContent class="flex-1 min-h-0 p-0 bg-gradient-to-b from-[#1e1e2e] to-[#181825] overflow-hidden">
<ScrollArea class="h-full" type="hover">
<div class="flex flex-col gap-4 p-6">
<template v-for="(group, groupIndex) in groupedMessages" :key="groupIndex">
<!-- Date separator -->
<div v-if="groupIndex === 0 ||
formatDate(group.timestamp) !== formatDate(groupedMessages[groupIndex - 1].timestamp)"
class="flex justify-center my-8">
<div
class="px-4 py-1.5 rounded-full bg-gradient-to-r from-[#313244]/30 to-[#45475a]/30 text-xs font-medium text-[#a6adc8] shadow-lg backdrop-blur-sm border border-[#45475a]/20">
{{ formatDate(group.timestamp) }}
</div>
</div>
<!-- Message group -->
<div :class="getMessageGroupClasses(group.sent)" class="w-full">
<div class="flex flex-col gap-[3px] w-full" :class="{ 'items-end': group.sent }">
<div v-for="(message, messageIndex) in group.messages" :key="message.id" :class="[
getMessageBubbleClasses(
group.sent,
messageIndex === 0,
messageIndex === group.messages.length - 1
),
'w-fit backdrop-blur-sm'
]">
<div class="p-3">
{{ message.content }}
</div>
<div class="px-3 pb-1.5 text-[10px] opacity-50">
{{ formatTime(message.created_at) }}
</div>
</div>
</div>
</div>
</template>
<div ref="messagesEndRef" />
</div>
</ScrollArea>
</CardContent>
<CardFooter class="flex-shrink-0 p-4 bg-[#181825]/95 backdrop-blur-md border-t border-[#313244]/50">
<form @submit="sendMessage" class="flex items-center gap-2 w-full">
<Input
v-model="input"
type="text"
placeholder="Type your message..."
class="flex-1 bg-[#1e1e2e] border-[#313244] text-[#cdd6f4] placeholder:text-[#6c7086] focus:ring-2 focus:ring-[#cba6f7] focus:border-[#cba6f7] transition-all duration-300"
/>
<Button
type="submit"
:disabled="inputLength === 0 || isSending"
class="bg-gradient-to-r from-[#cba6f7] to-[#89b4fa] text-[#11111b] hover:brightness-110 active:brightness-90 transition-all duration-300 shadow-lg hover:shadow-xl"
>
<Send class="h-4 w-4" />
</Button>
</form>
</CardFooter>
</Card>
</template>
<style scoped>
.animate-in {
animation: animate-in 0.5s cubic-bezier(0.21, 1.02, 0.73, 1);
}
@keyframes animate-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Improved focus styles */
:focus-visible {
outline: 2px solid #cba6f7;
outline-offset: 2px;
transition: outline-offset 0.2s ease;
}
/* Enhanced button hover states */
button:not(:disabled):hover {
transform: translateY(-1px) scale(1.01);
}
button:not(:disabled):active {
transform: translateY(0) scale(0.99);
}
/* Smooth transitions */
* {
transition-property: background-color, border-color, color, fill, stroke, opacity, box-shadow, transform, filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
/* Glass morphism effects */
.backdrop-blur-sm {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
</style>

144
src/components/Login.vue Normal file
View file

@ -0,0 +1,144 @@
<template>
<div class="flex items-center justify-center min-h-screen p-4 bg-gradient-to-b from-[#1e1e2e] to-[#181825]">
<Card class="w-full max-w-md animate-in slide-in-from-bottom duration-500 bg-[#181825] border-[#313244] shadow-2xl">
<CardHeader class="space-y-6 pb-8">
<div class="flex justify-center">
<div class="relative group">
<div class="absolute -inset-1 bg-gradient-to-r from-[#cba6f7] to-[#89b4fa] rounded-full opacity-75 group-hover:opacity-100 blur transition duration-300"></div>
<div class="relative h-16 w-16 rounded-full bg-gradient-to-br from-[#313244] to-[#45475a] flex items-center justify-center shadow-xl group-hover:shadow-2xl transition duration-300">
<KeyRound class="h-8 w-8 text-[#cdd6f4] group-hover:scale-110 transition duration-300" />
</div>
</div>
</div>
<div class="text-center space-y-2.5">
<CardTitle class="text-2xl font-bold bg-gradient-to-r from-[#cba6f7] to-[#89b4fa] inline-block text-transparent bg-clip-text">
Nostr Customer Support
</CardTitle>
<CardDescription class="text-[#a6adc8]">
Login with your Nostr private key or generate a new one
</CardDescription>
</div>
</CardHeader>
<CardContent>
<form @submit.prevent="handleLogin" class="space-y-6">
<div class="space-y-4">
<div class="relative group">
<div class="absolute -inset-0.5 bg-gradient-to-r from-[#313244] to-[#45475a] rounded-xl opacity-75 group-hover:opacity-100 blur transition duration-300"></div>
<Input
v-model="privkey"
type="password"
placeholder="Enter your private key"
class="relative font-mono text-sm bg-[#1e1e2e] border-[#313244] text-[#cdd6f4] placeholder:text-[#6c7086] focus:ring-2 focus:ring-[#cba6f7] focus:border-[#cba6f7] transition-all duration-300 shadow-lg hover:border-[#45475a] rounded-lg h-11"
/>
</div>
</div>
<div class="flex flex-col gap-3">
<Button
type="submit"
class="w-full bg-gradient-to-r from-[#cba6f7] to-[#89b4fa] text-[#11111b] hover:brightness-110 active:brightness-90 transition-all duration-300 shadow-lg hover:shadow-xl h-11 rounded-lg font-medium"
:disabled="!privkey"
>
<Key class="h-4 w-4 mr-2" />
Login Securely
</Button>
<Button
type="button"
variant="outline"
@click="generateNewKey"
class="w-full bg-gradient-to-r from-[#313244]/90 to-[#45475a]/90 hover:brightness-110 active:brightness-90 text-[#cdd6f4] border-[#45475a]/50 transition-all duration-300 shadow-lg hover:shadow-xl h-11 rounded-lg font-medium backdrop-blur-sm"
:disabled="isGenerating"
>
<template v-if="isGenerating">
<div class="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-[#cba6f7] border-r-transparent" />
Generating...
</template>
<template v-else>
<ShieldCheck class="h-4 w-4 mr-2" />
Generate New Key
</template>
</Button>
</div>
</form>
</CardContent>
<CardFooter class="text-center text-sm text-[#6c7086] pb-6">
<p class="max-w-[280px] mx-auto">
Make sure to save your private key in a secure location. You'll need it to access your chat history.
</p>
</CardFooter>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useNostrStore } from '@/stores/nostr'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card'
import { generatePrivateKey } from '@/lib/nostr'
import { Key, KeyRound, ShieldCheck } from 'lucide-vue-next'
const nostrStore = useNostrStore()
const privkey = ref('')
const isGenerating = ref(false)
const generateNewKey = async () => {
isGenerating.value = true
try {
privkey.value = generatePrivateKey()
} finally {
isGenerating.value = false
}
}
const handleLogin = async () => {
if (!privkey.value) return
await nostrStore.login(privkey.value)
}
</script>
<style scoped>
.animate-in {
animation: animate-in 0.5s cubic-bezier(0.21, 1.02, 0.73, 1);
}
@keyframes animate-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Improved focus styles */
:focus-visible {
outline: 2px solid #cba6f7;
outline-offset: 2px;
transition: outline-offset 0.2s ease;
}
/* Enhanced button hover states */
button:not(:disabled):hover {
transform: translateY(-1px) scale(1.01);
}
button:not(:disabled):active {
transform: translateY(0) scale(0.99);
}
/* Smooth transitions */
* {
transition-property: background-color, border-color, color, fill, stroke, opacity, box-shadow, transform, filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
/* Glass morphism effects */
.backdrop-blur-sm {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
</style>

View file

@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useNostrStore } from '@/stores/nostr'
import { npubToHex } from '@/lib/nostr'
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Send, AlertCircle } from 'lucide-vue-next'
import ChatBox from './ChatBox.vue'
const nostrStore = useNostrStore()
const input = ref('')
const isSending = ref(false)
const error = ref('')
const SUPPORT_NPUB = import.meta.env.VITE_SUPPORT_NPUB
if (!SUPPORT_NPUB) {
error.value = 'Support public key not configured'
}
const inputLength = computed(() => input.value.trim().length)
onMounted(async () => {
try {
if (!SUPPORT_NPUB) return
const supportPubkeyHex = npubToHex(SUPPORT_NPUB)
nostrStore.activeChat = supportPubkeyHex
await nostrStore.subscribeToMessages()
} catch (err) {
console.error('Failed to initialize support chat:', err)
error.value = 'Failed to connect to support. Please try again later.'
}
})
const sendMessage = async (event: Event) => {
event.preventDefault()
if (inputLength.value === 0 || !nostrStore.activeChat || isSending.value) return
try {
isSending.value = true
await nostrStore.sendMessage(nostrStore.activeChat, input.value)
input.value = ''
error.value = ''
} catch (err) {
console.error('Failed to send message:', err)
error.value = 'Failed to send message. Please try again.'
} finally {
isSending.value = false
}
}
</script>
<template>
<Card class="flex flex-col h-[calc(100vh-8rem)] bg-card">
<!-- Header -->
<CardHeader class="flex-shrink-0 flex flex-row items-center space-x-4 px-6 py-4">
<div class="flex items-center space-x-4">
<Avatar class="h-10 w-10">
<AvatarFallback class="bg-primary/10 text-primary">SA</AvatarFallback>
</Avatar>
<div>
<h3 class="font-semibold leading-none">Support Agent</h3>
<p class="text-sm text-muted-foreground mt-1">Here to help you</p>
</div>
</div>
</CardHeader>
<!-- Chat Area -->
<CardContent class="flex-1 p-0 overflow-hidden">
<ScrollArea class="h-full" type="hover">
<div class="p-6">
<!-- Error Message -->
<div v-if="error" class="mb-4 p-4 rounded-lg bg-destructive/10 text-destructive flex items-center space-x-2">
<AlertCircle class="h-4 w-4" />
<span>{{ error }}</span>
</div>
<!-- Chat Messages -->
<ChatBox />
</div>
</ScrollArea>
</CardContent>
<!-- Input Area -->
<CardFooter class="flex-shrink-0 p-4">
<form @submit="sendMessage" class="flex w-full space-x-2">
<Input
v-model="input"
type="text"
placeholder="Type your message..."
:disabled="!!error || isSending"
class="flex-1"
/>
<Button
type="submit"
:disabled="inputLength === 0 || !!error || isSending"
class="w-10 h-10 p-0"
>
<Send class="h-4 w-4" />
</Button>
</form>
</CardFooter>
</Card>
</template>
<style scoped>
.animate-in {
animation: animate-in 0.2s ease-out;
}
@keyframes animate-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Menu, X, Sun, Moon, Zap } from 'lucide-vue-next'
import { Menu, X, Sun, Moon, Zap, MessageSquareText } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useTheme } from '@/components/theme-provider'
@ -13,6 +13,7 @@ const navigation = computed(() => [
{ name: t('nav.home'), href: '/' },
{ name: t('nav.directory'), href: '/directory' },
{ name: t('nav.faq'), href: '/faq' },
{ name: t('nav.support'), href: '/support', icon: MessageSquareText },
])
const toggleMenu = () => {
@ -45,9 +46,10 @@ const toggleLocale = () => {
<!-- Desktop Navigation -->
<div class="hidden md:flex gap-4">
<router-link v-for="item in navigation" :key="item.name" :to="item.href"
class="text-muted-foreground hover:text-foreground transition-colors" :class="{
class="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2" :class="{
'text-foreground': $route.path === item.href
}">
<component v-if="item.icon" :is="item.icon" class="h-4 w-4" />
{{ item.name }}
</router-link>
</div>
@ -80,10 +82,11 @@ const toggleLocale = () => {
[@supports(backdrop-filter:blur(24px))]:bg-background/95 [@supports(backdrop-filter:blur(24px))]:backdrop-blur-sm">
<div class="container space-y-1 py-3">
<router-link v-for="item in navigation" :key="item.name" :to="item.href"
class="block px-3 py-2 text-base font-medium text-muted-foreground hover:text-foreground transition-colors"
class="block px-3 py-2 text-base font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2"
:class="{
'text-foreground': $route.path === item.href
}" @click="isOpen = false">
<component v-if="item.icon" :is="item.icon" class="h-4 w-4" />
{{ item.name }}
</router-link>
</div>

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
:class="cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', props.class)"
>
<slot />
</div>
</template>

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
:class="cn('flex h-full w-full items-center justify-center rounded-full bg-muted', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
src?: string
alt?: string
class?: HTMLAttributes['class']
}>()
</script>
<template>
<img
:src="src"
:alt="alt"
:class="cn('aspect-square h-full w-full', props.class)"
/>
</template>

View file

@ -0,0 +1,3 @@
export { default as Avatar } from './Avatar.vue'
export { default as AvatarImage } from './AvatarImage.vue'
export { default as AvatarFallback } from './AvatarFallback.vue'

View file

@ -9,12 +9,7 @@ const props = defineProps<{
<template>
<div
:class="
cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
props.class,
)
"
:class="cn('rounded-lg border bg-card text-card-foreground shadow-sm', props.class)"
>
<slot />
</div>

View file

@ -8,7 +8,11 @@ const props = defineProps<{
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<div
:class="
cn('p-6 pt-0', props.class)
"
>
<slot />
</div>
</template>

View file

@ -8,7 +8,11 @@ const props = defineProps<{
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<p
:class="
cn('text-sm text-muted-foreground', props.class)
"
>
<slot />
</p>
</template>

View file

@ -8,7 +8,11 @@ const props = defineProps<{
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<div
:class="
cn('flex items-center p-6 pt-0', props.class)
"
>
<slot />
</div>
</template>

View file

@ -8,7 +8,11 @@ const props = defineProps<{
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<div
:class="
cn('flex flex-col space-y-1.5 p-6', props.class)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,8 @@
import { Label as LabelPrimitive } from 'radix-vue'
import { tv } from 'tailwind-variants'
export const labelVariants = tv({
base: 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
})
export const Label = LabelPrimitive.Root

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import {
ScrollAreaCorner,
ScrollAreaRoot,
type ScrollAreaRootProps,
ScrollAreaViewport,
} from 'radix-vue'
import { computed, type HTMLAttributes } from 'vue'
import ScrollBar from './ScrollBar.vue'
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ScrollAreaRoot v-bind="delegatedProps" :class="cn('relative overflow-hidden', props.class)">
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>

View file

@ -0,0 +1,42 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import {
ScrollAreaScrollbar,
ScrollAreaThumb,
type ScrollAreaScrollbarProps,
} from 'radix-vue'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<
ScrollAreaScrollbarProps & {
class?: HTMLAttributes['class']
}
>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ScrollAreaScrollbar
v-bind="delegatedProps"
:class="cn(
'flex touch-none select-none transition-colors',
props.orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
props.orientation === 'horizontal' &&
'h-2.5 border-t border-t-transparent p-[1px]',
props.class
)"
>
<ScrollAreaThumb
:class="cn(
'relative rounded-full bg-border',
props.orientation === 'vertical' && 'flex-1'
)"
/>
</ScrollAreaScrollbar>
</template>

View file

@ -0,0 +1 @@
export { default as ScrollArea } from './ScrollArea.vue'