Add mobile accessibility and UI/UX improvements

- Add mobile card view for packages (replaces table on small screens)
- Implement design tokens system for consistent styling
- Add dark/light theme toggle with system preference support
- Create reusable StatusBadge and EmptyState components
- Add accessible form labels to package filters
- Add compact mobile stats display in navigation
- Add safe area insets for notched devices
- Add reduced motion support for accessibility
- Improve touch targets for WCAG compliance
- Add unit tests for composables
- Update Vuetify configuration and styling
This commit is contained in:
2025-11-26 16:46:02 +01:00
parent e384635da5
commit 5fac66a38c
34 changed files with 5635 additions and 607 deletions

42
frontend/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,42 @@
module.exports = {
root: true,
env: {
browser: true,
es2022: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:vue/vue3-recommended',
],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['@typescript-eslint', 'vue'],
rules: {
// TypeScript
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/ban-ts-comment': 'warn',
// Vue
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'vue/require-default-prop': 'off',
'vue/max-attributes-per-line': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/html-self-closing': 'off',
'vue/html-indent': 'off',
'vue/v-slot-style': 'off',
// General
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'error',
},
ignorePatterns: ['dist', 'node_modules', 'src/generated', '*.d.ts'],
}

View File

@@ -11,12 +11,15 @@ declare module 'vue' {
BuildServerStats: typeof import('./src/components/BuildServerStats.vue')['default']
BuildStats: typeof import('./src/components/MainNav/BuildStats.vue')['default']
CurrentlyBuilding: typeof import('./src/components/CurrentlyBuilding.vue')['default']
EmptyState: typeof import('./src/components/common/EmptyState.vue')['default']
MainNav: typeof import('./src/components/MainNav.vue')['default']
PackageCard: typeof import('./src/components/Packages/PackageCard.vue')['default']
PackageFilters: typeof import('./src/components/Packages/PackageFilters.vue')['default']
Packages: typeof import('./src/components/Packages.vue')['default']
PackageTable: typeof import('./src/components/Packages/PackageTable.vue')['default']
QueuedPackagesList: typeof import('./src/components/CurrentlyBuilding/QueuedPackagesList.vue')['default']
StatItem: typeof import('./src/components/MainNav/BuildStats/StatItem.vue')['default']
StatsListSection: typeof import('./src/components/MainNav/BuildStats/StatsListSection.vue')['default']
StatusBadge: typeof import('./src/components/common/StatusBadge.vue')['default']
}
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<meta content="width=device-width, initial-scale=1.0, viewport-fit=cover" name="viewport" />
<title>ALHP Status</title>
</head>

View File

@@ -6,7 +6,12 @@
"prebuild": "npm run generate-api-types",
"dev": "node --no-warnings ./node_modules/.bin/vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@fontsource/roboto": "^5.2.5",
@@ -20,8 +25,15 @@
},
"devDependencies": {
"@babel/types": "^7.27.0",
"@testing-library/vue": "^8.1.0",
"@types/node": "^22.14.1",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/test-utils": "^2.4.6",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.31.0",
"happy-dom": "^15.11.7",
"openapi-typescript": "^7.6.1",
"prettier": "^3.5.3",
"sass": "^1.86.3",
@@ -30,6 +42,7 @@
"unplugin-vue-components": "^28.5.0",
"vite": "^6.2.6",
"vite-plugin-vuetify": "^2.1.1",
"vitest": "^2.1.8",
"vue-tsc": "^2.2.8"
},
"packageManager": "yarn@4.7.0"

View File

@@ -1,14 +1,14 @@
<template>
<v-app>
<v-container class="mb-7" fluid style="width: 1440px">
<v-app class="app-root">
<main-nav />
<v-main>
<v-main class="main-content">
<v-container class="content-container" fluid>
<build-server-stats />
<currently-building />
<packages />
</v-main>
</v-container>
</v-main>
</v-app>
</template>
@@ -18,31 +18,19 @@ import BuildServerStats from '@/components/BuildServerStats.vue'
import CurrentlyBuilding from '@/components/CurrentlyBuilding.vue'
import Packages from '@/components/Packages.vue'
import { useStatsStore } from '@/stores/statsStore'
import { onBeforeMount, onUnmounted } from 'vue'
import { onBeforeMount } from 'vue'
import { usePackagesStore } from '@/stores'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
const statsStore = useStatsStore()
const packagesStore = usePackagesStore()
let refreshInterval: number | null = null
const startAutoRefresh = (intervalMinutes = Number(import.meta.env.VITE_UPDATE_INTERVAL) || 5) => {
stopAutoRefresh()
refreshInterval = window.setInterval(
() => {
const intervalMinutes = Number(import.meta.env.VITE_UPDATE_INTERVAL) || 5
const { start: startAutoRefresh } = useAutoRefresh(() => {
statsStore.fetchStats()
packagesStore.fetchPackages()
packagesStore.fetchCurrentlyBuilding()
},
intervalMinutes * 60 * 1000
)
}
const stopAutoRefresh = () => {
if (refreshInterval !== null) {
clearInterval(refreshInterval)
refreshInterval = null
}
}
}, intervalMinutes * 60 * 1000)
onBeforeMount(() => {
statsStore.fetchStats()
@@ -50,8 +38,32 @@ onBeforeMount(() => {
packagesStore.fetchCurrentlyBuilding()
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<style scoped>
.app-root {
background: var(--color-bg-primary) !important;
min-height: 100vh;
}
.main-content {
padding-top: var(--space-4);
padding-bottom: var(--space-8);
}
.content-container {
max-width: 1440px;
margin: 0 auto;
padding: var(--space-4);
}
@media (max-width: 600px) {
.main-content {
padding-top: var(--space-2);
}
.content-container {
padding: var(--space-4);
}
}
</style>

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useAutoRefresh, useNowTimer } from '@/composables/useAutoRefresh'
// Mock Vue's onUnmounted
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
onUnmounted: vi.fn(),
}
})
describe('useAutoRefresh', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('useAutoRefresh', () => {
it('should call callback at specified interval', () => {
const callback = vi.fn()
const { start } = useAutoRefresh(callback, 1000)
start()
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(2)
})
it('should stop calling callback after stop()', () => {
const callback = vi.fn()
const { start, stop } = useAutoRefresh(callback, 1000)
start()
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(1)
stop()
vi.advanceTimersByTime(2000)
expect(callback).toHaveBeenCalledTimes(1) // Still 1
})
it('should track running state', () => {
const callback = vi.fn()
const { start, stop, isRunning } = useAutoRefresh(callback, 1000)
expect(isRunning.value).toBe(false)
start()
expect(isRunning.value).toBe(true)
stop()
expect(isRunning.value).toBe(false)
})
it('should restart interval on restart()', () => {
const callback = vi.fn()
const { start, restart } = useAutoRefresh(callback, 1000)
start()
vi.advanceTimersByTime(500)
restart()
vi.advanceTimersByTime(500)
expect(callback).not.toHaveBeenCalled() // Timer was reset
vi.advanceTimersByTime(500)
expect(callback).toHaveBeenCalledTimes(1)
})
})
describe('useNowTimer', () => {
it('should update now value every second', () => {
const { now, start } = useNowTimer()
const initialValue = now.value
start()
vi.advanceTimersByTime(1000)
expect(now.value).toBeGreaterThan(initialValue)
vi.advanceTimersByTime(1000)
expect(now.value).toBeGreaterThan(initialValue + 1)
})
it('should stop updating after stop()', () => {
const { now, start, stop } = useNowTimer()
start()
vi.advanceTimersByTime(1000)
const valueAfterStart = now.value
stop()
vi.advanceTimersByTime(2000)
expect(now.value).toBe(valueAfterStart)
})
})
})

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest'
import { unixTimestampToLocalizedDate, useRelativeTime } from '@/composables/useDateFormat'
describe('useDateFormat', () => {
describe('unixTimestampToLocalizedDate', () => {
it('should convert unix timestamp to localized date string', () => {
// Mock navigator.language
vi.stubGlobal('navigator', { language: 'en-US' })
const timestamp = 1702612991 // 2023-12-15T03:43:11Z
const result = unixTimestampToLocalizedDate(timestamp)
// The exact format depends on locale, but it should be a string
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
it('should handle timestamp 0', () => {
const result = unixTimestampToLocalizedDate(0)
expect(typeof result).toBe('string')
})
})
describe('useRelativeTime', () => {
it('should format seconds correctly', () => {
const { formatTimeAgo } = useRelativeTime()
const result = formatTimeAgo(30)
expect(result).toContain('30')
expect(result).toContain('second')
})
it('should format minutes correctly', () => {
const { formatTimeAgo } = useRelativeTime()
const result = formatTimeAgo(120) // 2 minutes
expect(result).toContain('2')
expect(result).toContain('minute')
})
it('should format hours correctly', () => {
const { formatTimeAgo } = useRelativeTime()
const result = formatTimeAgo(7200) // 2 hours
expect(result).toContain('2')
expect(result).toContain('hour')
})
it('should use minutes for values between 60 and 3600 seconds', () => {
const { formatTimeAgo } = useRelativeTime()
const result = formatTimeAgo(180) // 3 minutes
expect(result).toContain('minute')
expect(result).not.toContain('second')
})
it('should use hours for values >= 3600 seconds', () => {
const { formatTimeAgo } = useRelativeTime()
const result = formatTimeAgo(3600) // 1 hour
expect(result).toContain('hour')
expect(result).not.toContain('minute')
})
})
})

View File

@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ref, nextTick } from 'vue'
import { useDebouncedRef, useDebounce } from '@/composables/useDebounce'
describe('useDebounce', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('useDebouncedRef', () => {
it('should debounce ref value changes', async () => {
const sourceRef = ref('initial')
const { debouncedValue } = useDebouncedRef(sourceRef, 300)
expect(debouncedValue.value).toBe('initial')
sourceRef.value = 'updated'
await nextTick() // Allow Vue watcher to trigger
expect(debouncedValue.value).toBe('initial') // Not yet updated
vi.advanceTimersByTime(200)
expect(debouncedValue.value).toBe('initial') // Still not updated
vi.advanceTimersByTime(100)
expect(debouncedValue.value).toBe('updated') // Now updated after 300ms
})
it('should cancel pending update when new value is set', async () => {
const sourceRef = ref('initial')
const { debouncedValue } = useDebouncedRef(sourceRef, 300)
sourceRef.value = 'first'
await nextTick()
vi.advanceTimersByTime(200)
sourceRef.value = 'second'
await nextTick()
vi.advanceTimersByTime(200)
expect(debouncedValue.value).toBe('initial') // Neither update applied yet
vi.advanceTimersByTime(100)
expect(debouncedValue.value).toBe('second') // Only second value applied
})
it('should allow manual cancellation', async () => {
const sourceRef = ref('initial')
const { debouncedValue, cancel } = useDebouncedRef(sourceRef, 300)
sourceRef.value = 'updated'
await nextTick()
vi.advanceTimersByTime(200)
cancel()
vi.advanceTimersByTime(200)
expect(debouncedValue.value).toBe('initial') // Cancelled, no update
})
})
describe('useDebounce', () => {
it('should debounce function calls', () => {
const callback = vi.fn()
const { debouncedFn } = useDebounce(callback, 300)
debouncedFn()
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(300)
expect(callback).toHaveBeenCalledTimes(1)
})
it('should pass arguments to callback', () => {
const callback = vi.fn()
const { debouncedFn } = useDebounce(callback, 300)
debouncedFn('arg1', 'arg2')
vi.advanceTimersByTime(300)
expect(callback).toHaveBeenCalledWith('arg1', 'arg2')
})
it('should only call callback once for rapid calls', () => {
const callback = vi.fn()
const { debouncedFn } = useDebounce(callback, 300)
debouncedFn()
debouncedFn()
debouncedFn()
vi.advanceTimersByTime(300)
expect(callback).toHaveBeenCalledTimes(1)
})
it('should allow manual cancellation', () => {
const callback = vi.fn()
const { debouncedFn, cancel } = useDebounce(callback, 300)
debouncedFn()
vi.advanceTimersByTime(200)
cancel()
vi.advanceTimersByTime(200)
expect(callback).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest'
import { usePackageDisplay } from '@/composables/Packages/usePackageDisplay'
describe('usePackageDisplay', () => {
const { repoName, repoVersion, getVersionColor, getStatusColor, getLto, getDs } =
usePackageDisplay()
describe('repoName', () => {
it('should extract repo name from full repo string', () => {
expect(repoName('extra-x86_64-v3')).toBe('extra')
expect(repoName('core-x86_64-v4')).toBe('core')
})
})
describe('repoVersion', () => {
it('should extract version from full repo string', () => {
expect(repoVersion('extra-x86_64-v3')).toBe('v3')
expect(repoVersion('core-x86_64-v4')).toBe('v4')
})
})
describe('getVersionColor', () => {
it('should return CSS variable for v2', () => {
expect(getVersionColor('v2')).toBe('var(--color-version-v2)')
})
it('should return CSS variable for v3', () => {
expect(getVersionColor('v3')).toBe('var(--color-version-v3)')
})
it('should return CSS variable for v4', () => {
expect(getVersionColor('v4')).toBe('var(--color-version-v4)')
})
it('should return muted color for unknown version', () => {
expect(getVersionColor('v1')).toBe('var(--color-text-muted)')
expect(getVersionColor('unknown')).toBe('var(--color-text-muted)')
})
})
describe('getStatusColor', () => {
it('should return empty string (status colors now handled by StatusBadge)', () => {
expect(getStatusColor('skipped')).toBe('')
expect(getStatusColor('queued')).toBe('')
expect(getStatusColor('latest')).toBe('')
expect(getStatusColor('failed')).toBe('')
expect(getStatusColor('signing')).toBe('')
expect(getStatusColor('building')).toBe('')
expect(getStatusColor('unknown')).toBe('')
})
it('should return empty string for undefined status', () => {
expect(getStatusColor(undefined)).toBe('')
})
})
describe('getLto', () => {
it('should return success info for enabled', () => {
const result = getLto('enabled')
expect(result.title).toBe('Built with LTO')
expect(result.icon).toBe('mdi-check-circle')
expect(result.color).toBe('var(--color-status-success)')
})
it('should return waiting info for unknown', () => {
const result = getLto('unknown')
expect(result.title).toBe('Not built with LTO yet')
expect(result.icon).toBe('mdi-timer-sand')
expect(result.color).toBe('var(--color-text-muted)')
})
it('should return error info for disabled', () => {
const result = getLto('disabled')
expect(result.title).toBe('LTO explicitly disabled')
expect(result.icon).toBe('mdi-close-circle')
expect(result.color).toBe('var(--color-status-error)')
})
it('should return warning info for auto_disabled', () => {
const result = getLto('auto_disabled')
expect(result.title).toBe('LTO automatically disabled')
expect(result.icon).toBe('mdi-alert-circle')
expect(result.color).toBe('var(--color-status-warning)')
})
it('should return empty for undefined', () => {
const result = getLto(undefined)
expect(result.title).toBe('')
expect(result.icon).toBe('')
expect(result.color).toBe('')
})
})
describe('getDs', () => {
it('should return success info for available', () => {
const result = getDs('available')
expect(result.title).toBe('Debug symbols available')
expect(result.icon).toBe('mdi-check-circle')
expect(result.color).toBe('var(--color-status-success)')
})
it('should return waiting info for unknown', () => {
const result = getDs('unknown')
expect(result.title).toBe('Not built yet')
expect(result.icon).toBe('mdi-timer-sand')
expect(result.color).toBe('var(--color-text-muted)')
})
it('should return error info for not_available', () => {
const result = getDs('not_available')
expect(result.title).toBe('Not built with debug symbols')
expect(result.icon).toBe('mdi-close-circle')
expect(result.color).toBe('var(--color-status-error)')
})
it('should return empty for undefined', () => {
const result = getDs(undefined)
expect(result.title).toBe('')
expect(result.icon).toBe('')
expect(result.color).toBe('')
})
})
})

View File

@@ -1,2 +1,3 @@
@use "@fontsource/roboto";
@use "fork-awesome/css/fork-awesome.min.css";
@use "./tokens.scss";

View File

@@ -0,0 +1,256 @@
// Design Tokens - CSS Custom Properties
// These tokens form the foundation of our design system
:root {
// ============================================
// Color Palette - Dark Theme (Default)
// ============================================
// Background colors
--color-bg-primary: #0f1419;
--color-bg-secondary: #1a1f2e;
--color-bg-tertiary: #242b3d;
--color-bg-elevated: #2d3548;
--color-bg-hover: #353d52;
// Surface colors
--color-surface: #1a1f2e;
--color-surface-variant: #242b3d;
// Brand colors
--color-brand-primary: #3b82f6;
--color-brand-accent: #609926;
// Status colors (improved contrast)
--color-status-success: #22c55e;
--color-status-warning: #f59e0b;
--color-status-error: #ef4444;
--color-status-info: #3b82f6;
--color-status-neutral: #6b7280;
// Status background colors (subtle)
--color-status-success-bg: rgba(34, 197, 94, 0.15);
--color-status-warning-bg: rgba(245, 158, 11, 0.15);
--color-status-error-bg: rgba(239, 68, 68, 0.15);
--color-status-info-bg: rgba(59, 130, 246, 0.15);
--color-status-neutral-bg: rgba(107, 114, 128, 0.15);
// Package status colors
--color-pkg-latest: #22c55e;
--color-pkg-built: #3b82f6;
--color-pkg-failed: #ef4444;
--color-pkg-skipped: #6b7280;
--color-pkg-delayed: #f59e0b;
--color-pkg-queued: #f97316;
--color-pkg-building: #14b8a6;
--color-pkg-signing: #6366f1;
--color-pkg-unknown: #9ca3af;
// Version colors
--color-version-v2: #60a5fa;
--color-version-v3: #fbbf24;
--color-version-v4: #34d399;
// Text colors
--color-text-primary: #f9fafb;
--color-text-secondary: #9ca3af;
--color-text-muted: #6b7280;
--color-text-inverse: #111827;
// Border colors
--color-border: #374151;
--color-border-light: #4b5563;
// ============================================
// Spacing Scale (8px base)
// ============================================
--space-0: 0;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
// ============================================
// Border Radius
// ============================================
--radius-none: 0;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
--radius-full: 9999px;
// ============================================
// Shadows
// ============================================
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
// ============================================
// Transitions
// ============================================
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 350ms ease;
// ============================================
// Typography
// ============================================
--font-family: 'Roboto', sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
// ============================================
// Safe Area Insets (for notched devices)
// ============================================
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-left: env(safe-area-inset-left, 0px);
}
// ============================================
// Light Theme Override
// ============================================
[data-theme="light"] {
// Background colors
--color-bg-primary: #ffffff;
--color-bg-secondary: #f9fafb;
--color-bg-tertiary: #f3f4f6;
--color-bg-elevated: #ffffff;
--color-bg-hover: #e5e7eb;
// Surface colors
--color-surface: #ffffff;
--color-surface-variant: #f3f4f6;
// Text colors
--color-text-primary: #111827;
--color-text-secondary: #4b5563;
--color-text-muted: #9ca3af;
--color-text-inverse: #f9fafb;
// Border colors
--color-border: #e5e7eb;
--color-border-light: #d1d5db;
// Status background colors (adjusted for light theme)
--color-status-success-bg: rgba(34, 197, 94, 0.1);
--color-status-warning-bg: rgba(245, 158, 11, 0.1);
--color-status-error-bg: rgba(239, 68, 68, 0.1);
--color-status-info-bg: rgba(59, 130, 246, 0.1);
--color-status-neutral-bg: rgba(107, 114, 128, 0.1);
// Shadows (lighter for light theme)
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
}
// ============================================
// Global Styles
// ============================================
// Theme transition class - only apply transitions during theme switch
html.theme-transition,
html.theme-transition *,
html.theme-transition *::before,
html.theme-transition *::after {
transition: background-color var(--transition-normal),
border-color var(--transition-normal),
color var(--transition-fast) !important;
}
// Focus states
:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
// Selection color
::selection {
background-color: var(--color-brand-primary);
color: var(--color-text-inverse);
}
// Scrollbar styling (WebKit)
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border-light);
border-radius: var(--radius-full);
&:hover {
background: var(--color-text-muted);
}
}
// Firefox scrollbar
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border-light) var(--color-bg-secondary);
}
// ============================================
// Vuetify Tooltip Override
// ============================================
.v-tooltip > .v-overlay__content {
background: var(--color-bg-elevated) !important;
color: var(--color-text-primary) !important;
border: 1px solid var(--color-border) !important;
border-radius: var(--radius-md) !important;
padding: var(--space-2) var(--space-3) !important;
font-size: var(--font-size-sm) !important;
box-shadow: var(--shadow-lg) !important;
}
[data-theme="light"] .v-tooltip > .v-overlay__content {
background: #1f2937 !important;
color: #f9fafb !important;
border-color: #374151 !important;
}
// ============================================
// Reduced Motion Support (accessibility)
// ============================================
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -1,12 +1,19 @@
<template>
<v-sheet class="mt-2" color="transparent">
<h5 class="text-h5">Buildserver Stats</h5>
<section class="server-stats-section" aria-labelledby="buildserver-stats-title">
<h2 id="buildserver-stats-title" class="section-title">
<v-icon icon="mdi-chart-line" size="24" class="title-icon" />
Buildserver Stats
</h2>
<div class="iframe-container">
<iframe
:height="iframeHeight"
allowtransparency="true"
class="w-100 border-0"
src="https://stats.itsh.dev/public-dashboards/0fb04abb0c5e4b7390cf26a98e6dead1"></iframe>
</v-sheet>
class="stats-iframe"
loading="lazy"
title="Buildserver statistics dashboard"
src="https://stats.itsh.dev/public-dashboards/0fb04abb0c5e4b7390cf26a98e6dead1" />
</div>
</section>
</template>
<script lang="ts" setup>
@@ -22,3 +29,49 @@ const iframeHeight = computed(() =>
width.value <= 800 ? `${NUMBER_OF_GRAPHS * GRAPH_HEIGHT}px` : '420px'
)
</script>
<style scoped>
.server-stats-section {
margin-bottom: var(--space-6);
}
.section-title {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin-bottom: var(--space-4);
}
.title-icon {
color: var(--color-brand-primary);
}
.iframe-container {
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.stats-iframe {
width: 100%;
border: none;
display: block;
}
@media (max-width: 600px) {
.iframe-container {
border-radius: 0;
border-left: none;
border-right: none;
margin: 0 calc(-1 * var(--space-4));
}
.section-title {
font-size: var(--font-size-lg);
}
}
</style>

View File

@@ -1,122 +1,109 @@
<template>
<v-card
border
class="my-6"
rounded
style="border-radius: 10px; border: 2px solid grey"
variant="elevated">
<v-card-title>
<v-row align="center" class="w-100" style="margin-top: 1px">
<v-col class="v-col-12 v-col-lg-1 mb-3">
<v-row :class="mobile ? 'mt-1' : ''" class="flex-nowrap ms-1 d-flex align-items-center">
<div class="currently-building">
<div class="status-header">
<div class="status-indicator">
<div
:class="
:class="[
'pulse-dot',
updateFailed
? 'pulsating-circle-error'
: packageArrays.building.length > 0
? 'pulsating-circle-amber'
: 'pulsating-circle-green'
"
class="circle-offset flex-circle" />
<span class="ms-2">
? 'pulse-dot--error'
: buildingPackages.length > 0
? 'pulse-dot--building'
: 'pulse-dot--idle'
]" />
<span class="status-text">
{{
updateFailed
? 'Could not fetch data.'
: packageArrays.building.length > 0
? 'Could not fetch data'
: buildingPackages.length > 0
? 'Building'
: 'Idle'
}}
</span>
</v-row>
</v-col>
<v-col v-if="packageArrays.building.length > 0" class="v-col-12 v-col-lg-8 mb-3">
</div>
<div v-if="buildingPackages.length > 0" class="progress-section">
<v-progress-linear
:max="
packageArrays.built.length +
packageArrays.building.length +
packageArrays.queued.length
"
:model-value="packageArrays.built.length"
color="light-blue"
height="10"
:max="builtPackages.length + buildingPackages.length + queuedPackages.length"
:model-value="builtPackages.length"
class="build-progress"
color="primary"
height="8"
rounded
striped></v-progress-linear>
</v-col>
<v-col
:class="mobile ? 'mt-n3' : 'text-end ms-auto'"
class="text-grey v-col-12 v-col-lg-2 mb-3"
cols="auto"
style="font-size: 13px">
<div v-if="!updateFailed" class="d-flex flex-column">
<span>
Last updated
{{ formatTimeAgo(lastUpdatedSeconds) }}
<v-tooltip activator="parent" location="start">
{{
unixTimestampToLocalizedDate(
Math.floor((packagesStore.state.lastUpdated || Date.now()) / 1000)
)
}}
</v-tooltip>
</span>
<span>
Last Mirror sync
{{ formatTimeAgo(lastMirrorSync) }}
<v-tooltip activator="parent" location="start">
{{
unixTimestampToLocalizedDate(
statsStore.state.stats?.last_mirror_timestamp || Math.floor(Date.now() / 1000)
)
}}
</v-tooltip>
striped />
<span class="progress-label">
{{ builtPackages.length }} / {{ builtPackages.length + buildingPackages.length + queuedPackages.length }}
</span>
</div>
<template v-else>Please try again later.</template>
</v-col>
</v-row>
</v-card-title>
<v-card-text
v-if="packageArrays.building.length > 0 || packageArrays.queued.length > 0"
class="d-flex flex-column">
<v-list v-if="packageArrays.building.length > 0" class="mb-4" width="100%">
<v-list-subheader>Building</v-list-subheader>
<v-list-item v-for="(pkg, index) in packageArrays.building" :key="index">
<template v-slot:prepend>
<div class="pulsating-circle-amber me-4" />
<div class="timestamps">
<template v-if="!updateFailed">
<div class="timestamp-item">
<v-icon icon="mdi-refresh" size="14" class="timestamp-icon" />
<span>{{ formatTimeAgo(lastUpdatedSeconds) }}</span>
<v-tooltip activator="parent" location="start">
Last updated:
{{ unixTimestampToLocalizedDate(Math.floor((packagesStore.state.lastUpdated || Date.now()) / 1000)) }}
</v-tooltip>
</div>
<div class="timestamp-item">
<v-icon icon="mdi-sync" size="14" class="timestamp-icon" />
<span>{{ formatTimeAgo(lastMirrorSync) }}</span>
<v-tooltip activator="parent" location="start">
Last mirror sync:
{{ unixTimestampToLocalizedDate(statsStore.state.stats?.last_mirror_timestamp || Math.floor(Date.now() / 1000)) }}
</v-tooltip>
</div>
</template>
<v-list-item-title>
{{ pkg.pkgbase }}
<span class="text-grey">({{ pkg.repo }})</span>
</v-list-item-title>
<v-list-item-subtitle>{{ pkg.arch_version }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-sheet class="ps-4" color="transparent" rounded width="100%">
<h4 class="mb-2 font-weight-light text-grey">Queued</h4>
<queued-packages-list :packages="packageArrays.queued" />
</v-sheet>
</v-card-text>
</v-card>
<span v-else class="error-message">Please try again later.</span>
</div>
</div>
<div v-if="buildingPackages.length > 0 || queuedPackages.length > 0" class="build-content">
<div v-if="buildingPackages.length > 0" class="building-section">
<h4 class="section-title">
<v-icon icon="mdi-hammer" size="18" class="section-icon" />
Currently Building
</h4>
<div class="package-list">
<div
v-for="pkg in buildingPackages"
:key="`${pkg.pkgbase}-${pkg.repo}`"
class="building-item">
<div class="pulse-dot pulse-dot--building pulse-dot--sm" />
<div class="package-info">
<span class="package-name">{{ pkg.pkgbase }}</span>
<span class="package-repo">{{ pkg.repo }}</span>
</div>
<code class="package-version">{{ pkg.arch_version }}</code>
</div>
</div>
</div>
<div v-if="queuedPackages.length > 0" class="queued-section">
<h4 class="section-title">
<v-icon icon="mdi-playlist-play" size="18" class="section-icon" />
Queued
<span class="queue-count">{{ queuedPackages.length }}</span>
</h4>
<queued-packages-list :packages="queuedPackages" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useDisplay } from 'vuetify'
import { computed, onMounted } from 'vue'
import QueuedPackagesList from '@/components/CurrentlyBuilding/QueuedPackagesList.vue'
import { usePackagesStore, useStatsStore } from '@/stores'
import { useNowTimer } from '@/composables/useAutoRefresh'
import { unixTimestampToLocalizedDate, useRelativeTime } from '@/composables/useDateFormat'
const statsStore = useStatsStore()
const packagesStore = usePackagesStore()
const { mobile } = useDisplay()
const rtf = new Intl.RelativeTimeFormat('en', {
localeMatcher: 'best fit',
numeric: 'always',
style: 'long'
})
const now = ref(Math.floor(Date.now() / 1000))
const { formatTimeAgo } = useRelativeTime()
const { now, start: startTimer } = useNowTimer()
const updateFailed = computed(
() => !!packagesStore.state.errorCurrentlyBuilding || !!statsStore.state.error
@@ -128,177 +115,288 @@ const lastMirrorSync = computed(
() => now.value - (statsStore.state.stats?.last_mirror_timestamp || Math.floor(Date.now() / 1000))
)
const packageArrays = reactive({
building: computed(
const buildingPackages = computed(
() =>
packagesStore.state.currentlyBuildingPackages.filter((pkg) => pkg.status === 'building') || []
),
queued: computed(
)
const queuedPackages = computed(
() =>
packagesStore.state.currentlyBuildingPackages.filter((pkg) => pkg.status === 'queued') || []
),
built: computed(
)
const builtPackages = computed(
() =>
packagesStore.state.currentlyBuildingPackages.filter((pkg) => pkg.status === 'built') || []
)
})
let updateTimer: number | undefined
const startLastUpdatedTimer = () => {
updateTimer = window.setInterval(() => {
now.value = Math.floor(Date.now() / 1000)
}, 1000)
}
const formatTimeAgo = (seconds: number): string => {
if (seconds >= 3600) {
return rtf.format(-Math.floor(seconds / 3600), 'hours')
} else if (seconds >= 60) {
return rtf.format(-Math.floor(seconds / 60), 'minutes')
} else {
return rtf.format(-seconds, 'seconds')
}
}
function unixTimestampToLocalizedDate(timestamp: number): string {
const date = new Date(timestamp * 1000) // convert to milliseconds
return date.toLocaleString(navigator.language)
}
onMounted(() => {
startLastUpdatedTimer()
})
onUnmounted(() => {
if (updateTimer) {
clearInterval(updateTimer)
}
startTimer()
})
</script>
<style lang="scss">
.pulsating-circle-green {
background-color: rgba(126, 206, 5, 0.94);
border-radius: 50%;
<style scoped>
.currently-building {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
margin-bottom: var(--space-4);
}
.status-header {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-4);
border-bottom: 1px solid var(--color-border-subtle);
flex-wrap: wrap;
}
.status-indicator {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 100px;
}
.status-text {
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
white-space: nowrap;
}
.progress-section {
flex: 1;
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 200px;
}
.build-progress {
flex: 1;
border-radius: var(--radius-full);
}
.progress-label {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-family: var(--font-family-mono);
white-space: nowrap;
}
.timestamps {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-align: right;
margin-left: auto;
}
.timestamp-item {
display: flex;
align-items: center;
gap: var(--space-1);
cursor: help;
justify-content: flex-end;
}
.timestamp-icon {
opacity: 0.6;
}
.error-message {
color: var(--color-status-error);
}
.build-content {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.building-section,
.queued-section {
background: var(--color-bg-tertiary);
border-radius: var(--radius-md);
padding: var(--space-3);
}
.section-title {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-3);
}
.section-icon {
opacity: 0.7;
}
.queue-count {
background: var(--color-bg-hover);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
margin-left: var(--space-1);
}
.package-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.building-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
transition: background-color var(--transition-fast);
}
.building-item:hover {
background: var(--color-bg-hover);
}
.package-info {
flex: 1;
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.package-name {
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.package-repo {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.package-version {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
}
/* Pulse dot animations */
.pulse-dot {
width: 12px;
height: 12px;
}
.pulsating-circle-green:before {
content: '';
display: block;
width: 200%;
height: 200%;
box-sizing: border-box;
margin-left: -50%;
margin-top: -50%;
border-radius: 50%;
background-color: rgba(126, 206, 5, 0.94);
-webkit-animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
.pulsating-circle-amber {
background-color: #f39c12f0;
border-radius: 50%;
width: 12px;
height: 12px;
}
.pulsating-circle-amber:before {
content: '';
display: block;
width: 200%;
height: 200%;
box-sizing: border-box;
margin-left: -50%;
margin-top: -50%;
border-radius: 50%;
background-color: #f39c12;
-webkit-animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
.pulsating-circle-error {
background-color: rgba(225, 64, 6, 0.94);
border-radius: 50%;
width: 12px;
height: 12px;
}
.pulsating-circle-error:before {
content: '';
display: block;
width: 200%;
height: 200%;
box-sizing: border-box;
margin-left: -50%;
margin-top: -50%;
border-radius: 50%;
background-color: rgba(225, 64, 6, 0.94);
-webkit-animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
.circle-offset {
transform: translateY(10px); /* Preserves vertical shift as needed */
}
.flex-circle {
flex-shrink: 0;
flex-grow: 0;
width: 12px;
height: 12px;
position: relative;
}
.pulse-dot--sm {
width: 8px;
height: 8px;
}
.pulse-dot--idle {
background: var(--color-status-success);
}
.pulse-dot--idle::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background: var(--color-status-success);
animation: pulse-ring 2s ease-in-out infinite;
}
@-webkit-keyframes pulse-ring {
0% {
transform: scale(0.33);
.pulse-dot--building {
background: var(--color-status-warning);
}
80%,
100% {
opacity: 0;
.pulse-dot--building::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background: var(--color-status-warning);
animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
.pulse-dot--error {
background: var(--color-status-error);
}
.pulse-dot--error::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background: var(--color-status-error);
animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
@keyframes pulse-ring {
0% {
transform: scale(0.33);
transform: scale(1);
opacity: 0.8;
}
80%,
100% {
transform: scale(2.5);
opacity: 0;
}
}
@-webkit-keyframes pulse-dot {
0% {
transform: scale(0.8);
/* Responsive */
@media (max-width: 960px) {
.status-header {
gap: var(--space-3);
}
50% {
transform: scale(1);
}
100% {
transform: scale(0.8);
.progress-section {
order: 3;
flex-basis: 100%;
min-width: auto;
}
}
@keyframes pulse-dot {
0% {
transform: scale(0.8);
@media (max-width: 600px) {
.currently-building {
border-radius: 0;
border-left: none;
border-right: none;
margin-left: calc(-1 * var(--space-4));
margin-right: calc(-1 * var(--space-4));
}
50% {
transform: scale(1);
.status-header {
flex-direction: column;
align-items: flex-start;
}
100% {
transform: scale(0.8);
.timestamps {
text-align: left;
margin-left: 0;
}
.timestamp-item {
justify-content: flex-start;
}
}
</style>

View File

@@ -1,45 +1,38 @@
<template>
<v-sheet color="transparent" rounded width="100%">
<!-- SHOW MESSAGE IF NO QUEUED PACKAGES -->
<div class="queued-list">
<template v-if="packages.length === 0">
<span class="text-grey"> No packages queued.</span>
<span class="empty-message">No packages queued.</span>
</template>
<!-- ELSE SHOW EXPANSION PANEL -->
<template v-else>
<!-- FULL LIST EXPANSION PANEL -->
<v-expansion-panels>
<v-expansion-panel bg-color="#303030" color="primary" elevation="0">
<v-expansion-panel-title>
<v-expansion-panels class="queued-expansion">
<v-expansion-panel class="queued-panel" elevation="0">
<v-expansion-panel-title class="panel-title">
<v-icon icon="mdi-format-list-bulleted" size="18" class="panel-icon" />
Show all queued packages
<span class="ms-1 text-disabled text-high-emphasis">({{ packages.length }})</span>
<span class="package-count">{{ packages.length }}</span>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list bg-color="transparent" outlined rounded>
<v-list-item v-for="(pkg, index) in packages" :key="index">
<template #prepend>
<v-icon
icon="mdi-chevron-right"
style="margin-left: -20px; margin-right: -20px" />
</template>
<v-list-item-title>
{{ pkg.pkgbase }}
<span class="text-grey">({{ pkg.repo }})</span>
</v-list-item-title>
<v-list-item-subtitle>
{{ pkg.arch_version }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-expansion-panel-text class="panel-content">
<div class="queued-items">
<div
v-for="pkg in packages"
:key="`${pkg.pkgbase}-${pkg.repo}`"
class="queued-item">
<div class="item-info">
<span class="item-name">{{ pkg.pkgbase }}</span>
<span class="item-repo">{{ pkg.repo }}</span>
</div>
<code class="item-version">{{ pkg.arch_version }}</code>
</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</template>
</v-sheet>
</div>
</template>
<script lang="ts" setup>
import { defineProps } from 'vue'
import { components } from '@/api'
import type { components } from '@/api'
defineProps({
packages: {
@@ -49,3 +42,125 @@ defineProps({
}
})
</script>
<style scoped>
.queued-list {
width: 100%;
}
.empty-message {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-style: italic;
}
.queued-expansion {
--v-expansion-panel-border-radius: var(--radius-md);
}
.queued-panel {
background: var(--color-bg-secondary) !important;
border: 1px solid var(--color-border-subtle);
}
.panel-title {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
padding: var(--space-3) !important;
min-height: auto !important;
}
.panel-icon {
margin-right: var(--space-2);
opacity: 0.7;
}
.package-count {
margin-left: var(--space-2);
background: var(--color-bg-hover);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.panel-content {
padding: 0 !important;
}
.panel-content :deep(.v-expansion-panel-text__wrapper) {
padding: var(--space-2);
}
.queued-items {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: 300px;
overflow-y: auto;
}
.queued-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--color-bg-tertiary);
border-radius: var(--radius-sm);
transition: background-color var(--transition-fast);
}
.queued-item:hover {
background: var(--color-bg-hover);
}
.item-info {
flex: 1;
display: flex;
align-items: baseline;
gap: var(--space-2);
min-width: 0;
}
.item-name {
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-repo {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
white-space: nowrap;
}
.item-version {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
white-space: nowrap;
}
/* Custom scrollbar for queued items */
.queued-items::-webkit-scrollbar {
width: 6px;
}
.queued-items::-webkit-scrollbar-track {
background: transparent;
}
.queued-items::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-full);
}
.queued-items::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
</style>

View File

@@ -1,26 +1,54 @@
<template>
<v-app-bar :color="appBarColors.background" aria-label="Main Navigation" role="navigation">
<v-app-bar class="main-nav" aria-label="Main Navigation" role="navigation">
<v-container :class="containerClasses" :style="{ maxWidth: maxContainerWidth }" fluid>
<v-row align="center">
<v-row align="center" no-gutters>
<v-app-bar-title class="app-title">
<span aria-label="Home" class="home-link" role="button" tabindex="0">
<button
aria-label="Go to home page"
class="home-link"
type="button"
@click="goHome"
@keydown.enter="goHome"
@keydown.space.prevent="goHome">
{{ appTitle }}
</span>
</button>
<a
:href="repoUrl"
aria-label="ALHP GitHub Repository"
class="ms-2 gitea-link"
aria-label="ALHP Git Repository (opens in new tab)"
class="ms-3 repo-link"
rel="noopener noreferrer"
target="_blank">
<i aria-hidden="true" class="fa fa-gitea"></i>
<v-icon icon="mdi-git" size="24" />
</a>
</v-app-bar-title>
<v-spacer v-if="isDesktop"></v-spacer>
<v-spacer></v-spacer>
<!-- Desktop/Tablet: Full stats -->
<build-stats v-if="isDesktop || isTablet" :show-lto="isDesktop" />
<!-- Mobile menu button could be added here -->
<!-- Mobile: Compact stats -->
<div v-else-if="!statsStore.state.loading && !statsStore.state.error" class="mobile-stats">
<span class="mobile-stat mobile-stat--success">
{{ statsStore.state.stats?.latest || 0 }}
</span>
<span class="mobile-stat mobile-stat--error">
{{ statsStore.state.stats?.failed || 0 }}
</span>
</div>
<v-btn
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
class="theme-toggle ms-4"
icon
size="small"
variant="text"
@click="toggleTheme">
<v-icon :icon="isDark ? 'mdi-weather-sunny' : 'mdi-weather-night'" />
<v-tooltip activator="parent" location="bottom">
{{ isDark ? 'Light mode' : 'Dark mode' }}
</v-tooltip>
</v-btn>
</v-row>
</v-container>
</v-app-bar>
@@ -30,23 +58,23 @@
import BuildStats from '@/components/MainNav/BuildStats.vue'
import { useDisplay } from 'vuetify'
import { computed } from 'vue'
import { usePackagesStore, useStatsStore } from '@/stores'
import { useTheme } from '@/composables/useTheme'
const { mobile, width } = useDisplay()
const packagesStore = usePackagesStore()
const statsStore = useStatsStore()
const { isDark, toggleTheme } = useTheme()
const goHome = () => {
packagesStore.resetFilters()
}
const isTablet = computed(() => mobile && width.value >= 650 && width.value < 960)
const isDesktop = computed(() => !mobile.value && !isTablet.value)
interface AppBarColors {
background: string
accent: string
}
const appTitle = 'ALHP Status'
const repoUrl = 'https://somegit.dev/ALHP/ALHP.GO'
const maxContainerWidth = '1440px'
const appBarColors: AppBarColors = {
background: '#0d1538',
accent: '#609926'
}
const containerClasses = computed(() => ({
'ms-3': width.value < 1440,
@@ -55,48 +83,99 @@ const containerClasses = computed(() => ({
</script>
<style scoped>
.main-nav {
background: var(--color-bg-secondary) !important;
border-bottom: 1px solid var(--color-border);
}
.app-title {
align-self: center;
font-size: 20px;
font-size: var(--font-size-xl);
display: flex;
align-items: center;
gap: var(--space-2);
}
.home-link {
color: white;
color: var(--color-text-primary);
text-decoration: none;
font-weight: 500;
transition: opacity 0.2s;
font-weight: var(--font-weight-semibold);
transition: color var(--transition-fast), transform var(--transition-fast);
background: none;
border: none;
padding: 0;
font-size: inherit;
font-family: inherit;
cursor: pointer;
}
.home-link:hover,
.home-link:focus {
opacity: 0.9;
outline: none;
.home-link:hover {
color: var(--color-brand-primary);
transform: translateY(-1px);
}
.gitea-link {
color: white;
font-size: 25px;
.home-link:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.repo-link {
color: var(--color-text-secondary);
text-decoration: none;
transition: color 0.2s;
transition: color var(--transition-fast), transform var(--transition-fast);
display: inline-flex;
align-items: center;
}
.gitea-link:hover,
.gitea-link:focus {
color: v-bind('appBarColors.accent');
outline: none;
.repo-link:hover {
color: var(--color-brand-accent);
transform: scale(1.1);
}
.repo-link:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.theme-toggle {
color: var(--color-text-secondary) !important;
transition: color var(--transition-fast), transform var(--transition-fast) !important;
}
.theme-toggle:hover {
color: var(--color-text-primary) !important;
transform: rotate(15deg);
}
/* Mobile stats display */
.mobile-stats {
display: flex;
align-items: center;
gap: var(--space-2);
}
.mobile-stat {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
font-family: var(--font-family-mono);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
background: var(--color-bg-tertiary);
}
.mobile-stat--success {
color: var(--color-status-success);
}
.mobile-stat--error {
color: var(--color-status-error);
}
@media (max-width: 600px) {
.app-title {
font-size: 18px;
}
.gitea-link {
font-size: 22px;
font-size: var(--font-size-lg);
}
}
</style>

View File

@@ -1,9 +1,5 @@
<template>
<v-sheet
v-if="!statsStore.state.loading && !statsStore.state.error"
:style="sheetStyles"
class="d-flex"
color="transparent">
<div v-if="!statsStore.state.loading && !statsStore.state.error" class="build-stats">
<StatsListSection title="Stats">
<StatItem
v-for="(stat, key) in generalStats"
@@ -21,22 +17,15 @@
:count="stat.count"
:title="key" />
</StatsListSection>
</v-sheet>
<v-sheet
v-else-if="statsStore.state.loading"
:style="sheetStyles"
class="d-flex align-center"
color="transparent">
<v-progress-circular class="mr-2" color="white" indeterminate size="20"></v-progress-circular>
<span class="text-caption">Loading stats...</span>
</v-sheet>
<v-sheet
v-else-if="statsStore.state.error"
:style="sheetStyles"
class="d-flex align-center"
color="transparent">
<span class="text-caption text-error">Error loading stats</span>
</v-sheet>
</div>
<div v-else-if="statsStore.state.loading" class="build-stats build-stats--loading">
<v-progress-circular color="primary" indeterminate size="20" />
<span class="loading-text">Loading stats...</span>
</div>
<div v-else-if="statsStore.state.error" class="build-stats build-stats--error">
<v-icon icon="mdi-alert-circle" size="18" />
<span class="error-text">Error loading stats</span>
</div>
</template>
<script lang="ts" setup>
@@ -53,52 +42,75 @@ withDefaults(defineProps<Props>(), {
showLto: true
})
const COLORS = {
SUCCESS: '#069b35',
WARNING: '#b97808',
ERROR: '#b30303',
NEUTRAL: '#878787'
}
const sheetStyles = { gap: '50px' }
const statsStore = useStatsStore()
const generalStats = computed(() => ({
latest: {
count: statsStore.state.stats?.latest || 0,
color: COLORS.SUCCESS
color: 'var(--color-status-success)'
},
queued: {
count: statsStore.state.stats?.queued || 0,
color: COLORS.WARNING
color: 'var(--color-status-warning)'
},
building: {
count: statsStore.state.stats?.building || 0,
color: COLORS.WARNING
color: 'var(--color-status-info)'
},
skipped: {
count: statsStore.state.stats?.skipped || 0,
color: COLORS.NEUTRAL
color: 'var(--color-status-neutral)'
},
failed: {
count: statsStore.state.stats?.failed || 0,
color: COLORS.ERROR
color: 'var(--color-status-error)'
}
}))
const ltoStats = computed(() => ({
enabled: {
count: statsStore.state.stats?.lto?.enabled || 0,
color: COLORS.SUCCESS
color: 'var(--color-status-success)'
},
disabled: {
count: statsStore.state.stats?.lto?.disabled || 0,
color: COLORS.ERROR
color: 'var(--color-status-error)'
},
unknown: {
count: statsStore.state.stats?.lto?.unknown || 0,
color: COLORS.NEUTRAL
color: 'var(--color-status-neutral)'
}
}))
</script>
<style scoped>
.build-stats {
display: flex;
align-items: center;
gap: var(--space-8);
}
.build-stats--loading,
.build-stats--error {
gap: var(--space-2);
}
.loading-text {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.build-stats--error {
color: var(--color-status-error);
}
.error-text {
font-size: var(--font-size-sm);
}
@media (max-width: 1200px) {
.build-stats {
gap: var(--space-6);
}
}
</style>

View File

@@ -1,7 +1,8 @@
<template>
<v-list-item :style="{ color }" :title="title">
{{ count }}
</v-list-item>
<div class="stat-item" :style="{ '--stat-color': color }">
<span class="stat-count">{{ count }}</span>
<span class="stat-title">{{ title }}</span>
</div>
</template>
<script lang="ts" setup>
@@ -11,3 +12,31 @@ defineProps<{
color: string
}>()
</script>
<style scoped>
.stat-item {
display: flex;
align-items: baseline;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
transition: background-color var(--transition-fast);
}
.stat-item:hover {
background: var(--color-bg-hover);
}
.stat-count {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-base);
color: var(--stat-color);
font-family: var(--font-family-mono);
}
.stat-title {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-transform: capitalize;
}
</style>

View File

@@ -1,8 +1,10 @@
<template>
<v-list bg-color="transparent" class="stats-list d-flex">
<v-list-subheader>{{ title }}:</v-list-subheader>
<div class="stats-section">
<span class="section-title">{{ title }}</span>
<div class="section-items">
<slot></slot>
</v-list>
</div>
</div>
</template>
<script lang="ts" setup>
@@ -11,8 +13,24 @@ defineProps<{
}>()
</script>
<style lang="scss" scoped>
.stats-list {
border-radius: 5px;
<style scoped>
.stats-section {
display: flex;
align-items: center;
gap: var(--space-2);
}
.section-title {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.section-items {
display: flex;
align-items: center;
gap: var(--space-1);
}
</style>

View File

@@ -1,15 +1,43 @@
<template>
<v-sheet :color="TRANSPARENT_COLOR" class="mt-6" width="100%">
<h5 class="text-h5 mb-4">Packages</h5>
<section class="packages-section" aria-labelledby="packages-title">
<h2 id="packages-title" class="section-title">
<v-icon icon="mdi-package-variant" size="24" class="title-icon" />
Packages
</h2>
<PackageFilters />
<PackageTable />
</v-sheet>
</section>
</template>
<script lang="ts" setup>
import { TRANSPARENT_COLOR } from '@/config/constants'
import PackageFilters from '@/components/Packages/PackageFilters.vue'
import PackageTable from '@/components/Packages/PackageTable.vue'
</script>
<style scoped>
.packages-section {
margin-top: var(--space-6);
}
.section-title {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin-bottom: var(--space-4);
}
.title-icon {
color: var(--color-brand-primary);
}
@media (max-width: 600px) {
.section-title {
font-size: var(--font-size-lg);
}
}
</style>

View File

@@ -0,0 +1,198 @@
<template>
<div class="package-card">
<div class="card-main">
<div class="card-left">
<StatusBadge :status="pkg.status" size="sm" />
<v-chip
:style="{ backgroundColor: getVersionColor(repoVersion(pkg.repo || '')), color: '#fff' }"
class="version-chip"
density="compact"
label
size="x-small">
{{ repoVersion(pkg.repo || '') }}
</v-chip>
</div>
<div class="card-center">
<span class="pkg-name">{{ pkg.pkgbase }}</span>
<span class="pkg-repo">{{ repoName(pkg.repo || '') }}</span>
</div>
<div class="card-actions">
<a
v-if="pkg.status === 'failed'"
:href="logUrl"
class="action-btn"
target="_blank"
rel="noopener noreferrer"
aria-label="View build log">
<v-icon icon="mdi-file-document-outline" size="18" />
<v-tooltip activator="parent" location="top">View build log</v-tooltip>
</a>
<a
:href="archWebUrl"
class="action-btn"
target="_blank"
rel="noopener noreferrer"
aria-label="View on ArchWeb">
<span class="aw-text">AW</span>
<v-tooltip activator="parent" location="top">View on ArchWeb</v-tooltip>
</a>
</div>
</div>
<div v-if="pkg.skip_reason" class="pkg-reason">{{ pkg.skip_reason }}</div>
<div class="card-versions">
<span class="version-item">
<span class="version-label">Arch:</span>
<code>{{ pkg.arch_version || '—' }}</code>
</span>
<span class="version-item">
<span class="version-label">ALHP:</span>
<code>{{ pkg.repo_version || '—' }}</code>
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import type { components } from '@/api'
import StatusBadge from '@/components/common/StatusBadge.vue'
import { usePackageDisplay } from '@/composables/Packages/usePackageDisplay'
const props = defineProps<{
pkg: components['schemas']['Package']
}>()
const { repoName, repoVersion, getVersionColor } = usePackageDisplay()
const logUrl = computed(() => {
const repo = props.pkg.repo || ''
const repoSuffix = repo.slice(repo.indexOf('-') + 1)
return `https://alhp.dev/logs/${repoSuffix}/${props.pkg.pkgbase}.log`
})
const archWebUrl = computed(() => {
return `https://archlinux.org/packages/?q=${props.pkg.pkgbase}`
})
</script>
<style scoped>
.package-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.card-main {
display: flex;
align-items: center;
gap: var(--space-3);
}
.card-left {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.version-chip {
font-weight: var(--font-weight-bold);
font-size: 10px;
height: 18px !important;
}
.card-center {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.pkg-name {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
font-family: var(--font-family-mono);
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pkg-repo {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.pkg-reason {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-style: italic;
}
.card-versions {
display: flex;
gap: var(--space-4);
padding-top: var(--space-2);
margin-top: var(--space-1);
border-top: 1px solid var(--color-border-subtle);
}
.version-item {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-xs);
}
.version-label {
color: var(--color-text-muted);
}
.version-item code {
font-family: var(--font-family-mono);
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: 11px;
}
.card-actions {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
}
.action-btn {
min-width: 36px;
min-height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
text-decoration: none;
background: var(--color-bg-tertiary);
transition: all var(--transition-fast);
}
.action-btn:hover {
color: var(--color-text-primary);
background: var(--color-bg-hover);
}
.action-btn:active {
transform: scale(0.95);
}
.aw-text {
font-family: var(--font-family-mono);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xs);
}
</style>

View File

@@ -1,62 +1,81 @@
<template>
<v-row :style="mobile ? '' : `height: ${ROW_HEIGHT}px`" width="100%">
<v-col class="v-col-12 v-col-sm-2 v-col-lg-2">
<div class="filters-wrapper">
<div class="filters-row">
<v-text-field
v-model="pkgbase"
aria-label="Search packages by name"
class="filter-search"
clearable
color="primary"
placeholder="Search Pkgbase"
variant="outlined"></v-text-field>
</v-col>
<v-col class="v-col-12 v-col-sm-2 v-col-lg-2 mt-n6 mt-sm-0">
density="compact"
hide-details
label="Search"
prepend-inner-icon="mdi-magnify"
variant="outlined" />
<v-select
v-model="repo"
:items="REPO_ITEMS"
aria-label="Filter by repository"
class="filter-select"
clearable
color="primary"
placeholder="Any Repo"
variant="outlined"></v-select>
</v-col>
<v-col class="v-col-12 v-col-sm-2 v-col-lg-3 mt-n6 mt-sm-0">
density="compact"
hide-details
label="Repository"
variant="outlined" />
<v-select
v-model="status"
:items="STATUS_ITEMS"
aria-label="Filter by package status"
chips
class="filter-select filter-select--status"
closable-chips
color="primary"
density="default"
density="compact"
hide-details
item-title="title"
item-value="value"
label="Status"
multiple
placeholder="Any Status"
return-object
variant="outlined"></v-select>
</v-col>
<v-col class="v-col-12 v-col-sm-2 v-col-lg-2 mt-n6 mt-sm-0">
<v-switch v-model="exact" color="primary" label="Exact search"></v-switch>
</v-col>
<v-col :class="mobile ? 'mt-n6' : ''" :cols="mobile ? 12 : 'auto'" class="ms-auto">
variant="outlined" />
<v-switch
v-model="exact"
aria-label="Toggle exact search matching"
class="filter-switch"
color="primary"
density="compact"
hide-details
label="Exact" />
</div>
<v-pagination
v-model="page"
:length="totalPages"
:total-visible="mobile ? undefined : 3"
:total-visible="mobile ? 3 : 5"
active-color="primary"
density="comfortable"
class="filter-pagination"
density="compact"
rounded
start="1"
variant="text"></v-pagination>
</v-col>
</v-row>
variant="text" />
</div>
</template>
<script lang="ts" setup>
import { useDisplay } from 'vuetify'
import { REPO_ITEMS, ROW_HEIGHT, STATUS_ITEMS } from '@/config/constants'
import { REPO_ITEMS, STATUS_ITEMS } from '@/config/constants'
import { usePackagesStore } from '@/stores'
import { computed, onMounted, ref, watch } from 'vue'
import { components } from '@/api'
import { useDebounce } from '@/composables/useDebounce'
const { mobile } = useDisplay()
const packagesStore = usePackagesStore()
const { debouncedFn: debouncedUpdateFilter, cancel: cancelDebounce } = useDebounce(
() => updateFilter(),
300
)
const page = ref<number>(1)
const pkgbase = ref<string>()
@@ -110,31 +129,131 @@ watch(
)
// Watcher for pkgbase with debounce
let pkgbaseTimeout: ReturnType<typeof setTimeout> | null = null
watch(
() => pkgbase.value,
() => {
if (pkgbaseTimeout) clearTimeout(pkgbaseTimeout)
pkgbaseTimeout = setTimeout(() => {
updateFilter()
}, 300)
debouncedUpdateFilter()
}
)
// Watcher for other filters (repo, status, exact) without debounce
watch(
[() => repo.value, () => status.value?.map((state) => state.value), () => exact.value],
[repo, status, exact],
() => {
// Cancel pending pkgbase debounce if any
if (pkgbaseTimeout) {
clearTimeout(pkgbaseTimeout)
pkgbaseTimeout = null
}
cancelDebounce()
updateFilter()
}
},
{ deep: true }
)
onMounted(() => {
initFilters()
})
</script>
<style scoped>
.filters-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
margin-bottom: var(--space-4);
}
.filters-row {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
flex: 1;
}
.filter-search {
width: 200px;
flex-shrink: 0;
}
.filter-select {
width: 140px;
flex-shrink: 0;
}
.filter-select--status {
width: 200px;
}
.filter-switch {
flex-shrink: 0;
}
.filter-switch :deep(.v-label) {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.filter-pagination {
flex-shrink: 0;
}
.filter-pagination :deep(.v-pagination__item) {
font-size: var(--font-size-sm);
}
.filter-pagination :deep(.v-btn--active) {
background: var(--color-brand-primary) !important;
color: white !important;
}
/* Vuetify field overrides */
.filters-row :deep(.v-field) {
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
}
.filters-row :deep(.v-field__prepend-inner) {
color: var(--color-text-muted);
}
.filters-row :deep(.v-chip) {
height: 22px;
font-size: var(--font-size-xs);
}
@media (max-width: 960px) {
.filters-wrapper {
flex-direction: column;
align-items: stretch;
}
.filters-row {
width: 100%;
}
.filter-search,
.filter-select,
.filter-select--status {
flex: 1;
min-width: 120px;
width: auto;
}
.filter-pagination {
justify-content: center;
}
}
@media (max-width: 600px) {
.filters-row {
gap: var(--space-2);
}
.filter-search,
.filter-select,
.filter-select--status {
width: 100%;
flex: none;
}
}
</style>

View File

@@ -1,5 +1,23 @@
<template>
<v-table class="mt-2 mt-sm-6" style="width: 100%; background: transparent; font-size: 1rem">
<!-- Mobile: Card view -->
<div v-if="isMobile" class="package-cards">
<template v-if="packagesStore.state.packages.length === 0">
<EmptyState
icon="mdi-package-variant-remove"
title="No packages found"
description="Try adjusting your search filters or check back later." />
</template>
<template v-else>
<PackageCard
v-for="pkg in packagesStore.state.packages"
:key="`${pkg.pkgbase}-${pkg.repo}`"
:pkg="pkg" />
</template>
</div>
<!-- Desktop/Tablet: Table view -->
<div v-else class="package-table-wrapper">
<v-table class="package-table">
<thead>
<tr>
<th scope="col">Repository</th>
@@ -7,7 +25,10 @@
<th scope="col">Status</th>
<th scope="col">Reason</th>
<th scope="col">
<span class="header-with-tooltip">
LTO
<v-icon icon="mdi-help-circle-outline" size="14" class="header-icon" />
</span>
<v-tooltip activator="parent" location="bottom">
Link time optimization;
<br />
@@ -15,98 +36,353 @@
</v-tooltip>
</th>
<th scope="col">
<span class="header-with-tooltip">
DS
<v-icon icon="mdi-help-circle-outline" size="14" class="header-icon" />
</span>
<v-tooltip activator="parent" location="bottom">
Debug-symbols available via debuginfod
</v-tooltip>
</th>
<th scope="col">Archlinux Version</th>
<th scope="col">ALHP Version</th>
<th class="text-end" scope="col" style="width: 30px">Info</th>
<th scope="col" class="actions-header">Info</th>
</tr>
</thead>
<tbody id="main-tbody">
<tbody>
<tr v-if="packagesStore.state.packages.length === 0">
No Packages found
<td colspan="9">
<EmptyState
icon="mdi-package-variant-remove"
title="No packages found"
description="Try adjusting your search filters or check back later." />
</td>
</tr>
<template v-else>
<tr
v-for="(pkg, index) in packagesStore.state.packages"
:key="index"
:style="`background-color: ${getStatusColor(pkg.status)};`">
<td class="font-weight-bold text-no-wrap">
v-for="pkg in packagesStore.state.packages"
:key="`${pkg.pkgbase}-${pkg.repo}`"
class="package-row">
<td class="repo-cell">
<v-chip
:color="getVersionColor(repoVersion(pkg.repo || ''))"
class="me-2"
:style="{ backgroundColor: getVersionColor(repoVersion(pkg.repo || '')), color: '#fff' }"
class="version-chip"
density="comfortable"
label
variant="flat">
{{ repoVersion(pkg.repo || '') }}
</v-chip>
{{ repoName(pkg.repo || '') }}
<span class="repo-name">{{ repoName(pkg.repo || '') }}</span>
</td>
<td class="text-no-wrap">{{ pkg.pkgbase }}</td>
<td>{{ (pkg.status || '').toLocaleUpperCase() }}</td>
<td>{{ pkg.skip_reason || '' }}</td>
<td><i :class="getLto(pkg.lto).class" :title="getLto(pkg.lto).title"></i></td>
<td>
<i :class="getDs(pkg.debug_symbols).class" :title="getDs(pkg.debug_symbols).title"></i>
<td class="pkgbase-cell">
<span class="pkgbase-name">{{ pkg.pkgbase }}</span>
</td>
<td>{{ pkg.arch_version }}</td>
<td>{{ pkg.repo_version }}</td>
<td class="d-flex align-center" style="gap: 3px">
<td class="status-cell">
<StatusBadge :status="pkg.status" size="sm" />
</td>
<td class="reason-cell">
<span v-if="pkg.skip_reason" class="skip-reason">{{ pkg.skip_reason }}</span>
<span v-else class="no-reason"></span>
</td>
<td class="icon-cell">
<v-icon
:icon="getLto(pkg.lto).icon"
:style="{ color: getLto(pkg.lto).color }"
size="20"
:aria-label="getLto(pkg.lto).title" />
<v-tooltip activator="parent" location="bottom">
{{ getLto(pkg.lto).title }}
</v-tooltip>
</td>
<td class="icon-cell">
<v-icon
:icon="getDs(pkg.debug_symbols).icon"
:style="{ color: getDs(pkg.debug_symbols).color }"
size="20"
:aria-label="getDs(pkg.debug_symbols).title" />
<v-tooltip activator="parent" location="bottom">
{{ getDs(pkg.debug_symbols).title }}
</v-tooltip>
</td>
<td class="version-cell">
<code class="version-text">{{ pkg.arch_version || '—' }}</code>
</td>
<td class="version-cell">
<code class="version-text">{{ pkg.repo_version || '—' }}</code>
</td>
<td class="actions-cell">
<div class="action-buttons">
<a
v-if="pkg.status === 'failed'"
:href="`https://alhp.dev/logs/${(pkg.repo || '').slice((pkg.repo || '').indexOf('-') + 1)}/${pkg.pkgbase}.log`"
class="text-decoration-none"
style="
color: darkgrey;
transform: translateY(-3px) translateX(-22px);
max-width: 15px;
"
target="_blank">
<i class="fa fa-file-text fa-lg"></i>
class="action-link action-link--log"
target="_blank"
rel="noopener noreferrer"
aria-label="View build log (opens in new tab)">
<v-icon icon="mdi-file-document-outline" size="18" />
<v-tooltip activator="parent" location="bottom">View build log</v-tooltip>
</a>
<span v-else style="width: 15px"></span>
<a
:href="`https://archlinux.org/packages/?q=${pkg.pkgbase}`"
class="text-decoration-none font-weight-bold"
style="
color: darkgrey;
font-size: 18px;
padding: 0;
margin: 0;
width: 15px;
transform: translateX(-15px);
"
class="action-link action-link--arch"
target="_blank"
title="ArchWeb">
AW
rel="noopener noreferrer"
aria-label="View on Arch Linux packages (opens in new tab)">
<span class="arch-link-text">AW</span>
<v-tooltip activator="parent" location="bottom">View on ArchWeb</v-tooltip>
</a>
<span
v-if="pkg.build_date && pkg.peak_mem"
class="fa fa-info-circle fa-lg"
style="color: darkgrey; transform: translateY(-1px); max-width: 15px !important">
class="action-link action-link--info">
<v-icon icon="mdi-information-outline" size="18" />
<v-tooltip activator="parent" location="start">
{{ `Built on ${pkg.build_date}` }}
<br />
{{ `Peak-Memory: ${pkg.peak_mem}` }}
<div class="build-info-tooltip">
<div><strong>Built:</strong> {{ pkg.build_date }}</div>
<div><strong>Peak Memory:</strong> {{ pkg.peak_mem }}</div>
</div>
</v-tooltip>
</span>
<span v-else style="max-width: 15px !important"></span>
</div>
</td>
</tr>
</template>
</tbody>
</v-table>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
import { usePackageDisplay } from '@/composables/Packages/usePackageDisplay'
import { usePackagesStore } from '@/stores'
import StatusBadge from '@/components/common/StatusBadge.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import PackageCard from '@/components/Packages/PackageCard.vue'
const { repoName, repoVersion, getVersionColor, getStatusColor, getLto, getDs } =
usePackageDisplay()
const { mobile } = useDisplay()
const isMobile = computed(() => mobile.value)
const { repoName, repoVersion, getVersionColor, getLto, getDs } = usePackageDisplay()
const packagesStore = usePackagesStore()
</script>
<style scoped>
/* Mobile card view */
.package-cards {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-3);
}
/* Desktop table view */
.package-table-wrapper {
margin-top: var(--space-4);
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.package-table {
width: 100%;
background: transparent !important;
font-size: var(--font-size-sm);
}
/* Header styles */
.package-table :deep(thead) {
background: var(--color-bg-tertiary);
}
.package-table :deep(thead th) {
color: var(--color-text-secondary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
.header-with-tooltip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
cursor: help;
}
.header-icon {
opacity: 0.6;
}
.actions-header {
text-align: right;
padding-right: var(--space-4) !important;
}
/* Row styles */
.package-row {
transition: background-color var(--transition-fast);
}
.package-row:nth-child(odd) {
background: var(--color-bg-secondary);
}
.package-row:nth-child(even) {
background: var(--color-bg-tertiary);
}
.package-row:hover {
background: var(--color-bg-hover) !important;
}
.package-table :deep(tbody td) {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-subtle);
vertical-align: middle;
}
/* Cell specific styles */
.repo-cell {
display: flex;
align-items: center;
gap: var(--space-2);
white-space: nowrap;
}
.version-chip {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xs);
min-width: 32px;
text-align: center;
}
.repo-name {
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.pkgbase-cell {
white-space: nowrap;
}
.pkgbase-name {
font-family: var(--font-family-mono);
color: var(--color-text-primary);
}
.status-cell {
white-space: nowrap;
}
.reason-cell {
max-width: 200px;
}
.skip-reason {
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.no-reason {
color: var(--color-text-muted);
}
.icon-cell {
text-align: center;
}
.version-cell {
white-space: nowrap;
}
.version-text {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
}
/* Action buttons - WCAG compliant touch targets */
.actions-cell {
text-align: right;
}
.action-buttons {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.action-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
min-height: 36px;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
text-decoration: none;
transition: all var(--transition-fast);
}
.action-link:hover {
color: var(--color-text-primary);
background: var(--color-bg-hover);
}
.action-link--log:hover {
color: var(--color-status-error);
}
.action-link--arch {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xs);
}
.action-link--arch:hover {
color: var(--color-brand-primary);
}
.arch-link-text {
font-family: var(--font-family-mono);
}
.action-link--info {
cursor: default;
}
.build-info-tooltip {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--font-size-xs);
}
/* Responsive adjustments */
@media (max-width: 960px) {
.package-table-wrapper {
margin-top: var(--space-3);
border-radius: var(--radius-md);
}
.package-table :deep(thead th),
.package-table :deep(tbody td) {
padding: var(--space-2) var(--space-3);
}
.version-chip {
min-width: 28px;
}
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<div class="empty-state">
<div class="empty-state__icon">
<v-icon :icon="icon" :size="iconSize" />
</div>
<h3 v-if="title" class="empty-state__title">{{ title }}</h3>
<p v-if="description" class="empty-state__description">{{ description }}</p>
<div v-if="$slots.action" class="empty-state__action">
<slot name="action" />
</div>
</div>
</template>
<script lang="ts" setup>
withDefaults(
defineProps<{
icon?: string
title?: string
description?: string
iconSize?: number
}>(),
{
icon: 'mdi-package-variant-remove',
iconSize: 64,
}
)
</script>
<style scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-12) var(--space-6);
text-align: center;
}
.empty-state__icon {
color: var(--color-text-muted);
opacity: 0.5;
margin-bottom: var(--space-4);
}
.empty-state__title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin: 0 0 var(--space-2);
}
.empty-state__description {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin: 0 0 var(--space-4);
max-width: 300px;
}
.empty-state__action {
margin-top: var(--space-2);
}
</style>

View File

@@ -0,0 +1,158 @@
<template>
<span :class="['status-badge', `status-badge--${statusType}`]">
<v-icon v-if="showIcon" :icon="icon" :size="iconSize" />
<span class="status-badge__text">{{ label }}</span>
</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import type { components } from '@/api'
type PackageStatus = components['schemas']['Package']['status']
const props = withDefaults(
defineProps<{
status: PackageStatus
size?: 'sm' | 'md' | 'lg'
showIcon?: boolean
}>(),
{
size: 'md',
showIcon: true,
}
)
const statusConfig: Record<
NonNullable<PackageStatus>,
{ label: string; icon: string; type: string }
> = {
latest: { label: 'Latest', icon: 'mdi-check-circle', type: 'success' },
built: { label: 'Built', icon: 'mdi-package-variant-closed', type: 'info' },
failed: { label: 'Failed', icon: 'mdi-alert-circle', type: 'error' },
skipped: { label: 'Skipped', icon: 'mdi-skip-next-circle', type: 'neutral' },
delayed: { label: 'Delayed', icon: 'mdi-clock-alert', type: 'warning' },
queued: { label: 'Queued', icon: 'mdi-playlist-plus', type: 'queued' },
building: { label: 'Building', icon: 'mdi-hammer', type: 'building' },
signing: { label: 'Signing', icon: 'mdi-key', type: 'signing' },
unknown: { label: 'Unknown', icon: 'mdi-help-circle', type: 'unknown' },
}
const config = computed(() => {
if (!props.status) return { label: 'Unknown', icon: 'mdi-help-circle', type: 'unknown' }
return statusConfig[props.status] || statusConfig.unknown
})
const label = computed(() => config.value.label)
const icon = computed(() => config.value.icon)
const statusType = computed(() => config.value.type)
const iconSize = computed(() => {
switch (props.size) {
case 'sm':
return 14
case 'lg':
return 20
default:
return 16
}
})
</script>
<style scoped>
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
line-height: 1;
white-space: nowrap;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.status-badge:hover {
transform: translateY(-1px);
}
.status-badge__text {
text-transform: uppercase;
letter-spacing: 0.025em;
}
/* Success - Latest */
.status-badge--success {
background: var(--color-status-success-bg);
color: var(--color-status-success);
border: 1px solid rgba(34, 197, 94, 0.3);
}
/* Info - Built */
.status-badge--info {
background: var(--color-status-info-bg);
color: var(--color-status-info);
border: 1px solid rgba(59, 130, 246, 0.3);
}
/* Error - Failed */
.status-badge--error {
background: var(--color-status-error-bg);
color: var(--color-status-error);
border: 1px solid rgba(239, 68, 68, 0.3);
}
/* Warning - Delayed */
.status-badge--warning {
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
border: 1px solid rgba(245, 158, 11, 0.3);
}
/* Neutral - Skipped */
.status-badge--neutral {
background: var(--color-status-neutral-bg);
color: var(--color-status-neutral);
border: 1px solid rgba(107, 114, 128, 0.3);
}
/* Queued */
.status-badge--queued {
background: rgba(249, 115, 22, 0.15);
color: #f97316;
border: 1px solid rgba(249, 115, 22, 0.3);
}
/* Building */
.status-badge--building {
background: rgba(20, 184, 166, 0.15);
color: #14b8a6;
border: 1px solid rgba(20, 184, 166, 0.3);
animation: pulse-building 2s ease-in-out infinite;
}
/* Signing */
.status-badge--signing {
background: rgba(99, 102, 241, 0.15);
color: #6366f1;
border: 1px solid rgba(99, 102, 241, 0.3);
}
/* Unknown */
.status-badge--unknown {
background: var(--color-status-neutral-bg);
color: var(--color-text-muted);
border: 1px solid rgba(156, 163, 175, 0.3);
}
@keyframes pulse-building {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
</style>

View File

@@ -7,61 +7,49 @@ export function usePackageDisplay() {
const getVersionColor = (version: string) => {
switch (version) {
case 'v2':
return '#3498db'
return 'var(--color-version-v2)'
case 'v3':
return '#f39c12'
return 'var(--color-version-v3)'
case 'v4':
return '#2ecc71'
return 'var(--color-version-v4)'
default:
return 'grey'
return 'var(--color-text-muted)'
}
}
const getStatusColor = (status: components['schemas']['Package']['status']) => {
switch (status) {
case 'skipped':
return '#373737'
case 'queued':
return '#5d2f03'
case 'latest':
// Return empty string - we now use StatusBadge component instead of row colors
return ''
case 'failed':
return '#4f140f'
case 'signing':
return '#093372'
case 'building':
return '#084f46'
case 'unknown':
return '#191919'
default:
return ''
}
}
const getLto = (lto: components['schemas']['Package']['lto']) => {
switch (lto) {
case 'enabled':
return {
title: 'built with LTO',
class: 'fa fa-check fa-lg text-success'
title: 'Built with LTO',
icon: 'mdi-check-circle',
color: 'var(--color-status-success)',
}
case 'unknown':
return {
title: 'not built with LTO yet',
class: 'fa fa-hourglass-o fa-lg text-grey'
title: 'Not built with LTO yet',
icon: 'mdi-timer-sand',
color: 'var(--color-text-muted)',
}
case 'disabled':
return {
title: 'LTO explicitly disabled',
class: 'fa fa-times fa-lg text-red'
icon: 'mdi-close-circle',
color: 'var(--color-status-error)',
}
case 'auto_disabled':
return {
title: 'LTO automatically disabled',
class: 'fa fa-times-circle-o fa-lg text-amber'
icon: 'mdi-alert-circle',
color: 'var(--color-status-warning)',
}
default:
return { title: '', class: '' }
return { title: '', icon: '', color: '' }
}
}
@@ -70,20 +58,23 @@ export function usePackageDisplay() {
case 'available':
return {
title: 'Debug symbols available',
class: 'fa fa-check fa-lg text-success'
icon: 'mdi-check-circle',
color: 'var(--color-status-success)',
}
case 'unknown':
return {
title: 'Not built yet',
class: 'fa fa-hourglass-o fa-lg text-grey'
icon: 'mdi-timer-sand',
color: 'var(--color-text-muted)',
}
case 'not_available':
return {
title: 'Not built with debug symbols',
class: 'fa fa-times fa-lg text-red'
icon: 'mdi-close-circle',
color: 'var(--color-status-error)',
}
default:
return { title: '', class: '' }
return { title: '', icon: '', color: '' }
}
}
@@ -93,6 +84,6 @@ export function usePackageDisplay() {
getVersionColor,
getStatusColor,
getLto,
getDs
getDs,
}
}

View File

@@ -0,0 +1,75 @@
import { onUnmounted, ref } from 'vue'
/**
* Creates an auto-refresh interval that can be started, stopped, and automatically cleaned up
* @param callback - The function to call on each interval
* @param intervalMs - Interval in milliseconds (default: 5 minutes)
* @returns Control functions for the interval
*/
export function useAutoRefresh(callback: () => void, intervalMs = 5 * 60 * 1000) {
const intervalId = ref<number | null>(null)
const isRunning = ref(false)
const stop = () => {
if (intervalId.value !== null) {
clearInterval(intervalId.value)
intervalId.value = null
isRunning.value = false
}
}
const start = () => {
stop()
intervalId.value = window.setInterval(callback, intervalMs)
isRunning.value = true
}
const restart = () => {
start()
}
// Clean up on unmount
onUnmounted(() => {
stop()
})
return {
start,
stop,
restart,
isRunning,
}
}
/**
* Creates a timer that updates a "now" ref every second for relative time displays
* @returns The current timestamp ref and control functions
*/
export function useNowTimer() {
const now = ref(Math.floor(Date.now() / 1000))
let timerId: number | undefined
const start = () => {
stop()
timerId = window.setInterval(() => {
now.value = Math.floor(Date.now() / 1000)
}, 1000)
}
const stop = () => {
if (timerId !== undefined) {
clearInterval(timerId)
timerId = undefined
}
}
onUnmounted(() => {
stop()
})
return {
now,
start,
stop,
}
}

View File

@@ -0,0 +1,40 @@
/**
* Converts a Unix timestamp to a localized date string
* @param timestamp - Unix timestamp in seconds
* @returns Localized date string
*/
export function unixTimestampToLocalizedDate(timestamp: number): string {
const date = new Date(timestamp * 1000)
return date.toLocaleString(navigator.language)
}
/**
* Creates a relative time formatter
* @returns Functions for formatting relative time
*/
export function useRelativeTime() {
const rtf = new Intl.RelativeTimeFormat('en', {
localeMatcher: 'best fit',
numeric: 'always',
style: 'long',
})
/**
* Formats seconds ago into a human-readable relative time string
* @param seconds - Number of seconds ago (positive = past)
* @returns Formatted relative time string
*/
const formatTimeAgo = (seconds: number): string => {
if (seconds >= 3600) {
return rtf.format(-Math.floor(seconds / 3600), 'hours')
} else if (seconds >= 60) {
return rtf.format(-Math.floor(seconds / 60), 'minutes')
} else {
return rtf.format(-seconds, 'seconds')
}
}
return {
formatTimeAgo,
}
}

View File

@@ -0,0 +1,54 @@
import { ref, watch, type Ref } from 'vue'
/**
* Creates a debounced version of a ref value
* @param value - The ref to debounce
* @param delay - Debounce delay in milliseconds (default: 300)
* @returns A tuple of [debouncedValue, cancel function]
*/
export function useDebouncedRef<T>(value: Ref<T>, delay = 300) {
const debouncedValue = ref(value.value) as Ref<T>
let timeout: ReturnType<typeof setTimeout> | null = null
const cancel = () => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
}
watch(value, (newValue) => {
cancel()
timeout = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})
return { debouncedValue, cancel }
}
/**
* Creates a debounced callback function
* @param callback - The function to debounce
* @param delay - Debounce delay in milliseconds (default: 300)
* @returns A tuple of [debounced function, cancel function]
*/
export function useDebounce<T extends (...args: Parameters<T>) => void>(callback: T, delay = 300) {
let timeout: ReturnType<typeof setTimeout> | null = null
const cancel = () => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
}
const debouncedFn = (...args: Parameters<T>) => {
cancel()
timeout = setTimeout(() => {
callback(...args)
}, delay)
}
return { debouncedFn, cancel }
}

View File

@@ -0,0 +1,122 @@
import { ref, watch, onMounted } from 'vue'
import { useTheme as useVuetifyTheme } from 'vuetify'
type ThemeMode = 'light' | 'dark' | 'system'
const STORAGE_KEY = 'alhp-theme-preference'
// Global reactive state (shared across components)
const themeMode = ref<ThemeMode>('system')
const isDark = ref(true)
/**
* Theme management composable
* Handles dark/light mode with OS preference detection and persistence
*/
export function useTheme() {
const vuetifyTheme = useVuetifyTheme()
/**
* Get system preference for dark mode
*/
const getSystemPreference = (): boolean => {
if (typeof window === 'undefined') return true
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
/**
* Apply theme to document and Vuetify
*/
const applyTheme = (dark: boolean, animate = false) => {
isDark.value = dark
// Add transition class for smooth theme switching
if (animate) {
document.documentElement.classList.add('theme-transition')
setTimeout(() => {
document.documentElement.classList.remove('theme-transition')
}, 300)
}
// Apply to Vuetify
vuetifyTheme.global.name.value = dark ? 'darkTheme' : 'lightTheme'
// Apply data attribute for CSS variables
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
// Update meta theme-color for mobile browsers
const metaThemeColor = document.querySelector('meta[name="theme-color"]')
if (metaThemeColor) {
metaThemeColor.setAttribute('content', dark ? '#0f1419' : '#ffffff')
}
}
/**
* Set theme mode and persist preference
*/
const setThemeMode = (mode: ThemeMode, animate = true) => {
themeMode.value = mode
localStorage.setItem(STORAGE_KEY, mode)
if (mode === 'system') {
applyTheme(getSystemPreference(), animate)
} else {
applyTheme(mode === 'dark', animate)
}
}
/**
* Toggle between dark and light modes
*/
const toggleTheme = () => {
if (themeMode.value === 'system') {
// If currently following system, switch to opposite of current
setThemeMode(isDark.value ? 'light' : 'dark')
} else {
setThemeMode(themeMode.value === 'dark' ? 'light' : 'dark')
}
}
/**
* Initialize theme from storage or system preference
*/
const initTheme = () => {
const stored = localStorage.getItem(STORAGE_KEY) as ThemeMode | null
if (stored && ['light', 'dark', 'system'].includes(stored)) {
themeMode.value = stored
} else {
themeMode.value = 'system'
}
// Don't animate on initial load
if (themeMode.value === 'system') {
applyTheme(getSystemPreference(), false)
} else {
applyTheme(themeMode.value === 'dark', false)
}
}
// Watch for system preference changes
onMounted(() => {
initTheme()
// Listen for system preference changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e: MediaQueryListEvent) => {
if (themeMode.value === 'system') {
applyTheme(e.matches, true)
}
}
mediaQuery.addEventListener('change', handleChange)
})
return {
themeMode,
isDark,
toggleTheme,
setThemeMode,
initTheme,
}
}

View File

@@ -11,20 +11,60 @@ import 'vuetify/styles'
// Composables
import { createVuetify, type ThemeDefinition } from 'vuetify'
const customDarkTheme: ThemeDefinition = {
const darkTheme: ThemeDefinition = {
dark: true,
colors: {
background: '#111217',
primary: '#0D6EFD'
background: '#0f1419',
surface: '#1a1f2e',
'surface-variant': '#242b3d',
primary: '#3b82f6',
secondary: '#609926',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6',
success: '#22c55e',
'on-background': '#f9fafb',
'on-surface': '#f9fafb',
'on-primary': '#ffffff',
},
}
const lightTheme: ThemeDefinition = {
dark: false,
colors: {
background: '#ffffff',
surface: '#f9fafb',
'surface-variant': '#f3f4f6',
primary: '#3b82f6',
secondary: '#609926',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6',
success: '#22c55e',
'on-background': '#111827',
'on-surface': '#111827',
'on-primary': '#ffffff',
},
}
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: {
defaultTheme: 'customDarkTheme',
defaultTheme: 'darkTheme',
themes: {
customDarkTheme
}
}
darkTheme,
lightTheme,
},
},
defaults: {
VBtn: {
variant: 'flat',
},
VCard: {
rounded: 'lg',
},
VChip: {
rounded: 'lg',
},
},
})

View File

@@ -54,12 +54,11 @@ export const usePackagesStore = defineStore('packages', () => {
filter.repo = state.filters.repo
}
// @ts-ignore
getPackages({
limit: state.limit,
offset: state.offset,
...filter
})
} as Parameters<typeof getPackages>[0])
.then((response) => {
if (!response) throw new Error('No response from API')
state.packages = response.packages || []
@@ -75,7 +74,6 @@ export const usePackagesStore = defineStore('packages', () => {
state.limit = Number(import.meta.env.VITE_LIMIT) || 50
} else {
state.error = err instanceof Error ? err.message : 'Failed to fetch packages'
console.error('Error fetching packages:', err)
}
})
.finally(() => {
@@ -102,7 +100,6 @@ export const usePackagesStore = defineStore('packages', () => {
} else {
state.errorCurrentlyBuilding =
err instanceof Error ? err.message : 'Failed to fetch currently building packages'
console.error('Error fetching queued packages:', err)
}
})
.finally(() => {
@@ -119,7 +116,7 @@ export const usePackagesStore = defineStore('packages', () => {
}
const setFilters = (newFilters: PackageFilters, page?: number) => {
state.filters = JSON.parse(JSON.stringify(newFilters))
state.filters = structuredClone(newFilters)
if (state.filters.exact === false) {
state.filters.exact = undefined
}
@@ -148,7 +145,7 @@ export const usePackagesStore = defineStore('packages', () => {
const updateUrlParams = () => {
const params = new URLSearchParams()
let page = state.offset / state.limit + 1
const page = state.offset / state.limit + 1
// Only add a page parameter if it's not the first page
if (page > 1) {
params.set('page', page.toString())

View File

@@ -20,8 +20,7 @@ export const useStatsStore = defineStore('stats', () => {
state.stats = response
})
.catch((err) => {
state.error = err instanceof Error ? err.message : 'Failed to fetch packages'
console.error('Error fetching packages:', err)
state.error = err instanceof Error ? err.message : 'Failed to fetch stats'
})
.finally(() => {
state.loading = false

22
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
include: ['src/**/*.{test,spec}.{js,ts,vue}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules', 'src/generated', 'dist'],
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})

File diff suppressed because it is too large Load Diff