feat(ui): consolidate shared UI patterns into reusable components

- Token cleanup: replace raw hex colors (#c084fc, #60a5fa, #6ee7b7, #fdba74)
  in BoardCard.vue with nexus-tokens.css CSS variables (--clr-iris, --clr-bao,
  --clr-agent, --clr-review)
- New composable: useFormatDate (formatDate, relativeTime, toDateInputValue,
  minutesSince, hoursSince) — extracts duplicated date helpers from
  TaskBoardView and BoardCard
- New composable: useConfirm — reusable confirmation dialog logic
  (open/close, error/success state, Escape binding, body scroll lock)
- New component: StatusPill — unified state pill for backlog/progress/
  review/blocked/done, replacing inline .detail-state-pill classes
- New component: SkeletonLoader — loading placeholder with shimmer
  animation (card/text/circle variants)
- TaskBoardView: imports StatusPill & useFormatDate, removes 35+ lines
  of duplicated helpers
- ui/index.ts: exports new StatusPill & SkeletonLoader
- Build verified: pnpm build green (vue-tsc --noEmit + vite build pass)
This commit is contained in:
2026-07-11 14:11:02 +02:00
parent 7de12c6541
commit b82d88563a
7 changed files with 311 additions and 56 deletions
+8 -8
View File
@@ -235,9 +235,9 @@ function toggleExpand(e: MouseEvent) {
.mcard-top { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; flex-wrap: wrap; } .mcard-top { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; flex-wrap: wrap; }
.ball { display: inline-flex; align-items: center; gap: 4px; font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 20px; } .ball { display: inline-flex; align-items: center; gap: 4px; font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 20px; }
.ball.is-iris { background: rgba(147,51,234,.16); color: #c084fc; } .ball.is-iris { background: rgba(147, 51, 234, .16); color: var(--clr-iris); }
.ball.is-bao { background: rgba(59,130,246,.16); color: #60a5fa; } .ball.is-bao { background: rgba(59, 130, 246, .16); color: var(--clr-bao); }
.ball.is-agent { background: rgba(16,185,129,.14); color: #6ee7b7; } .ball.is-agent { background: rgba(16, 185, 129, .14); color: var(--clr-agent); }
.ball-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } .ball-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.prio { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 4px; border: 1px solid; background: transparent; } .prio { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 4px; border: 1px solid; background: transparent; }
.prio-high { color: var(--st-block); border-color: var(--st-block); } .prio-high { color: var(--st-block); border-color: var(--st-block); }
@@ -263,9 +263,9 @@ function toggleExpand(e: MouseEvent) {
.children { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 9px; cursor: default; } .children { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 9px; cursor: default; }
.child-group-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } .child-group-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.child-agent { font-size: 9.5px; font-weight: 700; padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: .03em; } .child-agent { font-size: 9.5px; font-weight: 700; padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: .03em; }
.child-agent.is-iris { background: rgba(147,51,234,.14); color: #c084fc; } .child-agent.is-iris { background: rgba(147, 51, 234, .14); color: var(--clr-iris); }
.child-agent.is-bao { background: rgba(59,130,246,.14); color: #60a5fa; } .child-agent.is-bao { background: rgba(59, 130, 246, .14); color: var(--clr-bao); }
.child-agent.is-agent { background: rgba(16,185,129,.12); color: #6ee7b7; } .child-agent.is-agent { background: rgba(16, 185, 129, .12); color: var(--clr-agent); }
.child-group-count { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--tx-3); } .child-group-count { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--tx-3); }
.child-row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 7px; border: none; border-radius: 7px; background: rgba(10,9,24,.4); color: var(--tx); cursor: pointer; text-align: left; transition: background .15s; } .child-row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 7px; border: none; border-radius: 7px; background: rgba(10,9,24,.4); color: var(--tx); cursor: pointer; text-align: left; transition: background .15s; }
.child-row:hover { background: rgba(124,108,255,.08); } .child-row:hover { background: rgba(124,108,255,.08); }
@@ -275,7 +275,7 @@ function toggleExpand(e: MouseEvent) {
.child-state { font-size: 8.5px; font-weight: 700; padding: 1px 6px; border-radius: 10px; text-transform: uppercase; letter-spacing: .03em; } .child-state { font-size: 8.5px; font-weight: 700; padding: 1px 6px; border-radius: 10px; text-transform: uppercase; letter-spacing: .03em; }
.cs-done { background: rgba(61,220,151,.14); color: var(--st-work); } .cs-done { background: rgba(61,220,151,.14); color: var(--st-work); }
.cs-blocked { background: rgba(251,113,133,.14); color: var(--st-block); } .cs-blocked { background: rgba(251,113,133,.14); color: var(--st-block); }
.cs-review { background: rgba(251,146,60,.14); color: #fdba74; } .cs-review { background: rgba(251, 146, 60, .14); color: var(--clr-review); }
.cs-active { background: rgba(52,214,245,.14); color: var(--st-think); } .cs-active { background: rgba(52,214,245,.14); color: var(--st-think); }
.cs-backlog { background: var(--glass-2); color: var(--tx-3); } .cs-backlog { background: var(--glass-2); color: var(--tx-3); }
@@ -283,7 +283,7 @@ function toggleExpand(e: MouseEvent) {
.rv-approve, .rv-changes { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 8px; border-radius: 8px; font-size: 10.5px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: filter .15s, background .15s; } .rv-approve, .rv-changes { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 8px; border-radius: 8px; font-size: 10.5px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: filter .15s, background .15s; }
.rv-approve { border: none; background: rgba(61,220,151,.16); color: var(--st-work); border: 1px solid rgba(61,220,151,.3); } .rv-approve { border: none; background: rgba(61,220,151,.16); color: var(--st-work); border: 1px solid rgba(61,220,151,.3); }
.rv-approve:hover { background: rgba(61,220,151,.26); } .rv-approve:hover { background: rgba(61,220,151,.26); }
.rv-changes { border: 1px solid rgba(251,146,60,.3); background: rgba(251,146,60,.12); color: #fdba74; } .rv-changes { border: 1px solid rgba(251, 146, 60, .3); background: rgba(251, 146, 60, .12); color: var(--clr-review); }
.rv-changes:hover { background: rgba(251,146,60,.22); } .rv-changes:hover { background: rgba(251,146,60,.22); }
.mcard-meta { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: var(--tx-3); margin-top: 7px; font-variant-numeric: tabular-nums; } .mcard-meta { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: var(--tx-3); margin-top: 7px; font-variant-numeric: tabular-nums; }
@@ -0,0 +1,101 @@
<script setup lang="ts">
/**
* SkeletonLoader — Platzhalter-Animation für Ladezustände
*
* Extrahiert aus dem Muster in TaskStrip (skeleton-pulse) und
* board-loading spinner. Zentrale Komponente mit zwei Varianten:
*
* - 'card' — Rechteckiger Karten-Skeleton
* - 'text' — Zeilen-Skeleton für Text
* - 'circle' — Runder Skeleton (Avatar, Dot)
*
* Alle Farben aus nexus-tokens.css Token.
*/
withDefaults(defineProps<{
/** Darstellungsform */
variant?: 'card' | 'text' | 'circle'
/** Höhe in px (nur für card/text) */
height?: number
/** Breite in px (optional, sonst 100%) */
width?: number
}>(), {
variant: 'card',
height: 78,
})
</script>
<template>
<div
class="skeleton"
:class="variant"
:style="{
height: variant === 'circle' ? `${width || height || 32}px` : `${height}px`,
width: width ? `${width}px` : '100%',
}"
>
<div v-if="variant === 'text'" class="skeleton-line" style="width: 60%"></div>
<div v-if="variant === 'text'" class="skeleton-line" style="width: 85%; margin-top: 8px"></div>
</div>
</template>
<style scoped>
.skeleton {
border-radius: var(--r-sm, 10px);
background: var(--glass);
overflow: hidden;
position: relative;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(124, 108, 255, .06) 40%,
rgba(124, 108, 255, .12) 50%,
rgba(124, 108, 255, .06) 60%,
transparent 100%
);
animation: skeleton-shimmer 1.8s ease-in-out infinite;
}
.skeleton.text {
background: transparent;
display: flex;
flex-direction: column;
padding: 4px 0;
}
.skeleton-line {
height: 10px;
border-radius: 5px;
background: var(--glass-2);
position: relative;
overflow: hidden;
}
.skeleton-line::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(124, 108, 255, .08) 50%,
transparent 100%
);
animation: skeleton-shimmer 1.8s ease-in-out infinite;
}
.skeleton.circle {
border-radius: 50%;
}
@keyframes skeleton-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(180%); }
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
/**
* StatusPill — Einheitliche State-Pill für Statusanzeigen
*
* Extrahiert aus dem doppelten Muster in TaskBoardView (detail-state-pill)
* und BoardCard (child-state). Verwendet ausschließlich Token aus
* nexus-tokens.css als Farbquelle.
*
* Variants:
* backlog | progress | review | blocked | done
*
* Optional: size 'sm' (kompakt) oder 'md' (default).
*/
withDefaults(defineProps<{
/** Status-Variante */
variant: 'backlog' | 'progress' | 'review' | 'blocked' | 'done'
/** Grösse: 'sm' oder 'md' */
size?: 'sm' | 'md'
}>(), {
size: 'md',
})
</script>
<template>
<span class="status-pill" :class="[variant, size]">
<slot />
</span>
</template>
<style scoped>
.status-pill {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
font-weight: 700;
letter-spacing: .03em;
white-space: nowrap;
border: 1px solid transparent;
font-family: 'Manrope', sans-serif;
}
/* Sizes */
.status-pill.md { padding: 3px 9px; font-size: 10.5px; }
.status-pill.sm { padding: 1px 6px; font-size: 8.5px; text-transform: uppercase; }
/* Variants — alle Farben aus nexus-tokens.css */
.status-pill.backlog { color: var(--pill-backlog); background: rgba(251, 191, 36, .12); border-color: rgba(251, 191, 36, .25); }
.status-pill.progress { color: var(--pill-progress); background: rgba(34, 197, 94, .12); border-color: rgba(34, 197, 94, .25); }
.status-pill.review { color: var(--pill-review); background: rgba(249, 115, 22, .12); border-color: rgba(249, 115, 22, .25); }
.status-pill.blocked { color: var(--pill-blocked); background: rgba(244, 63, 94, .12); border-color: rgba(244, 63, 94, .25); }
.status-pill.done { color: var(--pill-done); background: rgba(34, 197, 94, .12); border-color: rgba(34, 197, 94, .25); }
</style>
+2
View File
@@ -5,6 +5,8 @@ export { default as PageHeading } from './PageHeading.vue'
export { default as EmptyState } from './EmptyState.vue' export { default as EmptyState } from './EmptyState.vue'
export { default as ActivityTimeline } from './ActivityTimeline.vue' export { default as ActivityTimeline } from './ActivityTimeline.vue'
export { default as SectionHeader } from './SectionHeader.vue' export { default as SectionHeader } from './SectionHeader.vue'
export { default as StatusPill } from './StatusPill.vue'
export { default as SkeletonLoader } from './SkeletonLoader.vue'
export { default as Input } from './Input.vue' export { default as Input } from './Input.vue'
export { default as Textarea } from './Textarea.vue' export { default as Textarea } from './Textarea.vue'
export { default as Select } from './Select.vue' export { default as Select } from './Select.vue'
+75
View File
@@ -0,0 +1,75 @@
/**
* useConfirm — Wiederverwendbare Confirmation-Dialog-Logik
*
* Extrahiert aus dem Modal-Muster in TaskBoardView
* (showCreateModal / showChangesModal / showDetailPanel),
* das sich auch in anderen Views wiederholt.
*
* Bietet reaktiven open/close-State, Form-Fehler-Management
* und Escape-Key-Bindung.
*/
import { ref, watch, onMounted, onUnmounted } from 'vue'
export function useConfirm() {
const isOpen = ref(false)
const error = ref('')
const success = ref('')
const submitting = ref(false)
function open() {
isOpen.value = true
error.value = ''
success.value = ''
}
function close() {
isOpen.value = false
}
function setError(msg: string) {
error.value = msg
success.value = ''
}
function setSuccess(msg: string) {
success.value = msg
error.value = ''
}
/** Escape-Taste schliesst den Dialog */
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen.value) {
close()
}
}
/** Overlay-Klick ausserhalb schliesst */
function onOverlayClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('modal-overlay')) {
close()
}
}
/** Sperrt Body-Scroll, wenn Dialog offen */
watch(isOpen, (open) => {
document.body.style.overflow = open ? 'hidden' : ''
})
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
document.body.style.overflow = ''
})
return {
isOpen,
error,
success,
submitting,
open,
close,
setError,
setSuccess,
onOverlayClick,
}
}
+60
View File
@@ -0,0 +1,60 @@
/**
* useFormatDate — Einheitliche Datums-/Zeitformatierung für Nexus V2
*
* Kapselt die mehrfach wiederholten formatDate-, relativeTime- und
* toDateInputValue-Helper, die bisher in TaskBoardView, BoardCard,
* mit anderen Views dupliziert waren.
*
* Alle Ausgaben orientieren sich an de-DE Locale.
*/
/**
* Datum als lesbaren String (de-DE, kurzes/medium-DateStyle).
*/
export function formatDate(date?: string | null, withTime = false): string {
if (!date) return '—'
return new Date(date).toLocaleString('de-DE', withTime
? { dateStyle: 'medium', timeStyle: 'short' }
: { dateStyle: 'medium' })
}
/**
* ISO-Datumsstring in YYYY-MM-DD für <input type="date">.
*/
export function toDateInputValue(date?: string | null): string {
if (!date) return ''
return new Date(date).toISOString().slice(0, 10)
}
/**
* Relative Zeit ("gerade eben", "vor 5 min", "vor 3 h", "vor 2 d").
* Optionaler Fallback, wenn date fehlt.
*/
export function relativeTime(date?: string | null, fallback = 'keine Aktivität'): string {
if (!date) return fallback
const diffMs = Date.now() - new Date(date).getTime()
const mins = Math.max(0, Math.round(diffMs / 60000))
if (mins < 1) return 'gerade eben'
if (mins < 60) return `vor ${mins} min`
const hours = Math.round(mins / 60)
if (hours < 24) return `vor ${hours} h`
const days = Math.round(hours / 24)
return `vor ${days} d`
}
/**
* Minuten seit einem Datum (oder Infinity falls kein Datum).
*/
export function minutesSince(dateStr?: string | null): number {
if (!dateStr) return Infinity
return (Date.now() - new Date(dateStr).getTime()) / 60000
}
/**
* Stunden seit einem Datum (gerundet).
*/
export function hoursSince(dateStr: string): number {
const now = Date.now()
const then = new Date(dateStr).getTime()
return Math.round((now - then) / 3600000)
}
+11 -48
View File
@@ -20,6 +20,8 @@ import { useTaskStore, type DashboardTaskDto } from '../stores/tasks'
import { useLiveSyncStore } from '../stores/liveSync' import { useLiveSyncStore } from '../stores/liveSync'
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool' import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
import BoardCard from '../components/board/BoardCard.vue' import BoardCard from '../components/board/BoardCard.vue'
import StatusPill from '../components/ui/StatusPill.vue'
import { formatDate, toDateInputValue, relativeTime, minutesSince, hoursSince } from '../composables/useFormatDate'
/** Schwelle (min) ohne Aktivität, ab der ein In-Bearbeitung-Task als „hängt" gilt. /** Schwelle (min) ohne Aktivität, ab der ein In-Bearbeitung-Task als „hängt" gilt.
* Spiegelt die Backend-Watchdog-Schwelle (TaskRecovery:StalledMinutes). */ * Spiegelt die Backend-Watchdog-Schwelle (TaskRecovery:StalledMinutes). */
@@ -186,32 +188,20 @@ async function onDrop(e: DragEvent, targetState: string) {
} }
/* ── Helpers ──────────────────────────────────────── */ /* ── Helpers ──────────────────────────────────────── */
function statusTone(state: string): string { /** Map state string to StatusPill variant */
switch (state.toLowerCase()) { function statusPillVariant(state: string): 'backlog' | 'progress' | 'review' | 'blocked' | 'done' {
case 'done': return 'is-done' const s = state.toLowerCase()
case 'blocked': return 'is-blocked' if (s === 'done') return 'done'
case 'review': return 'is-review' if (s === 'blocked') return 'blocked'
case 'in progress': return 'is-progress' if (s === 'review') return 'review'
default: return 'is-backlog' if (s === 'in progress') return 'progress'
} return 'backlog'
} }
function stateLabel(state: string): string { function stateLabel(state: string): string {
return state === 'Backlog' ? 'Offen' : state return state === 'Backlog' ? 'Offen' : state
} }
function formatDate(date?: string | null, withTime = false): string {
if (!date) return '—'
return new Date(date).toLocaleString('de-DE', withTime
? { dateStyle: 'medium', timeStyle: 'short' }
: { dateStyle: 'medium' })
}
function toDateInputValue(date?: string | null): string {
if (!date) return ''
return new Date(date).toISOString().slice(0, 10)
}
function flattenBoard() { function flattenBoard() {
const masters = [ const masters = [
...taskStore.board.offen, ...taskStore.board.offen,
@@ -246,11 +236,6 @@ const ballFilters = [
{ key: 'stalled', label: 'Hängt' }, { key: 'stalled', label: 'Hängt' },
] as const ] as const
function minutesSince(dateStr?: string | null): number {
if (!dateStr) return Infinity
return (Date.now() - new Date(dateStr).getTime()) / 60000
}
function isStalledTask(t: DashboardTaskDto): boolean { function isStalledTask(t: DashboardTaskDto): boolean {
if (t.state.toLowerCase() !== 'in progress') return false if (t.state.toLowerCase() !== 'in progress') return false
return minutesSince(t.lastActivityAt ?? t.updatedAt) > STALL_THRESHOLD_MIN return minutesSince(t.lastActivityAt ?? t.updatedAt) > STALL_THRESHOLD_MIN
@@ -319,23 +304,7 @@ function expectedFromLabel(expected: string | null | undefined): string {
return TASK_AGENT_LABELS[expected.toLowerCase()] ?? expected return TASK_AGENT_LABELS[expected.toLowerCase()] ?? expected
} }
function hoursSince(dateStr: string): number {
const now = Date.now()
const then = new Date(dateStr).getTime()
return Math.round((now - then) / 3600000)
}
function relativeTime(date?: string | null): string {
if (!date) return 'keine Updates'
const diffMs = Date.now() - new Date(date).getTime()
const mins = Math.max(0, Math.round(diffMs / 60000))
if (mins < 1) return 'gerade eben'
if (mins < 60) return `vor ${mins} min`
const hours = Math.round(mins / 60)
if (hours < 24) return `vor ${hours} h`
const days = Math.round(hours / 24)
return `vor ${days} d`
}
function childStatusSummary(taskId: string): string { function childStatusSummary(taskId: string): string {
const children = allBoardTasks.value.filter(task => task.parentTaskId === taskId) const children = allBoardTasks.value.filter(task => task.parentTaskId === taskId)
@@ -807,7 +776,7 @@ onUnmounted(() => {
<div class="detail-crumb"> <div class="detail-crumb">
<span class="crumb-root">Task Board</span> <span class="crumb-root">Task Board</span>
<span class="crumb-sep">/</span> <span class="crumb-sep">/</span>
<span class="detail-state-pill" :class="statusTone(selectedTask.state)">{{ stateLabel(selectedTask.state) }}</span> <StatusPill :variant="statusPillVariant(selectedTask.state)">{{ stateLabel(selectedTask.state) }}</StatusPill>
<button class="id-chip" :title="linkCopied ? 'Link kopiert!' : 'Link kopieren'" @click="copyTaskLink"> <button class="id-chip" :title="linkCopied ? 'Link kopiert!' : 'Link kopieren'" @click="copyTaskLink">
#{{ selectedTask.id.slice(0, 8) }} #{{ selectedTask.id.slice(0, 8) }}
<Check v-if="linkCopied" :size="11" /> <Check v-if="linkCopied" :size="11" />
@@ -1100,12 +1069,6 @@ select:disabled { opacity: .45; cursor: not-allowed; }
.id-chip:hover { color: var(--tx); border-color: var(--line-2); } .id-chip:hover { color: var(--tx); border-color: var(--line-2); }
.detail-topbar-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; } .detail-topbar-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
.detail-state-pill { border-radius: 999px; padding: 3px 9px; font-size: 10.5px; font-weight: 700; letter-spacing: .03em; border: 1px solid transparent; white-space: nowrap; }
.detail-state-pill.is-backlog { color: var(--pill-backlog); background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); }
.detail-state-pill.is-progress { color: var(--pill-progress); background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
.detail-state-pill.is-review { color: var(--pill-review); background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); }
.detail-state-pill.is-blocked { color: var(--pill-blocked); background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); }
.detail-state-pill.is-done { color: var(--pill-done); background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
.detail-content { display: grid; grid-template-columns: minmax(0, 1fr) 292px; min-height: 0; flex: 1; } .detail-content { display: grid; grid-template-columns: minmax(0, 1fr) 292px; min-height: 0; flex: 1; }
.detail-main { padding: 18px 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; } .detail-main { padding: 18px 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; }