feat: improve design #2

Merged
Sebastian merged 1 commits from feat/improve-ui into main 2026-02-22 05:42:02 +00:00
3 changed files with 223 additions and 174 deletions
Showing only changes of commit 11f13e23b4 - Show all commits

View File

@@ -16,7 +16,8 @@ export async function fetchModules(): Promise<Module[]> {
} }
const data = await response.json() const data = await response.json()
return data.modules || [] const raw = data.modules || {}
return Array.isArray(raw) ? raw : Object.values(raw)
} }
export async function manageModule(handle: string, action: ModuleAction): Promise<{ message?: string; error?: string }> { export async function manageModule(handle: string, action: ModuleAction): Promise<{ message?: string; error?: string }> {

View File

@@ -1,11 +1,14 @@
export interface Module { export interface Module {
id: string | null
handle: string handle: string
label: string label: string
author: string author: string
description: string description: string
version: string version: string
namespace: string | null
installed: boolean installed: boolean
enabled: boolean enabled: boolean
needsUpgrade: boolean
} }
export type ModuleAction = 'install' | 'uninstall' | 'enable' | 'disable' | 'upgrade' export type ModuleAction = 'install' | 'uninstall' | 'enable' | 'disable' | 'upgrade'

View File

@@ -2,15 +2,6 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { fetchModules, manageModule } from '@/services/moduleService' import { fetchModules, manageModule } from '@/services/moduleService'
import type { Module, ModuleAction } from '@/types/module' import type { Module, ModuleAction } from '@/types/module'
import {
mdiPackageVariant,
mdiDownload,
mdiTrashCan,
mdiCheckCircle,
mdiCloseCircle,
mdiUpdate,
mdiLoading,
} from '@mdi/js'
const modules = ref<Module[]>([]) const modules = ref<Module[]>([])
const loading = ref(true) const loading = ref(true)
@@ -20,31 +11,43 @@ const snackbar = ref(false)
const snackbarText = ref('') const snackbarText = ref('')
const snackbarColor = ref('success') const snackbarColor = ref('success')
const search = ref('') const search = ref('')
const filterStatus = ref<'all' | 'installed' | 'not-installed' | 'enabled' | 'disabled'>('all') const filterStatus = ref<string>('all')
const upgradeCount = computed(() => modules.value.filter(m => m.needsUpgrade).length)
const statusFilters = computed(() => [
{ title: 'All', value: 'all' as const, count: modules.value.length },
{ title: 'Installed', value: 'installed' as const, count: modules.value.filter(m => m.installed).length },
{ title: 'Not Installed', value: 'not-installed' as const, count: modules.value.filter(m => !m.installed).length },
{ title: 'Enabled', value: 'enabled' as const, count: modules.value.filter(m => m.enabled).length },
{ title: 'Disabled', value: 'disabled' as const, count: modules.value.filter(m => m.installed && !m.enabled).length },
])
const filteredModules = computed(() => { const filteredModules = computed(() => {
let result = modules.value let result = modules.value
// Filter by status // Filter by status
if (filterStatus.value === 'installed') { const status = filterStatus.value
if (status === 'installed') {
result = result.filter(m => m.installed) result = result.filter(m => m.installed)
} else if (filterStatus.value === 'not-installed') { } else if (status === 'not-installed') {
result = result.filter(m => !m.installed) result = result.filter(m => !m.installed)
} else if (filterStatus.value === 'enabled') { } else if (status === 'enabled') {
result = result.filter(m => m.enabled) result = result.filter(m => m.enabled)
} else if (filterStatus.value === 'disabled') { } else if (status === 'disabled') {
result = result.filter(m => m.installed && !m.enabled) result = result.filter(m => m.installed && !m.enabled)
} }
// Filter by search // Filter by search
if (search.value) { const q = (search.value ?? '').trim().toLowerCase()
const searchLower = search.value.toLowerCase() if (q) {
result = result.filter( result = result.filter(
m => m =>
m.handle.toLowerCase().includes(searchLower) || (m.handle ?? '').toLowerCase().includes(q) ||
m.label.toLowerCase().includes(searchLower) || (m.label ?? '').toLowerCase().includes(q) ||
m.description.toLowerCase().includes(searchLower) || (m.description ?? '').toLowerCase().includes(q) ||
m.author.toLowerCase().includes(searchLower) (m.author ?? '').toLowerCase().includes(q) ||
(m.namespace ?? '').toLowerCase().includes(q)
) )
} }
@@ -107,186 +110,221 @@ onMounted(() => {
</script> </script>
<template> <template>
<v-container fluid> <v-container fluid class="pa-4">
<v-row> <!-- Header -->
<v-col cols="12"> <div class="d-flex align-center mb-6">
<v-card> <div class="d-flex align-center ga-3">
<v-card-title class="d-flex align-center"> <v-avatar color="primary" rounded="lg" size="44">
<v-icon :icon="mdiPackageVariant" class="mr-2"></v-icon> <v-icon icon="mdi-package-variant" color="white"></v-icon>
<span>Module Manager</span> </v-avatar>
<v-spacer></v-spacer> <div>
<v-btn <h1 class="text-h5 font-weight-bold">Module Manager</h1>
color="primary" <p class="text-caption text-medium-emphasis mb-0">
variant="text" {{ modules.length }} module{{ modules.length !== 1 ? 's' : '' }} available
:loading="loading" </p>
@click="loadModules" </div>
> </div>
Refresh <v-spacer></v-spacer>
</v-btn> <v-btn
</v-card-title> icon="mdi-refresh"
variant="tonal"
:loading="loading"
@click="loadModules"
></v-btn>
</div>
<v-card-text> <!-- Filters -->
<v-row class="mb-4"> <div class="d-flex align-center ga-3 flex-wrap mb-4">
<v-col cols="12" md="8"> <v-text-field
<v-text-field v-model="search"
v-model="search" prepend-inner-icon="mdi-magnify"
label="Search modules" label="Search modules"
prepend-inner-icon="mdi-magnify" variant="outlined"
variant="outlined" density="compact"
density="compact" clearable
clearable hide-details
hide-details style="min-width: 220px; max-width: 360px;"
></v-text-field> ></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="filterStatus"
:items="[
{ title: 'All Modules', value: 'all' },
{ title: 'Installed', value: 'installed' },
{ title: 'Not Installed', value: 'not-installed' },
{ title: 'Enabled', value: 'enabled' },
{ title: 'Disabled', value: 'disabled' },
]"
label="Filter by status"
variant="outlined"
density="compact"
hide-details
></v-select>
</v-col>
</v-row>
<v-alert v-if="error" type="error" class="mb-4"> <v-btn-toggle
{{ error }} v-model="filterStatus"
</v-alert> density="compact"
variant="outlined"
color="primary"
rounded="lg"
mandatory
>
<v-btn
v-for="filter in statusFilters"
:key="filter.value"
:value="filter.value"
size="small"
>
{{ filter.title }} ({{ filter.count }})
</v-btn>
</v-btn-toggle>
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear> <v-spacer></v-spacer>
<v-row v-else> <v-chip v-if="upgradeCount > 0" color="warning" variant="flat" size="small" prepend-icon="mdi-alert-circle">
<v-col {{ upgradeCount }} upgrade{{ upgradeCount !== 1 ? 's' : '' }} available
v-for="module in filteredModules" </v-chip>
:key="module.handle" </div>
cols="12"
md="6" <!-- Error -->
lg="4" <v-alert v-if="error" type="error" variant="tonal" class="mb-4" prepend-icon="mdi-alert-circle">
{{ error }}
</v-alert>
<!-- Loading -->
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" rounded></v-progress-linear>
<!-- Module Grid -->
<v-row v-else>
<v-col
v-for="module in filteredModules"
:key="module.handle"
cols="12"
md="6"
xl="4"
>
<v-card
:class="['module-card', { 'module-card--upgrade': module.needsUpgrade }]"
:border="module.needsUpgrade ? 'warning md' : true"
rounded="lg"
>
<!-- Card Header -->
<div class="module-card__header pa-4 d-flex align-start ga-3">
<!-- Left: text content -->
<div class="flex-grow-1 min-width-0">
<div class="d-flex justify-start align-center ga-2 flex-wrap mb-1">
<v-avatar
:color="getStatusColor(module)"
rounded="md"
size="28"
class="flex-shrink-0"
>
<v-icon icon="mdi-package-variant" size="16" color="white"></v-icon>
</v-avatar>
<span class="text-subtitle-1 font-weight-semibold text-truncate">{{ module.label || module.handle }}</span>
<span class="text-caption text-medium-emphasis flex-shrink-0">v{{ module.version }}</span>
<v-chip
v-if="module.needsUpgrade"
color="warning"
size="x-small"
variant="flat"
class="flex-shrink-0"
prepend-icon="mdi-update"
>
Update available
</v-chip>
</div>
<div class="d-flex justify-start align-center ga-1 text-caption text-medium-emphasis mb-2">
<v-icon icon="mdi-account" size="13"></v-icon>
<span>{{ module.author || 'Unknown' }}</span>
</div>
<p class="text-body-1 mb-0 module-description">{{ module.description || 'No description provided.' }}</p>
</div>
<!-- Right: action buttons stacked -->
<div class="d-flex flex-column ga-1 flex-shrink-0">
<v-btn
v-if="!module.installed"
color="primary"
variant="tonal"
prepend-icon="mdi-download"
:loading="isActionLoading(module, 'install')"
@click="handleAction(module, 'install')"
> >
<v-card elevation="2" class="module-card"> Install
<v-card-title class="d-flex align-center"> </v-btn>
<v-icon :icon="mdiPackageVariant" class="mr-2"></v-icon>
<span>{{ module.label }}</span>
<v-spacer></v-spacer>
<v-chip
:color="getStatusColor(module)"
size="small"
variant="flat"
>
{{ getStatusText(module) }}
</v-chip>
</v-card-title>
<v-card-subtitle> <template v-if="module.installed">
<div class="text-caption">{{ module.handle }}</div> <v-btn
<div class="text-caption text-grey">by {{ module.author }} v{{ module.version }}</div> v-if="!module.enabled"
</v-card-subtitle> color="success"
variant="tonal"
prepend-icon="mdi-check-circle"
:loading="isActionLoading(module, 'enable')"
@click="handleAction(module, 'enable')"
>
Enable
</v-btn>
<v-card-text> <v-btn
<p class="module-description">{{ module.description }}</p> v-if="module.enabled"
</v-card-text> color="warning"
variant="tonal"
prepend-icon="mdi-close-circle"
:loading="isActionLoading(module, 'disable')"
@click="handleAction(module, 'disable')"
>
Disable
</v-btn>
<v-card-actions> <v-btn
<v-btn v-if="module.needsUpgrade"
v-if="!module.installed" color="warning"
color="primary" variant="tonal"
variant="tonal" prepend-icon="mdi-update"
size="small" :loading="isActionLoading(module, 'upgrade')"
:loading="isActionLoading(module, 'install')" @click="handleAction(module, 'upgrade')"
@click="handleAction(module, 'install')" >
> Upgrade
<v-icon :icon="mdiDownload" start></v-icon> </v-btn>
Install
</v-btn>
<template v-else> <v-btn
<v-btn color="error"
v-if="module.enabled" variant="tonal"
color="warning" prepend-icon="mdi-trash-can"
variant="tonal" :loading="isActionLoading(module, 'uninstall')"
size="small" @click="handleAction(module, 'uninstall')"
:loading="isActionLoading(module, 'disable')" >
@click="handleAction(module, 'disable')" Uninstall
> </v-btn>
<v-icon :icon="mdiCloseCircle" start></v-icon> </template>
Disable </div>
</v-btn> </div>
<v-btn
v-else
color="success"
variant="tonal"
size="small"
:loading="isActionLoading(module, 'enable')"
@click="handleAction(module, 'enable')"
>
<v-icon :icon="mdiCheckCircle" start></v-icon>
Enable
</v-btn>
<v-btn
color="info"
variant="tonal"
size="small"
:loading="isActionLoading(module, 'upgrade')"
@click="handleAction(module, 'upgrade')"
>
<v-icon :icon="mdiUpdate" start></v-icon>
Upgrade
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="error"
variant="tonal"
size="small"
:loading="isActionLoading(module, 'uninstall')"
@click="handleAction(module, 'uninstall')"
>
<v-icon :icon="mdiTrashCan" start></v-icon>
Uninstall
</v-btn>
</template>
</v-card-actions>
</v-card>
</v-col>
<v-col v-if="filteredModules.length === 0" cols="12">
<v-alert type="info">
No modules found matching your criteria.
</v-alert>
</v-col>
</v-row>
</v-card-text>
</v-card> </v-card>
</v-col> </v-col>
<v-col v-if="filteredModules.length === 0 && !loading" cols="12">
<v-empty-state
icon="mdi-package-variant"
title="No modules found"
text="Try adjusting your search or filter criteria."
></v-empty-state>
</v-col>
</v-row> </v-row>
<v-snackbar <v-snackbar
v-model="snackbar" v-model="snackbar"
:color="snackbarColor" :color="snackbarColor"
:timeout="3000" :timeout="3000"
location="bottom right"
> >
{{ snackbarText }} {{ snackbarText }}
<template #actions>
<v-btn variant="text" @click="snackbar = false">Close</v-btn>
</template>
</v-snackbar> </v-snackbar>
</v-container> </v-container>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.module-card { .module-card {
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
transition: box-shadow 0.2s ease;
.v-card-text { &:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12) !important;
}
&__header {
border-bottom: none;
flex: 1; flex: 1;
} }
@@ -296,7 +334,14 @@ onMounted(() => {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
min-height: 4.5em;
} }
} }
.cursor-pointer {
cursor: pointer;
}
.min-width-0 {
min-width: 0;
}
</style> </style>