Compare commits
4
Commits
a7a10fd16d
...
22639fe770
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22639fe770 | ||
|
|
76f07f64d8 | ||
|
|
37e6ad3e04 | ||
|
|
b6eee52cc4 |
@@ -56,7 +56,7 @@
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.6);
|
||||
--radius-3xl: calc(var(--radius) * 2.4);
|
||||
}
|
||||
@@ -142,7 +142,7 @@
|
||||
--font-sans: 'Figtree', sans-serif;
|
||||
--font-heading: 'Manrope', sans-serif;
|
||||
--font-logo: 'Archivo Black', sans-serif;
|
||||
--radius: 0.625rem;
|
||||
--radius: 0rem;
|
||||
--background: #323232;
|
||||
--foreground: oklch(0.95 0 0);
|
||||
--card: #323232;
|
||||
@@ -176,6 +176,12 @@
|
||||
--sidebar-ring: #ff0096;
|
||||
}
|
||||
|
||||
[data-theme="4f"] *,
|
||||
[data-theme="4f"] *::before,
|
||||
[data-theme="4f"] *::after {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Cyberpunk-Theme (tweakcn „赛博朋克", Dark-Variante):
|
||||
Neon-Grün auf Schwarz, Magenta-Primary, eckig, Glow-Schatten */
|
||||
[data-theme="cyberpunk"] {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const { user } = useAuth()
|
||||
const dms = useDirectMessages()
|
||||
|
||||
// Auf der dedizierten /messages-Seite keine schwebenden Fenster doppelt zeigen
|
||||
const visible = computed(() =>
|
||||
!!user.value
|
||||
&& !route.path.startsWith('/messages')
|
||||
&& dms.floatingChats.value.length > 0,
|
||||
)
|
||||
|
||||
// Z-Index-Reihenfolge: zuletzt angeklicktes Fenster liegt ganz vorne
|
||||
const zOrder = ref<number[]>([])
|
||||
function bringToFront(userId: number) {
|
||||
zOrder.value = [...zOrder.value.filter(id => id !== userId), userId]
|
||||
}
|
||||
function zIndexFor(userId: number, idx: number) {
|
||||
const pos = zOrder.value.indexOf(userId)
|
||||
return 40 + (pos >= 0 ? pos + 10 : idx)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="pointer-events-none fixed inset-0 z-40"
|
||||
>
|
||||
<DmFloatingWindow
|
||||
v-for="(tab, idx) in dms.floatingChats.value"
|
||||
:key="tab.user.id"
|
||||
:tab="tab"
|
||||
:index="idx"
|
||||
:z-index="zIndexFor(tab.user.id, idx)"
|
||||
@focus="bringToFront(tab.user.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,576 @@
|
||||
<script setup lang="ts">
|
||||
import { toast } from 'vue-sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { FloatingChatTab } from '@/composables/useDirectMessages'
|
||||
import type { DmMessage, DmReactionEvent, DmUser } from '~~/shared/utils/messages'
|
||||
|
||||
const props = defineProps<{
|
||||
tab: FloatingChatTab
|
||||
index: number
|
||||
zIndex: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
focus: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { user } = useAuth()
|
||||
const dms = useDirectMessages()
|
||||
const timeAgo = useTimeAgo()
|
||||
|
||||
const MIN_W = 260
|
||||
const MIN_H = 280
|
||||
const DEFAULT_W = 340
|
||||
const DEFAULT_H = 430
|
||||
|
||||
const windowEl = ref<HTMLElement>()
|
||||
// null = unten rechts angedockt; sobald gezogen/resized wurde, gelten x/y (px)
|
||||
const pos = ref<{ x: number, y: number } | null>(null)
|
||||
const size = ref<{ w: number, h: number }>({ w: DEFAULT_W, h: DEFAULT_H })
|
||||
const interacting = ref(false)
|
||||
|
||||
function clampToViewport(x: number, y: number, w: number, h: number) {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const effectiveH = props.tab.minimized ? 40 : h
|
||||
return {
|
||||
x: Math.max(8, Math.min(vw - w - 8, x)),
|
||||
y: Math.max(8, Math.min(vh - effectiveH - 8, y)),
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAbsolutePos() {
|
||||
if (pos.value || !windowEl.value) return
|
||||
const rect = windowEl.value.getBoundingClientRect()
|
||||
pos.value = { x: rect.left, y: rect.top }
|
||||
}
|
||||
|
||||
const windowStyle = computed<Record<string, string>>(() => {
|
||||
const w = props.tab.minimized ? Math.min(size.value.w, 260) : size.value.w
|
||||
const h = props.tab.minimized ? 40 : size.value.h
|
||||
const base: Record<string, string> = {
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
zIndex: String(props.zIndex),
|
||||
}
|
||||
if (pos.value) {
|
||||
base.left = `${pos.value.x}px`
|
||||
base.top = `${pos.value.y}px`
|
||||
}
|
||||
else {
|
||||
// Angedockter Standardplatz unten rechts (links von der Kommentar-Sidebar)
|
||||
const offset = props.index * (DEFAULT_W + 12)
|
||||
base.right = `calc(var(--sidebar-width, 0px) + 1rem + ${offset}px)`
|
||||
base.bottom = '0px'
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
// --- Verschieben über die Kopfleiste ---
|
||||
let dragMoved = false
|
||||
function startDrag(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if ((e.target as HTMLElement).closest('button, a')) return
|
||||
emit('focus')
|
||||
ensureAbsolutePos()
|
||||
if (!pos.value) return
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const origX = pos.value.x
|
||||
const origY = pos.value.y
|
||||
dragMoved = false
|
||||
interacting.value = true
|
||||
|
||||
function onMove(ev: PointerEvent) {
|
||||
const dx = ev.clientX - startX
|
||||
const dy = ev.clientY - startY
|
||||
if (!dragMoved && Math.hypot(dx, dy) < 5) return
|
||||
dragMoved = true
|
||||
pos.value = clampToViewport(origX + dx, origY + dy, size.value.w, size.value.h)
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
interacting.value = false
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
if (!dragMoved) {
|
||||
dms.toggleMinimizeFloatingChat(props.tab.user.id)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
}
|
||||
|
||||
// --- Manuelles Vergrößern/Verkleinern an allen Kanten & Ecken ---
|
||||
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
||||
function startResize(dir: ResizeDir, e: PointerEvent) {
|
||||
if (e.button !== 0 || props.tab.minimized) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
emit('focus')
|
||||
ensureAbsolutePos()
|
||||
if (!pos.value) return
|
||||
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const origX = pos.value.x
|
||||
const origY = pos.value.y
|
||||
const origW = size.value.w
|
||||
const origH = size.value.h
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
interacting.value = true
|
||||
|
||||
function onMove(ev: PointerEvent) {
|
||||
const dx = ev.clientX - startX
|
||||
const dy = ev.clientY - startY
|
||||
let nextX = origX
|
||||
let nextY = origY
|
||||
let nextW = origW
|
||||
let nextH = origH
|
||||
|
||||
if (dir.includes('e')) {
|
||||
nextW = Math.min(vw - origX - 8, Math.max(MIN_W, origW + dx))
|
||||
}
|
||||
if (dir.includes('s')) {
|
||||
nextH = Math.min(vh - origY - 8, Math.max(MIN_H, origH + dy))
|
||||
}
|
||||
if (dir.includes('w')) {
|
||||
const maxLeft = origX + origW - MIN_W
|
||||
nextX = Math.max(8, Math.min(maxLeft, origX + dx))
|
||||
nextW = origW + (origX - nextX)
|
||||
}
|
||||
if (dir.includes('n')) {
|
||||
const maxTop = origY + origH - MIN_H
|
||||
nextY = Math.max(8, Math.min(maxTop, origY + dy))
|
||||
nextH = origH + (origY - nextY)
|
||||
}
|
||||
|
||||
pos.value = { x: nextX, y: nextY }
|
||||
size.value = { w: nextW, h: nextH }
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
interacting.value = false
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
}
|
||||
|
||||
const partner = ref<DmUser>(props.tab.user)
|
||||
watch(() => props.tab.user, (u) => {
|
||||
partner.value = { ...partner.value, ...u }
|
||||
})
|
||||
|
||||
const chatMessages = ref<DmMessage[]>([])
|
||||
const messageIds = new Set<number>()
|
||||
const hasMore = ref(false)
|
||||
const loadingChat = ref(false)
|
||||
const loadingOlder = ref(false)
|
||||
const listEl = ref<HTMLElement>()
|
||||
const draft = ref('')
|
||||
const sending = ref(false)
|
||||
|
||||
const unreadCount = computed(() => {
|
||||
const conv = dms.conversations.value.find(c => c.kind === 'dm' && c.user?.id === props.tab.user.id)
|
||||
return conv?.unread ?? 0
|
||||
})
|
||||
|
||||
function nearBottom() {
|
||||
const el = listEl.value
|
||||
if (!el) return true
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 60
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const el = listEl.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
async function markRead() {
|
||||
if (props.tab.minimized) return
|
||||
dms.markConversationRead(props.tab.user.id)
|
||||
await $fetch(`/api/messages/${props.tab.user.id}/read`, { method: 'POST' }).catch(() => {})
|
||||
}
|
||||
|
||||
async function loadConversation(before?: number) {
|
||||
try {
|
||||
const res = await $fetch<{ partner: DmUser, messages: DmMessage[], hasMore: boolean }>(
|
||||
`/api/messages/${props.tab.user.id}`,
|
||||
{ query: before ? { before } : undefined },
|
||||
)
|
||||
partner.value = res.partner
|
||||
if (before) {
|
||||
const fresh = res.messages.filter(m => !messageIds.has(m.id))
|
||||
for (const m of fresh) messageIds.add(m.id)
|
||||
chatMessages.value = [...fresh, ...chatMessages.value]
|
||||
}
|
||||
else {
|
||||
messageIds.clear()
|
||||
for (const m of res.messages) messageIds.add(m.id)
|
||||
chatMessages.value = res.messages
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
}
|
||||
hasMore.value = res.hasMore
|
||||
}
|
||||
catch {
|
||||
toast.error(t('dm.loadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
const first = chatMessages.value[0]
|
||||
if (!hasMore.value || !first || loadingOlder.value) return
|
||||
const el = listEl.value
|
||||
const oldHeight = el?.scrollHeight ?? 0
|
||||
const oldTop = el?.scrollTop ?? 0
|
||||
loadingOlder.value = true
|
||||
await loadConversation(first.id)
|
||||
loadingOlder.value = false
|
||||
await nextTick()
|
||||
if (el) el.scrollTop = el.scrollHeight - oldHeight + oldTop
|
||||
}
|
||||
|
||||
function applyDmReaction(m: DmMessage, e: { emoji: string, actorId: number, actorName: string, active: boolean, count: number }) {
|
||||
m.reactions ??= []
|
||||
let entry = m.reactions.find(r => r.emoji === e.emoji)
|
||||
if (e.active) {
|
||||
if (!entry) {
|
||||
entry = { emoji: e.emoji, count: 0, me: false, names: [] }
|
||||
m.reactions.push(entry)
|
||||
}
|
||||
if (!entry.names.includes(e.actorName)) entry.names.push(e.actorName)
|
||||
}
|
||||
else if (entry) {
|
||||
entry.names = entry.names.filter(n => n !== e.actorName)
|
||||
}
|
||||
if (!entry) return
|
||||
entry.count = e.count
|
||||
if (e.actorId === user.value?.id) entry.me = e.active
|
||||
if (entry.count <= 0) m.reactions.splice(m.reactions.indexOf(entry), 1)
|
||||
}
|
||||
|
||||
async function toggleReaction(m: DmMessage, emoji: string) {
|
||||
const me = user.value
|
||||
if (!me) return
|
||||
const before = m.reactions?.find(r => r.emoji === emoji)
|
||||
applyDmReaction(m, {
|
||||
emoji,
|
||||
actorId: me.id,
|
||||
actorName: me.name,
|
||||
active: !before?.me,
|
||||
count: (before?.count ?? 0) + (before?.me ? -1 : 1),
|
||||
})
|
||||
try {
|
||||
const res = await $fetch<{ active: boolean, count: number }>(`/api/messages/reactions/${m.id}`, {
|
||||
method: 'POST',
|
||||
body: { emoji },
|
||||
})
|
||||
applyDmReaction(m, { emoji, actorId: me.id, actorName: me.name, active: res.active, count: res.count })
|
||||
}
|
||||
catch {
|
||||
applyDmReaction(m, { emoji, actorId: me.id, actorName: me.name, active: !!before?.me, count: before?.count ?? 0 })
|
||||
toast.error(t('comments.reactionFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const content = draft.value.trim()
|
||||
if (!content || sending.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
const msg = await $fetch<DmMessage>(`/api/messages/${props.tab.user.id}`, {
|
||||
method: 'POST',
|
||||
body: { content },
|
||||
})
|
||||
draft.value = ''
|
||||
if (!messageIds.has(msg.id)) {
|
||||
messageIds.add(msg.id)
|
||||
chatMessages.value.push(msg)
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
}
|
||||
dms.addOwnMessage(partner.value, msg)
|
||||
}
|
||||
catch {
|
||||
toast.error(t('dm.sendFailed'))
|
||||
}
|
||||
finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function insertSticker(name: string) {
|
||||
draft.value = `${draft.value}${draft.value && !draft.value.endsWith(' ') ? ' ' : ''}:${name}: `
|
||||
}
|
||||
|
||||
const lastOwnId = computed(() => {
|
||||
for (let i = chatMessages.value.length - 1; i >= 0; i--) {
|
||||
if (chatMessages.value[i]!.senderId === user.value?.id) return chatMessages.value[i]!.id
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
watch(() => props.tab.minimized, async (min) => {
|
||||
if (!min) {
|
||||
if (pos.value) {
|
||||
pos.value = clampToViewport(pos.value.x, pos.value.y, size.value.w, size.value.h)
|
||||
}
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
markRead()
|
||||
}
|
||||
})
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
onMounted(async () => {
|
||||
loadingChat.value = true
|
||||
await loadConversation()
|
||||
loadingChat.value = false
|
||||
if (!props.tab.minimized) markRead()
|
||||
|
||||
unsubscribe = dms.subscribeFloating({
|
||||
onMessage(msg, other, grp) {
|
||||
if (grp) return
|
||||
const pid = props.tab.user.id
|
||||
if (msg.senderId !== pid && msg.recipientId !== pid) return
|
||||
if (other) partner.value = other
|
||||
if (messageIds.has(msg.id)) return
|
||||
messageIds.add(msg.id)
|
||||
chatMessages.value.push(msg)
|
||||
const stick = nearBottom()
|
||||
nextTick(() => { if (stick && !props.tab.minimized) scrollToBottom() })
|
||||
if (!props.tab.minimized && msg.recipientId === user.value?.id) markRead()
|
||||
},
|
||||
onRead(pid, at) {
|
||||
if (pid !== props.tab.user.id) return
|
||||
for (const m of chatMessages.value) {
|
||||
if (m.senderId === user.value?.id && !m.readAt) m.readAt = at
|
||||
}
|
||||
},
|
||||
onReaction(e: DmReactionEvent) {
|
||||
if (e.group) return
|
||||
const msg = chatMessages.value.find(m => m.id === e.messageId)
|
||||
if (msg) applyDmReaction(msg, e)
|
||||
if (!props.tab.minimized && e.actorId !== user.value?.id) markRead()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="windowEl"
|
||||
class="pointer-events-auto fixed flex flex-col overflow-hidden border bg-popover text-popover-foreground shadow-2xl"
|
||||
:class="[
|
||||
pos ? 'rounded-md' : 'rounded-t-md border-b-0',
|
||||
interacting && 'select-none',
|
||||
]"
|
||||
:style="windowStyle"
|
||||
@pointerdown="emit('focus')"
|
||||
>
|
||||
<!-- Resize-Griffe an allen 4 Kanten & 4 Ecken (nur wenn aufgeklappt) -->
|
||||
<template v-if="!tab.minimized">
|
||||
<div class="absolute inset-x-2 top-0 z-20 h-1.5 cursor-ns-resize" @pointerdown="startResize('n', $event)" />
|
||||
<div class="absolute inset-x-2 bottom-0 z-20 h-1.5 cursor-ns-resize" @pointerdown="startResize('s', $event)" />
|
||||
<div class="absolute inset-y-2 left-0 z-20 w-1.5 cursor-ew-resize" @pointerdown="startResize('w', $event)" />
|
||||
<div class="absolute inset-y-2 right-0 z-20 w-1.5 cursor-ew-resize" @pointerdown="startResize('e', $event)" />
|
||||
<div class="absolute top-0 left-0 z-30 size-3 cursor-nwse-resize" @pointerdown="startResize('nw', $event)" />
|
||||
<div class="absolute top-0 right-0 z-30 size-3 cursor-nesw-resize" @pointerdown="startResize('ne', $event)" />
|
||||
<div class="absolute bottom-0 left-0 z-30 size-3 cursor-nesw-resize" @pointerdown="startResize('sw', $event)" />
|
||||
<div class="absolute right-0 bottom-0 z-30 size-3.5 cursor-nwse-resize" @pointerdown="startResize('se', $event)">
|
||||
<svg viewBox="0 0 12 12" class="size-full text-muted-foreground/50">
|
||||
<path d="M10 2L2 10M10 6L6 10M10 10L10 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Messenger-Kopfleiste (Ziehen zum Verschieben, Klick zum Minimieren) -->
|
||||
<div
|
||||
class="flex h-10 shrink-0 cursor-move items-center gap-2 border-b bg-card px-2.5 select-none"
|
||||
@pointerdown="startDrag"
|
||||
>
|
||||
<Avatar class="size-6 shrink-0">
|
||||
<AvatarImage v-if="partner.avatarUrl" :src="partner.avatarUrl" :alt="partner.name" />
|
||||
<AvatarFallback
|
||||
class="text-[10px]"
|
||||
:style="partner.color ? { backgroundColor: partner.color, color: '#fff' } : undefined"
|
||||
>
|
||||
{{ partner.name.slice(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<NuxtLink
|
||||
:to="`/user/${partner.id}`"
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium hover:underline"
|
||||
:style="partner.color ? { color: partner.color } : undefined"
|
||||
@click.stop
|
||||
>{{ partner.name }}</NuxtLink>
|
||||
<Badge
|
||||
v-if="tab.minimized && unreadCount > 0"
|
||||
variant="default"
|
||||
class="px-1.5 py-0 text-[10px] tabular-nums"
|
||||
>{{ unreadCount > 9 ? '9+' : unreadCount }}</Badge>
|
||||
<div class="flex shrink-0 items-center gap-0.5" @click.stop>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="size-6 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
as-child
|
||||
>
|
||||
<NuxtLink :to="`/messages/${partner.id}`" :aria-label="$t('dm.openFullView')">
|
||||
<Icon name="ph:arrows-out-simple-bold" class="size-3.5" />
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('dm.openFullView') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="size-6 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
:aria-label="tab.minimized ? $t('dm.expandChat') : $t('dm.minimizeChat')"
|
||||
@click="dms.toggleMinimizeFloatingChat(tab.user.id)"
|
||||
>
|
||||
<Icon :name="tab.minimized ? 'ph:caret-up-bold' : 'ph:minus-bold'" class="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ tab.minimized ? $t('dm.expandChat') : $t('dm.minimizeChat') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="size-6 cursor-pointer text-muted-foreground hover:text-destructive"
|
||||
:aria-label="$t('common.close')"
|
||||
@click="dms.closeFloatingChat(tab.user.id)"
|
||||
>
|
||||
<Icon name="ph:x-bold" class="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('common.close') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nachrichtenverlauf + Eingabe -->
|
||||
<template v-if="!tab.minimized">
|
||||
<div ref="listEl" class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-3 py-2.5 text-sm">
|
||||
<div v-if="hasMore" class="flex justify-center">
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" :disabled="loadingOlder" @click="loadOlder">
|
||||
<Spinner v-if="loadingOlder" />
|
||||
{{ $t('dm.loadOlder') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="loadingChat" class="flex flex-col gap-2 py-1">
|
||||
<div
|
||||
v-for="(w, i) in ['60%', '45%', '70%']"
|
||||
:key="i"
|
||||
class="flex flex-col"
|
||||
:class="i % 2 ? 'items-end' : 'items-start'"
|
||||
>
|
||||
<Skeleton class="h-7 rounded-md" :style="{ width: w }" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="!chatMessages.length" class="py-6 text-center text-xs text-muted-foreground">
|
||||
{{ $t('dm.empty') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="m in chatMessages"
|
||||
:key="m.id"
|
||||
:class="cn('flex flex-col', m.senderId === user?.id ? 'items-end' : 'items-start')"
|
||||
>
|
||||
<div
|
||||
class="group/message flex w-full items-center gap-1"
|
||||
:class="m.senderId === user?.id && 'flex-row-reverse'"
|
||||
>
|
||||
<div
|
||||
:class="cn(
|
||||
'max-w-[82%] rounded-md px-2.5 py-1.5 text-xs',
|
||||
m.senderId === user?.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted',
|
||||
)"
|
||||
>
|
||||
<div v-if="m.attachments?.length" class="max-w-full">
|
||||
<CommentAttachments :attachments="m.attachments" />
|
||||
</div>
|
||||
<CommentContent v-if="m.content" :content="m.content" />
|
||||
</div>
|
||||
<DmReactionPicker @select="toggleReaction(m, $event)" />
|
||||
</div>
|
||||
<div
|
||||
v-if="m.reactions?.length"
|
||||
class="mt-1 flex flex-wrap gap-1"
|
||||
:class="m.senderId === user?.id && 'justify-end'"
|
||||
>
|
||||
<button
|
||||
v-for="r in m.reactions"
|
||||
:key="r.emoji"
|
||||
type="button"
|
||||
:class="cn(
|
||||
'flex cursor-pointer items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] transition-colors',
|
||||
r.me
|
||||
? 'border-foreground/40 bg-secondary'
|
||||
: 'border-border bg-muted/60',
|
||||
)"
|
||||
@click="toggleReaction(m, r.emoji)"
|
||||
>
|
||||
<span>{{ r.emoji }}</span>
|
||||
<span class="tabular-nums">{{ r.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-1 px-1 text-[10px] text-muted-foreground">
|
||||
<span>{{ timeAgo(m.createdAt) }}</span>
|
||||
<template v-if="m.id === lastOwnId">
|
||||
·
|
||||
<Icon v-if="m.readAt" name="ph:checks-fill" class="size-3 text-primary" />
|
||||
<Icon v-else name="ph:check-fill" class="size-3" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="flex items-center gap-1 border-t bg-background p-2" @submit.prevent="send">
|
||||
<input
|
||||
v-model="draft"
|
||||
type="text"
|
||||
:placeholder="$t('dm.placeholder')"
|
||||
class="min-w-0 flex-1 rounded-md border bg-muted/30 px-2.5 py-1.5 text-xs outline-none focus:border-primary"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
>
|
||||
<StickerPicker @select="insertSticker" />
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 cursor-pointer"
|
||||
:disabled="sending || !draft.trim()"
|
||||
:aria-label="$t('dm.send')"
|
||||
>
|
||||
<Spinner v-if="sending" class="size-3.5" />
|
||||
<Icon v-else name="ph:paper-plane-tilt-fill" class="size-3.5" />
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,12 +4,18 @@ import {
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { CardBorderEffectKey } from '~~/shared/utils/cardBorderEffects'
|
||||
import { isCardBorderEffect } from '~~/shared/utils/cardBorderEffects'
|
||||
import type { CardEffectKey } from '~~/shared/utils/cardEffects'
|
||||
import { isCardEffect } from '~~/shared/utils/cardEffects'
|
||||
import type { CardHoloEffectKey } from '~~/shared/utils/cardHoloEffects'
|
||||
import { isCardHoloEffect } from '~~/shared/utils/cardHoloEffects'
|
||||
import CardBackgroundEffect from '@/components/effects/CardBackgroundEffect.vue'
|
||||
import CardBorderEffect from '@/components/effects/CardBorderEffect.vue'
|
||||
import CardHoloEffect from '@/components/effects/CardHoloEffect.vue'
|
||||
import CardVignette from '@/components/effects/CardVignette.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -20,6 +26,8 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { summaries, ensureSummary } = useUserSummaries()
|
||||
const { user: currentUser } = useAuth()
|
||||
const { openFloatingChat } = useDirectMessages()
|
||||
const { locale } = useI18n()
|
||||
const summary = computed(() => summaries.value[props.userId])
|
||||
const avatar = computed(() => summary.value?.avatarUrl ?? props.userAvatarUrl ?? null)
|
||||
@@ -32,13 +40,30 @@ const borderEffect = computed<CardBorderEffectKey | null>(() => {
|
||||
const key = summary.value?.cardBorderEffect
|
||||
return key && isCardBorderEffect(key) ? key : null
|
||||
})
|
||||
const holoEffect = computed<CardHoloEffectKey | null>(() => {
|
||||
const key = summary.value?.cardHoloEffect
|
||||
return key && isCardHoloEffect(key) ? key : null
|
||||
})
|
||||
|
||||
const open = ref(false)
|
||||
const holoActive = computed(() => open.value && !!holoEffect.value)
|
||||
const { holoStyle, onPointerMove, onPointerLeave } = useHoloTilt(holoActive)
|
||||
|
||||
function onOpen(v: boolean) {
|
||||
open.value = v
|
||||
if (v) ensureSummary(props.userId)
|
||||
}
|
||||
|
||||
function startFloatingDm() {
|
||||
openFloatingChat({
|
||||
id: props.userId,
|
||||
name: summary.value?.name ?? props.userName,
|
||||
color: color.value,
|
||||
avatarUrl: avatar.value,
|
||||
})
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString(locale.value, { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
@@ -52,15 +77,21 @@ function formatDate(value: string) {
|
||||
<HoverCardContent
|
||||
v-if="open"
|
||||
class="relative w-72 overflow-hidden rounded-md ring-0 border-2"
|
||||
:class="borderEffect && 'border-transparent'"
|
||||
:style="!borderEffect ? { borderColor: color || 'var(--color-border)' } : undefined"
|
||||
:class="[borderEffect && 'border-transparent', holoEffect && 'shadow-[0_14px_28px_-6px_rgba(0,0,0,0.65)]']"
|
||||
:style="{
|
||||
...(!borderEffect ? { borderColor: color || 'var(--color-border)' } : {}),
|
||||
...holoStyle,
|
||||
}"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerleave="onPointerLeave"
|
||||
>
|
||||
<div v-if="effect" class="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] [clip-path:inset(0_round_var(--radius-md))]">
|
||||
<CardBackgroundEffect :effect="effect" :color="summary?.cardEffectColor ?? color ?? '#6366f1'" />
|
||||
<div class="absolute inset-0 bg-popover/60" />
|
||||
<CardVignette v-if="summary?.cardVignette" />
|
||||
</div>
|
||||
<div class="relative flex items-start gap-3">
|
||||
<CardHoloEffect v-if="holoEffect" :effect="holoEffect" />
|
||||
<div class="relative z-10 flex items-start gap-3">
|
||||
<NuxtImg
|
||||
v-if="avatar"
|
||||
:src="avatar"
|
||||
@@ -76,13 +107,21 @@ function formatDate(value: string) {
|
||||
:style="color ? { backgroundColor: color, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
|
||||
>{{ userName.slice(0, 2).toUpperCase() }}</span>
|
||||
<div class="min-w-0">
|
||||
<NuxtLink
|
||||
:to="`/user/${userId}`"
|
||||
class="block truncate text-base font-medium hover:underline"
|
||||
:style="color ? { color } : undefined"
|
||||
>{{ summary?.name ?? userName }}</NuxtLink>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<NuxtLink
|
||||
:to="`/user/${userId}`"
|
||||
class="truncate text-base font-medium hover:underline"
|
||||
:style="color ? { color } : undefined"
|
||||
>{{ summary?.name ?? userName }}</NuxtLink>
|
||||
<Badge
|
||||
v-for="role in summary?.roles"
|
||||
:key="role"
|
||||
variant="secondary"
|
||||
class="px-1.5 py-0 text-[10px] uppercase tracking-wider"
|
||||
>{{ role }}</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
#{{ userId }}
|
||||
ID {{ userId }}
|
||||
</p>
|
||||
<p v-if="summary" class="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Icon name="ph:calendar-fill" class="size-3" />
|
||||
@@ -90,7 +129,11 @@ function formatDate(value: string) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="summary" class="relative mt-3 flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||
<p
|
||||
v-if="summary?.bio"
|
||||
class="relative z-10 mt-2.5 line-clamp-3 break-words whitespace-pre-line text-xs text-muted-foreground"
|
||||
>{{ summary.bio }}</p>
|
||||
<div v-if="summary" class="relative z-10 mt-3 flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon name="ph:cloud-arrow-up-fill" class="size-3.5" />
|
||||
{{ summary.stats.uploads }} {{ $t('misc.uploadsCol') }}
|
||||
@@ -99,6 +142,20 @@ function formatDate(value: string) {
|
||||
<Icon name="ph:chat-fill" class="size-3.5" />
|
||||
{{ summary.stats.comments }} {{ $t('misc.commentsCol') }}
|
||||
</span>
|
||||
<Tooltip v-if="currentUser && currentUser.id !== userId">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="ml-auto size-7 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
:aria-label="$t('dm.message')"
|
||||
@click.stop.prevent="startFloatingDm"
|
||||
>
|
||||
<Icon name="ph:chats-circle-fill" class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('dm.message') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<CardBorderEffect v-if="borderEffect" :effect="borderEffect" :color="summary?.cardBorderEffectColor ?? color ?? '#6366f1'" />
|
||||
</HoverCardContent>
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
<script setup lang="ts">
|
||||
import type { CardHoloEffectKey } from '~~/shared/utils/cardHoloEffects'
|
||||
|
||||
defineProps<{
|
||||
effect: CardHoloEffectKey
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="holo-layer holo-shine pointer-events-none absolute inset-0 z-20 rounded-[inherit]"
|
||||
:data-holo="effect"
|
||||
/>
|
||||
<div
|
||||
class="holo-layer holo-shine-secondary pointer-events-none absolute inset-0 z-20 rounded-[inherit]"
|
||||
:data-holo="effect"
|
||||
/>
|
||||
<div
|
||||
class="holo-layer holo-glare pointer-events-none absolute inset-0 z-20 rounded-[inherit]"
|
||||
:data-holo="effect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Alle Farbverläufe schließen nahtlos (Startfarbe === Endfarbe) und nutzen
|
||||
weiche Übergänge statt harter Pixel-Scanlines - so entstehen weder
|
||||
Abriss-Kanten noch Moiré-Linien bei 3D-Neigung. */
|
||||
.holo-layer {
|
||||
--sunpillar-1: hsl(2, 100%, 73%);
|
||||
--sunpillar-2: hsl(53, 100%, 69%);
|
||||
--sunpillar-3: hsl(93, 100%, 69%);
|
||||
--sunpillar-4: hsl(176, 100%, 76%);
|
||||
--sunpillar-5: hsl(228, 100%, 74%);
|
||||
--sunpillar-6: hsl(283, 100%, 73%);
|
||||
--holo-spectrum: repeating-linear-gradient(
|
||||
115deg,
|
||||
var(--sunpillar-1) 0%,
|
||||
var(--sunpillar-2) 14%,
|
||||
var(--sunpillar-3) 28%,
|
||||
var(--sunpillar-4) 42%,
|
||||
var(--sunpillar-5) 57%,
|
||||
var(--sunpillar-6) 71%,
|
||||
var(--sunpillar-1) 85%,
|
||||
var(--sunpillar-2) 100%
|
||||
);
|
||||
--glitter-dots:
|
||||
radial-gradient(circle at 16% 24%, rgba(255, 255, 255, 0.85) 0 0.9px, transparent 2.4px),
|
||||
radial-gradient(circle at 76% 20%, rgba(255, 255, 255, 0.8) 0 1.1px, transparent 2.6px),
|
||||
radial-gradient(circle at 42% 66%, rgba(255, 255, 255, 0.85) 0 0.9px, transparent 2.4px),
|
||||
radial-gradient(circle at 84% 72%, rgba(255, 255, 255, 0.75) 0 1.1px, transparent 2.6px),
|
||||
radial-gradient(circle at 26% 80%, rgba(255, 255, 255, 0.8) 0 1px, transparent 2.5px),
|
||||
radial-gradient(circle at 58% 32%, rgba(255, 255, 255, 0.8) 0 0.9px, transparent 2.4px);
|
||||
}
|
||||
|
||||
.holo-shine {
|
||||
-webkit-mask-image: radial-gradient(
|
||||
farthest-corner circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
|
||||
rgba(0, 0, 0, 0.85) 0%,
|
||||
rgba(0, 0, 0, 0.42) 38%,
|
||||
rgba(0, 0, 0, 0.08) 72%,
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: radial-gradient(
|
||||
farthest-corner circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
|
||||
rgba(0, 0, 0, 0.85) 0%,
|
||||
rgba(0, 0, 0, 0.42) 38%,
|
||||
rgba(0, 0, 0, 0.08) 72%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.holo-shine-secondary {
|
||||
-webkit-mask-image: radial-gradient(
|
||||
farthest-corner circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
|
||||
rgba(0, 0, 0, 0.75) 0%,
|
||||
rgba(0, 0, 0, 0.28) 42%,
|
||||
transparent 82%
|
||||
);
|
||||
mask-image: radial-gradient(
|
||||
farthest-corner circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
|
||||
rgba(0, 0, 0, 0.75) 0%,
|
||||
rgba(0, 0, 0, 0.28) 42%,
|
||||
transparent 82%
|
||||
);
|
||||
}
|
||||
|
||||
/* Dezenter Specular-Glare um den Mauszeiger */
|
||||
.holo-glare {
|
||||
background-image: radial-gradient(
|
||||
farthest-corner circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
|
||||
hsla(0, 0%, 100%, 0.12) 0%,
|
||||
hsla(195, 90%, 88%, 0.04) 30%,
|
||||
transparent 60%
|
||||
);
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
1) REGULAR HOLO (weiche vertikale Lichtsäulen + Spektrum)
|
||||
========================================================= */
|
||||
.holo-shine[data-holo="holo"] {
|
||||
background-image: var(--holo-spectrum);
|
||||
background-position: calc(((50% - var(--background-x, 50%)) * 2.2) + 50%) calc(((50% - var(--background-y, 50%)) * 2.2) + 50%);
|
||||
background-size: 300% 300%;
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.holo-shine-secondary[data-holo="holo"] {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
hsla(190, 100%, 85%, 0.28) 22%,
|
||||
transparent 38%,
|
||||
hsla(295, 100%, 85%, 0.25) 54%,
|
||||
transparent 70%,
|
||||
hsla(52, 100%, 82%, 0.22) 86%,
|
||||
transparent 100%
|
||||
);
|
||||
background-position: calc(((50% - var(--background-x, 50%)) * 1.8) + 50%) 50%;
|
||||
background-size: 220% 100%;
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
2) REVERSE HOLO (metallischer Foil-Schimmer + Gegenlicht)
|
||||
========================================================= */
|
||||
.holo-shine[data-holo="reverse-holo"] {
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
|
||||
hsla(195, 100%, 88%, 0.65) 0%,
|
||||
hsla(265, 85%, 74%, 0.28) 38%,
|
||||
transparent 72%
|
||||
),
|
||||
linear-gradient(
|
||||
-45deg,
|
||||
transparent 20%,
|
||||
hsla(180, 95%, 82%, 0.4) 46%,
|
||||
hsla(320, 90%, 80%, 0.35) 54%,
|
||||
transparent 80%
|
||||
);
|
||||
background-size: 130% 130%, 220% 220%;
|
||||
background-position:
|
||||
center center,
|
||||
calc(var(--pointer-from-left, 0.5) * 100%) calc(var(--pointer-from-top, 0.5) * 100%);
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.holo-shine-secondary[data-holo="reverse-holo"] {
|
||||
background-image: radial-gradient(
|
||||
circle at calc(100% - var(--pointer-x, 50%)) calc(100% - var(--pointer-y, 50%)),
|
||||
hsla(52, 100%, 80%, 0.28) 0%,
|
||||
hsla(175, 90%, 75%, 0.12) 40%,
|
||||
transparent 70%
|
||||
);
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.26;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
3) COSMOS HOLOFOIL (3-Layer aus cosmos-holo.css:
|
||||
cosmos-bottom + cosmos-middle-trans + cosmos-top-trans
|
||||
mit Parallax-Versatz + 82deg-Regenbogen-Gradient)
|
||||
========================================================= */
|
||||
.holo-shine[data-holo="cosmos"] {
|
||||
--space: 4%;
|
||||
background-image:
|
||||
url("/holo/cosmos-bottom.png"),
|
||||
repeating-linear-gradient(
|
||||
82deg,
|
||||
hsl(53, 65%, 60%) calc(var(--space) * 1),
|
||||
hsl(93, 56%, 50%) calc(var(--space) * 2),
|
||||
hsl(176, 54%, 49%) calc(var(--space) * 3),
|
||||
hsl(228, 59%, 55%) calc(var(--space) * 4),
|
||||
hsl(283, 60%, 55%) calc(var(--space) * 5),
|
||||
hsl(326, 59%, 51%) calc(var(--space) * 6),
|
||||
hsl(326, 59%, 51%) calc(var(--space) * 7),
|
||||
hsl(283, 60%, 55%) calc(var(--space) * 8),
|
||||
hsl(228, 59%, 55%) calc(var(--space) * 9),
|
||||
hsl(176, 54%, 49%) calc(var(--space) * 10),
|
||||
hsl(93, 56%, 50%) calc(var(--space) * 11),
|
||||
hsl(53, 65%, 60%) calc(var(--space) * 12)
|
||||
),
|
||||
radial-gradient(
|
||||
farthest-corner circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
|
||||
hsla(180, 100%, 89%, 0.6) 5%,
|
||||
hsla(180, 14%, 57%, 0.3) 40%,
|
||||
hsl(0, 0%, 0%) 120%
|
||||
);
|
||||
background-blend-mode: color-burn, multiply;
|
||||
background-position:
|
||||
center center,
|
||||
calc(10% + (var(--pointer-from-left, 0.5) * 80%)) calc(10% + (var(--pointer-from-top, 0.5) * 80%)),
|
||||
center center;
|
||||
background-size: cover, 400% 900%, cover;
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.holo-shine-secondary[data-holo="cosmos"] {
|
||||
--space: 4%;
|
||||
background-image:
|
||||
url("/holo/cosmos-middle-trans.png"),
|
||||
url("/holo/cosmos-top-trans.png"),
|
||||
repeating-linear-gradient(
|
||||
82deg,
|
||||
hsl(53, 65%, 60%) calc(var(--space) * 1),
|
||||
hsl(93, 56%, 50%) calc(var(--space) * 2),
|
||||
hsl(176, 54%, 49%) calc(var(--space) * 3),
|
||||
hsl(228, 59%, 55%) calc(var(--space) * 4),
|
||||
hsl(283, 60%, 55%) calc(var(--space) * 5),
|
||||
hsl(326, 59%, 51%) calc(var(--space) * 6),
|
||||
hsl(326, 59%, 51%) calc(var(--space) * 7),
|
||||
hsl(283, 60%, 55%) calc(var(--space) * 8),
|
||||
hsl(228, 59%, 55%) calc(var(--space) * 9),
|
||||
hsl(176, 54%, 49%) calc(var(--space) * 10),
|
||||
hsl(93, 56%, 50%) calc(var(--space) * 11),
|
||||
hsl(53, 65%, 60%) calc(var(--space) * 12)
|
||||
);
|
||||
background-blend-mode: lighten, multiply;
|
||||
background-position:
|
||||
center center,
|
||||
center center,
|
||||
calc(18% + (var(--pointer-from-left, 0.5) * 64%)) calc(18% + (var(--pointer-from-top, 0.5) * 64%));
|
||||
background-size: cover, cover, 400% 900%;
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
3b) GALAXY HOLOFOIL (Classic galaxy.jpg + Cosmos-Ringe)
|
||||
========================================================= */
|
||||
.holo-shine[data-holo="galaxy"] {
|
||||
--space: 4%;
|
||||
background-image:
|
||||
url("/holo/galaxy.jpg"),
|
||||
repeating-linear-gradient(
|
||||
82deg,
|
||||
hsl(53, 65%, 60%) calc(var(--space) * 1),
|
||||
hsl(93, 56%, 50%) calc(var(--space) * 2),
|
||||
hsl(176, 54%, 49%) calc(var(--space) * 3),
|
||||
hsl(228, 59%, 55%) calc(var(--space) * 4),
|
||||
hsl(283, 60%, 55%) calc(var(--space) * 5),
|
||||
hsl(326, 59%, 51%) calc(var(--space) * 6),
|
||||
hsl(326, 59%, 51%) calc(var(--space) * 7),
|
||||
hsl(283, 60%, 55%) calc(var(--space) * 8),
|
||||
hsl(228, 59%, 55%) calc(var(--space) * 9),
|
||||
hsl(176, 54%, 49%) calc(var(--space) * 10),
|
||||
hsl(93, 56%, 50%) calc(var(--space) * 11),
|
||||
hsl(53, 65%, 60%) calc(var(--space) * 12)
|
||||
);
|
||||
background-blend-mode: color-burn;
|
||||
background-position:
|
||||
center center,
|
||||
calc(12% + (var(--pointer-from-left, 0.5) * 76%)) calc(12% + (var(--pointer-from-top, 0.5) * 76%));
|
||||
background-size: cover, 400% 900%;
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.44;
|
||||
}
|
||||
|
||||
.holo-shine-secondary[data-holo="galaxy"] {
|
||||
background-image:
|
||||
url("/holo/cosmos-middle-trans.png"),
|
||||
var(--holo-spectrum);
|
||||
background-blend-mode: multiply;
|
||||
background-position:
|
||||
center center,
|
||||
calc(20% + (var(--pointer-from-left, 0.5) * 60%)) calc(20% + (var(--pointer-from-top, 0.5) * 60%));
|
||||
background-size: cover, 300% 300%;
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
4) AMAZING RARE (funkelnder Glitzer + Sunpillar-Wellen)
|
||||
========================================================= */
|
||||
.holo-shine[data-holo="amazing-rare"] {
|
||||
background-image: var(--holo-spectrum);
|
||||
background-size: 320% 320%;
|
||||
background-position: calc(50% + (50% - var(--background-x, 50%)) * 2.2) calc(50% + (50% - var(--background-y, 50%)) * 2.2);
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.holo-shine-secondary[data-holo="amazing-rare"] {
|
||||
background-image: var(--glitter-dots);
|
||||
background-size: 40px 40px, 48px 48px, 36px 36px, 54px 54px, 44px 44px, 32px 32px;
|
||||
background-position:
|
||||
calc(var(--pointer-x, 50%) * 0.35) calc(var(--pointer-y, 50%) * 0.35),
|
||||
calc(var(--pointer-x, 50%) * -0.25) calc(var(--pointer-y, 50%) * 0.3),
|
||||
calc(var(--pointer-x, 50%) * 0.2) calc(var(--pointer-y, 50%) * -0.25),
|
||||
center center,
|
||||
calc(var(--pointer-x, 50%) * -0.3) calc(var(--pointer-y, 50%) * -0.2),
|
||||
center center;
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
5) RADIANT CROSSHATCH (weiches 45° / -45° Diamant-Schimmern)
|
||||
========================================================= */
|
||||
.holo-shine[data-holo="radiant"] {
|
||||
background-image:
|
||||
var(--holo-spectrum),
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
#000 0px,
|
||||
#444 7px,
|
||||
#fff 14px,
|
||||
#444 21px,
|
||||
#000 28px
|
||||
);
|
||||
background-size: 300% 300%, 200% 200%;
|
||||
background-position:
|
||||
calc(((var(--background-x, 50%) - 50%) * -2) + 50%) calc(((var(--background-y, 50%) - 50%) * -2) + 50%),
|
||||
calc(((var(--background-x, 50%) - 50%) * 1.4) + 50%) calc(((var(--background-y, 50%) - 50%) * 1.4) + 50%);
|
||||
background-blend-mode: multiply;
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.24;
|
||||
}
|
||||
|
||||
.holo-shine-secondary[data-holo="radiant"] {
|
||||
background-image:
|
||||
var(--holo-spectrum),
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
#000 0px,
|
||||
#444 7px,
|
||||
#fff 14px,
|
||||
#444 21px,
|
||||
#000 28px
|
||||
);
|
||||
background-size: 300% 300%, 200% 200%;
|
||||
background-position:
|
||||
calc(((var(--background-x, 50%) - 50%) * 1.8) + 50%) calc(((var(--background-y, 50%) - 50%) * 1.8) + 50%),
|
||||
calc(((var(--background-x, 50%) - 50%) * -1.4) + 50%) calc(((var(--background-y, 50%) - 50%) * -1.4) + 50%);
|
||||
background-blend-mode: multiply;
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
6) HOLO V (weiche 133°-Lichtbänder + Sunpillars)
|
||||
========================================================= */
|
||||
.holo-shine[data-holo="v-holo"] {
|
||||
background-image:
|
||||
var(--holo-spectrum),
|
||||
linear-gradient(
|
||||
133deg,
|
||||
#000 0%,
|
||||
#000 26%,
|
||||
#d8f3f3 36%,
|
||||
#000 46%,
|
||||
#000 64%,
|
||||
#ffffff 74%,
|
||||
#000 84%,
|
||||
#000 100%
|
||||
);
|
||||
background-blend-mode: multiply;
|
||||
background-size: 250% 250%, 240% 240%;
|
||||
background-position:
|
||||
0% var(--background-y, 50%),
|
||||
var(--background-x, 50%) var(--background-y, 50%);
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.holo-shine-secondary[data-holo="v-holo"] {
|
||||
background-image: linear-gradient(
|
||||
133deg,
|
||||
transparent 15%,
|
||||
hsla(185, 100%, 86%, 0.38) 38%,
|
||||
transparent 52%,
|
||||
hsla(300, 100%, 86%, 0.32) 72%,
|
||||
transparent 88%
|
||||
);
|
||||
background-size: 220% 220%;
|
||||
background-position: calc(100% - var(--background-x, 50%)) calc(100% - var(--background-y, 50%));
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
7) FULL ART ULTRA (weicher Sunpillar-Glanz + Radial-Halo)
|
||||
========================================================= */
|
||||
.holo-shine[data-holo="v-full-art"] {
|
||||
background-image: var(--holo-spectrum);
|
||||
background-size: 260% 260%;
|
||||
background-position: calc(var(--background-x, 50%) + (var(--background-y, 50%) * 0.2)) var(--background-y, 50%);
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.holo-shine-secondary[data-holo="v-full-art"] {
|
||||
background-image: radial-gradient(
|
||||
circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
|
||||
hsla(195, 100%, 88%, 0.38) 0%,
|
||||
hsla(280, 90%, 80%, 0.18) 32%,
|
||||
transparent 68%
|
||||
);
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
8) VMAX (-33° / 133° Prismatic-Schimmer + Glitzer)
|
||||
========================================================= */
|
||||
.holo-shine[data-holo="v-max"] {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
-33deg,
|
||||
hsl(2, 88%, 62%) 0%,
|
||||
hsl(228, 85%, 70%) 20%,
|
||||
hsl(176, 82%, 58%) 40%,
|
||||
hsl(123, 80%, 56%) 60%,
|
||||
hsl(283, 88%, 66%) 80%,
|
||||
hsl(2, 88%, 62%) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
133deg,
|
||||
#000 0%,
|
||||
#000 28%,
|
||||
#c8f4f4 48%,
|
||||
#000 68%,
|
||||
#000 100%
|
||||
);
|
||||
background-blend-mode: multiply;
|
||||
background-size: 280% 280%, 240% 240%;
|
||||
background-position:
|
||||
var(--background-x, 50%) var(--background-y, 50%),
|
||||
calc(var(--background-x, 50%) * 1.2) var(--background-y, 50%);
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.holo-shine-secondary[data-holo="v-max"] {
|
||||
background-image: var(--glitter-dots);
|
||||
background-size: 46px 46px, 56px 56px, 40px 40px, 62px 62px, 50px 50px, 36px 36px;
|
||||
background-position:
|
||||
calc(var(--pointer-x, 50%) * 0.4) calc(var(--pointer-y, 50%) * 0.4),
|
||||
calc(var(--pointer-x, 50%) * -0.3) calc(var(--pointer-y, 50%) * 0.35),
|
||||
center center,
|
||||
center center,
|
||||
calc(var(--pointer-x, 50%) * 0.25) calc(var(--pointer-y, 50%) * -0.25),
|
||||
center center;
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
9) VSTAR (Radialer Halo-Ring + Conic-Strahlen)
|
||||
========================================================= */
|
||||
.holo-shine[data-holo="v-star"] {
|
||||
background-image:
|
||||
var(--holo-spectrum),
|
||||
conic-gradient(
|
||||
from calc(var(--pointer-from-left, 0.5) * 120deg) at var(--pointer-x, 50%) var(--pointer-y, 50%),
|
||||
#000 0deg,
|
||||
#fff 30deg,
|
||||
#000 60deg,
|
||||
#fff 120deg,
|
||||
#000 180deg,
|
||||
#fff 240deg,
|
||||
#000 300deg,
|
||||
#fff 330deg,
|
||||
#000 360deg
|
||||
);
|
||||
background-blend-mode: multiply;
|
||||
background-size: 260% 260%, cover;
|
||||
background-position: 0% var(--background-y, 50%), center center;
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.holo-shine-secondary[data-holo="v-star"] {
|
||||
background-image: radial-gradient(
|
||||
circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
|
||||
transparent 10%,
|
||||
hsla(185, 100%, 82%, 0.32) 26%,
|
||||
hsla(305, 100%, 82%, 0.24) 38%,
|
||||
transparent 58%
|
||||
);
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
10) TRAINER GALLERY (sanfte -22°-Pastell-Regenbogenwellen)
|
||||
========================================================= */
|
||||
.holo-shine[data-holo="trainer-gallery"] {
|
||||
background-image: linear-gradient(
|
||||
-22deg,
|
||||
hsla(283, 75%, 68%, 0.85) 0%,
|
||||
hsla(2, 85%, 66%, 0.85) 16%,
|
||||
hsla(53, 85%, 62%, 0.85) 33%,
|
||||
hsla(93, 75%, 60%, 0.85) 50%,
|
||||
hsla(176, 70%, 60%, 0.85) 66%,
|
||||
hsla(228, 100%, 78%, 0.85) 83%,
|
||||
hsla(283, 75%, 68%, 0.85) 100%
|
||||
);
|
||||
background-size: 280% 280%;
|
||||
background-position: var(--background-x, 50%) var(--background-y, 50%);
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.holo-shine-secondary[data-holo="trainer-gallery"] {
|
||||
background-image: radial-gradient(
|
||||
farthest-corner ellipse at calc((var(--pointer-x, 50%) * 0.5) + 25%) calc((var(--pointer-y, 50%) * 0.5) + 25%),
|
||||
hsla(0, 0%, 100%, 0.32) 0%,
|
||||
hsla(300, 90%, 75%, 0.14) 38%,
|
||||
transparent 72%
|
||||
);
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
11) RAINBOW SECRET (weiches Regenbogen-Prisma + Glitzer)
|
||||
========================================================= */
|
||||
.holo-shine[data-holo="rainbow"] {
|
||||
background-image: linear-gradient(
|
||||
-35deg,
|
||||
hsl(0, 90%, 66%) 0%,
|
||||
hsl(42, 95%, 64%) 16%,
|
||||
hsl(98, 85%, 60%) 33%,
|
||||
hsl(178, 90%, 60%) 50%,
|
||||
hsl(220, 92%, 68%) 66%,
|
||||
hsl(285, 88%, 68%) 83%,
|
||||
hsl(0, 90%, 66%) 100%
|
||||
);
|
||||
background-size: 320% 320%;
|
||||
background-position: calc(25% + (var(--pointer-x, 50%) / 2)) calc(25% + (var(--pointer-y, 50%) / 2));
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.holo-shine-secondary[data-holo="rainbow"] {
|
||||
background-image: var(--glitter-dots);
|
||||
background-size: 42px 42px, 52px 52px, 38px 38px, 58px 58px, 46px 46px, 34px 34px;
|
||||
background-position:
|
||||
calc(var(--pointer-x, 50%) * 0.3) calc(var(--pointer-y, 50%) * 0.3),
|
||||
calc(var(--pointer-x, 50%) * -0.25) calc(var(--pointer-y, 50%) * 0.25),
|
||||
center center,
|
||||
center center,
|
||||
calc(var(--pointer-x, 50%) * 0.2) calc(var(--pointer-y, 50%) * -0.2),
|
||||
center center;
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
12) GOLD SECRET RARE (Conic-Sunpillar + Gold-Glitzer)
|
||||
========================================================= */
|
||||
.holo-shine[data-holo="gold"] {
|
||||
background-image: conic-gradient(
|
||||
from calc(var(--pointer-from-left, 0.5) * 180deg) at var(--pointer-x, 50%) var(--pointer-y, 50%),
|
||||
hsl(46, 100%, 66%),
|
||||
hsl(36, 100%, 58%),
|
||||
hsl(54, 100%, 76%),
|
||||
hsl(176, 85%, 70%),
|
||||
hsl(46, 100%, 66%)
|
||||
);
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.holo-shine-secondary[data-holo="gold"] {
|
||||
background-image:
|
||||
var(--glitter-dots),
|
||||
linear-gradient(
|
||||
45deg,
|
||||
transparent 28%,
|
||||
hsla(48, 100%, 70%, 0.32) 46%,
|
||||
hsla(54, 100%, 88%, 0.5) 50%,
|
||||
hsla(44, 100%, 64%, 0.32) 54%,
|
||||
transparent 72%
|
||||
);
|
||||
background-size: 40px 40px, 50px 50px, 36px 36px, 54px 54px, 44px 44px, 32px 32px, 240% 240%;
|
||||
background-position:
|
||||
calc(var(--pointer-x, 50%) * 0.35) calc(var(--pointer-y, 50%) * 0.35),
|
||||
calc(var(--pointer-x, 50%) * -0.25) calc(var(--pointer-y, 50%) * 0.25),
|
||||
center center,
|
||||
center center,
|
||||
calc(var(--pointer-x, 50%) * 0.2) calc(var(--pointer-y, 50%) * -0.2),
|
||||
center center,
|
||||
var(--pointer-x, 50%) var(--pointer-y, 50%);
|
||||
mix-blend-mode: plus-lighter;
|
||||
opacity: 0.52;
|
||||
}
|
||||
</style>
|
||||
@@ -3,11 +3,13 @@ export interface AuthUser {
|
||||
name: string
|
||||
email: string | null
|
||||
color: string | null
|
||||
bio: string | null
|
||||
cardEffect: string | null
|
||||
cardEffectColor: string | null
|
||||
cardVignette: boolean
|
||||
cardBorderEffect: string | null
|
||||
cardBorderEffectColor: string | null
|
||||
cardHoloEffect: string | null
|
||||
avatarUrl: string | null
|
||||
roles: string[]
|
||||
permissions: string[]
|
||||
|
||||
@@ -19,13 +19,29 @@ interface DmGroupReadEvent {
|
||||
at: string
|
||||
}
|
||||
|
||||
export interface FloatingChatTab {
|
||||
user: DmUser
|
||||
minimized: boolean
|
||||
}
|
||||
|
||||
type MessageListener = (msg: DmMessage, other: DmUser | null, group: DmGroup | null) => void
|
||||
type ReadListener = (partnerId: number, at: string) => void
|
||||
type ReactionListener = (e: DmReactionEvent) => void
|
||||
|
||||
// Direkt-Callbacks der offenen Chat-Ansicht - Modul-Singletons, weil
|
||||
// Plugin (WS-Event) und Page (Registrierung) verschiedene Composable-Aufrufe nutzen
|
||||
const liveMessageHandler = shallowRef<((msg: DmMessage, other: DmUser | null, group: DmGroup | null) => void) | null>(null)
|
||||
const liveReadHandler = shallowRef<((partnerId: number, at: string) => void) | null>(null)
|
||||
const liveReactionHandler = shallowRef<((e: DmReactionEvent) => void) | null>(null)
|
||||
const liveMessageHandler = shallowRef<MessageListener | null>(null)
|
||||
const liveReadHandler = shallowRef<ReadListener | null>(null)
|
||||
const liveReactionHandler = shallowRef<ReactionListener | null>(null)
|
||||
const liveGroupHandler = shallowRef<((group: DmGroup) => void) | null>(null)
|
||||
|
||||
// Zusätzliche Listener für schwebende Messenger-Fenster (mehrere gleichzeitig möglich)
|
||||
const floatingMessageListeners = new Set<MessageListener>()
|
||||
const floatingReadListeners = new Set<ReadListener>()
|
||||
const floatingReactionListeners = new Set<ReactionListener>()
|
||||
|
||||
const MAX_FLOATING_CHATS = 3
|
||||
|
||||
export function useDirectMessages() {
|
||||
const { user } = useAuth()
|
||||
const conversations = useState<DmConversation[]>('dm:conversations', () => [])
|
||||
@@ -33,6 +49,65 @@ export function useDirectMessages() {
|
||||
// Aktuell geöffnete Konversation (messages-Seite setzt/löscht das)
|
||||
const openPartnerId = useState<number | null>('dm:open', () => null)
|
||||
const openGroupId = useState<number | null>('dm:open-group', () => null)
|
||||
// Schwebende 1:1-Chat-Fenster (Messenger-Stil)
|
||||
const floatingChats = useState<FloatingChatTab[]>('dm:floating', () => [])
|
||||
|
||||
function isPartnerOpen(partnerId: number): boolean {
|
||||
if (openPartnerId.value === partnerId) return true
|
||||
return floatingChats.value.some(c => c.user.id === partnerId && !c.minimized)
|
||||
}
|
||||
|
||||
function openFloatingChat(partner: DmUser, minimized = false) {
|
||||
if (!user.value || partner.id === user.value.id) return
|
||||
const existing = floatingChats.value.find(c => c.user.id === partner.id)
|
||||
if (existing) {
|
||||
existing.user = { ...existing.user, ...partner }
|
||||
existing.minimized = minimized
|
||||
return
|
||||
}
|
||||
const next = [...floatingChats.value, { user: partner, minimized }]
|
||||
if (next.length > MAX_FLOATING_CHATS) next.shift()
|
||||
floatingChats.value = next
|
||||
}
|
||||
|
||||
function toggleMinimizeFloatingChat(userId: number) {
|
||||
const tab = floatingChats.value.find(c => c.user.id === userId)
|
||||
if (tab) tab.minimized = !tab.minimized
|
||||
}
|
||||
|
||||
function closeFloatingChat(userId: number) {
|
||||
floatingChats.value = floatingChats.value.filter(c => c.user.id !== userId)
|
||||
}
|
||||
|
||||
function subscribeFloating(handlers: {
|
||||
onMessage?: MessageListener
|
||||
onRead?: ReadListener
|
||||
onReaction?: ReactionListener
|
||||
}) {
|
||||
if (handlers.onMessage) floatingMessageListeners.add(handlers.onMessage)
|
||||
if (handlers.onRead) floatingReadListeners.add(handlers.onRead)
|
||||
if (handlers.onReaction) floatingReactionListeners.add(handlers.onReaction)
|
||||
return () => {
|
||||
if (handlers.onMessage) floatingMessageListeners.delete(handlers.onMessage)
|
||||
if (handlers.onRead) floatingReadListeners.delete(handlers.onRead)
|
||||
if (handlers.onReaction) floatingReactionListeners.delete(handlers.onReaction)
|
||||
}
|
||||
}
|
||||
|
||||
function notifyMessageListeners(msg: DmMessage, other: DmUser | null, group: DmGroup | null) {
|
||||
liveMessageHandler.value?.(msg, other, group)
|
||||
for (const fn of floatingMessageListeners) fn(msg, other, group)
|
||||
}
|
||||
|
||||
function notifyReadListeners(partnerId: number, at: string) {
|
||||
liveReadHandler.value?.(partnerId, at)
|
||||
for (const fn of floatingReadListeners) fn(partnerId, at)
|
||||
}
|
||||
|
||||
function notifyReactionListeners(e: DmReactionEvent) {
|
||||
liveReactionHandler.value?.(e)
|
||||
for (const fn of floatingReactionListeners) fn(e)
|
||||
}
|
||||
|
||||
async function fetchConversations() {
|
||||
const res = await $fetch<{ conversations: DmConversation[], unreadTotal: number }>('/api/messages')
|
||||
@@ -72,7 +147,7 @@ export function useDirectMessages() {
|
||||
const isOpen = openGroupId.value === g.id
|
||||
upsertGroup(g, msg, isMine || isOpen ? 0 : 1)
|
||||
if (!isMine && !isOpen) unreadTotal.value += 1
|
||||
if (isOpen) liveMessageHandler.value?.(msg, null, g)
|
||||
if (isOpen) notifyMessageListeners(msg, null, g)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,19 +155,20 @@ export function useDirectMessages() {
|
||||
const other = isForMe ? event.sender : event.recipient!
|
||||
|
||||
if (isForMe) {
|
||||
const isOpen = openPartnerId.value === msg.senderId
|
||||
// Wie beim Messenger: eingehende 1:1-Nachricht poppt automatisch als
|
||||
// Floating-Tab auf, sofern man nicht schon in /messages mit dem User ist
|
||||
if (openPartnerId.value !== msg.senderId && !floatingChats.value.some(c => c.user.id === other.id)) {
|
||||
openFloatingChat(other, false)
|
||||
}
|
||||
const isOpen = isPartnerOpen(msg.senderId)
|
||||
upsertDm(other, msg, isOpen ? 0 : 1)
|
||||
if (isOpen) {
|
||||
liveMessageHandler.value?.(msg, other, null)
|
||||
}
|
||||
else {
|
||||
unreadTotal.value += 1
|
||||
}
|
||||
if (!isOpen) unreadTotal.value += 1
|
||||
notifyMessageListeners(msg, other, null)
|
||||
}
|
||||
else {
|
||||
// Eigene Nachricht aus anderem Tab/Fenster synchronisieren
|
||||
upsertDm(other, msg, 0)
|
||||
if (openPartnerId.value === msg.recipientId) liveMessageHandler.value?.(msg, other, null)
|
||||
notifyMessageListeners(msg, other, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +179,7 @@ export function useDirectMessages() {
|
||||
if (e.group) {
|
||||
const gid = e.group.id
|
||||
if (openGroupId.value === gid) {
|
||||
liveReactionHandler.value?.(e)
|
||||
notifyReactionListeners(e)
|
||||
return
|
||||
}
|
||||
if (isMine) return
|
||||
@@ -114,11 +190,8 @@ export function useDirectMessages() {
|
||||
return
|
||||
}
|
||||
const partnerId = isMine ? e.other!.id : e.actorId
|
||||
if (openPartnerId.value === partnerId) {
|
||||
liveReactionHandler.value?.(e)
|
||||
return
|
||||
}
|
||||
if (isMine) return
|
||||
notifyReactionListeners(e)
|
||||
if (isPartnerOpen(partnerId) || isMine) return
|
||||
const conv = conversations.value.find(c => c.kind === 'dm' && c.user?.id === partnerId)
|
||||
const delta = e.active ? 1 : -1
|
||||
if (conv) conv.unread = Math.max(0, conv.unread + delta)
|
||||
@@ -136,7 +209,7 @@ export function useDirectMessages() {
|
||||
}
|
||||
else if (event.partner === user.value?.id) {
|
||||
// Gegenüber hat meine Nachrichten gelesen
|
||||
liveReadHandler.value?.(event.reader, event.at)
|
||||
notifyReadListeners(event.reader, event.at)
|
||||
const conv = conversations.value.find(c => c.kind === 'dm' && c.user?.id === event.reader)
|
||||
if (conv?.lastMessage && conv.lastMessage.senderId === user.value?.id && !conv.lastMessage.readAt) {
|
||||
conv.lastMessage.readAt = event.at
|
||||
@@ -163,6 +236,7 @@ export function useDirectMessages() {
|
||||
// Optimistisch: eigene gesendete Nachricht sofort in die Liste
|
||||
function addOwnMessage(partner: DmUser, msg: DmMessage) {
|
||||
upsertDm(partner, msg, 0)
|
||||
notifyMessageListeners(msg, partner, null)
|
||||
}
|
||||
|
||||
function addOwnGroupMessage(group: DmGroup, msg: DmMessage) {
|
||||
@@ -190,6 +264,7 @@ export function useDirectMessages() {
|
||||
unreadTotal.value = 0
|
||||
openPartnerId.value = null
|
||||
openGroupId.value = null
|
||||
floatingChats.value = []
|
||||
liveMessageHandler.value = null
|
||||
liveReadHandler.value = null
|
||||
liveReactionHandler.value = null
|
||||
@@ -201,6 +276,11 @@ export function useDirectMessages() {
|
||||
unreadTotal,
|
||||
openPartnerId,
|
||||
openGroupId,
|
||||
floatingChats,
|
||||
openFloatingChat,
|
||||
toggleMinimizeFloatingChat,
|
||||
closeFloatingChat,
|
||||
subscribeFloating,
|
||||
fetchConversations,
|
||||
handleIncoming,
|
||||
handleReaction,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
function clamp(v: number, min = 0, max = 100) {
|
||||
return Math.min(Math.max(v, min), max)
|
||||
}
|
||||
|
||||
function adjust(value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) {
|
||||
return toMin + ((toMax - toMin) * (value - fromMin)) / (fromMax - fromMin)
|
||||
}
|
||||
|
||||
// Berechnet die CSS-Variablen im Stil von poke-holo.simey.me für
|
||||
// 3D-Kartenrotation + Holo-Shine/Glare. Solange der Zeiger nicht direkt
|
||||
// auf der Karte liegt, schwenkt eine sanfte Sinus-Bewegung das Hologramm,
|
||||
// damit man den 3D-/Holo-Effekt schon beim Öffnen der Hover-Card sieht.
|
||||
export function useHoloTilt(active: Ref<boolean>) {
|
||||
const pointerX = ref(50)
|
||||
const pointerY = ref(50)
|
||||
const isHovering = ref(false)
|
||||
let rafId: number | null = null
|
||||
|
||||
function tick(now: number) {
|
||||
if (!active.value) {
|
||||
rafId = null
|
||||
return
|
||||
}
|
||||
if (!isHovering.value) {
|
||||
const t = now * 0.0022
|
||||
pointerX.value = 50 + Math.cos(t) * 32
|
||||
pointerY.value = 50 + Math.sin(t * 1.3) * 28
|
||||
}
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
watch(active, (v) => {
|
||||
if (!import.meta.client) return
|
||||
if (v && rafId === null) {
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
else if (!v && rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
isHovering.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
})
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!active.value) return
|
||||
const el = e.currentTarget as HTMLElement | null
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
isHovering.value = true
|
||||
pointerX.value = clamp(((e.clientX - rect.left) / rect.width) * 100)
|
||||
pointerY.value = clamp(((e.clientY - rect.top) / rect.height) * 100)
|
||||
}
|
||||
|
||||
function onPointerLeave() {
|
||||
isHovering.value = false
|
||||
}
|
||||
|
||||
const holoStyle = computed<Record<string, string>>(() => {
|
||||
if (!active.value) return {}
|
||||
const px = pointerX.value
|
||||
const py = pointerY.value
|
||||
const bgX = adjust(px, 0, 100, 35, 65)
|
||||
const bgY = adjust(py, 0, 100, 33, 67)
|
||||
const centerX = px - 50
|
||||
const centerY = py - 50
|
||||
const fromCenter = clamp(Math.hypot(centerX, centerY) / 50, 0, 1)
|
||||
const rotateY = -(centerX / 3.2)
|
||||
const rotateX = centerY / 2.8
|
||||
|
||||
return {
|
||||
'--pointer-x': `${px.toFixed(2)}%`,
|
||||
'--pointer-y': `${py.toFixed(2)}%`,
|
||||
'--background-x': `${bgX.toFixed(2)}%`,
|
||||
'--background-y': `${bgY.toFixed(2)}%`,
|
||||
'--pointer-from-center': fromCenter.toFixed(3),
|
||||
'--pointer-from-top': (py / 100).toFixed(3),
|
||||
'--pointer-from-left': (px / 100).toFixed(3),
|
||||
'--rotate-x': `${rotateX.toFixed(2)}deg`,
|
||||
'--rotate-y': `${rotateY.toFixed(2)}deg`,
|
||||
transform: `perspective(600px) rotateX(${rotateX.toFixed(2)}deg) rotateY(${rotateY.toFixed(2)}deg) translateZ(0)`,
|
||||
transformStyle: 'preserve-3d',
|
||||
willChange: 'transform',
|
||||
transition: isHovering.value ? 'transform 60ms ease-out, box-shadow 200ms ease' : 'transform 180ms ease-out, box-shadow 300ms ease',
|
||||
}
|
||||
})
|
||||
|
||||
return { holoStyle, onPointerMove, onPointerLeave }
|
||||
}
|
||||
@@ -2,11 +2,14 @@ export interface UserSummary {
|
||||
id: number
|
||||
name: string
|
||||
color: string | null
|
||||
bio: string | null
|
||||
roles: string[]
|
||||
cardEffect: string | null
|
||||
cardEffectColor: string | null
|
||||
cardVignette: boolean
|
||||
cardBorderEffect: string | null
|
||||
cardBorderEffectColor: string | null
|
||||
cardHoloEffect: string | null
|
||||
avatarUrl: string | null
|
||||
createdAt: string
|
||||
stats: {
|
||||
@@ -22,7 +25,7 @@ export function useUserSummaries() {
|
||||
const summaries = useState<Record<number, UserSummary>>('user-summaries', () => ({}))
|
||||
|
||||
function ensureSummary(id: number) {
|
||||
if (summaries.value[id] || inflight.has(id)) return
|
||||
if ((summaries.value[id] && Array.isArray(summaries.value[id].roles)) || inflight.has(id)) return
|
||||
inflight.set(id, (async () => {
|
||||
try {
|
||||
summaries.value[id] = await $fetch<UserSummary>(`/api/users/${id}/summary`)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import CommentsSidebar from '@/components/CommentsSidebar.vue'
|
||||
import CommandPalette from '@/components/CommandPalette.vue'
|
||||
import DmFloatingContainer from '@/components/DmFloatingContainer.vue'
|
||||
import GlobalDropZone from '@/components/GlobalDropZone.vue'
|
||||
import MotdBanner from '@/components/MotdBanner.vue'
|
||||
import SiteBreadcrumb from '@/components/SiteBreadcrumb.vue'
|
||||
@@ -44,6 +45,7 @@ watch(() => route.path, (to, from) => {
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<CommentsSidebar v-if="!locked" />
|
||||
<DmFloatingContainer v-if="!locked" />
|
||||
<CommandPalette v-if="!locked" />
|
||||
<GlobalDropZone v-if="!locked" />
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -41,6 +41,7 @@ interface AdminUser {
|
||||
}
|
||||
|
||||
const { user, can } = useAuth()
|
||||
const { invalidate: invalidateUserSummary } = useUserSummaries()
|
||||
const { t } = useI18n()
|
||||
|
||||
useHead({ title: computed(() => t('nav.admin')) })
|
||||
@@ -197,6 +198,7 @@ async function addRole(userId: number) {
|
||||
try {
|
||||
await $fetch(`/api/admin/users/${userId}/roles`, { method: 'POST', body: { roleId } })
|
||||
selectedRole.value[userId] = undefined
|
||||
invalidateUserSummary(userId)
|
||||
await refreshAll()
|
||||
}
|
||||
catch (err) {
|
||||
@@ -210,6 +212,7 @@ async function removeRole(userId: number, roleId: number) {
|
||||
try {
|
||||
await $fetch(`/api/admin/users/${userId}/roles/${roleId}`, { method: 'DELETE' })
|
||||
roleRemoveConfirm.value = null
|
||||
invalidateUserSummary(userId)
|
||||
await refreshAll()
|
||||
}
|
||||
catch (err) {
|
||||
|
||||
@@ -682,29 +682,44 @@ async function saveGroupEdit() {
|
||||
<TooltipContent>{{ $t('dm.invite') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
<UserHoverCard
|
||||
v-else-if="partner"
|
||||
:user-id="partner.id"
|
||||
:user-name="partner.name"
|
||||
:user-color="partner.color"
|
||||
:user-avatar-url="partner.avatarUrl"
|
||||
>
|
||||
<NuxtLink :to="`/user/${partner.id}`" class="flex items-center gap-2.5">
|
||||
<Avatar class="size-8">
|
||||
<AvatarImage v-if="partner.avatarUrl" :src="partner.avatarUrl" :alt="partner.name" />
|
||||
<AvatarFallback
|
||||
class="text-xs"
|
||||
:style="partner.color ? { backgroundColor: partner.color, color: '#fff' } : undefined"
|
||||
<template v-else-if="partner">
|
||||
<UserHoverCard
|
||||
:user-id="partner.id"
|
||||
:user-name="partner.name"
|
||||
:user-color="partner.color"
|
||||
:user-avatar-url="partner.avatarUrl"
|
||||
>
|
||||
<NuxtLink :to="`/user/${partner.id}`" class="flex items-center gap-2.5">
|
||||
<Avatar class="size-8">
|
||||
<AvatarImage v-if="partner.avatarUrl" :src="partner.avatarUrl" :alt="partner.name" />
|
||||
<AvatarFallback
|
||||
class="text-xs"
|
||||
:style="partner.color ? { backgroundColor: partner.color, color: '#fff' } : undefined"
|
||||
>
|
||||
{{ partner.name.slice(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
class="font-medium hover:underline"
|
||||
:style="partner.color ? { color: partner.color } : undefined"
|
||||
>{{ partner.name }}</span>
|
||||
</NuxtLink>
|
||||
</UserHoverCard>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="ml-auto size-8 cursor-pointer text-muted-foreground"
|
||||
:aria-label="$t('dm.openFloating')"
|
||||
@click="dms.openFloatingChat(partner); navigateTo('/')"
|
||||
>
|
||||
{{ partner.name.slice(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
class="font-medium hover:underline"
|
||||
:style="partner.color ? { color: partner.color } : undefined"
|
||||
>{{ partner.name }}</span>
|
||||
</NuxtLink>
|
||||
</UserHoverCard>
|
||||
<Icon name="ph:picture-in-picture-fill" class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('dm.openFloating') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
<h1 v-else class="text-sm font-semibold">{{ $t('dm.title') }}</h1>
|
||||
</header>
|
||||
|
||||
|
||||
+61
-3
@@ -3,7 +3,9 @@ import type { Component } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { CARD_BORDER_EFFECTS, type CardBorderEffectKey, isCardBorderEffect } from '~~/shared/utils/cardBorderEffects'
|
||||
import { CARD_EFFECTS, type CardEffectKey } from '~~/shared/utils/cardEffects'
|
||||
import { CARD_HOLO_EFFECTS, type CardHoloEffectKey, isCardHoloEffect } from '~~/shared/utils/cardHoloEffects'
|
||||
import CardBorderEffect from '@/components/effects/CardBorderEffect.vue'
|
||||
import CardHoloEffect from '@/components/effects/CardHoloEffect.vue'
|
||||
import CardVignette from '@/components/effects/CardVignette.vue'
|
||||
import FileDropzone from '@/components/FileDropzone.vue'
|
||||
import EffectAcidSquares from '@/components/effects/EffectAcidSquares.vue'
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
const { user, refresh } = useAuth()
|
||||
const { invalidate } = useUserSummaries()
|
||||
@@ -64,6 +67,7 @@ async function deleteSticker(stickerId: number) {
|
||||
}
|
||||
|
||||
const color = ref(user.value?.color ?? '#6366f1')
|
||||
const bio = ref(user.value?.bio ?? '')
|
||||
const cardEffect = ref<string>(user.value?.cardEffect ?? 'none')
|
||||
const cardEffectColor = ref<string>(user.value?.cardEffectColor ?? '#6366f1')
|
||||
const cardEffectColorEnabled = ref(!!user.value?.cardEffectColor)
|
||||
@@ -71,6 +75,7 @@ const cardVignette = ref(user.value?.cardVignette ?? false)
|
||||
const cardBorderEffect = ref<string>(user.value?.cardBorderEffect ?? 'none')
|
||||
const cardBorderEffectColor = ref<string>(user.value?.cardBorderEffectColor ?? '#6366f1')
|
||||
const cardBorderEffectColorEnabled = ref(!!user.value?.cardBorderEffectColor)
|
||||
const cardHoloEffect = ref<string>(user.value?.cardHoloEffect ?? 'none')
|
||||
const avatar = ref<File | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
@@ -98,6 +103,11 @@ const previewEffect = computed(() =>
|
||||
const previewBorder = computed<CardBorderEffectKey | null>(() =>
|
||||
isCardBorderEffect(cardBorderEffect.value) ? cardBorderEffect.value : null,
|
||||
)
|
||||
const previewHolo = computed<CardHoloEffectKey | null>(() =>
|
||||
isCardHoloEffect(cardHoloEffect.value) ? cardHoloEffect.value : null,
|
||||
)
|
||||
const previewHoloActive = computed(() => !!previewHolo.value)
|
||||
const { holoStyle: previewHoloStyle, onPointerMove: onPreviewMove, onPointerLeave: onPreviewLeave } = useHoloTilt(previewHoloActive)
|
||||
// Effekt-Farben sind optional - ohne Häkchen fällt der Effekt auf die Namensfarbe
|
||||
const previewEffectColor = computed(() =>
|
||||
cardEffectColorEnabled.value ? cardEffectColor.value : color.value,
|
||||
@@ -105,11 +115,14 @@ const previewEffectColor = computed(() =>
|
||||
const previewBorderColor = computed(() =>
|
||||
cardBorderEffectColorEnabled.value ? cardBorderEffectColor.value : color.value,
|
||||
)
|
||||
const showPreview = computed(() => previewEffect.value || previewBorder.value)
|
||||
const showPreview = computed(() => previewEffect.value || previewBorder.value || previewHolo.value)
|
||||
|
||||
watch(() => user.value?.color, (c) => {
|
||||
if (c) color.value = c
|
||||
})
|
||||
watch(() => user.value?.bio, (b) => {
|
||||
bio.value = b ?? ''
|
||||
})
|
||||
watch(() => user.value?.cardEffect, (e) => {
|
||||
cardEffect.value = e ?? 'none'
|
||||
})
|
||||
@@ -127,6 +140,9 @@ watch(() => user.value?.cardBorderEffectColor, (c) => {
|
||||
cardBorderEffectColorEnabled.value = !!c
|
||||
if (c) cardBorderEffectColor.value = c
|
||||
})
|
||||
watch(() => user.value?.cardHoloEffect, (e) => {
|
||||
cardHoloEffect.value = e ?? 'none'
|
||||
})
|
||||
|
||||
async function save() {
|
||||
if (saving.value || !user.value) return
|
||||
@@ -134,11 +150,13 @@ async function save() {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('color', color.value)
|
||||
formData.append('bio', bio.value.trim())
|
||||
formData.append('cardEffect', cardEffect.value === 'none' ? '' : cardEffect.value)
|
||||
formData.append('cardEffectColor', cardEffectColorEnabled.value ? cardEffectColor.value : '')
|
||||
formData.append('cardVignette', cardVignette.value ? '1' : '')
|
||||
formData.append('cardBorderEffect', cardBorderEffect.value === 'none' ? '' : cardBorderEffect.value)
|
||||
formData.append('cardBorderEffectColor', cardBorderEffectColorEnabled.value ? cardBorderEffectColor.value : '')
|
||||
formData.append('cardHoloEffect', cardHoloEffect.value === 'none' ? '' : cardHoloEffect.value)
|
||||
if (avatar.value) formData.append('avatar', avatar.value)
|
||||
await $fetch('/api/users/me', { method: 'POST', body: formData })
|
||||
await refresh()
|
||||
@@ -198,6 +216,20 @@ async function save() {
|
||||
<span class="text-sm font-medium" :style="{ color }">{{ user.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="bio">{{ $t('settings.bio') }}</Label>
|
||||
<span class="text-xs tabular-nums text-muted-foreground">{{ bio.length }} / 160</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="bio"
|
||||
v-model="bio"
|
||||
:placeholder="$t('settings.bioPlaceholder')"
|
||||
maxlength="160"
|
||||
rows="3"
|
||||
class="resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('settings.cardEffect') }}</Label>
|
||||
<Select v-model="cardEffect">
|
||||
@@ -262,13 +294,39 @@ async function save() {
|
||||
</ColorPicker>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('settings.cardHolo') }}</Label>
|
||||
<Select v-model="cardHoloEffect">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue :placeholder="$t('settings.noEffectPlaceholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{{ $t('settings.noEffect') }}
|
||||
</SelectItem>
|
||||
<SelectItem v-for="e in CARD_HOLO_EFFECTS" :key="e.key" :value="e.key">
|
||||
{{ e.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="showPreview" class="space-y-1.5">
|
||||
<Label>{{ $t('settings.preview') }}</Label>
|
||||
<div class="relative h-24 overflow-hidden rounded-md border bg-popover">
|
||||
<div
|
||||
class="relative mx-auto h-28 w-72 max-w-full overflow-hidden rounded-md border-2 bg-popover"
|
||||
:class="[previewBorder && 'border-transparent', previewHolo && 'shadow-[0_14px_28px_-6px_rgba(0,0,0,0.65)]']"
|
||||
:style="{
|
||||
...(!previewBorder ? { borderColor: color || 'var(--color-border)' } : {}),
|
||||
...previewHoloStyle,
|
||||
}"
|
||||
@pointermove="onPreviewMove"
|
||||
@pointerleave="onPreviewLeave"
|
||||
>
|
||||
<component :is="previewEffect" v-if="previewEffect" :color="previewEffectColor" />
|
||||
<div v-if="previewEffect" class="absolute inset-0 bg-popover/60" />
|
||||
<CardVignette v-if="previewEffect && cardVignette" />
|
||||
<span class="absolute inset-0 flex items-center justify-center text-sm font-medium" :style="{ color }">
|
||||
<CardHoloEffect v-if="previewHolo" :effect="previewHolo" />
|
||||
<span class="absolute inset-0 z-10 flex items-center justify-center text-sm font-medium" :style="{ color }">
|
||||
{{ user.name }}
|
||||
</span>
|
||||
<CardBorderEffect v-if="previewBorder" :effect="previewBorder" :color="previewBorderColor" />
|
||||
|
||||
+30
-11
@@ -6,6 +6,7 @@ import CardVignette from '@/components/effects/CardVignette.vue'
|
||||
import { isCardEffect } from '~~/shared/utils/cardEffects'
|
||||
import { isCardBorderEffect } from '~~/shared/utils/cardBorderEffects'
|
||||
import type { CommentAttachment } from '~~/shared/utils/attachments'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -55,6 +56,8 @@ interface UserProfile {
|
||||
id: number
|
||||
name: string
|
||||
color: string | null
|
||||
bio: string | null
|
||||
roles: string[]
|
||||
avatarUrl: string | null
|
||||
cardEffect: string | null
|
||||
cardEffectColor: string | null
|
||||
@@ -73,6 +76,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { locale } = useI18n()
|
||||
const { user } = useAuth()
|
||||
const { openFloatingChat } = useDirectMessages()
|
||||
const userId = computed(() => route.params.id as string)
|
||||
const isOwnProfile = computed(() => user.value?.id === data.value?.user.id)
|
||||
|
||||
@@ -218,12 +222,20 @@ const timeAgo = useTimeAgo()
|
||||
:style="data.user.color ? { backgroundColor: data.user.color, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
|
||||
>{{ initials }}</span>
|
||||
<div class="min-w-0">
|
||||
<h1
|
||||
class="truncate text-2xl font-semibold"
|
||||
:style="data.user.color ? { color: data.user.color } : undefined"
|
||||
>
|
||||
{{ data.user.name }}
|
||||
</h1>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1
|
||||
class="truncate text-2xl font-semibold"
|
||||
:style="data.user.color ? { color: data.user.color } : undefined"
|
||||
>
|
||||
{{ data.user.name }}
|
||||
</h1>
|
||||
<Badge
|
||||
v-for="role in data.user.roles"
|
||||
:key="role"
|
||||
variant="secondary"
|
||||
class="px-2 py-0.5 text-xs uppercase tracking-wider"
|
||||
>{{ role }}</Badge>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
ID {{ data.user.id }}
|
||||
</p>
|
||||
@@ -231,6 +243,9 @@ const timeAgo = useTimeAgo()
|
||||
<Icon name="ph:calendar-fill" class="size-3.5" />
|
||||
{{ $t('misc.memberSince', { date: joinDate }) }}
|
||||
</p>
|
||||
<p v-if="data.user.bio" class="mt-2 break-words whitespace-pre-line text-sm text-muted-foreground">
|
||||
{{ data.user.bio }}
|
||||
</p>
|
||||
<div class="mt-3 flex items-center gap-4 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
@@ -250,11 +265,15 @@ const timeAgo = useTimeAgo()
|
||||
<Icon name="ph:chat-fill" class="size-4" />
|
||||
{{ data.stats.comments }} {{ $t('misc.commentsCol') }}
|
||||
</button>
|
||||
<Button v-if="user && !isOwnProfile" variant="outline" size="sm" class="ml-auto gap-1.5" as-child>
|
||||
<NuxtLink :to="`/messages/${data.user.id}`">
|
||||
<Icon name="ph:chats-circle-fill" class="size-4" />
|
||||
{{ $t('dm.message') }}
|
||||
</NuxtLink>
|
||||
<Button
|
||||
v-if="user && !isOwnProfile"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="ml-auto cursor-pointer gap-1.5"
|
||||
@click="openFloatingChat({ id: data.user.id, name: data.user.name, color: data.user.color, avatarUrl: data.user.avatarUrl })"
|
||||
>
|
||||
<Icon name="ph:chats-circle-fill" class="size-4" />
|
||||
{{ $t('dm.message') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,6 +190,10 @@
|
||||
"delivered": "Zugestellt",
|
||||
"you": "Du",
|
||||
"back": "Zurück",
|
||||
"openFloating": "Als schwebendes Fenster öffnen",
|
||||
"openFullView": "In Vollansicht öffnen",
|
||||
"minimizeChat": "Minimieren",
|
||||
"expandChat": "Aufklappen",
|
||||
"newGroup": "Neue Gruppe",
|
||||
"groupNamePlaceholder": "Gruppenname (optional)",
|
||||
"inviteSearch": "Mitglieder suchen…",
|
||||
@@ -285,6 +289,8 @@
|
||||
"desc": "Profil und Sprache anpassen.",
|
||||
"nameColor": "Name-Farbe",
|
||||
"pickColor": "Farbe auswählen",
|
||||
"bio": "Bio",
|
||||
"bioPlaceholder": "Erzähl kurz etwas über dich…",
|
||||
"cardEffect": "Hover-Card-Effekt",
|
||||
"noEffect": "Keiner",
|
||||
"noEffectPlaceholder": "Kein Effekt",
|
||||
@@ -294,6 +300,7 @@
|
||||
"cardBorder": "Hover-Card-Border",
|
||||
"ownBorderColor": "Eigene Border-Farbe",
|
||||
"pickBorderColor": "Border-Farbe auswählen",
|
||||
"cardHolo": "Hover-Card-Holo-Effekt (3D)",
|
||||
"preview": "Vorschau",
|
||||
"previewHint": "So sieht deine Hover-Card für andere aus.",
|
||||
"avatar": "Avatar",
|
||||
|
||||
@@ -190,6 +190,10 @@
|
||||
"delivered": "Delivered",
|
||||
"you": "You",
|
||||
"back": "Back",
|
||||
"openFloating": "Open as floating window",
|
||||
"openFullView": "Open in full view",
|
||||
"minimizeChat": "Minimize",
|
||||
"expandChat": "Expand",
|
||||
"newGroup": "New group",
|
||||
"groupNamePlaceholder": "Group name (optional)",
|
||||
"inviteSearch": "Search members…",
|
||||
@@ -285,6 +289,8 @@
|
||||
"desc": "Adjust your profile and language.",
|
||||
"nameColor": "Name color",
|
||||
"pickColor": "Pick color",
|
||||
"bio": "Bio",
|
||||
"bioPlaceholder": "Write a short bio about yourself…",
|
||||
"cardEffect": "Hover card effect",
|
||||
"noEffect": "None",
|
||||
"noEffectPlaceholder": "No effect",
|
||||
@@ -294,6 +300,7 @@
|
||||
"cardBorder": "Hover card border",
|
||||
"ownBorderColor": "Custom border color",
|
||||
"pickBorderColor": "Pick border color",
|
||||
"cardHolo": "Hover card holo effect (3D)",
|
||||
"preview": "Preview",
|
||||
"previewHint": "This is how your hover card looks to others.",
|
||||
"avatar": "Avatar",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
@@ -18,5 +18,5 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
await createSession(event, user.id)
|
||||
return { user: { id: user.id, name: user.name, email: user.email, color: user.color, cardEffect: user.cardEffect, cardEffectColor: user.cardEffectColor, cardVignette: user.cardVignette, cardBorderEffect: user.cardBorderEffect, cardBorderEffectColor: user.cardBorderEffectColor, avatarUrl: avatarUrl(user.avatar), roles: await getUserRoles(user.id), permissions: await getUserPermissions(user.id) } }
|
||||
return { user: { id: user.id, name: user.name, email: user.email, color: user.color, bio: user.bio, cardEffect: user.cardEffect, cardEffectColor: user.cardEffectColor, cardVignette: user.cardVignette, cardBorderEffect: user.cardBorderEffect, cardBorderEffectColor: user.cardBorderEffectColor, cardHoloEffect: user.cardHoloEffect, avatarUrl: avatarUrl(user.avatar), roles: await getUserRoles(user.id), permissions: await getUserPermissions(user.id) } }
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await getSessionUser(event)
|
||||
return {
|
||||
user: user ? { id: user.id, name: user.name, email: user.email, color: user.color, cardEffect: user.cardEffect, cardEffectColor: user.cardEffectColor, cardVignette: user.cardVignette, cardBorderEffect: user.cardBorderEffect, cardBorderEffectColor: user.cardBorderEffectColor, avatarUrl: avatarUrl(user.avatar), roles: user.roles, permissions: user.permissions } : null,
|
||||
user: user ? { id: user.id, name: user.name, email: user.email, color: user.color, bio: user.bio, cardEffect: user.cardEffect, cardEffectColor: user.cardEffectColor, cardVignette: user.cardVignette, cardBorderEffect: user.cardBorderEffect, cardBorderEffectColor: user.cardBorderEffectColor, cardHoloEffect: user.cardHoloEffect, avatarUrl: avatarUrl(user.avatar), roles: user.roles, permissions: user.permissions } : null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
await createSession(event, userId)
|
||||
return { user: { id: userId, name: body.name, email: body.email || null, color, cardEffect: null, cardEffectColor: null, cardVignette: false, cardBorderEffect: null, cardBorderEffectColor: null, avatarUrl: null, roles: roleNames, permissions: await getUserPermissions(userId) } }
|
||||
return { user: { id: userId, name: body.name, email: body.email || null, color, bio: null, cardEffect: null, cardEffectColor: null, cardVignette: false, cardBorderEffect: null, cardBorderEffectColor: null, cardHoloEffect: null, avatarUrl: null, roles: roleNames, permissions: await getUserPermissions(userId) } }
|
||||
}
|
||||
catch (err: unknown) {
|
||||
if (typeof err === 'object' && err !== null && (err as { errno?: number }).errno === 1062) {
|
||||
|
||||
@@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const db = useDb()
|
||||
const [user] = await db
|
||||
.select({ id: users.id, name: users.name, color: users.color, avatar: users.avatar, cardEffect: users.cardEffect, cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette, cardBorderEffect: users.cardBorderEffect, cardBorderEffectColor: users.cardBorderEffectColor, createdAt: users.createdAt })
|
||||
.select({ id: users.id, name: users.name, color: users.color, bio: users.bio, avatar: users.avatar, cardEffect: users.cardEffect, cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette, cardBorderEffect: users.cardBorderEffect, cardBorderEffectColor: users.cardBorderEffectColor, createdAt: users.createdAt })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
@@ -19,21 +19,19 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
|
||||
}
|
||||
|
||||
const [uploadStats] = await db
|
||||
.select({ count: count() })
|
||||
.from(uploads)
|
||||
.where(eq(uploads.userId, user.id))
|
||||
|
||||
const [commentStats] = await db
|
||||
.select({ count: count() })
|
||||
.from(comments)
|
||||
.where(eq(comments.userId, user.id))
|
||||
const [[uploadStats], [commentStats], roles] = await Promise.all([
|
||||
db.select({ count: count() }).from(uploads).where(eq(uploads.userId, user.id)),
|
||||
db.select({ count: count() }).from(comments).where(eq(comments.userId, user.id)),
|
||||
getUserRoles(user.id),
|
||||
])
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
color: user.color,
|
||||
bio: user.bio,
|
||||
roles,
|
||||
avatarUrl: avatarUrl(user.avatar),
|
||||
cardEffect: user.cardEffect,
|
||||
cardEffectColor: user.cardEffectColor, cardVignette: user.cardVignette,
|
||||
|
||||
@@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const db = useDb()
|
||||
const [user] = await db
|
||||
.select({ id: users.id, name: users.name, color: users.color, avatar: users.avatar, cardEffect: users.cardEffect, cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette, cardBorderEffect: users.cardBorderEffect, cardBorderEffectColor: users.cardBorderEffectColor, createdAt: users.createdAt })
|
||||
.select({ id: users.id, name: users.name, color: users.color, bio: users.bio, avatar: users.avatar, cardEffect: users.cardEffect, cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette, cardBorderEffect: users.cardBorderEffect, cardBorderEffectColor: users.cardBorderEffectColor, cardHoloEffect: users.cardHoloEffect, createdAt: users.createdAt })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
@@ -19,24 +19,23 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
|
||||
}
|
||||
|
||||
const [uploadStats] = await db
|
||||
.select({ count: count() })
|
||||
.from(uploads)
|
||||
.where(eq(uploads.userId, user.id))
|
||||
|
||||
const [commentStats] = await db
|
||||
.select({ count: count() })
|
||||
.from(comments)
|
||||
.where(eq(comments.userId, user.id))
|
||||
const [[uploadStats], [commentStats], roles] = await Promise.all([
|
||||
db.select({ count: count() }).from(uploads).where(eq(uploads.userId, user.id)),
|
||||
db.select({ count: count() }).from(comments).where(eq(comments.userId, user.id)),
|
||||
getUserRoles(user.id),
|
||||
])
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
color: user.color,
|
||||
bio: user.bio,
|
||||
roles,
|
||||
cardEffect: user.cardEffect,
|
||||
cardEffectColor: user.cardEffectColor, cardVignette: user.cardVignette,
|
||||
cardBorderEffect: user.cardBorderEffect,
|
||||
cardBorderEffectColor: user.cardBorderEffectColor,
|
||||
cardHoloEffect: user.cardHoloEffect,
|
||||
avatarUrl: avatarUrl(user.avatar),
|
||||
createdAt: user.createdAt,
|
||||
stats: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { isCardBorderEffect } from '../../../shared/utils/cardBorderEffects'
|
||||
import { isCardEffect } from '../../../shared/utils/cardEffects'
|
||||
import { isCardHoloEffect } from '../../../shared/utils/cardHoloEffects'
|
||||
import { users } from '../../db/schema'
|
||||
|
||||
const AVATAR_MIME_EXT: Record<string, string> = {
|
||||
@@ -19,11 +20,13 @@ export default defineEventHandler(async (event) => {
|
||||
const parts = await readMultipartFormData(event)
|
||||
|
||||
const colorField = parts?.find(p => p.name === 'color')?.data.toString('utf8').trim()
|
||||
const bioField = parts?.find(p => p.name === 'bio')?.data.toString('utf8').trim()
|
||||
const cardEffectField = parts?.find(p => p.name === 'cardEffect')?.data.toString('utf8').trim()
|
||||
const cardEffectColorField = parts?.find(p => p.name === 'cardEffectColor')?.data.toString('utf8').trim()
|
||||
const cardVignetteField = parts?.find(p => p.name === 'cardVignette')?.data.toString('utf8').trim()
|
||||
const cardBorderEffectField = parts?.find(p => p.name === 'cardBorderEffect')?.data.toString('utf8').trim()
|
||||
const cardBorderEffectColorField = parts?.find(p => p.name === 'cardBorderEffectColor')?.data.toString('utf8').trim()
|
||||
const cardHoloEffectField = parts?.find(p => p.name === 'cardHoloEffect')?.data.toString('utf8').trim()
|
||||
const avatarFile = parts?.find(p => p.name === 'avatar')
|
||||
|
||||
let color: string | null | undefined
|
||||
@@ -39,6 +42,14 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
let bio: string | null | undefined
|
||||
if (bioField !== undefined) {
|
||||
if (bioField.length > 160) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bio ist zu lang (max. 160 Zeichen)' })
|
||||
}
|
||||
bio = bioField || null
|
||||
}
|
||||
|
||||
let cardEffect: string | null | undefined
|
||||
if (cardEffectField !== undefined) {
|
||||
if (cardEffectField === '') {
|
||||
@@ -65,6 +76,19 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
let cardHoloEffect: string | null | undefined
|
||||
if (cardHoloEffectField !== undefined) {
|
||||
if (cardHoloEffectField === '') {
|
||||
cardHoloEffect = null
|
||||
}
|
||||
else if (isCardHoloEffect(cardHoloEffectField)) {
|
||||
cardHoloEffect = cardHoloEffectField
|
||||
}
|
||||
else {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Holo-Effekt' })
|
||||
}
|
||||
}
|
||||
|
||||
const parseOptionalColor = (field: string | undefined): string | null | undefined => {
|
||||
if (field === undefined) return undefined
|
||||
if (field === '') return null
|
||||
@@ -93,11 +117,13 @@ export default defineEventHandler(async (event) => {
|
||||
const db = useDb()
|
||||
await db.update(users).set({
|
||||
...(color !== undefined && { color }),
|
||||
...(bio !== undefined && { bio }),
|
||||
...(cardEffect !== undefined && { cardEffect }),
|
||||
...(cardEffectColor !== undefined && { cardEffectColor }),
|
||||
...(cardVignette !== undefined && { cardVignette }),
|
||||
...(cardBorderEffect !== undefined && { cardBorderEffect }),
|
||||
...(cardBorderEffectColor !== undefined && { cardBorderEffectColor }),
|
||||
...(cardHoloEffect !== undefined && { cardHoloEffect }),
|
||||
...(avatarName && { avatar: avatarName }),
|
||||
}).where(eq(users.id, user.id))
|
||||
|
||||
@@ -112,11 +138,13 @@ export default defineEventHandler(async (event) => {
|
||||
name: updated!.name,
|
||||
email: updated!.email,
|
||||
color: updated!.color,
|
||||
bio: updated!.bio,
|
||||
cardEffect: updated!.cardEffect,
|
||||
cardEffectColor: updated!.cardEffectColor,
|
||||
cardVignette: updated!.cardVignette,
|
||||
cardBorderEffect: updated!.cardBorderEffect,
|
||||
cardBorderEffectColor: updated!.cardBorderEffectColor,
|
||||
cardHoloEffect: updated!.cardHoloEffect,
|
||||
avatarUrl: avatarUrl(updated!.avatar),
|
||||
roles: user.roles,
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `users` ADD `card_holo_effect` varchar(32);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `users` ADD `bio` varchar(160);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -288,6 +288,20 @@
|
||||
"when": 1792330220263,
|
||||
"tag": "0040_new_celestials",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "5",
|
||||
"when": 1792434764156,
|
||||
"tag": "0041_puzzling_medusa",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 42,
|
||||
"version": "5",
|
||||
"when": 1792437112047,
|
||||
"tag": "0042_young_kat_farrell",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,12 +7,14 @@ export const users = mysqlTable('users', {
|
||||
email: varchar('email', { length: 255 }).unique(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
color: varchar('color', { length: 7 }),
|
||||
bio: varchar('bio', { length: 160 }),
|
||||
avatar: varchar('avatar', { length: 255 }),
|
||||
cardEffect: varchar('card_effect', { length: 32 }),
|
||||
cardEffectColor: varchar('card_effect_color', { length: 7 }),
|
||||
cardVignette: boolean('card_vignette').default(false).notNull(),
|
||||
cardBorderEffect: varchar('card_border_effect', { length: 32 }),
|
||||
cardBorderEffectColor: varchar('card_border_effect_color', { length: 7 }),
|
||||
cardHoloEffect: varchar('card_holo_effect', { length: 32 }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Katalog der verfügbaren 3D-Hologramm-Effekte für die User-Hover-Card
|
||||
// (basierend auf den Raritäten von poke-holo.simey.me)
|
||||
export const CARD_HOLO_EFFECTS = [
|
||||
{ key: 'galaxy', label: 'Galaxy Holofoil (Classic)' },
|
||||
{ key: 'cosmos', label: 'Cosmos Holofoil (3-Layer)' },
|
||||
{ key: 'holo', label: 'Regular Holo (Vertical)' },
|
||||
{ key: 'reverse-holo', label: 'Reverse Holo (Foil)' },
|
||||
{ key: 'amazing-rare', label: 'Amazing Rare (Shimmer)' },
|
||||
{ key: 'radiant', label: 'Radiant Crosshatch' },
|
||||
{ key: 'v-holo', label: 'Holo V (Diagonal)' },
|
||||
{ key: 'v-full-art', label: 'Full Art Ultra (Etched)' },
|
||||
{ key: 'v-max', label: 'VMAX (Prismatic)' },
|
||||
{ key: 'v-star', label: 'VSTAR (Radial Halo)' },
|
||||
{ key: 'trainer-gallery', label: 'Trainer Gallery (Soft)' },
|
||||
{ key: 'rainbow', label: 'Rainbow Secret Rare' },
|
||||
{ key: 'gold', label: 'Gold Secret Rare' },
|
||||
] as const
|
||||
|
||||
export type CardHoloEffectKey = typeof CARD_HOLO_EFFECTS[number]['key']
|
||||
|
||||
const KEYS = new Set<string>(CARD_HOLO_EFFECTS.map(e => e.key))
|
||||
|
||||
export function isCardHoloEffect(value: string): value is CardHoloEffectKey {
|
||||
return KEYS.has(value)
|
||||
}
|
||||
Reference in New Issue
Block a user