feat(app): glassmorphism chat UI with widget embed system
- Glassmorphism chat window matching Proux design rules (glass layers, gradient backgrounds, glow effects, blur intensities, inner glows) - Conversation ID displayed in header, side-switching (left/right layout) - Chat store with Pinia: conversations, messages, streaming state - OpenRouter AI integration with SSE streaming (Llama 4 Maverick free model) - Chat components: ChatWindow, ChatHeader, ChatInput, ChatMessage, StreamingDots (typing indicator) - Embeddable widget system: floating action button (FAB) + modal popup with scale-in animation, side-aware positioning - Widget demo page (/widget-demo) showing AIUI embedded in a mock "Acme App" with documentation of 4 integration methods: script tag, web component, npm package, and iframe - Theme composable with dark/light mode, system preference detection - Custom CSS: glass variants (subtle/medium/strong/light/dark), glow effects, gradient text, animation keyframes, thin scrollbar - Responsive: mobile-first, 44px touch targets, dvh viewport Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<WidgetFab
|
||||
:is-open="isOpen"
|
||||
:position="fabPosition"
|
||||
@toggle="isOpen = !isOpen"
|
||||
/>
|
||||
<WidgetModal
|
||||
:is-open="isOpen"
|
||||
:side="side"
|
||||
@close="isOpen = false"
|
||||
@switch-side="toggleSide"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import WidgetFab from './WidgetFab.vue'
|
||||
import WidgetModal from './WidgetModal.vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
const side = ref<'left' | 'right'>('right')
|
||||
|
||||
const fabPosition = computed(() =>
|
||||
side.value === 'left' ? 'bottom-left' : 'bottom-right'
|
||||
)
|
||||
|
||||
function toggleSide() {
|
||||
side.value = side.value === 'right' ? 'left' : 'right'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<button
|
||||
class="fixed z-50 w-14 h-14 rounded-full glass-strong flex items-center justify-center
|
||||
shadow-lg hover:shadow-xl transition-all duration-300
|
||||
hover:scale-105 active:scale-95 glow-accent"
|
||||
:class="positionClasses"
|
||||
:aria-label="isOpen ? 'Close AIUI' : 'Open AIUI'"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-accent transition-transform duration-300"
|
||||
:class="isOpen ? 'rotate-45' : ''"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
v-if="!isOpen"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
<path
|
||||
v-else
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
isOpen: boolean
|
||||
position?: 'bottom-right' | 'bottom-left'
|
||||
}>(),
|
||||
{ position: 'bottom-right' }
|
||||
)
|
||||
|
||||
defineEmits<{ toggle: [] }>()
|
||||
|
||||
const positionClasses = computed(() =>
|
||||
props.position === 'bottom-left'
|
||||
? 'bottom-6 left-6'
|
||||
: 'bottom-6 right-6'
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="widget">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed z-40 animate-scale-in"
|
||||
:class="positionClasses"
|
||||
>
|
||||
<div
|
||||
class="w-[380px] h-[600px] md:w-[420px] md:h-[640px] rounded-2xl overflow-hidden shadow-2xl"
|
||||
:style="{ boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5), 0 0 40px rgba(247, 147, 26, 0.08)' }"
|
||||
>
|
||||
<ChatWindow
|
||||
variant="modal"
|
||||
:side="side"
|
||||
show-close
|
||||
@switch-side="$emit('switchSide')"
|
||||
@close="$emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import ChatWindow from '@/components/chat/ChatWindow.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
isOpen: boolean
|
||||
side?: 'left' | 'right'
|
||||
}>(),
|
||||
{ side: 'right' }
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
switchSide: []
|
||||
}>()
|
||||
|
||||
const positionClasses = computed(() =>
|
||||
props.side === 'left'
|
||||
? 'bottom-24 left-6'
|
||||
: 'bottom-24 right-6'
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.widget-enter-active {
|
||||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.widget-leave-active {
|
||||
transition: all 0.2s ease-in;
|
||||
}
|
||||
.widget-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(20px);
|
||||
}
|
||||
.widget-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(20px);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user