Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions web/components/LayoutViews/Card/Card.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';

import type { Component } from 'vue';

import CardGrid from './CardGrid.vue';
import CardHeader from './CardHeader.vue';

export interface Item {
id: string;
label: string;
icon?: string;
badge?: string | number;
slot?: string;
status?: {
label: string;
dotColor: string;
}[];
children?: Item[];
isGroup?: boolean;
}

export interface TabItem {
key: string;
label: string;
component?: Component;
props?: Record<string, unknown>;
disabled?: boolean;
}

interface Props {
items?: Item[];
tabs?: TabItem[];
defaultItemId?: string;
defaultTabKey?: string;
navigationLabel?: string;
showFilter?: boolean;
showGrouping?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
items: () => [],
tabs: () => [],
defaultItemId: undefined,
defaultTabKey: undefined,
navigationLabel: 'Docker Overview',
showFilter: true,
showGrouping: true,
});

const selectedItemId = ref(props.defaultItemId || props.items[0]?.id || '');
const selectedItems = ref<string[]>([]);
const expandedGroups = ref<Record<string, boolean>>({});
const filterQuery = ref('');
const groupBy = ref<string>('none');
const autostartStates = ref<Record<string, boolean>>({});
const runningStates = ref<Record<string, boolean>>({});

// Initialize expanded state for groups
const initializeExpandedState = () => {
props.items.forEach((item) => {
if (item.isGroup) {
expandedGroups.value[item.id] = true;
}
});
};

initializeExpandedState();

watch(
() => props.items,
() => {
initializeExpandedState();
},
{ deep: true }
);

const filteredItems = computed(() => {
if (!filterQuery.value) return props.items;

const query = filterQuery.value.toLowerCase();
return props.items.filter((item) => {
const matchesItem = item.label.toLowerCase().includes(query);
const matchesChildren = item.children?.some((child) => child.label.toLowerCase().includes(query));
return matchesItem || matchesChildren;
});
});

const groupedItems = computed(() => {
if (groupBy.value === 'none') {
return filteredItems.value;
}

// For now, return items as-is since grouping logic depends on data structure
return filteredItems.value;
});

// Reusable function to collect all selectable items
const collectSelectableItems = (items: Item[]): string[] => {
const selectableItems: string[] = [];

const collect = (items: Item[]) => {
for (const item of items) {
if (!item.isGroup) {
selectableItems.push(item.id);
}

if (item.children) {
collect(item.children);
}
}
};

collect(items);

return selectableItems;
};

const selectAllItems = () => {
selectedItems.value = [...collectSelectableItems(props.items)];
};

const clearAllSelections = () => {
selectedItems.value = [];
};

const handleAddAction = () => {
console.log('Add action triggered');
};

const handleManageSelectedAction = (action: string) => {
console.log('Manage selected action:', action);
};

const handleItemSelect = (itemId: string) => {
selectedItemId.value = itemId;
};

const handleItemsSelectionUpdate = (items: string[]) => {
selectedItems.value = items;
};

const handleAutostartUpdate = (itemId: string, value: boolean) => {
console.log('Autostart update for item:', itemId, 'value:', value);
autostartStates.value[itemId] = value;
};

const handleToggleRunning = (itemId: string) => {
// TODO: Wire up to actual docker/VM start/stop API
const currentState = runningStates.value[itemId] || false;
runningStates.value[itemId] = !currentState;
console.log('Toggle running for item:', itemId, 'new state:', !currentState);
};
</script>

<template>
<div class="flex flex-col h-full">
<!-- Header -->
<CardHeader
:title="navigationLabel"
:filter-query="filterQuery"
:group-by="groupBy"
:selected-items="selectedItems"
:show-filter="showFilter"
:show-grouping="showGrouping"
@update:filter-query="filterQuery = $event"
@update:group-by="groupBy = $event"
@add="handleAddAction"
@select-all="selectAllItems"
@clear-all="clearAllSelections"
@manage-action="handleManageSelectedAction"
/>

<!-- Card Grid -->
<div class="flex-1 overflow-auto w-full">
<CardGrid
:items="groupedItems"
:selected-items="selectedItems"
:selected-item-id="selectedItemId"
:expanded-groups="expandedGroups"
:autostart-states="autostartStates"
:running-states="runningStates"
@update:selected-items="handleItemsSelectionUpdate"
@update:expanded-groups="expandedGroups = $event"
@item-select="handleItemSelect"
@update:autostart="handleAutostartUpdate"
@toggle-running="handleToggleRunning"
/>
</div>
</div>
</template>
120 changes: 120 additions & 0 deletions web/components/LayoutViews/Card/CardGrid.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed } from 'vue';

import type { Item } from './Card.vue';

import CardGroupHeader from './CardGroupHeader.vue';
import CardItem from './CardItem.vue';

interface Props {
items: Item[];
selectedItems: string[];
selectedItemId?: string;
expandedGroups: Record<string, boolean>;
autostartStates: Record<string, boolean>;
runningStates: Record<string, boolean>;
}

const props = defineProps<Props>();

const emit = defineEmits<{
'update:selectedItems': [items: string[]];
'item-select': [itemId: string];
'update:expandedGroups': [groups: Record<string, boolean>];
'update:autostart': [itemId: string, value: boolean];
'toggle-running': [itemId: string];
}>();

const flattenedItems = computed(() => {
const result: Array<Item & { isGroupChild?: boolean; parentGroup?: string }> = [];

for (const item of props.items) {
if (item.isGroup && item.children) {
// Add group header
result.push(item);
// Add children only if group is expanded
if (props.expandedGroups[item.id]) {
for (const child of item.children) {
result.push({
...child,
isGroupChild: true,
parentGroup: item.id,
});
}
}
} else {
result.push(item);
}
}

return result;
});

const toggleItemSelection = (itemId: string) => {
const newItems = [...props.selectedItems];
const index = newItems.indexOf(itemId);

if (index > -1) {
newItems.splice(index, 1);
} else {
newItems.push(itemId);
}

emit('update:selectedItems', newItems);
};

const isItemSelected = (itemId: string) => {
return props.selectedItems.includes(itemId);
};

const handleItemClick = (itemId: string) => {
emit('item-select', itemId);
};

const toggleGroupExpansion = (groupId: string) => {
const newGroups = { ...props.expandedGroups };
newGroups[groupId] = !newGroups[groupId];
emit('update:expandedGroups', newGroups);
};

const handleAutostartUpdate = (itemId: string, value: boolean) => {
emit('update:autostart', itemId, value);
};

const handleToggleRunning = (itemId: string) => {
emit('toggle-running', itemId);
};
</script>

<template>
<div class="p-6 w-full">
<div class="space-y-4 w-full max-w-full">
<template v-for="item in flattenedItems" :key="item.id">
<!-- Group Header -->
<CardGroupHeader
v-if="item.isGroup"
:label="item.label"
:icon="item.icon"
:badge="item.badge"
:is-expanded="expandedGroups[item.id]"
@toggle="toggleGroupExpansion(item.id)"
/>

<!-- Regular Card Item -->
<CardItem
v-else
:item="item"
:is-selected="isItemSelected(item.id)"
:is-active="selectedItemId === item.id"
:is-group-child="item.isGroupChild"
:autostart-value="autostartStates[item.id] || false"
:is-running="runningStates[item.id] || false"
@toggle-selection="toggleItemSelection"
@click="handleItemClick"
@update:autostart="handleAutostartUpdate(item.id, $event)"
@toggle-running="handleToggleRunning"
/>
</template>
</div>
</div>
</template>
53 changes: 53 additions & 0 deletions web/components/LayoutViews/Card/CardGroupHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script setup lang="ts">
interface Props {
label: string;
icon?: string;
badge?: string | number;
isExpanded?: boolean;
}

defineProps<Props>();
</script>

<template>
<UCard
class="w-full cursor-pointer transition-all duration-200 hover:shadow-md"
@click="$emit('toggle')"

Check warning on line 15 in web/components/LayoutViews/Card/CardGroupHeader.vue

View workflow job for this annotation

GitHub Actions / Build Web App

The "toggle" event has been triggered but not declared on `defineEmits`
>
<div class="flex items-center gap-4 py-2">
<!-- Icon -->
<div class="flex-shrink-0 pl-2">
<UIcon v-if="icon" :name="icon" class="h-8 w-8" />
<div v-else class="h-8 w-8 rounded flex items-center justify-center">
<UIcon name="i-lucide-folder" class="h-5 w-5 text-gray-500" />
</div>
</div>

<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold truncate">
{{ label }}
</h2>
<UBadge v-if="badge" size="sm" :label="String(badge)" variant="subtle" />
<!-- Edit icon -->
<UIcon
name="i-lucide-pencil"
class="h-4 w-4 text-gray-400 hover:text-gray-600 transition-colors"
/>
</div>
</div>

<!-- Expansion Arrow on right side -->
<div class="flex-shrink-0 pr-2">
<UIcon
name="i-lucide-chevron-right"
:class="[
'h-5 w-5 text-gray-500 transform transition-transform duration-200',
isExpanded ? 'rotate-90' : 'rotate-0',
]"
/>
</div>
</div>
</UCard>
</template>
Loading
Loading