Replace 30+ hardcoded strings across 15 components with $t() calls. Added keys for common actions, drawers titles, empty states, and modals. Ticket: T-020 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
242 lines
8.8 KiB
Vue
242 lines
8.8 KiB
Vue
<template>
|
|
<div
|
|
ref="blockEl"
|
|
class="absolute z-10 cursor-pointer rounded-md text-xs text-white shadow-sm select-none"
|
|
:style="blockStyle"
|
|
:class="{ 'opacity-40': isDragSource }"
|
|
@contextmenu.prevent="emit('contextmenu', $event, entry)"
|
|
@mousedown="onMouseDown"
|
|
@click.stop
|
|
>
|
|
<!-- Resize handle top (outside block) -->
|
|
<div
|
|
class="absolute left-0 right-0 h-3 cursor-n-resize group"
|
|
style="bottom: 100%"
|
|
@mousedown.stop.prevent="onResizeTopStart"
|
|
>
|
|
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
|
</div>
|
|
|
|
<div class="px-1.5 py-0.5 h-full overflow-hidden">
|
|
<!-- Full display: title + project + type dot + duration -->
|
|
<template v-if="sizeLevel >= 3">
|
|
<div class="flex items-center gap-1">
|
|
<div class="font-semibold truncate">{{ entry.title || $t('common.untitled') }}</div>
|
|
<span class="ml-auto shrink-0 text-[10px] tabular-nums opacity-80">{{ duration }}</span>
|
|
</div>
|
|
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
|
|
<div v-if="entry.tags.length" class="mt-0.5 flex items-center gap-1 overflow-hidden">
|
|
<span
|
|
v-for="tag in entry.tags"
|
|
:key="tag.id"
|
|
class="inline-flex items-center gap-0.5 truncate text-[9px] opacity-90"
|
|
>
|
|
<span class="inline-block h-1.5 w-1.5 shrink-0 rounded-full" :style="{ backgroundColor: tag.color }" />
|
|
{{ tag.label }}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Medium: title + duration -->
|
|
<template v-else-if="sizeLevel === 2">
|
|
<div class="font-semibold truncate">{{ entry.title || $t('common.untitled') }}</div>
|
|
<div class="text-[10px] tabular-nums opacity-80">{{ duration }}</div>
|
|
</template>
|
|
|
|
<!-- Small: title only -->
|
|
<template v-else-if="sizeLevel === 1">
|
|
<div class="font-semibold truncate text-[10px] leading-tight">{{ entry.title || $t('common.untitled') }}</div>
|
|
</template>
|
|
|
|
<!-- Tiny: just a colored bar, no text -->
|
|
</div>
|
|
|
|
<!-- Resize handle bottom (outside block) -->
|
|
<div
|
|
class="absolute left-0 right-0 h-3 cursor-s-resize group"
|
|
style="top: 100%"
|
|
@mousedown.stop.prevent="onResizeBottomStart"
|
|
>
|
|
<div class="absolute top-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
|
|
|
const props = defineProps<{
|
|
entry: TimeEntry
|
|
hourHeight: number
|
|
dayStartHour: number
|
|
isDragSource?: boolean
|
|
columnIndex?: number
|
|
totalColumns?: number
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'click', entry: TimeEntry): void
|
|
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry): void
|
|
(e: 'resize', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
|
(e: 'moveStart', payload: { entry: TimeEntry; offsetY: number }): void
|
|
}>()
|
|
|
|
const blockEl = ref<HTMLElement | null>(null)
|
|
|
|
const startDate = computed(() => new Date(props.entry.startedAt))
|
|
const endDate = computed(() => props.entry.stoppedAt ? new Date(props.entry.stoppedAt) : new Date())
|
|
|
|
const resizeTopDeltaMinutes = ref(0)
|
|
const resizeBottomDeltaMinutes = ref(0)
|
|
|
|
const duration = computed(() => {
|
|
const mins = Math.floor((endDate.value.getTime() + resizeBottomDeltaMinutes.value * 60000
|
|
- startDate.value.getTime() - resizeTopDeltaMinutes.value * 60000) / 60000)
|
|
const h = Math.floor(mins / 60)
|
|
const m = mins % 60
|
|
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
|
})
|
|
|
|
const heightPx = computed(() => {
|
|
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
|
const endMinutes = endDate.value.getHours() * 60 + endDate.value.getMinutes() + resizeBottomDeltaMinutes.value
|
|
return Math.max(((endMinutes - startMinutes) / 60) * props.hourHeight, 20)
|
|
})
|
|
|
|
// Responsive content levels based on block height
|
|
// 3 = full (title + project + types + duration)
|
|
// 2 = medium (title + duration)
|
|
// 1 = small (title only)
|
|
// 0 = tiny (colored bar only)
|
|
const sizeLevel = computed(() => {
|
|
const h = heightPx.value
|
|
if (h >= 50) return 3
|
|
if (h >= 35) return 2
|
|
if (h >= 20) return 1
|
|
return 0
|
|
})
|
|
|
|
const blockStyle = computed(() => {
|
|
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
|
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
|
|
const bgColor = props.entry.project?.color ?? '#94a3b8'
|
|
|
|
const col = props.columnIndex ?? 0
|
|
const total = props.totalColumns ?? 1
|
|
const gapPx = 2
|
|
const leftPercent = (col / total) * 100
|
|
const widthPercent = (1 / total) * 100
|
|
|
|
return {
|
|
top: `${topPx}px`,
|
|
height: `${heightPx.value}px`,
|
|
backgroundColor: bgColor,
|
|
left: `calc(${leftPercent}% + ${gapPx}px)`,
|
|
width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
|
|
}
|
|
})
|
|
|
|
// --- Click / Drag detection ---
|
|
let mouseDownPos = { x: 0, y: 0 }
|
|
let mouseDownHandled = false
|
|
|
|
function onMouseDown(event: MouseEvent) {
|
|
if (event.button !== 0) return
|
|
if ((event.target as HTMLElement).closest('.cursor-s-resize, .cursor-n-resize')) return
|
|
|
|
mouseDownPos = { x: event.clientX, y: event.clientY }
|
|
mouseDownHandled = false
|
|
|
|
document.addEventListener('mousemove', onMouseMoveDetect)
|
|
document.addEventListener('mouseup', onMouseUpDetect)
|
|
}
|
|
|
|
function onMouseMoveDetect(event: MouseEvent) {
|
|
const dx = event.clientX - mouseDownPos.x
|
|
const dy = event.clientY - mouseDownPos.y
|
|
if (Math.abs(dx) + Math.abs(dy) > 5 && !mouseDownHandled) {
|
|
mouseDownHandled = true
|
|
document.removeEventListener('mousemove', onMouseMoveDetect)
|
|
document.removeEventListener('mouseup', onMouseUpDetect)
|
|
|
|
const rect = blockEl.value!.getBoundingClientRect()
|
|
emit('moveStart', {
|
|
entry: props.entry,
|
|
offsetY: mouseDownPos.y - rect.top,
|
|
})
|
|
}
|
|
}
|
|
|
|
function onMouseUpDetect() {
|
|
document.removeEventListener('mousemove', onMouseMoveDetect)
|
|
document.removeEventListener('mouseup', onMouseUpDetect)
|
|
if (!mouseDownHandled) {
|
|
emit('click', props.entry)
|
|
}
|
|
}
|
|
|
|
// --- Resize bottom (change stoppedAt) ---
|
|
function onResizeBottomStart(event: MouseEvent) {
|
|
const startY = event.clientY
|
|
resizeBottomDeltaMinutes.value = 0
|
|
|
|
document.body.style.userSelect = 'none'
|
|
document.body.style.cursor = 's-resize'
|
|
|
|
function onMouseMove(e: MouseEvent) {
|
|
const delta = e.clientY - startY
|
|
resizeBottomDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
|
}
|
|
|
|
function onMouseUp() {
|
|
document.removeEventListener('mousemove', onMouseMove)
|
|
document.removeEventListener('mouseup', onMouseUp)
|
|
document.body.style.userSelect = ''
|
|
document.body.style.cursor = ''
|
|
|
|
const finalDelta = resizeBottomDeltaMinutes.value
|
|
resizeBottomDeltaMinutes.value = 0
|
|
|
|
if (finalDelta !== 0) {
|
|
const newEnd = new Date(endDate.value.getTime() + finalDelta * 60000)
|
|
emit('resize', props.entry, props.entry.startedAt, newEnd.toISOString())
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousemove', onMouseMove)
|
|
document.addEventListener('mouseup', onMouseUp)
|
|
}
|
|
|
|
// --- Resize top (change startedAt) ---
|
|
function onResizeTopStart(event: MouseEvent) {
|
|
const startY = event.clientY
|
|
resizeTopDeltaMinutes.value = 0
|
|
|
|
document.body.style.userSelect = 'none'
|
|
document.body.style.cursor = 'n-resize'
|
|
|
|
function onMouseMove(e: MouseEvent) {
|
|
const delta = e.clientY - startY
|
|
resizeTopDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
|
}
|
|
|
|
function onMouseUp() {
|
|
document.removeEventListener('mousemove', onMouseMove)
|
|
document.removeEventListener('mouseup', onMouseUp)
|
|
document.body.style.userSelect = ''
|
|
document.body.style.cursor = ''
|
|
|
|
const finalDelta = resizeTopDeltaMinutes.value
|
|
resizeTopDeltaMinutes.value = 0
|
|
|
|
if (finalDelta !== 0) {
|
|
const newStart = new Date(startDate.value.getTime() + finalDelta * 60000)
|
|
emit('resize', props.entry, newStart.toISOString(), props.entry.stoppedAt ?? endDate.value.toISOString())
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousemove', onMouseMove)
|
|
document.addEventListener('mouseup', onMouseUp)
|
|
}
|
|
</script>
|