* chore: remove old locales + just enable all locales now * feat: debug panel for i18n + tooltips * feat: dedupe * fix: debugger for app * fix: crowdin code mismatches * fix: lint
543 lines
16 KiB
Vue
543 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
EyeIcon,
|
|
EyeOffIcon,
|
|
MaximizeIcon,
|
|
MinusIcon,
|
|
ScanEyeIcon,
|
|
SearchIcon,
|
|
XIcon,
|
|
} from '@modrinth/assets'
|
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
|
|
import { injectI18nDebug } from '../../composables/i18n-debug'
|
|
import ButtonStyled from './ButtonStyled.vue'
|
|
import StyledInput from './StyledInput.vue'
|
|
|
|
const debugContext = injectI18nDebug()
|
|
|
|
const searchQuery = ref('')
|
|
const minimized = ref(false)
|
|
const copiedKey = ref<string | null>(null)
|
|
const highlightedEl = ref<Element | null>(null)
|
|
const searchInputRef = ref<HTMLInputElement | null>(null)
|
|
const activeEntryIndex = ref(-1)
|
|
const listContainerRef = ref<HTMLElement | null>(null)
|
|
|
|
// Dragging state
|
|
const isDragging = ref(false)
|
|
const panelPos = ref({ x: -1, y: -1 })
|
|
const dragOffset = ref({ x: 0, y: 0 })
|
|
|
|
// Resize state
|
|
const isResizing = ref(false)
|
|
const panelWidth = ref(380)
|
|
const panelHeight = ref(420)
|
|
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
|
|
|
|
const filteredEntries = computed(() => {
|
|
if (!debugContext) return []
|
|
const entries = Array.from(debugContext.registry.values())
|
|
const q = searchQuery.value.toLowerCase()
|
|
if (!q) return entries
|
|
return entries.filter((e) => e.key.toLowerCase().includes(q) || e.value.toLowerCase().includes(q))
|
|
})
|
|
|
|
const keyCount = computed(() => debugContext?.registry.size ?? 0)
|
|
|
|
// Reset active index when search changes
|
|
watch(searchQuery, () => {
|
|
activeEntryIndex.value = -1
|
|
})
|
|
|
|
function truncate(str: string, max: number): string {
|
|
return str.length > max ? str.slice(0, max) + '\u2026' : str
|
|
}
|
|
|
|
function highlightMatch(text: string, query: string): string {
|
|
if (!query) return escapeHtml(text)
|
|
const escaped = escapeHtml(text)
|
|
const q = escapeHtml(query)
|
|
const regex = new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
|
|
return escaped.replace(regex, '<mark class="bg-brand/20 text-brand rounded-sm px-0.5">$1</mark>')
|
|
}
|
|
|
|
function escapeHtml(str: string): string {
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
}
|
|
|
|
function toggleKeyReveal() {
|
|
if (debugContext) {
|
|
debugContext.keyReveal.value = !debugContext.keyReveal.value
|
|
}
|
|
}
|
|
|
|
function toggleOverlay() {
|
|
if (debugContext?.enabled.value) {
|
|
document.body.classList.toggle('i18n-debug')
|
|
}
|
|
}
|
|
|
|
function closePanel() {
|
|
if (debugContext) {
|
|
debugContext.panelOpen.value = false
|
|
}
|
|
}
|
|
|
|
function highlightElement(key: string) {
|
|
clearHighlight()
|
|
const el = document.querySelector(`[data-i18n-key="${CSS.escape(key)}"]`)
|
|
if (el) {
|
|
highlightedEl.value = el
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
;(el as HTMLElement).style.outline = '2px solid var(--color-brand)'
|
|
;(el as HTMLElement).style.outlineOffset = '3px'
|
|
;(el as HTMLElement).style.borderRadius = '4px'
|
|
}
|
|
}
|
|
|
|
function clearHighlight() {
|
|
if (highlightedEl.value) {
|
|
;(highlightedEl.value as HTMLElement).style.outline = ''
|
|
;(highlightedEl.value as HTMLElement).style.outlineOffset = ''
|
|
;(highlightedEl.value as HTMLElement).style.borderRadius = ''
|
|
highlightedEl.value = null
|
|
}
|
|
}
|
|
|
|
async function copyKey(key: string) {
|
|
try {
|
|
await navigator.clipboard.writeText(key)
|
|
copiedKey.value = key
|
|
setTimeout(() => {
|
|
copiedKey.value = null
|
|
}, 2000)
|
|
} catch {
|
|
// clipboard not available
|
|
}
|
|
}
|
|
|
|
function onPanelKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
activeEntryIndex.value = Math.min(activeEntryIndex.value + 1, filteredEntries.value.length - 1)
|
|
scrollActiveIntoView()
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
activeEntryIndex.value = Math.max(activeEntryIndex.value - 1, 0)
|
|
scrollActiveIntoView()
|
|
} else if (e.key === 'Enter' && activeEntryIndex.value >= 0) {
|
|
e.preventDefault()
|
|
const entry = filteredEntries.value[activeEntryIndex.value]
|
|
if (entry) copyKey(entry.key)
|
|
} else if (e.key === 'Escape') {
|
|
if (searchQuery.value) {
|
|
searchQuery.value = ''
|
|
} else {
|
|
closePanel()
|
|
}
|
|
} else if (e.key === '/' && document.activeElement !== searchInputRef.value) {
|
|
e.preventDefault()
|
|
searchInputRef.value?.focus()
|
|
}
|
|
}
|
|
|
|
function scrollActiveIntoView() {
|
|
nextTick(() => {
|
|
const activeEl = listContainerRef.value?.querySelector('[data-active="true"]')
|
|
activeEl?.scrollIntoView({ block: 'nearest' })
|
|
})
|
|
}
|
|
|
|
// Drag handling
|
|
function onHeaderMouseDown(e: MouseEvent) {
|
|
if ((e.target as HTMLElement).closest('button')) return
|
|
isDragging.value = true
|
|
const panel = (e.currentTarget as HTMLElement).closest('.i18n-debug-panel') as HTMLElement
|
|
if (panel) {
|
|
const rect = panel.getBoundingClientRect()
|
|
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
|
}
|
|
document.addEventListener('mousemove', onMouseMove)
|
|
document.addEventListener('mouseup', onMouseUp)
|
|
}
|
|
|
|
function onMouseMove(e: MouseEvent) {
|
|
if (!isDragging.value) return
|
|
panelPos.value = {
|
|
x: Math.max(0, Math.min(e.clientX - dragOffset.value.x, window.innerWidth - 100)),
|
|
y: Math.max(0, Math.min(e.clientY - dragOffset.value.y, window.innerHeight - 60)),
|
|
}
|
|
}
|
|
|
|
function onMouseUp() {
|
|
isDragging.value = false
|
|
document.removeEventListener('mousemove', onMouseMove)
|
|
document.removeEventListener('mouseup', onMouseUp)
|
|
}
|
|
|
|
// Resize handling
|
|
function onResizeMouseDown(e: MouseEvent) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
isResizing.value = true
|
|
resizeStart.value = {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
w: panelWidth.value,
|
|
h: panelHeight.value,
|
|
}
|
|
document.addEventListener('mousemove', onResizeMove)
|
|
document.addEventListener('mouseup', onResizeUp)
|
|
}
|
|
|
|
function onResizeMove(e: MouseEvent) {
|
|
if (!isResizing.value) return
|
|
const dx = e.clientX - resizeStart.value.x
|
|
const dy = e.clientY - resizeStart.value.y
|
|
panelWidth.value = Math.max(320, Math.min(600, resizeStart.value.w + dx))
|
|
panelHeight.value = Math.max(280, Math.min(700, resizeStart.value.h + dy))
|
|
}
|
|
|
|
function onResizeUp() {
|
|
isResizing.value = false
|
|
document.removeEventListener('mousemove', onResizeMove)
|
|
document.removeEventListener('mouseup', onResizeUp)
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
clearHighlight()
|
|
document.removeEventListener('mousemove', onMouseMove)
|
|
document.removeEventListener('mouseup', onMouseUp)
|
|
document.removeEventListener('mousemove', onResizeMove)
|
|
document.removeEventListener('mouseup', onResizeUp)
|
|
})
|
|
|
|
const panelStyle = computed(() => {
|
|
const base: Record<string, string> = {
|
|
width: minimized.value ? 'auto' : `${panelWidth.value}px`,
|
|
}
|
|
if (panelPos.value.x >= 0 && panelPos.value.y >= 0) {
|
|
base.left = `${panelPos.value.x}px`
|
|
base.top = `${panelPos.value.y}px`
|
|
base.right = 'auto'
|
|
base.bottom = 'auto'
|
|
} else {
|
|
base.right = '20px'
|
|
base.bottom = '20px'
|
|
}
|
|
return base
|
|
})
|
|
|
|
const listMaxHeight = computed(() => `${panelHeight.value - 120}px`)
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<Transition
|
|
enter-active-class="transition-all duration-200 ease-out"
|
|
enter-from-class="opacity-0 translate-y-3 scale-95"
|
|
enter-to-class="opacity-100 translate-y-0 scale-100"
|
|
leave-active-class="transition-all duration-150 ease-in"
|
|
leave-from-class="opacity-100 translate-y-0 scale-100"
|
|
leave-to-class="opacity-0 translate-y-3 scale-95"
|
|
>
|
|
<div
|
|
v-if="debugContext?.panelOpen.value"
|
|
tabindex="-1"
|
|
class="i18n-debug-panel fixed z-[9998] flex flex-col overflow-hidden rounded-xl border-2 border-solid border-surface-5 bg-surface-2 shadow-2xl outline-none"
|
|
:class="{
|
|
'cursor-grabbing': isDragging,
|
|
'select-none': isDragging || isResizing,
|
|
}"
|
|
:style="panelStyle"
|
|
@keydown="onPanelKeydown"
|
|
>
|
|
<!-- Resize handle (bottom-right corner) -->
|
|
<div
|
|
v-if="!minimized"
|
|
class="absolute -bottom-0.5 -right-0.5 z-10 h-4 w-4 cursor-se-resize"
|
|
@mousedown="onResizeMouseDown"
|
|
>
|
|
<svg
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 10 10"
|
|
class="absolute bottom-1 right-1 text-secondary/40"
|
|
>
|
|
<circle cx="8.5" cy="8.5" r="1" fill="currentColor" />
|
|
<circle cx="5" cy="8.5" r="1" fill="currentColor" />
|
|
<circle cx="8.5" cy="5" r="1" fill="currentColor" />
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- Header -->
|
|
<div
|
|
class="flex items-center gap-2.5 px-3.5 py-2.5 cursor-move select-none border-b border-surface-5/50"
|
|
@mousedown="onHeaderMouseDown"
|
|
>
|
|
<!-- Title group -->
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex h-6 w-6 items-center justify-center rounded-md bg-brand/10">
|
|
<ScanEyeIcon class="h-3.5 w-3.5 text-brand" />
|
|
</div>
|
|
<span class="text-[13px] font-semibold tracking-tight text-primary">
|
|
i18n Inspector
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Key count badge -->
|
|
<div class="flex items-center gap-1 rounded-full bg-surface-5/50 px-2 py-0.5">
|
|
<span class="text-[11px] font-medium tabular-nums text-secondary">
|
|
{{ keyCount }} {{ keyCount === 1 ? 'key' : 'keys' }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Toolbar -->
|
|
<div class="ml-auto flex items-center gap-0.5">
|
|
<ButtonStyled circular type="transparent">
|
|
<button
|
|
v-tooltip="
|
|
debugContext?.keyReveal.value ? 'Hide keys inline' : 'Reveal keys inline'
|
|
"
|
|
@click="toggleKeyReveal"
|
|
>
|
|
<component :is="debugContext?.keyReveal.value ? EyeOffIcon : EyeIcon" />
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled circular type="transparent">
|
|
<button v-tooltip="'Toggle CSS debug overlay'" @click="toggleOverlay">
|
|
<ScanEyeIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
|
|
<div class="mx-0.5 h-4 w-px bg-surface-5/60" />
|
|
|
|
<ButtonStyled circular type="transparent">
|
|
<button
|
|
v-tooltip="minimized ? 'Expand panel' : 'Minimize panel'"
|
|
@click="minimized = !minimized"
|
|
>
|
|
<component :is="minimized ? MaximizeIcon : MinusIcon" />
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled circular type="transparent">
|
|
<button v-tooltip="'Close inspector'" @click="closePanel">
|
|
<XIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Body (hidden when minimized) -->
|
|
<Transition
|
|
enter-active-class="transition-all duration-200 ease-out"
|
|
enter-from-class="opacity-0 max-h-0"
|
|
enter-to-class="opacity-100 max-h-[600px]"
|
|
leave-active-class="transition-all duration-150 ease-in"
|
|
leave-from-class="opacity-100 max-h-[600px]"
|
|
leave-to-class="opacity-0 max-h-0"
|
|
>
|
|
<div v-if="!minimized" class="flex flex-col overflow-hidden w-full">
|
|
<!-- Search -->
|
|
<div class="px-3 py-2.5 !w-full">
|
|
<StyledInput
|
|
ref="searchInputRef"
|
|
v-model="searchQuery"
|
|
placeholder="Search keys or values..."
|
|
clearable
|
|
:icon="SearchIcon"
|
|
size="small"
|
|
wrapper-class="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Entry list -->
|
|
<div
|
|
ref="listContainerRef"
|
|
class="overflow-y-auto overscroll-contain scroll-smooth"
|
|
:style="{ maxHeight: listMaxHeight }"
|
|
>
|
|
<TransitionGroup
|
|
move-class="transition-transform duration-200"
|
|
enter-active-class="transition-all duration-150 ease-out"
|
|
enter-from-class="opacity-0 -translate-x-2"
|
|
enter-to-class="opacity-100 translate-x-0"
|
|
leave-active-class="transition-all duration-100 ease-in absolute w-full"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<div
|
|
v-for="(entry, index) in filteredEntries"
|
|
:key="entry.key"
|
|
class="group relative flex items-center gap-2.5 px-3.5 py-2 transition-colors cursor-pointer"
|
|
:class="[activeEntryIndex === index ? 'bg-brand/8' : 'hover:bg-surface-5/40']"
|
|
:data-active="activeEntryIndex === index"
|
|
@mouseenter="
|
|
() => {
|
|
highlightElement(entry.key)
|
|
activeEntryIndex = index
|
|
}
|
|
"
|
|
@mouseleave="clearHighlight"
|
|
@click="copyKey(entry.key)"
|
|
>
|
|
<!-- Active indicator -->
|
|
<div
|
|
v-if="activeEntryIndex === index"
|
|
class="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-brand transition-all"
|
|
/>
|
|
|
|
<!-- Entry content -->
|
|
<div class="min-w-0 flex-1">
|
|
<div
|
|
class="font-mono text-[12px] leading-relaxed text-primary truncate"
|
|
:title="entry.key"
|
|
v-html="highlightMatch(entry.key, searchQuery)"
|
|
/>
|
|
<div
|
|
class="mt-0.5 text-[11px] leading-relaxed text-secondary truncate"
|
|
:title="entry.value"
|
|
v-html="highlightMatch(truncate(entry.value, 50), searchQuery)"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex shrink-0 items-center gap-1">
|
|
<!-- Copied feedback -->
|
|
<Transition
|
|
enter-active-class="transition-all duration-150 ease-out"
|
|
enter-from-class="opacity-0 scale-90"
|
|
enter-to-class="opacity-100 scale-100"
|
|
leave-active-class="transition-all duration-100"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0 scale-90"
|
|
>
|
|
<span
|
|
v-if="copiedKey === entry.key"
|
|
class="flex items-center gap-1 rounded-md bg-green/10 px-1.5 py-0.5 text-[10px] font-medium text-green"
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 16 16" fill="none">
|
|
<path
|
|
d="M3 8.5L6.5 12L13 4"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
Copied
|
|
</span>
|
|
</Transition>
|
|
|
|
<!-- Copy hint (shown on hover when not copied) -->
|
|
<span
|
|
v-if="copiedKey !== entry.key"
|
|
class="text-[10px] text-secondary/0 transition-colors group-hover:text-secondary/60"
|
|
>
|
|
click to copy
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</TransitionGroup>
|
|
|
|
<!-- Empty state -->
|
|
<div
|
|
v-if="filteredEntries.length === 0"
|
|
class="flex flex-col items-center justify-center px-4 py-10"
|
|
>
|
|
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-surface-5/40">
|
|
<SearchIcon class="h-4 w-4 text-secondary/60" />
|
|
</div>
|
|
<p class="mt-3 text-[13px] font-medium text-primary">
|
|
{{ searchQuery ? 'No matches found' : 'No keys registered' }}
|
|
</p>
|
|
<p class="mt-1 text-[11px] text-secondary">
|
|
{{
|
|
searchQuery
|
|
? 'Try a different search term'
|
|
: 'Navigate the app to discover i18n keys'
|
|
}}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer status bar -->
|
|
<div class="flex items-center justify-between border-t border-surface-5/50 px-3.5 py-2">
|
|
<div class="flex items-center gap-2">
|
|
<div class="h-1.5 w-1.5 rounded-full bg-green animate-pulse" />
|
|
<span class="text-[11px] text-secondary"> Watching </span>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<span class="text-[10px] text-secondary/60">
|
|
<kbd
|
|
class="rounded border border-surface-5/40 bg-surface-3/60 px-1 py-px text-[10px]"
|
|
>↑</kbd
|
|
>
|
|
<kbd
|
|
class="rounded border border-surface-5/40 bg-surface-3/60 px-1 py-px text-[10px]"
|
|
>↓</kbd
|
|
>
|
|
navigate
|
|
</span>
|
|
<span class="text-[10px] text-secondary/60">
|
|
<kbd
|
|
class="rounded border border-surface-5/40 bg-surface-3/60 px-1 py-px text-[10px]"
|
|
>↵</kbd
|
|
>
|
|
copy
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.i18n-debug-panel {
|
|
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
|
-webkit-font-smoothing: antialiased;
|
|
box-shadow:
|
|
0 0 0 1px rgba(0, 0, 0, 0.03),
|
|
0 2px 4px rgba(0, 0, 0, 0.04),
|
|
0 12px 24px rgba(0, 0, 0, 0.12),
|
|
0 24px 48px rgba(0, 0, 0, 0.06);
|
|
}
|
|
|
|
/* Custom scrollbar */
|
|
.i18n-debug-panel ::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.i18n-debug-panel ::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.i18n-debug-panel ::-webkit-scrollbar-thumb {
|
|
background: var(--surface-5);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.i18n-debug-panel ::-webkit-scrollbar-thumb:hover {
|
|
background: var(--color-text-tertiary);
|
|
}
|
|
|
|
/* Animate the pulse indicator */
|
|
@keyframes soft-pulse {
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
opacity: 0.4;
|
|
}
|
|
}
|
|
|
|
.animate-pulse {
|
|
animation: soft-pulse 2s ease-in-out infinite;
|
|
}
|
|
</style>
|