feat: create base Detail component and placeholder tab components

This commit is contained in:
mdatelle
2025-07-22 16:45:23 -04:00
committed by Eli Bosley
parent bb8c4a133e
commit 78ce64e357
8 changed files with 560 additions and 69 deletions

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { ref } from 'vue';
import { UButton } from '#components';
interface Props {
item: {
id: string;
label: string;
icon?: string;
badge?: string | number;
};
}
const props = defineProps<Props>();
const command = ref('');
const output = ref<string[]>([
`root@${props.item.id}:/# echo "Welcome to ${props.item.label}"`,
`Welcome to ${props.item.label}`,
`root@${props.item.id}:/#`,
]);
const executeCommand = () => {
if (command.value.trim()) {
output.value.push(`root@${props.item.id}:/# ${command.value}`);
output.value.push(`${command.value}: command executed`);
output.value.push(`root@${props.item.id}:/#`);
command.value = '';
}
};
</script>
<template>
<div class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-lg font-medium">Terminal</h3>
<div class="flex gap-2">
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-maximize-2">
Fullscreen
</UButton>
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-refresh-cw">
Restart
</UButton>
</div>
</div>
<div class="bg-black text-green-400 p-4 rounded-lg font-mono text-sm h-96 overflow-y-auto">
<div v-for="(line, index) in output" :key="index">
{{ line }}
</div>
<div class="flex items-center">
<span>root@{{ item.id }}:/# </span>
<input
v-model="command"
class="bg-transparent outline-none flex-1 ml-1"
type="text"
@keyup.enter="executeCommand"
>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref } from 'vue';
import { UButton, UCard, UInput, USelectMenu, UIcon, UFormField } from '#components';
interface Props {
item: {
id: string;
label: string;
icon?: string;
badge?: string | number;
};
}
const props = defineProps<Props>();
const config = ref({
name: props.item.label,
image: 'ghcr.io/imagegenius/immich:latest',
network: 'bridge',
restartPolicy: 'unless-stopped',
cpuLimit: '',
memoryLimit: '',
ports: [{ container: '7878', host: '7878', protocol: 'tcp' }],
volumes: [
{ container: '/config', host: '/mnt/user/appdata/immich' },
{ container: '/media', host: '/mnt/user/media' },
],
environment: [
{ key: 'PUID', value: '99' },
{ key: 'PGID', value: '100' },
],
});
</script>
<template>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h3 class="text-lg font-medium">Container Configuration</h3>
<div class="flex gap-2">
<UButton color="primary" variant="outline">Cancel</UButton>
<UButton color="primary">Save Changes</UButton>
</div>
</div>
<UCard>
<template #header>
<h4 class="font-medium">Basic Settings</h4>
</template>
<div class="space-y-4">
<UFormField label="Container Name">
<UInput v-model="config.name" />
</UFormField>
<UFormField label="Image">
<UInput v-model="config.image" />
</UFormField>
<UFormField label="Network Mode">
<USelectMenu v-model="config.network" :options="['bridge', 'host', 'none', 'custom']" />
</UFormField>
<UFormField label="Restart Policy">
<USelectMenu
v-model="config.restartPolicy"
:options="['no', 'always', 'unless-stopped', 'on-failure']"
/>
</UFormField>
</div>
</UCard>
<UCard>
<template #header>
<h4 class="font-medium">Resource Limits</h4>
</template>
<div class="grid grid-cols-2 gap-4">
<UFormField label="CPU Limit">
<UInput v-model="config.cpuLimit" placeholder="e.g., 0.5 or 2" />
</UFormField>
<UFormField label="Memory Limit">
<UInput v-model="config.memoryLimit" placeholder="e.g., 512m or 2g" />
</UFormField>
</div>
</UCard>
<UCard>
<template #header>
<h4 class="font-medium">Port Mappings</h4>
</template>
<div class="space-y-2">
<div v-for="(port, index) in config.ports" :key="index" class="flex gap-2 items-center">
<UInput v-model="port.host" placeholder="Host Port" class="flex-1" />
<UIcon name="i-lucide-arrow-right" class="text-gray-400" />
<UInput v-model="port.container" placeholder="Container Port" class="flex-1" />
<USelectMenu v-model="port.protocol" :options="['tcp', 'udp']" class="w-24" />
<UButton icon="i-lucide-trash-2" color="primary" variant="ghost" size="sm" />
</div>
<UButton icon="i-lucide-plus" size="sm" variant="outline">Add Port</UButton>
</div>
</UCard>
<UCard>
<template #header>
<h4 class="font-medium">Volume Mappings</h4>
</template>
<div class="space-y-2">
<div v-for="(volume, index) in config.volumes" :key="index" class="flex gap-2 items-center">
<UInput v-model="volume.host" placeholder="Host Path" class="flex-1" />
<UIcon name="i-lucide-arrow-right" class="text-gray-400" />
<UInput v-model="volume.container" placeholder="Container Path" class="flex-1" />
<UButton icon="i-lucide-trash-2" color="primary" variant="ghost" size="sm" />
</div>
<UButton icon="i-lucide-plus" size="sm" variant="outline">Add Volume</UButton>
</div>
</UCard>
<UCard>
<template #header>
<h4 class="font-medium">Environment Variables</h4>
</template>
<div class="space-y-2">
<div v-for="(env, index) in config.environment" :key="index" class="flex gap-2 items-center">
<UInput v-model="env.key" placeholder="Variable Name" class="flex-1" />
<UInput v-model="env.value" placeholder="Value" class="flex-1" />
<UButton icon="i-lucide-trash-2" color="primary" variant="ghost" size="sm" />
</div>
<UButton icon="i-lucide-plus" size="sm" variant="outline">Add Variable</UButton>
</div>
</UCard>
</div>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { UButton } from '#components';
interface Props {
item: {
id: string;
label: string;
icon?: string;
badge?: string | number;
};
}
const props = defineProps<Props>();
const sampleLogs = [
{ timestamp: '2024-01-22 10:15:23', message: `Starting ${props.item.label}...` },
{ timestamp: '2024-01-22 10:15:24', message: 'Container initialized successfully' },
{ timestamp: '2024-01-22 10:15:25', message: 'Listening on configured port' },
{ timestamp: '2024-01-22 10:15:26', message: 'Health check passed' },
{ timestamp: '2024-01-22 10:15:27', message: 'Ready to accept connections' },
];
</script>
<template>
<div class="space-y-2">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium">Container Logs</h3>
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-download"> Export </UButton>
</div>
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg font-mono text-sm overflow-x-auto">
<div v-for="(log, index) in sampleLogs" :key="index" class="whitespace-nowrap">
<span class="text-gray-500">[{{ log.timestamp }}]</span> {{ log.message }}
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
interface ContainerDetails {
network: string;
lanIpPort: string;
containerIp: string;
uptime: string;
containerPort: string;
creationDate: string;
containerId: string;
maintainer: string;
}
interface Props {
item: {
id: string;
label: string;
icon?: string;
badge?: string | number;
};
details?: ContainerDetails;
}
defineProps<Props>();
</script>
<template>
<div v-if="details" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Network:</p>
<p class="mt-1">{{ details.network }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">LAN IP:Port</p>
<p class="mt-1">{{ details.lanIpPort }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Container IP:</p>
<p class="mt-1">{{ details.containerIp }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Uptime:</p>
<p class="mt-1">{{ details.uptime }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Container Port:</p>
<p class="mt-1">{{ details.containerPort }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Creation Date:</p>
<p class="mt-1">{{ details.creationDate }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Container Id:</p>
<p class="mt-1 font-mono text-sm">{{ details.containerId }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Maintainer:</p>
<p class="mt-1 text-sm">{{ details.maintainer }}</p>
</div>
</div>
</div>
<div v-else class="text-gray-500 dark:text-gray-400">
No details available for {{ item.label }}
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { UButton, UIcon } from '#components';
interface Props {
item: {
id: string;
label: string;
icon?: string;
badge?: string | number;
};
port?: string;
}
const props = defineProps<Props>();
const previewUrl = props.port ? `http://localhost:${props.port}` : null;
</script>
<template>
<div class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-lg font-medium">Web Preview</h3>
<div class="flex gap-2">
<UButton
v-if="previewUrl"
size="sm"
color="primary"
variant="outline"
icon="i-lucide-external-link"
:to="previewUrl"
target="_blank"
>
Open in new tab
</UButton>
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-refresh-cw"> Refresh </UButton>
</div>
</div>
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div v-if="previewUrl" class="bg-gray-100 dark:bg-gray-800 px-4 py-2 flex items-center gap-2">
<UIcon name="i-lucide-lock" class="w-4 h-4 text-gray-500" />
<span class="text-sm text-gray-600 dark:text-gray-400">{{ previewUrl }}</span>
</div>
<div class="p-8 text-center h-96 flex items-center justify-center">
<div v-if="previewUrl" class="text-gray-500 dark:text-gray-400">
<UIcon name="i-lucide-globe" class="w-16 h-16 mx-auto mb-4" />
<p>Web interface preview for {{ item.label }}</p>
<p class="text-sm mt-2">Container must be running and accessible on port {{ port }}</p>
</div>
<div v-else class="text-gray-500 dark:text-gray-400">
<UIcon name="i-lucide-alert-circle" class="w-16 h-16 mx-auto mb-4" />
<p>No web interface available for {{ item.label }}</p>
<p class="text-sm mt-2">This container does not expose a web interface</p>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { Component } from 'vue';
import { UIcon, UBadge, UTabs } from '#components';
interface NavigationItem {
id: string;
label: string;
icon?: string;
badge?: string | number;
}
interface TabItem {
key: string;
label: string;
component?: Component;
props?: Record<string, unknown>;
disabled?: boolean;
}
interface Props {
navigationItems?: NavigationItem[];
tabs?: TabItem[];
defaultNavigationId?: string;
defaultTabKey?: string;
}
const props = withDefaults(defineProps<Props>(), {
navigationItems: () => [],
tabs: () => [],
defaultNavigationId: undefined,
defaultTabKey: undefined,
});
const selectedNavigationId = ref(props.defaultNavigationId || props.navigationItems[0]?.id || '');
const selectedTab = ref(props.defaultTabKey || '0');
const selectedNavigationItem = computed(() =>
props.navigationItems.find((item) => item.id === selectedNavigationId.value)
);
const tabItems = computed(() =>
props.tabs.map((tab) => ({
label: tab.label,
key: tab.key,
disabled: tab.disabled,
}))
);
const selectNavigationItem = (id: string) => {
selectedNavigationId.value = id;
selectedTab.value = '0'; // Reset to first tab index
};
// UTabs uses index, so convert to tab key
const getCurrentTabComponent = () => {
const tabIndex = parseInt(selectedTab.value);
return props.tabs[tabIndex]?.component;
};
const getCurrentTabProps = () => {
const tabIndex = parseInt(selectedTab.value);
const currentTab = props.tabs[tabIndex];
return {
item: selectedNavigationItem.value,
...currentTab?.props,
};
};
</script>
<template>
<div class="flex h-full gap-6">
<!-- Left Navigation Section -->
<div class="w-64 flex-shrink-0">
<nav class="space-y-1">
<button
v-for="item in navigationItems"
:key="item.id"
:class="[
'group flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
selectedNavigationId === item.id
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white',
]"
@click="selectNavigationItem(item.id)"
>
<UIcon v-if="item.icon" :name="item.icon" class="h-5 w-5 flex-shrink-0" />
<span class="truncate">{{ item.label }}</span>
<UBadge v-if="item.badge" size="xs" :label="String(item.badge)" class="ml-auto" />
</button>
</nav>
</div>
<!-- Right Content Section -->
<div class="flex-1 min-w-0">
<UTabs v-model="selectedTab" :items="tabItems" class="w-full" />
<!-- Tab Content -->
<div class="mt-6">
<component
:is="getCurrentTabComponent()"
v-if="getCurrentTabComponent() && selectedNavigationItem"
v-bind="getCurrentTabProps()"
/>
<div v-else-if="!selectedNavigationItem" class="text-gray-500 dark:text-gray-400">
No item selected
</div>
<div v-else class="text-gray-500 dark:text-gray-400">No content available</div>
</div>
</div>
</div>
</template>

View File

@@ -1,67 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue';
import { UNavigationMenu, UPage } from '#components';
const items = ref([
{
label: 'Guide',
icon: 'i-lucide-book-open',
to: '/getting-started',
children: [
{
label: 'Introduction',
description: 'Fully styled and customizable components for Nuxt.',
icon: 'i-lucide-house',
},
{
label: 'Installation',
description: 'Learn how to install and configure Nuxt UI in your application.',
icon: 'i-lucide-cloud-download',
},
],
},
{
label: 'Components',
icon: 'i-lucide-box',
to: '/components',
active: true,
children: [
{
label: 'Link',
icon: 'i-lucide-file-text',
description: 'Use NuxtLink with superpowers.',
to: '/components/link',
},
{
label: 'Modal',
icon: 'i-lucide-file-text',
description: 'Display a modal within your application.',
to: '/components/modal',
},
],
},
{
label: 'GitHub',
icon: 'i-simple-icons-github',
badge: '3.8k',
to: 'https://github.com/nuxt/ui',
target: '_blank',
},
{
label: 'Help',
icon: 'i-lucide-circle-help',
disabled: true,
},
]);
import { UPage } from '#components';
</script>
<template>
<UPage>
<template #left>
<UNavigationMenu collapsed orientation="vertical" :items="items" />
</template>
<slot />
</UPage>
</template>

View File

@@ -1,22 +1,112 @@
<script setup lang="ts">
import { UButton, UPageHeader } from '#components';
import { definePageMeta } from '#imports';
import Console from '../components/Docker/Console.vue';
import Edit from '../components/Docker/Edit.vue';
import Logs from '../components/Docker/Logs.vue';
import Overview from '../components/Docker/Overview.vue';
import Preview from '../components/Docker/Preview.vue';
import Detail from '../components/LayoutViews/Detail.vue';
definePageMeta({
layout: 'unraid-next',
});
const test = 'Unraid Next Page';
const description = 'This is a sample page using the Unraid Next layout.';
interface ContainerDetails {
network: string;
lanIpPort: string;
containerIp: string;
uptime: string;
containerPort: string;
creationDate: string;
containerId: string;
maintainer: string;
}
const dockerContainers = [
{
id: 'immich',
label: 'immich',
icon: 'i-lucide-play-circle',
},
{
id: 'organizrv2',
label: 'organizrv2',
icon: 'i-lucide-layers',
},
{
id: 'jellyfin',
label: 'Jellyfin',
icon: 'i-lucide-film',
},
{
id: 'mongodb',
label: 'MongoDB',
icon: 'i-lucide-database',
badge: 'DB',
},
{
id: 'postgres17',
label: 'postgres17',
icon: 'i-lucide-database',
badge: 'DB',
},
{
id: 'redis',
label: 'Redis',
icon: 'i-lucide-database',
badge: 'DB',
},
];
const containerDetails: Record<string, ContainerDetails> = {
immich: {
network: 'Bridge',
lanIpPort: '7878',
containerIp: '172.17.0.4',
uptime: '13 hours',
containerPort: '9696:TCP',
creationDate: '2 weeks ago',
containerId: '472b4c2442b9',
maintainer: 'ghcr.io/imagegenius/immich',
},
};
const getTabsWithProps = (containerId: string) => [
{
key: 'overview',
label: 'Overview',
component: Overview,
props: { details: containerDetails[containerId] },
},
{
key: 'logs',
label: 'Logs',
component: Logs,
},
{
key: 'console',
label: 'Console',
component: Console,
},
{
key: 'preview',
label: 'Preview',
component: Preview,
props: { port: containerDetails[containerId]?.lanIpPort || '8080' },
},
{
key: 'edit',
label: 'Edit',
component: Edit,
},
];
const tabs = getTabsWithProps('immich');
</script>
<template>
<div>
<UPageHeader :title="test" :description="description" />
<p class="mb-4">This is a sample page using the Unraid Next layout.</p>
<p class="mb-4">You can customize this page as needed.</p>
<UButton color="primary" />
<div class="h-full">
<Detail :navigation-items="dockerContainers" :tabs="tabs" default-navigation-id="immich" />
</div>
</template>