Compare commits

...
4 Commits
Author SHA1 Message Date
FliesentischKingandDevin 22639fe770 Add draggable and resizable floating direct message chat windows
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 22:25:41 +02:00
FliesentischKingandDevin 76f07f64d8 Add user bio and role badges to hover cards and profile pages
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 22:25:30 +02:00
FliesentischKingandDevin 37e6ad3e04 Add 3D holographic hover card effects including Galaxy and Cosmos holofoil
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 22:24:34 +02:00
FliesentischKingandDevin b6eee52cc4 Remove rounded corners in the 4f theme
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 22:22:56 +02:00
34 changed files with 6173 additions and 91 deletions
+8 -2
View File
@@ -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"] {
+38
View File
@@ -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>
+576
View File
@@ -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>
+67 -10
View File
@@ -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>
+596
View File
@@ -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>
+2
View File
@@ -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[]
+99 -19
View File
@@ -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,
+98
View File
@@ -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 }
}
+4 -1
View File
@@ -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`)
+2
View File
@@ -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>
+3
View File
@@ -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) {
+37 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+7
View File
@@ -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",
+7
View File
@@ -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

+1 -1
View File
@@ -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 -1
View File
@@ -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,
}
})
+1 -1
View File
@@ -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) {
+8 -10
View File
@@ -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,
+9 -10
View File
@@ -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: {
+28
View File
@@ -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
+14
View File
@@ -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
}
]
}
+2
View File
@@ -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(),
})
+25
View File
@@ -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)
}