Files
poshmanager/client/src/views/AssetsView.vue
2026-08-09 17:33:11 -05:00

824 lines
26 KiB
Vue

<template>
<section class="asset-library-page">
<div class="page-header compact-header">
<div>
<span class="eyebrow">ASSET LIBRARY</span>
<h2>Deployment assets</h2>
<p>Store installers, support files, icons, configs, detection scripts, and package content for PSADT, Intune, and general PowerShell automation.</p>
</div>
<div class="page-actions">
<button class="ghost-button compact" type="button" @click="openFolderModal(null)">
<FolderPlus :size="16" />New folder
</button>
<button class="primary-action compact" type="button" @click="openUploadModal">
<Upload :size="16" />Upload asset
</button>
</div>
</div>
<div class="asset-stats-strip">
<article v-for="stat in assetStats" :key="stat.label" class="glass-panel asset-stat-card">
<span><component :is="stat.icon" :size="18" /></span>
<div>
<strong>{{ stat.value }}</strong>
<small>{{ stat.label }}</small>
</div>
</article>
</div>
<div class="asset-library-shell">
<aside class="asset-folder-panel glass-panel">
<header>
<div>
<span class="eyebrow">FOLDERS</span>
<strong>Package tree</strong>
</div>
<button title="New folder" type="button" @click="openFolderModal(null)"><FolderPlus :size="15" /></button>
</header>
<div class="asset-folder-summary">
<span><strong>{{ assets.length }}</strong> assets</span>
<span><strong>{{ folders.length }}</strong> folders</span>
</div>
<AssetTree
:folders="folders"
:assets="assets"
:active-folder-id="activeFolderId"
:open-ids="openFolderIds"
@select="activeFolderId = $event"
@toggle="toggleFolder"
@create-child="openFolderModal(null, $event.id)"
@edit="openFolderModal"
@delete="deleteFolder"
/>
<div v-if="!folders.length" class="asset-tree-empty">
<FolderPlus :size="18" />
<strong>No folders yet</strong>
<button class="ghost-button compact" type="button" @click="openFolderModal(null)">Create folder</button>
</div>
</aside>
<main class="asset-table-region">
<ResourceTable
:columns="assetColumns"
:rows="pagedAssets"
:total="filteredAssets.length"
:search="assetFilters.search"
:page="assetFilters.page"
:page-count="assetPageCount"
:page-size="assetFilters.pageSize"
:sort="assetFilters.sort"
:direction="assetFilters.direction"
search-placeholder="Search assets, package paths, checksum, folders..."
item-label="assets"
empty-text="No assets match the current folder or filters."
@update:search="assetFilters.search = $event"
@update:page="assetFilters.page = $event"
@update:page-size="assetFilters.pageSize = $event"
@sort="setSort($event)"
>
<template #toolbar-actions>
<button class="ghost-button compact" type="button" @click="openFolderModal(null)">
<FolderPlus :size="14" />Folder
</button>
<button class="primary-action compact" type="button" @click="openUploadModal">
<Upload :size="14" />Upload
</button>
</template>
<template #cell-name="{ row }">
<button :class="['asset-row-title', { active: selectedAsset?.id === row.id }]" type="button" @click="selectAsset(row)">
<component :is="assetIcon(row)" :size="16" />
<span>{{ row.name }}</span>
</button>
</template>
<template #cell-kind="{ row }"><span :class="['status-pill', row.kind]">{{ row.kind }}</span></template>
<template #cell-sizeBytes="{ row }">{{ formatBytes(row.sizeBytes) }}</template>
<template #cell-folderName="{ row }">{{ row.folderName || 'Unfiled' }}</template>
<template #cell-packagePath="{ row }"><code>{{ row.packagePath || row.originalName }}</code></template>
<template #cell-actions="{ row }">
<div class="table-action-row">
<button class="ghost-button compact icon-button" type="button" aria-label="View asset" title="View asset" @click="selectAsset(row); viewAsset(row)"><Eye :size="15" /></button>
<button class="ghost-button compact icon-button" type="button" aria-label="Download asset" title="Download asset" @click="downloadAsset(row)"><Download :size="15" /></button>
<button class="ghost-button compact icon-button danger-text" type="button" aria-label="Delete asset" title="Delete asset" @click="deleteAsset(row)"><Trash2 :size="15" /></button>
</div>
</template>
<template #empty>
<section class="asset-empty-state">
<div class="asset-empty-orb"><PackageOpen :size="28" /></div>
<div>
<span class="eyebrow">PACKAGE CONTENT STARTS HERE</span>
<h3>{{ activeFolderId ? 'This folder is empty' : 'No deployment assets yet' }}</h3>
<p>Upload installers, scripts, detection files, icons, archives, configs, or PSADT support content. Assets can be linked into scripts, RunPlans, PSADT profiles, and Intune deployment plans.</p>
</div>
<div class="asset-empty-actions">
<button class="primary-action compact" type="button" @click="openUploadModal">
<Upload :size="15" />Upload asset
</button>
<button class="ghost-button compact" type="button" @click="openFolderModal(null)">
<FolderPlus :size="15" />New folder
</button>
</div>
</section>
</template>
</ResourceTable>
</main>
<AssetPreviewPanel
:asset="selectedAsset"
:preview-url="previewUrl"
:text-preview="textPreview"
@view="viewAsset"
@download="downloadAsset"
@insert-reference="insertReference"
@link="openLinkModal"
@refresh="$emit('refresh')"
/>
</div>
<BaseModal :open="folderModalOpen" :title="folderForm.id ? 'Edit folder' : 'New folder'" @close="folderModalOpen = false">
<form class="modal-form" @submit.prevent="saveFolder">
<label>
<span>Name</span>
<input v-model="folderForm.name" required placeholder="Installers" />
</label>
<label>
<span>Description</span>
<textarea v-model="folderForm.description" rows="3" placeholder="Optional package notes" />
</label>
<label>
<span>Parent folder</span>
<select v-model="folderForm.parentId">
<option :value="null">Library root</option>
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
</select>
</label>
<label>
<span>Visibility</span>
<select v-model="folderForm.visibility">
<option value="personal">Personal</option>
<option value="shared">Shared</option>
<option value="group">Group</option>
</select>
</label>
<label v-if="folderForm.visibility === 'group'">
<span>Group</span>
<select v-model="folderForm.groupId">
<option :value="null">Choose group</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</label>
<button class="primary-action full-width" type="submit">Save folder</button>
</form>
</BaseModal>
<BaseModal :open="uploadModalOpen" title="Upload asset" @close="uploadModalOpen = false">
<form class="modal-form" @submit.prevent="submitUpload">
<label class="file-drop-zone">
<Upload :size="22" />
<span>{{ uploadFile?.name || 'Choose a package asset' }}</span>
<small>Installers, scripts, icons, configs, archives, or support files</small>
<input type="file" @change="handleFileSelect" />
</label>
<label>
<span>Name</span>
<input v-model="assetForm.name" required placeholder="Contoso VPN Installer" />
</label>
<label>
<span>Description</span>
<textarea v-model="assetForm.description" rows="3" placeholder="How this asset is used by scripts or packages" />
</label>
<label>
<span>Folder</span>
<select v-model="assetForm.folderId">
<option :value="null">Unfiled</option>
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
</select>
</label>
<label>
<span>Package path</span>
<input v-model="assetForm.packagePath" placeholder="Files/installer.exe or SupportFiles/config.json" />
</label>
<div class="form-grid two">
<label>
<span>Visibility</span>
<select v-model="assetForm.visibility">
<option value="personal">Personal</option>
<option value="shared">Shared</option>
<option value="group">Group</option>
</select>
</label>
<label v-if="assetForm.visibility === 'group'">
<span>Group</span>
<select v-model="assetForm.groupId">
<option :value="null">Choose group</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</label>
</div>
<button class="primary-action full-width" :disabled="!uploadFile" type="submit">Upload asset</button>
</form>
</BaseModal>
<BaseModal :open="linkModalOpen" title="Link asset to target" @close="linkModalOpen = false">
<form class="modal-form" @submit.prevent="saveLink">
<label>
<span>Target type</span>
<select v-model="linkForm.targetType">
<option value="script">Script</option>
<option value="runplan">RunPlan</option>
<option value="psadt_profile">PSADT profile</option>
<option value="intune_deployment">Intune deployment</option>
</select>
</label>
<label>
<span>Target</span>
<select v-model="linkForm.targetId" required>
<option value="">Choose target</option>
<option v-for="target in linkTargets" :key="target.id" :value="target.id">{{ target.name }}</option>
</select>
</label>
<label>
<span>Role</span>
<select v-model="linkForm.linkRole">
<option value="reference">Reference</option>
<option value="package-file">Package file</option>
<option value="installer">Installer</option>
<option value="support-file">Support file</option>
<option value="icon">Icon</option>
<option value="detection-script">Detection script</option>
</select>
</label>
<label>
<span>Package path override</span>
<input v-model="linkForm.packagePath" placeholder="Files/setup.exe" />
</label>
<button class="primary-action full-width" type="submit">Create link</button>
</form>
</BaseModal>
</section>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { Archive, Download, Eye, FileCog, FileText, FolderPlus, Image, Link2, Package, PackageOpen, ScrollText, Trash2, Upload } from '@lucide/vue';
import AssetPreviewPanel from '../components/assets/AssetPreviewPanel.vue';
import AssetTree from '../components/assets/AssetTree.vue';
import BaseModal from '../components/ui/BaseModal.vue';
import ResourceTable from '../components/ui/ResourceTable.vue';
const props = defineProps({
folders: { type: Array, default: () => [] },
assets: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
scripts: { type: Array, default: () => [] },
runplans: { type: Array, default: () => [] },
psadtProfiles: { type: Array, default: () => [] },
intuneDeployments: { type: Array, default: () => [] },
token: { type: String, default: '' }
});
const emit = defineEmits(['refresh', 'save-folder', 'delete-folder', 'upload-asset', 'delete-asset', 'link-asset', 'insert-reference']);
const assetColumns = [
{ key: 'name', label: 'Asset' },
{ key: 'kind', label: 'Type' },
{ key: 'sizeBytes', label: 'Size' },
{ key: 'folderName', label: 'Folder' },
{ key: 'packagePath', label: 'Package path' },
{ key: 'actions', label: 'Actions', sortable: false, class: 'table-actions-cell' }
];
const activeFolderId = ref(null);
const selectedAssetId = ref('');
const openFolderIds = ref([]);
const folderModalOpen = ref(false);
const uploadModalOpen = ref(false);
const linkModalOpen = ref(false);
const uploadFile = ref(null);
const previewUrl = ref('');
const textPreview = ref('');
const assetFilters = reactive({ search: '', sort: 'name', direction: 'asc', page: 1, pageSize: 8 });
const folderForm = reactive({ id: null, parentId: null, name: '', description: '', visibility: 'personal', groupId: null });
const assetForm = reactive({ folderId: null, name: '', description: '', packagePath: '', visibility: 'personal', groupId: null });
const linkForm = reactive({ assetId: '', targetType: 'script', targetId: '', linkRole: 'reference', packagePath: '' });
const selectedAsset = computed(() => props.assets.find((asset) => asset.id === selectedAssetId.value) || props.assets[0] || null);
const assetStats = computed(() => [
{ label: 'Stored assets', value: props.assets.length, icon: PackageOpen },
{ label: 'Folder groups', value: props.folders.length, icon: FolderPlus },
{ label: 'Package paths', value: props.assets.filter((asset) => asset.packagePath).length, icon: Link2 },
{ label: 'Installers', value: props.assets.filter((asset) => asset.kind === 'installer').length, icon: Package }
]);
const filteredAssets = computed(() => {
const query = assetFilters.search.trim().toLowerCase();
return props.assets
.filter((asset) => !activeFolderId.value || asset.folderId === activeFolderId.value)
.filter((asset) => {
if (!query) return true;
return [asset.name, asset.originalName, asset.kind, asset.folderName, asset.packagePath, asset.checksum, asset.description]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(query);
})
.sort((a, b) => compare(a, b, assetFilters.sort, assetFilters.direction));
});
const assetPageCount = computed(() => Math.max(1, Math.ceil(filteredAssets.value.length / assetFilters.pageSize)));
const pagedAssets = computed(() => {
const page = Math.min(assetFilters.page, assetPageCount.value);
return filteredAssets.value.slice((page - 1) * assetFilters.pageSize, page * assetFilters.pageSize);
});
const linkTargets = computed(() => ({
script: props.scripts,
runplan: props.runplans,
psadt_profile: props.psadtProfiles,
intune_deployment: props.intuneDeployments
}[linkForm.targetType] || []));
watch(() => props.assets, () => {
if (!selectedAssetId.value && props.assets.length) selectedAssetId.value = props.assets[0].id;
}, { immediate: true });
watch(() => [assetFilters.search, assetFilters.sort, assetFilters.direction, assetFilters.pageSize, activeFolderId.value], () => {
assetFilters.page = 1;
});
watch(() => linkForm.targetType, () => {
linkForm.targetId = '';
});
function compare(a, b, key, direction) {
const left = a?.[key] ?? '';
const right = b?.[key] ?? '';
const result = typeof left === 'number' || typeof right === 'number'
? Number(left || 0) - Number(right || 0)
: String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
return direction === 'desc' ? result * -1 : result;
}
function setSort(key) {
if (assetFilters.sort === key) assetFilters.direction = assetFilters.direction === 'asc' ? 'desc' : 'asc';
else {
assetFilters.sort = key;
assetFilters.direction = 'asc';
}
}
function toggleFolder(folderId) {
openFolderIds.value = openFolderIds.value.includes(folderId)
? openFolderIds.value.filter((id) => id !== folderId)
: [...openFolderIds.value, folderId];
}
function openFolderModal(folder = null, parentId = null) {
Object.assign(folderForm, {
id: folder?.id || null,
parentId: folder?.parentId ?? parentId,
name: folder?.name || '',
description: folder?.description || '',
visibility: folder?.visibility || 'personal',
groupId: folder?.groupId || null
});
folderModalOpen.value = true;
}
function saveFolder() {
emit('save-folder', { ...folderForm });
folderModalOpen.value = false;
}
function deleteFolder(folder) {
emit('delete-folder', folder);
}
function openUploadModal() {
uploadFile.value = null;
Object.assign(assetForm, {
folderId: activeFolderId.value,
name: '',
description: '',
packagePath: '',
visibility: 'personal',
groupId: null
});
uploadModalOpen.value = true;
}
function handleFileSelect(event) {
const [file] = event.target.files || [];
uploadFile.value = file || null;
if (!file) return;
assetForm.name = assetForm.name || file.name;
assetForm.packagePath = assetForm.packagePath || file.name;
}
async function submitUpload() {
if (!uploadFile.value) return;
emit('upload-asset', {
...assetForm,
file: uploadFile.value,
originalName: uploadFile.value.name,
mimeType: uploadFile.value.type || 'application/octet-stream'
});
uploadModalOpen.value = false;
}
function selectAsset(asset) {
selectedAssetId.value = asset.id;
clearPreview();
}
async function fetchAssetBlob(asset, route = 'view') {
const response = await fetch(route === 'download' ? asset.downloadUrl : asset.viewUrl, {
headers: props.token ? { Authorization: `Bearer ${props.token}` } : {}
});
if (!response.ok) throw new Error(`Asset request failed: ${response.status}`);
return response.blob();
}
async function viewAsset(asset) {
clearPreview();
const blob = await fetchAssetBlob(asset, 'view');
if (asset.mimeType?.startsWith('text/') || ['script', 'config', 'text'].includes(asset.kind)) {
textPreview.value = await blob.text();
return;
}
previewUrl.value = URL.createObjectURL(blob);
}
async function downloadAsset(asset) {
const blob = await fetchAssetBlob(asset, 'download');
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = asset.originalName || asset.name;
anchor.click();
URL.revokeObjectURL(url);
}
function deleteAsset(asset) {
emit('delete-asset', asset);
if (selectedAssetId.value === asset.id) selectedAssetId.value = '';
}
function openLinkModal(asset) {
Object.assign(linkForm, {
assetId: asset.id,
targetType: 'script',
targetId: '',
linkRole: asset.kind === 'installer' ? 'installer' : 'reference',
packagePath: asset.packagePath || asset.originalName
});
linkModalOpen.value = true;
}
function saveLink() {
emit('link-asset', { ...linkForm });
linkModalOpen.value = false;
}
function insertReference(asset) {
emit('insert-reference', asset);
}
function clearPreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
previewUrl.value = '';
textPreview.value = '';
}
function assetIcon(asset) {
const map = {
archive: Archive,
config: FileCog,
document: FileText,
image: Image,
installer: Package,
script: ScrollText,
text: FileText
};
return map[asset.kind] || PackageOpen;
}
function formatBytes(bytes = 0) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`;
}
</script>
<style scoped>
.asset-library-page {
display: grid;
gap: 12px;
min-width: 0;
}
.asset-library-page :deep(.page-header),
.asset-library-page .page-header {
min-height: 58px;
margin-bottom: 0;
}
.asset-library-page .page-header p {
max-width: 760px;
}
.asset-stats-strip {
display: grid;
grid-template-columns: repeat(4, minmax(140px, 1fr));
gap: 10px;
}
.asset-stat-card {
min-height: 62px;
padding: 11px 12px;
display: flex;
align-items: center;
gap: 12px;
border-radius: 15px;
background:
radial-gradient(circle at 12% 0, rgba(125, 232, 255, .16), transparent 38%),
linear-gradient(145deg, rgba(19, 27, 62, .88), rgba(6, 18, 45, .78));
}
.asset-stat-card > span {
width: 36px;
height: 36px;
flex: 0 0 36px;
display: grid;
place-items: center;
border: 1px solid rgba(125, 232, 255, .2);
border-radius: 12px;
color: #72e8ff;
background: rgba(95, 219, 255, .1);
}
.asset-stat-card strong {
display: block;
color: #fff;
font: 850 21px var(--display-font);
line-height: 1;
}
.asset-stat-card small {
display: block;
margin-top: 5px;
color: rgba(214, 226, 244, .64);
font-size: 10px;
text-transform: uppercase;
letter-spacing: .08em;
}
.asset-library-shell {
display: grid;
grid-template-columns: minmax(220px, 260px) minmax(520px, 1fr) minmax(270px, 320px);
gap: 14px;
align-items: stretch;
min-width: 0;
}
.asset-folder-panel {
min-height: 430px;
padding: 16px;
border-radius: 18px;
background:
radial-gradient(circle at 0 0, rgba(125, 232, 255, .09), transparent 38%),
linear-gradient(145deg, rgba(31, 18, 58, .86), rgba(8, 15, 42, .8));
}
.asset-folder-panel header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 14px;
}
.asset-folder-panel header button {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
color: rgba(255, 255, 255, 0.84);
background: rgba(255, 255, 255, 0.06);
}
.asset-folder-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.asset-folder-summary span {
min-height: 50px;
padding: 10px;
border: 1px solid rgba(125, 232, 255, .11);
border-radius: 13px;
color: rgba(214, 226, 244, .58);
background: rgba(255, 255, 255, .035);
font-size: 10px;
text-transform: uppercase;
letter-spacing: .07em;
}
.asset-folder-summary strong {
display: block;
color: #fff;
font-size: 17px;
line-height: 1.1;
margin-bottom: 4px;
}
.asset-tree-empty {
min-height: 170px;
margin-top: 12px;
padding: 18px;
display: grid;
place-items: center;
align-content: center;
gap: 10px;
border: 1px dashed rgba(125, 232, 255, .18);
border-radius: 16px;
color: rgba(214, 226, 244, .66);
background: rgba(4, 12, 31, .3);
text-align: center;
}
.asset-tree-empty svg {
color: #79eaff;
}
.asset-tree-empty strong {
color: #fff;
font-size: 12px;
}
.asset-table-region {
min-width: 0;
}
.asset-table-region :deep(.resource-table-panel) {
padding: 14px;
border-radius: 18px;
min-height: 430px;
height: 100%;
display: flex;
flex-direction: column;
}
.asset-table-region :deep(.resource-toolbar) {
grid-template-columns: minmax(260px, 1fr) auto auto auto auto;
}
.asset-table-region :deep(.toolbar-count) {
margin-left: 0;
}
.asset-table-region :deep(.resource-empty-shell) {
flex: 1;
min-height: 270px;
}
.asset-table-region :deep(.pagination) {
margin-top: 12px;
}
.asset-row-title {
width: 100%;
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
color: rgba(255, 255, 255, 0.86);
background: transparent;
text-align: left;
font-weight: 750;
}
.asset-row-title span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-row-title.active {
color: #8beaff;
}
.table-action-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.asset-empty-state {
width: min(620px, 100%);
display: grid;
justify-items: center;
gap: 14px;
padding: 28px 20px;
text-align: center;
}
.asset-empty-orb {
width: 66px;
height: 66px;
display: grid;
place-items: center;
border: 1px solid rgba(125, 232, 255, .22);
border-radius: 22px;
color: #79eaff;
background:
radial-gradient(circle at 28% 18%, rgba(255, 255, 255, .2), transparent 24%),
linear-gradient(135deg, rgba(88, 221, 255, .22), rgba(154, 134, 255, .18));
box-shadow: 0 18px 42px rgba(88, 221, 255, .11);
}
.asset-empty-state h3 {
margin: 5px 0 8px;
color: #fff;
font: 800 22px var(--display-font);
letter-spacing: -.03em;
}
.asset-empty-state p {
max-width: 560px;
margin: 0;
color: rgba(214, 226, 244, .72);
font-size: 12px;
line-height: 1.6;
}
.asset-empty-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 9px;
}
.file-drop-zone {
min-height: 128px;
display: grid;
place-items: center;
gap: 6px;
padding: 18px;
border: 1px dashed rgba(125, 232, 255, 0.35);
border-radius: 16px;
color: rgba(255, 255, 255, 0.82);
background: rgba(95, 219, 255, 0.07);
text-align: center;
cursor: pointer;
}
.file-drop-zone small {
color: rgba(255, 255, 255, 0.48);
}
.file-drop-zone input {
display: none;
}
.form-grid.two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 1420px) {
.asset-stats-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.asset-library-shell {
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
}
.asset-library-shell :deep(.asset-preview) {
grid-column: 1 / -1;
}
}
@media (max-width: 860px) {
.asset-stats-strip {
grid-template-columns: 1fr;
}
.asset-library-shell {
grid-template-columns: 1fr;
}
.asset-folder-panel {
min-height: auto;
}
.asset-table-region :deep(.resource-toolbar) {
grid-template-columns: 1fr;
}
}
</style>