Skip to content
Open
6 changes: 0 additions & 6 deletions src/api/queries/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,7 @@ const campKeys = {
dashboard: (id: number) => ['camps', 'detail', id, 'dashboard'] as const,
}

const iconKeys = {
all: ['icons'] as const,
user: (id: string) => ['icons', 'user', id] as const,
}

export const qk = {
me: meKeys,
camps: campKeys,
icons: iconKeys,
}
93 changes: 26 additions & 67 deletions src/components/generic/UserIcon.vue
Original file line number Diff line number Diff line change
@@ -1,86 +1,45 @@
<script setup lang="ts">
import { computed, ref, useAttrs } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { qk } from '@/api/queries/keys'
import { computed, ref } from 'vue'
import { useUserStore } from '@/store'

const props = defineProps<{ id?: string; size: number; idTooltip?: boolean }>()
// idTooltip ... クリック時に Tooltip で ID を表示するかどうか
const userId = props.id ?? useUserStore().user.id // id が指定されていない場合は自分のアイコンを表示
const props = defineProps<{
id?: string
size: number
/** クリック時に Tooltip で ID を表示 */
idTooltip?: boolean
}>()

const attrs = useAttrs()
const iconRef = ref<HTMLElement | undefined>()

const imageStyle = {
width: `${props.size}px`,
height: `${props.size}px`,
objectFit: 'contain' as const,
borderRadius: '50%',
display: 'block',
cursor: props.idTooltip ? 'pointer' : 'default',
}

const directUrl = `https://q.trap.jp/api/v3/public/icon/${userId}`

const {
data: cachedIconUrl,
isLoading,
isFetching,
isError,
} = useQuery<string, Error>({
queryKey: qk.icons.user(userId),
staleTime: 24 * 60 * 60_000, // 24h
gcTime: 24 * 60 * 60_000, // 24h
retry: 0,
// データURLで保存する
queryFn: async () => {
const res = await fetch(directUrl)
if (!res.ok) throw new Error(`アイコンを取得できませんでした: ${res.status}`)

const blob = await res.blob()
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error('アイコンの変換に失敗しました'))
reader.readAsDataURL(blob)
})
return dataUrl
},
})

const showSkeleton = computed(
() => !cachedIconUrl.value || isLoading.value || isFetching.value || isError.value,
)

const tooltipText = `@${userId}`
const userId = computed(() => props.id ?? useUserStore().user.id)
const iconUrl = computed(() => `https://q.trap.jp/api/v3/public/icon/${userId.value}`)
const cursorStyle = computed(() => ({ cursor: props.idTooltip ? 'pointer' : 'default' }))
</script>

<template>
<template v-if="showSkeleton">
<v-avatar :size="size">
<v-skeleton-loader type="image" class="w-100 h-100" />
<div class="rounded-circle overflow-hidden">
<v-avatar ref="iconRef" :size="size" class="d-block" :style="cursorStyle">
<v-img :src="iconUrl" :alt="`${userId}のアイコン`" cover :transition="false">
<template #placeholder>
<v-skeleton-loader type="image" class="w-100 h-100" />
</template>
<template #error>
<v-skeleton-loader type="image" class="w-100 h-100" />
</template>
</v-img>
</v-avatar>
</template>

<template v-else>
<img
ref="iconRef"
tabindex="0"
v-bind="attrs"
:style="imageStyle"
:src="cachedIconUrl"
loading="lazy"
/>

<v-tooltip
v-if="idTooltip"
:activator="iconRef"
:open-on-hover="idTooltip"
:open-on-click="idTooltip"
open-on-hover
open-on-click
:open-delay="1000"
location="top"
>
<span class="text-white font-weight-medium">{{ tooltipText }}</span>
<span class="text-white font-weight-medium">{{ `@${userId}` }}</span>
</v-tooltip>
Comment on lines 32 to 41
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The v-tooltip is positioned outside the v-avatar but references it via activator. This creates a DOM structure where the tooltip's visual container (div.rounded-circle) wraps both the avatar and the tooltip element. However, with the current structure, the tooltip will always be rendered in the DOM even when idTooltip is false, which could have performance implications in lists with many UserIcon components.

Consider moving the v-tooltip inside the v-avatar or conditionally rendering it only when idTooltip is true.

Copilot uses AI. Check for mistakes.
</template>
</div>
</template>

<style module>
Expand Down
16 changes: 16 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ export default defineConfig(({ mode }) => {
disableDevLogs: true, // workbox のログを無効化
globPatterns: ['**/*.{js,css,html,svg,png,jpg,webp,woff2}'],
navigateFallbackDenylist: [/^\/api/, /^\/login/],
runtimeCaching: [
{
urlPattern: /^https:\/\/q\.trap\.jp\/api\/v3\/public\/icon\/.*/,
handler: 'CacheFirst',
options: {
cacheName: 'user-icons',
expiration: {
maxEntries: 200,
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 週間
},
cacheableResponse: {
statuses: [200],
},
},
},
],
},
devOptions: { enabled: true },
}),
Expand Down