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:
42
frontend/.eslintrc.cjs
Normal file
42
frontend/.eslintrc.cjs
Normal 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'],
|
||||||
|
}
|
||||||
3
frontend/components.d.ts
vendored
3
frontend/components.d.ts
vendored
@@ -11,12 +11,15 @@ declare module 'vue' {
|
|||||||
BuildServerStats: typeof import('./src/components/BuildServerStats.vue')['default']
|
BuildServerStats: typeof import('./src/components/BuildServerStats.vue')['default']
|
||||||
BuildStats: typeof import('./src/components/MainNav/BuildStats.vue')['default']
|
BuildStats: typeof import('./src/components/MainNav/BuildStats.vue')['default']
|
||||||
CurrentlyBuilding: typeof import('./src/components/CurrentlyBuilding.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']
|
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']
|
PackageFilters: typeof import('./src/components/Packages/PackageFilters.vue')['default']
|
||||||
Packages: typeof import('./src/components/Packages.vue')['default']
|
Packages: typeof import('./src/components/Packages.vue')['default']
|
||||||
PackageTable: typeof import('./src/components/Packages/PackageTable.vue')['default']
|
PackageTable: typeof import('./src/components/Packages/PackageTable.vue')['default']
|
||||||
QueuedPackagesList: typeof import('./src/components/CurrentlyBuilding/QueuedPackagesList.vue')['default']
|
QueuedPackagesList: typeof import('./src/components/CurrentlyBuilding/QueuedPackagesList.vue')['default']
|
||||||
StatItem: typeof import('./src/components/MainNav/BuildStats/StatItem.vue')['default']
|
StatItem: typeof import('./src/components/MainNav/BuildStats/StatItem.vue')['default']
|
||||||
StatsListSection: typeof import('./src/components/MainNav/BuildStats/StatsListSection.vue')['default']
|
StatsListSection: typeof import('./src/components/MainNav/BuildStats/StatsListSection.vue')['default']
|
||||||
|
StatusBadge: typeof import('./src/components/common/StatusBadge.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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>
|
<title>ALHP Status</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
"prebuild": "npm run generate-api-types",
|
"prebuild": "npm run generate-api-types",
|
||||||
"dev": "node --no-warnings ./node_modules/.bin/vite",
|
"dev": "node --no-warnings ./node_modules/.bin/vite",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"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": {
|
"dependencies": {
|
||||||
"@fontsource/roboto": "^5.2.5",
|
"@fontsource/roboto": "^5.2.5",
|
||||||
@@ -20,8 +25,15 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/types": "^7.27.0",
|
"@babel/types": "^7.27.0",
|
||||||
|
"@testing-library/vue": "^8.1.0",
|
||||||
"@types/node": "^22.14.1",
|
"@types/node": "^22.14.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||||
|
"@typescript-eslint/parser": "^8.18.0",
|
||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@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",
|
"openapi-typescript": "^7.6.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"sass": "^1.86.3",
|
"sass": "^1.86.3",
|
||||||
@@ -30,6 +42,7 @@
|
|||||||
"unplugin-vue-components": "^28.5.0",
|
"unplugin-vue-components": "^28.5.0",
|
||||||
"vite": "^6.2.6",
|
"vite": "^6.2.6",
|
||||||
"vite-plugin-vuetify": "^2.1.1",
|
"vite-plugin-vuetify": "^2.1.1",
|
||||||
|
"vitest": "^2.1.8",
|
||||||
"vue-tsc": "^2.2.8"
|
"vue-tsc": "^2.2.8"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.7.0"
|
"packageManager": "yarn@4.7.0"
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-app>
|
<v-app class="app-root">
|
||||||
<v-container class="mb-7" fluid style="width: 1440px">
|
|
||||||
<main-nav />
|
<main-nav />
|
||||||
|
|
||||||
<v-main>
|
<v-main class="main-content">
|
||||||
|
<v-container class="content-container" fluid>
|
||||||
<build-server-stats />
|
<build-server-stats />
|
||||||
<currently-building />
|
<currently-building />
|
||||||
<packages />
|
<packages />
|
||||||
</v-main>
|
|
||||||
</v-container>
|
</v-container>
|
||||||
|
</v-main>
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -18,31 +18,19 @@ import BuildServerStats from '@/components/BuildServerStats.vue'
|
|||||||
import CurrentlyBuilding from '@/components/CurrentlyBuilding.vue'
|
import CurrentlyBuilding from '@/components/CurrentlyBuilding.vue'
|
||||||
import Packages from '@/components/Packages.vue'
|
import Packages from '@/components/Packages.vue'
|
||||||
import { useStatsStore } from '@/stores/statsStore'
|
import { useStatsStore } from '@/stores/statsStore'
|
||||||
import { onBeforeMount, onUnmounted } from 'vue'
|
import { onBeforeMount } from 'vue'
|
||||||
import { usePackagesStore } from '@/stores'
|
import { usePackagesStore } from '@/stores'
|
||||||
|
import { useAutoRefresh } from '@/composables/useAutoRefresh'
|
||||||
|
|
||||||
const statsStore = useStatsStore()
|
const statsStore = useStatsStore()
|
||||||
const packagesStore = usePackagesStore()
|
const packagesStore = usePackagesStore()
|
||||||
|
|
||||||
let refreshInterval: number | null = null
|
const intervalMinutes = Number(import.meta.env.VITE_UPDATE_INTERVAL) || 5
|
||||||
const startAutoRefresh = (intervalMinutes = Number(import.meta.env.VITE_UPDATE_INTERVAL) || 5) => {
|
const { start: startAutoRefresh } = useAutoRefresh(() => {
|
||||||
stopAutoRefresh()
|
|
||||||
refreshInterval = window.setInterval(
|
|
||||||
() => {
|
|
||||||
statsStore.fetchStats()
|
statsStore.fetchStats()
|
||||||
packagesStore.fetchPackages()
|
packagesStore.fetchPackages()
|
||||||
packagesStore.fetchCurrentlyBuilding()
|
packagesStore.fetchCurrentlyBuilding()
|
||||||
},
|
}, intervalMinutes * 60 * 1000)
|
||||||
intervalMinutes * 60 * 1000
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopAutoRefresh = () => {
|
|
||||||
if (refreshInterval !== null) {
|
|
||||||
clearInterval(refreshInterval)
|
|
||||||
refreshInterval = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeMount(() => {
|
onBeforeMount(() => {
|
||||||
statsStore.fetchStats()
|
statsStore.fetchStats()
|
||||||
@@ -50,8 +38,32 @@ onBeforeMount(() => {
|
|||||||
packagesStore.fetchCurrentlyBuilding()
|
packagesStore.fetchCurrentlyBuilding()
|
||||||
startAutoRefresh()
|
startAutoRefresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopAutoRefresh()
|
|
||||||
})
|
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
105
frontend/src/__tests__/composables/useAutoRefresh.spec.ts
Normal file
105
frontend/src/__tests__/composables/useAutoRefresh.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
65
frontend/src/__tests__/composables/useDateFormat.spec.ts
Normal file
65
frontend/src/__tests__/composables/useDateFormat.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
111
frontend/src/__tests__/composables/useDebounce.spec.ts
Normal file
111
frontend/src/__tests__/composables/useDebounce.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
123
frontend/src/__tests__/composables/usePackageDisplay.spec.ts
Normal file
123
frontend/src/__tests__/composables/usePackageDisplay.spec.ts
Normal 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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
@use "@fontsource/roboto";
|
@use "@fontsource/roboto";
|
||||||
@use "fork-awesome/css/fork-awesome.min.css";
|
@use "fork-awesome/css/fork-awesome.min.css";
|
||||||
|
@use "./tokens.scss";
|
||||||
|
|||||||
256
frontend/src/assets/styles/tokens.scss
Normal file
256
frontend/src/assets/styles/tokens.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-sheet class="mt-2" color="transparent">
|
<section class="server-stats-section" aria-labelledby="buildserver-stats-title">
|
||||||
<h5 class="text-h5">Buildserver Stats</h5>
|
<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
|
<iframe
|
||||||
:height="iframeHeight"
|
:height="iframeHeight"
|
||||||
allowtransparency="true"
|
allowtransparency="true"
|
||||||
class="w-100 border-0"
|
class="stats-iframe"
|
||||||
src="https://stats.itsh.dev/public-dashboards/0fb04abb0c5e4b7390cf26a98e6dead1"></iframe>
|
loading="lazy"
|
||||||
</v-sheet>
|
title="Buildserver statistics dashboard"
|
||||||
|
src="https://stats.itsh.dev/public-dashboards/0fb04abb0c5e4b7390cf26a98e6dead1" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@@ -22,3 +29,49 @@ const iframeHeight = computed(() =>
|
|||||||
width.value <= 800 ? `${NUMBER_OF_GRAPHS * GRAPH_HEIGHT}px` : '420px'
|
width.value <= 800 ? `${NUMBER_OF_GRAPHS * GRAPH_HEIGHT}px` : '420px'
|
||||||
)
|
)
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -1,122 +1,109 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card
|
<div class="currently-building">
|
||||||
border
|
<div class="status-header">
|
||||||
class="my-6"
|
<div class="status-indicator">
|
||||||
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
|
<div
|
||||||
:class="
|
:class="[
|
||||||
|
'pulse-dot',
|
||||||
updateFailed
|
updateFailed
|
||||||
? 'pulsating-circle-error'
|
? 'pulse-dot--error'
|
||||||
: packageArrays.building.length > 0
|
: buildingPackages.length > 0
|
||||||
? 'pulsating-circle-amber'
|
? 'pulse-dot--building'
|
||||||
: 'pulsating-circle-green'
|
: 'pulse-dot--idle'
|
||||||
"
|
]" />
|
||||||
class="circle-offset flex-circle" />
|
<span class="status-text">
|
||||||
<span class="ms-2">
|
|
||||||
{{
|
{{
|
||||||
updateFailed
|
updateFailed
|
||||||
? 'Could not fetch data.'
|
? 'Could not fetch data'
|
||||||
: packageArrays.building.length > 0
|
: buildingPackages.length > 0
|
||||||
? 'Building'
|
? 'Building'
|
||||||
: 'Idle'
|
: 'Idle'
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</v-row>
|
</div>
|
||||||
</v-col>
|
|
||||||
<v-col v-if="packageArrays.building.length > 0" class="v-col-12 v-col-lg-8 mb-3">
|
<div v-if="buildingPackages.length > 0" class="progress-section">
|
||||||
<v-progress-linear
|
<v-progress-linear
|
||||||
:max="
|
:max="builtPackages.length + buildingPackages.length + queuedPackages.length"
|
||||||
packageArrays.built.length +
|
:model-value="builtPackages.length"
|
||||||
packageArrays.building.length +
|
class="build-progress"
|
||||||
packageArrays.queued.length
|
color="primary"
|
||||||
"
|
height="8"
|
||||||
:model-value="packageArrays.built.length"
|
|
||||||
color="light-blue"
|
|
||||||
height="10"
|
|
||||||
rounded
|
rounded
|
||||||
striped></v-progress-linear>
|
striped />
|
||||||
</v-col>
|
<span class="progress-label">
|
||||||
<v-col
|
{{ builtPackages.length }} / {{ builtPackages.length + buildingPackages.length + queuedPackages.length }}
|
||||||
: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>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>Please try again later.</template>
|
<div class="timestamps">
|
||||||
</v-col>
|
<template v-if="!updateFailed">
|
||||||
</v-row>
|
<div class="timestamp-item">
|
||||||
</v-card-title>
|
<v-icon icon="mdi-refresh" size="14" class="timestamp-icon" />
|
||||||
<v-card-text
|
<span>{{ formatTimeAgo(lastUpdatedSeconds) }}</span>
|
||||||
v-if="packageArrays.building.length > 0 || packageArrays.queued.length > 0"
|
<v-tooltip activator="parent" location="start">
|
||||||
class="d-flex flex-column">
|
Last updated:
|
||||||
<v-list v-if="packageArrays.building.length > 0" class="mb-4" width="100%">
|
{{ unixTimestampToLocalizedDate(Math.floor((packagesStore.state.lastUpdated || Date.now()) / 1000)) }}
|
||||||
<v-list-subheader>Building</v-list-subheader>
|
</v-tooltip>
|
||||||
<v-list-item v-for="(pkg, index) in packageArrays.building" :key="index">
|
</div>
|
||||||
<template v-slot:prepend>
|
<div class="timestamp-item">
|
||||||
<div class="pulsating-circle-amber me-4" />
|
<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>
|
</template>
|
||||||
<v-list-item-title>
|
<span v-else class="error-message">Please try again later.</span>
|
||||||
{{ pkg.pkgbase }}
|
</div>
|
||||||
<span class="text-grey">({{ pkg.repo }})</span>
|
</div>
|
||||||
</v-list-item-title>
|
|
||||||
<v-list-item-subtitle>{{ pkg.arch_version }}</v-list-item-subtitle>
|
<div v-if="buildingPackages.length > 0 || queuedPackages.length > 0" class="build-content">
|
||||||
</v-list-item>
|
<div v-if="buildingPackages.length > 0" class="building-section">
|
||||||
</v-list>
|
<h4 class="section-title">
|
||||||
<v-sheet class="ps-4" color="transparent" rounded width="100%">
|
<v-icon icon="mdi-hammer" size="18" class="section-icon" />
|
||||||
<h4 class="mb-2 font-weight-light text-grey">Queued</h4>
|
Currently Building
|
||||||
<queued-packages-list :packages="packageArrays.queued" />
|
</h4>
|
||||||
</v-sheet>
|
<div class="package-list">
|
||||||
</v-card-text>
|
<div
|
||||||
</v-card>
|
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>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useDisplay } from 'vuetify'
|
|
||||||
import QueuedPackagesList from '@/components/CurrentlyBuilding/QueuedPackagesList.vue'
|
import QueuedPackagesList from '@/components/CurrentlyBuilding/QueuedPackagesList.vue'
|
||||||
import { usePackagesStore, useStatsStore } from '@/stores'
|
import { usePackagesStore, useStatsStore } from '@/stores'
|
||||||
|
import { useNowTimer } from '@/composables/useAutoRefresh'
|
||||||
|
import { unixTimestampToLocalizedDate, useRelativeTime } from '@/composables/useDateFormat'
|
||||||
|
|
||||||
const statsStore = useStatsStore()
|
const statsStore = useStatsStore()
|
||||||
const packagesStore = usePackagesStore()
|
const packagesStore = usePackagesStore()
|
||||||
|
|
||||||
const { mobile } = useDisplay()
|
const { formatTimeAgo } = useRelativeTime()
|
||||||
const rtf = new Intl.RelativeTimeFormat('en', {
|
const { now, start: startTimer } = useNowTimer()
|
||||||
localeMatcher: 'best fit',
|
|
||||||
numeric: 'always',
|
|
||||||
style: 'long'
|
|
||||||
})
|
|
||||||
|
|
||||||
const now = ref(Math.floor(Date.now() / 1000))
|
|
||||||
|
|
||||||
const updateFailed = computed(
|
const updateFailed = computed(
|
||||||
() => !!packagesStore.state.errorCurrentlyBuilding || !!statsStore.state.error
|
() => !!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))
|
() => now.value - (statsStore.state.stats?.last_mirror_timestamp || Math.floor(Date.now() / 1000))
|
||||||
)
|
)
|
||||||
|
|
||||||
const packageArrays = reactive({
|
const buildingPackages = computed(
|
||||||
building: computed(
|
|
||||||
() =>
|
() =>
|
||||||
packagesStore.state.currentlyBuildingPackages.filter((pkg) => pkg.status === 'building') || []
|
packagesStore.state.currentlyBuildingPackages.filter((pkg) => pkg.status === 'building') || []
|
||||||
),
|
)
|
||||||
queued: computed(
|
const queuedPackages = computed(
|
||||||
() =>
|
() =>
|
||||||
packagesStore.state.currentlyBuildingPackages.filter((pkg) => pkg.status === 'queued') || []
|
packagesStore.state.currentlyBuildingPackages.filter((pkg) => pkg.status === 'queued') || []
|
||||||
),
|
)
|
||||||
built: computed(
|
const builtPackages = computed(
|
||||||
() =>
|
() =>
|
||||||
packagesStore.state.currentlyBuildingPackages.filter((pkg) => pkg.status === 'built') || []
|
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(() => {
|
onMounted(() => {
|
||||||
startLastUpdatedTimer()
|
startTimer()
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (updateTimer) {
|
|
||||||
clearInterval(updateTimer)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style scoped>
|
||||||
.pulsating-circle-green {
|
.currently-building {
|
||||||
background-color: rgba(126, 206, 5, 0.94);
|
background: var(--color-bg-secondary);
|
||||||
border-radius: 50%;
|
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;
|
width: 12px;
|
||||||
height: 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%;
|
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-shrink: 0;
|
||||||
flex-grow: 0;
|
position: relative;
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@-webkit-keyframes pulse-ring {
|
.pulse-dot--sm {
|
||||||
0% {
|
width: 8px;
|
||||||
transform: scale(0.33);
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
80%,
|
.pulse-dot--idle {
|
||||||
100% {
|
background: var(--color-status-success);
|
||||||
opacity: 0;
|
}
|
||||||
}
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-dot--building {
|
||||||
|
background: var(--color-status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
@keyframes pulse-ring {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(0.33);
|
transform: scale(1);
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
80%,
|
|
||||||
100% {
|
100% {
|
||||||
|
transform: scale(2.5);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@-webkit-keyframes pulse-dot {
|
/* Responsive */
|
||||||
0% {
|
@media (max-width: 960px) {
|
||||||
transform: scale(0.8);
|
.status-header {
|
||||||
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
.progress-section {
|
||||||
transform: scale(1);
|
order: 3;
|
||||||
}
|
flex-basis: 100%;
|
||||||
|
min-width: auto;
|
||||||
100% {
|
|
||||||
transform: scale(0.8);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-dot {
|
@media (max-width: 600px) {
|
||||||
0% {
|
.currently-building {
|
||||||
transform: scale(0.8);
|
border-radius: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
margin-left: calc(-1 * var(--space-4));
|
||||||
|
margin-right: calc(-1 * var(--space-4));
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
.status-header {
|
||||||
transform: scale(1);
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
.timestamps {
|
||||||
transform: scale(0.8);
|
text-align: left;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp-item {
|
||||||
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,45 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-sheet color="transparent" rounded width="100%">
|
<div class="queued-list">
|
||||||
<!-- SHOW MESSAGE IF NO QUEUED PACKAGES -->
|
|
||||||
<template v-if="packages.length === 0">
|
<template v-if="packages.length === 0">
|
||||||
<span class="text-grey"> No packages queued.</span>
|
<span class="empty-message">No packages queued.</span>
|
||||||
</template>
|
</template>
|
||||||
<!-- ELSE SHOW EXPANSION PANEL -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- FULL LIST EXPANSION PANEL -->
|
<v-expansion-panels class="queued-expansion">
|
||||||
<v-expansion-panels>
|
<v-expansion-panel class="queued-panel" elevation="0">
|
||||||
<v-expansion-panel bg-color="#303030" color="primary" elevation="0">
|
<v-expansion-panel-title class="panel-title">
|
||||||
<v-expansion-panel-title>
|
<v-icon icon="mdi-format-list-bulleted" size="18" class="panel-icon" />
|
||||||
Show all queued packages
|
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-title>
|
||||||
<v-expansion-panel-text>
|
<v-expansion-panel-text class="panel-content">
|
||||||
<v-list bg-color="transparent" outlined rounded>
|
<div class="queued-items">
|
||||||
<v-list-item v-for="(pkg, index) in packages" :key="index">
|
<div
|
||||||
<template #prepend>
|
v-for="pkg in packages"
|
||||||
<v-icon
|
:key="`${pkg.pkgbase}-${pkg.repo}`"
|
||||||
icon="mdi-chevron-right"
|
class="queued-item">
|
||||||
style="margin-left: -20px; margin-right: -20px" />
|
<div class="item-info">
|
||||||
</template>
|
<span class="item-name">{{ pkg.pkgbase }}</span>
|
||||||
<v-list-item-title>
|
<span class="item-repo">{{ pkg.repo }}</span>
|
||||||
{{ pkg.pkgbase }}
|
</div>
|
||||||
<span class="text-grey">({{ pkg.repo }})</span>
|
<code class="item-version">{{ pkg.arch_version }}</code>
|
||||||
</v-list-item-title>
|
</div>
|
||||||
<v-list-item-subtitle>
|
</div>
|
||||||
{{ pkg.arch_version }}
|
|
||||||
</v-list-item-subtitle>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-expansion-panel-text>
|
</v-expansion-panel-text>
|
||||||
</v-expansion-panel>
|
</v-expansion-panel>
|
||||||
</v-expansion-panels>
|
</v-expansion-panels>
|
||||||
</template>
|
</template>
|
||||||
</v-sheet>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineProps } from 'vue'
|
import type { components } from '@/api'
|
||||||
import { components } from '@/api'
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
packages: {
|
packages: {
|
||||||
@@ -49,3 +42,125 @@ defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -1,26 +1,54 @@
|
|||||||
<template>
|
<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-container :class="containerClasses" :style="{ maxWidth: maxContainerWidth }" fluid>
|
||||||
<v-row align="center">
|
<v-row align="center" no-gutters>
|
||||||
<v-app-bar-title class="app-title">
|
<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 }}
|
{{ appTitle }}
|
||||||
</span>
|
</button>
|
||||||
<a
|
<a
|
||||||
:href="repoUrl"
|
:href="repoUrl"
|
||||||
aria-label="ALHP GitHub Repository"
|
aria-label="ALHP Git Repository (opens in new tab)"
|
||||||
class="ms-2 gitea-link"
|
class="ms-3 repo-link"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
target="_blank">
|
target="_blank">
|
||||||
<i aria-hidden="true" class="fa fa-gitea"></i>
|
<v-icon icon="mdi-git" size="24" />
|
||||||
</a>
|
</a>
|
||||||
</v-app-bar-title>
|
</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" />
|
<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-row>
|
||||||
</v-container>
|
</v-container>
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
@@ -30,23 +58,23 @@
|
|||||||
import BuildStats from '@/components/MainNav/BuildStats.vue'
|
import BuildStats from '@/components/MainNav/BuildStats.vue'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { usePackagesStore, useStatsStore } from '@/stores'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
const { mobile, width } = useDisplay()
|
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 isTablet = computed(() => mobile && width.value >= 650 && width.value < 960)
|
||||||
const isDesktop = computed(() => !mobile.value && !isTablet.value)
|
const isDesktop = computed(() => !mobile.value && !isTablet.value)
|
||||||
|
|
||||||
interface AppBarColors {
|
|
||||||
background: string
|
|
||||||
accent: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const appTitle = 'ALHP Status'
|
const appTitle = 'ALHP Status'
|
||||||
const repoUrl = 'https://somegit.dev/ALHP/ALHP.GO'
|
const repoUrl = 'https://somegit.dev/ALHP/ALHP.GO'
|
||||||
const maxContainerWidth = '1440px'
|
const maxContainerWidth = '1440px'
|
||||||
const appBarColors: AppBarColors = {
|
|
||||||
background: '#0d1538',
|
|
||||||
accent: '#609926'
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerClasses = computed(() => ({
|
const containerClasses = computed(() => ({
|
||||||
'ms-3': width.value < 1440,
|
'ms-3': width.value < 1440,
|
||||||
@@ -55,48 +83,99 @@ const containerClasses = computed(() => ({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.main-nav {
|
||||||
|
background: var(--color-bg-secondary) !important;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
.app-title {
|
.app-title {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
font-size: 20px;
|
font-size: var(--font-size-xl);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-link {
|
.home-link {
|
||||||
color: white;
|
color: var(--color-text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: var(--font-weight-semibold);
|
||||||
transition: opacity 0.2s;
|
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:hover {
|
||||||
.home-link:focus {
|
color: var(--color-brand-primary);
|
||||||
opacity: 0.9;
|
transform: translateY(-1px);
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gitea-link {
|
.home-link:focus-visible {
|
||||||
color: white;
|
outline: 2px solid var(--color-brand-primary);
|
||||||
font-size: 25px;
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-link {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.2s;
|
transition: color var(--transition-fast), transform var(--transition-fast);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gitea-link:hover,
|
.repo-link:hover {
|
||||||
.gitea-link:focus {
|
color: var(--color-brand-accent);
|
||||||
color: v-bind('appBarColors.accent');
|
transform: scale(1.1);
|
||||||
outline: none;
|
}
|
||||||
|
|
||||||
|
.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) {
|
@media (max-width: 600px) {
|
||||||
.app-title {
|
.app-title {
|
||||||
font-size: 18px;
|
font-size: var(--font-size-lg);
|
||||||
}
|
|
||||||
|
|
||||||
.gitea-link {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-sheet
|
<div v-if="!statsStore.state.loading && !statsStore.state.error" class="build-stats">
|
||||||
v-if="!statsStore.state.loading && !statsStore.state.error"
|
|
||||||
:style="sheetStyles"
|
|
||||||
class="d-flex"
|
|
||||||
color="transparent">
|
|
||||||
<StatsListSection title="Stats">
|
<StatsListSection title="Stats">
|
||||||
<StatItem
|
<StatItem
|
||||||
v-for="(stat, key) in generalStats"
|
v-for="(stat, key) in generalStats"
|
||||||
@@ -21,22 +17,15 @@
|
|||||||
:count="stat.count"
|
:count="stat.count"
|
||||||
:title="key" />
|
:title="key" />
|
||||||
</StatsListSection>
|
</StatsListSection>
|
||||||
</v-sheet>
|
</div>
|
||||||
<v-sheet
|
<div v-else-if="statsStore.state.loading" class="build-stats build-stats--loading">
|
||||||
v-else-if="statsStore.state.loading"
|
<v-progress-circular color="primary" indeterminate size="20" />
|
||||||
:style="sheetStyles"
|
<span class="loading-text">Loading stats...</span>
|
||||||
class="d-flex align-center"
|
</div>
|
||||||
color="transparent">
|
<div v-else-if="statsStore.state.error" class="build-stats build-stats--error">
|
||||||
<v-progress-circular class="mr-2" color="white" indeterminate size="20"></v-progress-circular>
|
<v-icon icon="mdi-alert-circle" size="18" />
|
||||||
<span class="text-caption">Loading stats...</span>
|
<span class="error-text">Error loading stats</span>
|
||||||
</v-sheet>
|
</div>
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@@ -53,52 +42,75 @@ withDefaults(defineProps<Props>(), {
|
|||||||
showLto: true
|
showLto: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const COLORS = {
|
|
||||||
SUCCESS: '#069b35',
|
|
||||||
WARNING: '#b97808',
|
|
||||||
ERROR: '#b30303',
|
|
||||||
NEUTRAL: '#878787'
|
|
||||||
}
|
|
||||||
|
|
||||||
const sheetStyles = { gap: '50px' }
|
|
||||||
|
|
||||||
const statsStore = useStatsStore()
|
const statsStore = useStatsStore()
|
||||||
|
|
||||||
const generalStats = computed(() => ({
|
const generalStats = computed(() => ({
|
||||||
latest: {
|
latest: {
|
||||||
count: statsStore.state.stats?.latest || 0,
|
count: statsStore.state.stats?.latest || 0,
|
||||||
color: COLORS.SUCCESS
|
color: 'var(--color-status-success)'
|
||||||
},
|
},
|
||||||
queued: {
|
queued: {
|
||||||
count: statsStore.state.stats?.queued || 0,
|
count: statsStore.state.stats?.queued || 0,
|
||||||
color: COLORS.WARNING
|
color: 'var(--color-status-warning)'
|
||||||
},
|
},
|
||||||
building: {
|
building: {
|
||||||
count: statsStore.state.stats?.building || 0,
|
count: statsStore.state.stats?.building || 0,
|
||||||
color: COLORS.WARNING
|
color: 'var(--color-status-info)'
|
||||||
},
|
},
|
||||||
skipped: {
|
skipped: {
|
||||||
count: statsStore.state.stats?.skipped || 0,
|
count: statsStore.state.stats?.skipped || 0,
|
||||||
color: COLORS.NEUTRAL
|
color: 'var(--color-status-neutral)'
|
||||||
},
|
},
|
||||||
failed: {
|
failed: {
|
||||||
count: statsStore.state.stats?.failed || 0,
|
count: statsStore.state.stats?.failed || 0,
|
||||||
color: COLORS.ERROR
|
color: 'var(--color-status-error)'
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const ltoStats = computed(() => ({
|
const ltoStats = computed(() => ({
|
||||||
enabled: {
|
enabled: {
|
||||||
count: statsStore.state.stats?.lto?.enabled || 0,
|
count: statsStore.state.stats?.lto?.enabled || 0,
|
||||||
color: COLORS.SUCCESS
|
color: 'var(--color-status-success)'
|
||||||
},
|
},
|
||||||
disabled: {
|
disabled: {
|
||||||
count: statsStore.state.stats?.lto?.disabled || 0,
|
count: statsStore.state.stats?.lto?.disabled || 0,
|
||||||
color: COLORS.ERROR
|
color: 'var(--color-status-error)'
|
||||||
},
|
},
|
||||||
unknown: {
|
unknown: {
|
||||||
count: statsStore.state.stats?.lto?.unknown || 0,
|
count: statsStore.state.stats?.lto?.unknown || 0,
|
||||||
color: COLORS.NEUTRAL
|
color: 'var(--color-status-neutral)'
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-list-item :style="{ color }" :title="title">
|
<div class="stat-item" :style="{ '--stat-color': color }">
|
||||||
{{ count }}
|
<span class="stat-count">{{ count }}</span>
|
||||||
</v-list-item>
|
<span class="stat-title">{{ title }}</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@@ -11,3 +12,31 @@ defineProps<{
|
|||||||
color: string
|
color: string
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-list bg-color="transparent" class="stats-list d-flex">
|
<div class="stats-section">
|
||||||
<v-list-subheader>{{ title }}:</v-list-subheader>
|
<span class="section-title">{{ title }}</span>
|
||||||
|
<div class="section-items">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</v-list>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@@ -11,8 +13,24 @@ defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style scoped>
|
||||||
.stats-list {
|
.stats-section {
|
||||||
border-radius: 5px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,43 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-sheet :color="TRANSPARENT_COLOR" class="mt-6" width="100%">
|
<section class="packages-section" aria-labelledby="packages-title">
|
||||||
<h5 class="text-h5 mb-4">Packages</h5>
|
<h2 id="packages-title" class="section-title">
|
||||||
|
<v-icon icon="mdi-package-variant" size="24" class="title-icon" />
|
||||||
|
Packages
|
||||||
|
</h2>
|
||||||
|
|
||||||
<PackageFilters />
|
<PackageFilters />
|
||||||
|
|
||||||
<PackageTable />
|
<PackageTable />
|
||||||
</v-sheet>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { TRANSPARENT_COLOR } from '@/config/constants'
|
|
||||||
import PackageFilters from '@/components/Packages/PackageFilters.vue'
|
import PackageFilters from '@/components/Packages/PackageFilters.vue'
|
||||||
import PackageTable from '@/components/Packages/PackageTable.vue'
|
import PackageTable from '@/components/Packages/PackageTable.vue'
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
198
frontend/src/components/Packages/PackageCard.vue
Normal file
198
frontend/src/components/Packages/PackageCard.vue
Normal 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>
|
||||||
@@ -1,62 +1,81 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-row :style="mobile ? '' : `height: ${ROW_HEIGHT}px`" width="100%">
|
<div class="filters-wrapper">
|
||||||
<v-col class="v-col-12 v-col-sm-2 v-col-lg-2">
|
<div class="filters-row">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="pkgbase"
|
v-model="pkgbase"
|
||||||
|
aria-label="Search packages by name"
|
||||||
|
class="filter-search"
|
||||||
clearable
|
clearable
|
||||||
color="primary"
|
density="compact"
|
||||||
placeholder="Search Pkgbase"
|
hide-details
|
||||||
variant="outlined"></v-text-field>
|
label="Search"
|
||||||
</v-col>
|
prepend-inner-icon="mdi-magnify"
|
||||||
<v-col class="v-col-12 v-col-sm-2 v-col-lg-2 mt-n6 mt-sm-0">
|
variant="outlined" />
|
||||||
|
|
||||||
<v-select
|
<v-select
|
||||||
v-model="repo"
|
v-model="repo"
|
||||||
:items="REPO_ITEMS"
|
:items="REPO_ITEMS"
|
||||||
|
aria-label="Filter by repository"
|
||||||
|
class="filter-select"
|
||||||
clearable
|
clearable
|
||||||
color="primary"
|
density="compact"
|
||||||
placeholder="Any Repo"
|
hide-details
|
||||||
variant="outlined"></v-select>
|
label="Repository"
|
||||||
</v-col>
|
variant="outlined" />
|
||||||
<v-col class="v-col-12 v-col-sm-2 v-col-lg-3 mt-n6 mt-sm-0">
|
|
||||||
<v-select
|
<v-select
|
||||||
v-model="status"
|
v-model="status"
|
||||||
:items="STATUS_ITEMS"
|
:items="STATUS_ITEMS"
|
||||||
|
aria-label="Filter by package status"
|
||||||
chips
|
chips
|
||||||
|
class="filter-select filter-select--status"
|
||||||
closable-chips
|
closable-chips
|
||||||
color="primary"
|
density="compact"
|
||||||
density="default"
|
hide-details
|
||||||
item-title="title"
|
item-title="title"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
|
label="Status"
|
||||||
multiple
|
multiple
|
||||||
placeholder="Any Status"
|
|
||||||
return-object
|
return-object
|
||||||
variant="outlined"></v-select>
|
variant="outlined" />
|
||||||
</v-col>
|
|
||||||
<v-col class="v-col-12 v-col-sm-2 v-col-lg-2 mt-n6 mt-sm-0">
|
<v-switch
|
||||||
<v-switch v-model="exact" color="primary" label="Exact search"></v-switch>
|
v-model="exact"
|
||||||
</v-col>
|
aria-label="Toggle exact search matching"
|
||||||
<v-col :class="mobile ? 'mt-n6' : ''" :cols="mobile ? 12 : 'auto'" class="ms-auto">
|
class="filter-switch"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
label="Exact" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<v-pagination
|
<v-pagination
|
||||||
v-model="page"
|
v-model="page"
|
||||||
:length="totalPages"
|
:length="totalPages"
|
||||||
:total-visible="mobile ? undefined : 3"
|
:total-visible="mobile ? 3 : 5"
|
||||||
active-color="primary"
|
active-color="primary"
|
||||||
density="comfortable"
|
class="filter-pagination"
|
||||||
|
density="compact"
|
||||||
|
rounded
|
||||||
start="1"
|
start="1"
|
||||||
variant="text"></v-pagination>
|
variant="text" />
|
||||||
</v-col>
|
</div>
|
||||||
</v-row>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useDisplay } from 'vuetify'
|
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 { usePackagesStore } from '@/stores'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { components } from '@/api'
|
import { components } from '@/api'
|
||||||
|
import { useDebounce } from '@/composables/useDebounce'
|
||||||
|
|
||||||
const { mobile } = useDisplay()
|
const { mobile } = useDisplay()
|
||||||
const packagesStore = usePackagesStore()
|
const packagesStore = usePackagesStore()
|
||||||
|
const { debouncedFn: debouncedUpdateFilter, cancel: cancelDebounce } = useDebounce(
|
||||||
|
() => updateFilter(),
|
||||||
|
300
|
||||||
|
)
|
||||||
|
|
||||||
const page = ref<number>(1)
|
const page = ref<number>(1)
|
||||||
const pkgbase = ref<string>()
|
const pkgbase = ref<string>()
|
||||||
@@ -110,31 +129,131 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Watcher for pkgbase with debounce
|
// Watcher for pkgbase with debounce
|
||||||
let pkgbaseTimeout: ReturnType<typeof setTimeout> | null = null
|
|
||||||
watch(
|
watch(
|
||||||
() => pkgbase.value,
|
() => pkgbase.value,
|
||||||
() => {
|
() => {
|
||||||
if (pkgbaseTimeout) clearTimeout(pkgbaseTimeout)
|
debouncedUpdateFilter()
|
||||||
pkgbaseTimeout = setTimeout(() => {
|
|
||||||
updateFilter()
|
|
||||||
}, 300)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Watcher for other filters (repo, status, exact) without debounce
|
// Watcher for other filters (repo, status, exact) without debounce
|
||||||
watch(
|
watch(
|
||||||
[() => repo.value, () => status.value?.map((state) => state.value), () => exact.value],
|
[repo, status, exact],
|
||||||
() => {
|
() => {
|
||||||
// Cancel pending pkgbase debounce if any
|
// Cancel pending pkgbase debounce if any
|
||||||
if (pkgbaseTimeout) {
|
cancelDebounce()
|
||||||
clearTimeout(pkgbaseTimeout)
|
|
||||||
pkgbaseTimeout = null
|
|
||||||
}
|
|
||||||
updateFilter()
|
updateFilter()
|
||||||
}
|
},
|
||||||
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initFilters()
|
initFilters()
|
||||||
})
|
})
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
<template>
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Repository</th>
|
<th scope="col">Repository</th>
|
||||||
@@ -7,7 +25,10 @@
|
|||||||
<th scope="col">Status</th>
|
<th scope="col">Status</th>
|
||||||
<th scope="col">Reason</th>
|
<th scope="col">Reason</th>
|
||||||
<th scope="col">
|
<th scope="col">
|
||||||
|
<span class="header-with-tooltip">
|
||||||
LTO
|
LTO
|
||||||
|
<v-icon icon="mdi-help-circle-outline" size="14" class="header-icon" />
|
||||||
|
</span>
|
||||||
<v-tooltip activator="parent" location="bottom">
|
<v-tooltip activator="parent" location="bottom">
|
||||||
Link time optimization;
|
Link time optimization;
|
||||||
<br />
|
<br />
|
||||||
@@ -15,98 +36,353 @@
|
|||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</th>
|
</th>
|
||||||
<th scope="col">
|
<th scope="col">
|
||||||
|
<span class="header-with-tooltip">
|
||||||
DS
|
DS
|
||||||
|
<v-icon icon="mdi-help-circle-outline" size="14" class="header-icon" />
|
||||||
|
</span>
|
||||||
<v-tooltip activator="parent" location="bottom">
|
<v-tooltip activator="parent" location="bottom">
|
||||||
Debug-symbols available via debuginfod
|
Debug-symbols available via debuginfod
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</th>
|
</th>
|
||||||
<th scope="col">Archlinux Version</th>
|
<th scope="col">Archlinux Version</th>
|
||||||
<th scope="col">ALHP 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="main-tbody">
|
<tbody>
|
||||||
<tr v-if="packagesStore.state.packages.length === 0">
|
<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>
|
</tr>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<tr
|
<tr
|
||||||
v-for="(pkg, index) in packagesStore.state.packages"
|
v-for="pkg in packagesStore.state.packages"
|
||||||
:key="index"
|
:key="`${pkg.pkgbase}-${pkg.repo}`"
|
||||||
:style="`background-color: ${getStatusColor(pkg.status)};`">
|
class="package-row">
|
||||||
<td class="font-weight-bold text-no-wrap">
|
<td class="repo-cell">
|
||||||
<v-chip
|
<v-chip
|
||||||
:color="getVersionColor(repoVersion(pkg.repo || ''))"
|
:style="{ backgroundColor: getVersionColor(repoVersion(pkg.repo || '')), color: '#fff' }"
|
||||||
class="me-2"
|
class="version-chip"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
label
|
label
|
||||||
variant="flat">
|
variant="flat">
|
||||||
{{ repoVersion(pkg.repo || '') }}
|
{{ repoVersion(pkg.repo || '') }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
{{ repoName(pkg.repo || '') }}
|
<span class="repo-name">{{ repoName(pkg.repo || '') }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-no-wrap">{{ pkg.pkgbase }}</td>
|
<td class="pkgbase-cell">
|
||||||
<td>{{ (pkg.status || '').toLocaleUpperCase() }}</td>
|
<span class="pkgbase-name">{{ pkg.pkgbase }}</span>
|
||||||
<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>
|
</td>
|
||||||
<td>{{ pkg.arch_version }}</td>
|
<td class="status-cell">
|
||||||
<td>{{ pkg.repo_version }}</td>
|
<StatusBadge :status="pkg.status" size="sm" />
|
||||||
<td class="d-flex align-center" style="gap: 3px">
|
</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
|
<a
|
||||||
v-if="pkg.status === 'failed'"
|
v-if="pkg.status === 'failed'"
|
||||||
:href="`https://alhp.dev/logs/${(pkg.repo || '').slice((pkg.repo || '').indexOf('-') + 1)}/${pkg.pkgbase}.log`"
|
:href="`https://alhp.dev/logs/${(pkg.repo || '').slice((pkg.repo || '').indexOf('-') + 1)}/${pkg.pkgbase}.log`"
|
||||||
class="text-decoration-none"
|
class="action-link action-link--log"
|
||||||
style="
|
target="_blank"
|
||||||
color: darkgrey;
|
rel="noopener noreferrer"
|
||||||
transform: translateY(-3px) translateX(-22px);
|
aria-label="View build log (opens in new tab)">
|
||||||
max-width: 15px;
|
<v-icon icon="mdi-file-document-outline" size="18" />
|
||||||
"
|
<v-tooltip activator="parent" location="bottom">View build log</v-tooltip>
|
||||||
target="_blank">
|
|
||||||
<i class="fa fa-file-text fa-lg"></i>
|
|
||||||
</a>
|
</a>
|
||||||
<span v-else style="width: 15px"></span>
|
|
||||||
<a
|
<a
|
||||||
:href="`https://archlinux.org/packages/?q=${pkg.pkgbase}`"
|
:href="`https://archlinux.org/packages/?q=${pkg.pkgbase}`"
|
||||||
class="text-decoration-none font-weight-bold"
|
class="action-link action-link--arch"
|
||||||
style="
|
|
||||||
color: darkgrey;
|
|
||||||
font-size: 18px;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
width: 15px;
|
|
||||||
transform: translateX(-15px);
|
|
||||||
"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="ArchWeb">
|
rel="noopener noreferrer"
|
||||||
AW
|
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>
|
</a>
|
||||||
<span
|
<span
|
||||||
v-if="pkg.build_date && pkg.peak_mem"
|
v-if="pkg.build_date && pkg.peak_mem"
|
||||||
class="fa fa-info-circle fa-lg"
|
class="action-link action-link--info">
|
||||||
style="color: darkgrey; transform: translateY(-1px); max-width: 15px !important">
|
<v-icon icon="mdi-information-outline" size="18" />
|
||||||
<v-tooltip activator="parent" location="start">
|
<v-tooltip activator="parent" location="start">
|
||||||
{{ `Built on ${pkg.build_date}` }}
|
<div class="build-info-tooltip">
|
||||||
<br />
|
<div><strong>Built:</strong> {{ pkg.build_date }}</div>
|
||||||
{{ `Peak-Memory: ${pkg.peak_mem}` }}
|
<div><strong>Peak Memory:</strong> {{ pkg.peak_mem }}</div>
|
||||||
|
</div>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</span>
|
</span>
|
||||||
<span v-else style="max-width: 15px !important"></span>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</v-table>
|
</v-table>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
import { usePackageDisplay } from '@/composables/Packages/usePackageDisplay'
|
import { usePackageDisplay } from '@/composables/Packages/usePackageDisplay'
|
||||||
import { usePackagesStore } from '@/stores'
|
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 } =
|
const { mobile } = useDisplay()
|
||||||
usePackageDisplay()
|
const isMobile = computed(() => mobile.value)
|
||||||
|
|
||||||
|
const { repoName, repoVersion, getVersionColor, getLto, getDs } = usePackageDisplay()
|
||||||
const packagesStore = usePackagesStore()
|
const packagesStore = usePackagesStore()
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
62
frontend/src/components/common/EmptyState.vue
Normal file
62
frontend/src/components/common/EmptyState.vue
Normal 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>
|
||||||
158
frontend/src/components/common/StatusBadge.vue
Normal file
158
frontend/src/components/common/StatusBadge.vue
Normal 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>
|
||||||
@@ -7,61 +7,49 @@ export function usePackageDisplay() {
|
|||||||
const getVersionColor = (version: string) => {
|
const getVersionColor = (version: string) => {
|
||||||
switch (version) {
|
switch (version) {
|
||||||
case 'v2':
|
case 'v2':
|
||||||
return '#3498db'
|
return 'var(--color-version-v2)'
|
||||||
case 'v3':
|
case 'v3':
|
||||||
return '#f39c12'
|
return 'var(--color-version-v3)'
|
||||||
case 'v4':
|
case 'v4':
|
||||||
return '#2ecc71'
|
return 'var(--color-version-v4)'
|
||||||
default:
|
default:
|
||||||
return 'grey'
|
return 'var(--color-text-muted)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusColor = (status: components['schemas']['Package']['status']) => {
|
const getStatusColor = (status: components['schemas']['Package']['status']) => {
|
||||||
switch (status) {
|
// Return empty string - we now use StatusBadge component instead of row colors
|
||||||
case 'skipped':
|
|
||||||
return '#373737'
|
|
||||||
case 'queued':
|
|
||||||
return '#5d2f03'
|
|
||||||
case 'latest':
|
|
||||||
return ''
|
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']) => {
|
const getLto = (lto: components['schemas']['Package']['lto']) => {
|
||||||
switch (lto) {
|
switch (lto) {
|
||||||
case 'enabled':
|
case 'enabled':
|
||||||
return {
|
return {
|
||||||
title: 'built with LTO',
|
title: 'Built with LTO',
|
||||||
class: 'fa fa-check fa-lg text-success'
|
icon: 'mdi-check-circle',
|
||||||
|
color: 'var(--color-status-success)',
|
||||||
}
|
}
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
return {
|
return {
|
||||||
title: 'not built with LTO yet',
|
title: 'Not built with LTO yet',
|
||||||
class: 'fa fa-hourglass-o fa-lg text-grey'
|
icon: 'mdi-timer-sand',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
}
|
}
|
||||||
case 'disabled':
|
case 'disabled':
|
||||||
return {
|
return {
|
||||||
title: 'LTO explicitly disabled',
|
title: 'LTO explicitly disabled',
|
||||||
class: 'fa fa-times fa-lg text-red'
|
icon: 'mdi-close-circle',
|
||||||
|
color: 'var(--color-status-error)',
|
||||||
}
|
}
|
||||||
case 'auto_disabled':
|
case 'auto_disabled':
|
||||||
return {
|
return {
|
||||||
title: 'LTO automatically disabled',
|
title: 'LTO automatically disabled',
|
||||||
class: 'fa fa-times-circle-o fa-lg text-amber'
|
icon: 'mdi-alert-circle',
|
||||||
|
color: 'var(--color-status-warning)',
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return { title: '', class: '' }
|
return { title: '', icon: '', color: '' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,20 +58,23 @@ export function usePackageDisplay() {
|
|||||||
case 'available':
|
case 'available':
|
||||||
return {
|
return {
|
||||||
title: 'Debug symbols available',
|
title: 'Debug symbols available',
|
||||||
class: 'fa fa-check fa-lg text-success'
|
icon: 'mdi-check-circle',
|
||||||
|
color: 'var(--color-status-success)',
|
||||||
}
|
}
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
return {
|
return {
|
||||||
title: 'Not built yet',
|
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':
|
case 'not_available':
|
||||||
return {
|
return {
|
||||||
title: 'Not built with debug symbols',
|
title: 'Not built with debug symbols',
|
||||||
class: 'fa fa-times fa-lg text-red'
|
icon: 'mdi-close-circle',
|
||||||
|
color: 'var(--color-status-error)',
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return { title: '', class: '' }
|
return { title: '', icon: '', color: '' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +84,6 @@ export function usePackageDisplay() {
|
|||||||
getVersionColor,
|
getVersionColor,
|
||||||
getStatusColor,
|
getStatusColor,
|
||||||
getLto,
|
getLto,
|
||||||
getDs
|
getDs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
75
frontend/src/composables/useAutoRefresh.ts
Normal file
75
frontend/src/composables/useAutoRefresh.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
40
frontend/src/composables/useDateFormat.ts
Normal file
40
frontend/src/composables/useDateFormat.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
54
frontend/src/composables/useDebounce.ts
Normal file
54
frontend/src/composables/useDebounce.ts
Normal 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 }
|
||||||
|
}
|
||||||
122
frontend/src/composables/useTheme.ts
Normal file
122
frontend/src/composables/useTheme.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,22 +9,62 @@ import '@mdi/font/css/materialdesignicons.css'
|
|||||||
import 'vuetify/styles'
|
import 'vuetify/styles'
|
||||||
|
|
||||||
// Composables
|
// Composables
|
||||||
import {createVuetify, type ThemeDefinition} from 'vuetify'
|
import { createVuetify, type ThemeDefinition } from 'vuetify'
|
||||||
|
|
||||||
const customDarkTheme: ThemeDefinition = {
|
const darkTheme: ThemeDefinition = {
|
||||||
dark: true,
|
dark: true,
|
||||||
colors: {
|
colors: {
|
||||||
background: '#111217',
|
background: '#0f1419',
|
||||||
primary: '#0D6EFD'
|
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
|
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
|
||||||
export default createVuetify({
|
export default createVuetify({
|
||||||
theme: {
|
theme: {
|
||||||
defaultTheme: 'customDarkTheme',
|
defaultTheme: 'darkTheme',
|
||||||
themes: {
|
themes: {
|
||||||
customDarkTheme
|
darkTheme,
|
||||||
}
|
lightTheme,
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
VBtn: {
|
||||||
|
variant: 'flat',
|
||||||
|
},
|
||||||
|
VCard: {
|
||||||
|
rounded: 'lg',
|
||||||
|
},
|
||||||
|
VChip: {
|
||||||
|
rounded: 'lg',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,12 +54,11 @@ export const usePackagesStore = defineStore('packages', () => {
|
|||||||
filter.repo = state.filters.repo
|
filter.repo = state.filters.repo
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
getPackages({
|
getPackages({
|
||||||
limit: state.limit,
|
limit: state.limit,
|
||||||
offset: state.offset,
|
offset: state.offset,
|
||||||
...filter
|
...filter
|
||||||
})
|
} as Parameters<typeof getPackages>[0])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!response) throw new Error('No response from API')
|
if (!response) throw new Error('No response from API')
|
||||||
state.packages = response.packages || []
|
state.packages = response.packages || []
|
||||||
@@ -75,7 +74,6 @@ export const usePackagesStore = defineStore('packages', () => {
|
|||||||
state.limit = Number(import.meta.env.VITE_LIMIT) || 50
|
state.limit = Number(import.meta.env.VITE_LIMIT) || 50
|
||||||
} else {
|
} else {
|
||||||
state.error = err instanceof Error ? err.message : 'Failed to fetch packages'
|
state.error = err instanceof Error ? err.message : 'Failed to fetch packages'
|
||||||
console.error('Error fetching packages:', err)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -102,7 +100,6 @@ export const usePackagesStore = defineStore('packages', () => {
|
|||||||
} else {
|
} else {
|
||||||
state.errorCurrentlyBuilding =
|
state.errorCurrentlyBuilding =
|
||||||
err instanceof Error ? err.message : 'Failed to fetch currently building packages'
|
err instanceof Error ? err.message : 'Failed to fetch currently building packages'
|
||||||
console.error('Error fetching queued packages:', err)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -119,7 +116,7 @@ export const usePackagesStore = defineStore('packages', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const setFilters = (newFilters: PackageFilters, page?: number) => {
|
const setFilters = (newFilters: PackageFilters, page?: number) => {
|
||||||
state.filters = JSON.parse(JSON.stringify(newFilters))
|
state.filters = structuredClone(newFilters)
|
||||||
if (state.filters.exact === false) {
|
if (state.filters.exact === false) {
|
||||||
state.filters.exact = undefined
|
state.filters.exact = undefined
|
||||||
}
|
}
|
||||||
@@ -148,7 +145,7 @@ export const usePackagesStore = defineStore('packages', () => {
|
|||||||
const updateUrlParams = () => {
|
const updateUrlParams = () => {
|
||||||
const params = new URLSearchParams()
|
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
|
// Only add a page parameter if it's not the first page
|
||||||
if (page > 1) {
|
if (page > 1) {
|
||||||
params.set('page', page.toString())
|
params.set('page', page.toString())
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ export const useStatsStore = defineStore('stats', () => {
|
|||||||
state.stats = response
|
state.stats = response
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
state.error = err instanceof Error ? err.message : 'Failed to fetch packages'
|
state.error = err instanceof Error ? err.message : 'Failed to fetch stats'
|
||||||
console.error('Error fetching packages:', err)
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
|
|||||||
22
frontend/vitest.config.ts
Normal file
22
frontend/vitest.config.ts
Normal 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)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
2724
frontend/yarn.lock
2724
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user