Compare commits

..

No commits in common. "b489b56e6af03833337ff1858f9963085a5bed5e" and "109f7170c72fb002853e0d3622a89ac0736c01b2" have entirely different histories.

12 changed files with 181 additions and 163 deletions

View file

@ -8,7 +8,7 @@
<b-icon-chevron-down v-else></b-icon-chevron-down>
</button>
<span v-else class="tree-view-toggle-spacer"></span>
<div class="tree-view-content text-truncate">
<div class="tree-view-content">
<slot :item="node" :expanded="isExpanded(node)" :has-children="hasChildren(node)"
:depth="depth" :toggle="() => toggleNode(node)"></slot>
</div>
@ -64,6 +64,9 @@
.tree-view-content {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

View file

@ -13,40 +13,45 @@
<div class="upload-section mb-4">
<div class="card">
<div class="card-body">
<drag-drop-file-source v-if="!uploadedFile" @input="handleFilesDropped">
<div class="upload-dropzone text-center py-5">
<b-icon-cloud-upload class="text-primary mb-3" style="font-size: 4rem;"></b-icon-cloud-upload>
<h5 class="mb-3">Upload Your Data File</h5>
<p class="text-muted mb-4">
Drag and drop your CSV or Excel file here, or click to browse
</p>
<div v-if="!uploadedFile" class="upload-dropzone text-center py-5"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleFileDrop"
:class="{ 'dragover': isDragOver }"
@dragenter="isDragOver = true"
@dragleave="isDragOver = false">
<input
type="file"
ref="fileInput"
accept=".csv,.xlsx,.xls"
@change="handleFileSelect"
class="d-none"
/>
<b-icon-cloud-upload class="text-primary mb-3" style="font-size: 4rem;"></b-icon-cloud-upload>
<h5 class="mb-3">Upload Your Data File</h5>
<p class="text-muted mb-4">
Drag and drop your CSV or Excel file here, or click to browse
</p>
<div class="mb-3">
<button class="btn btn-primary btn-lg me-2" @click="$refs.fileInput.click()">
<b-icon-folder-open class="me-2"></b-icon-folder-open>
Choose File
</button>
<button class="btn btn-outline-info" @click="downloadTemplate">
<b-icon-download class="me-2"></b-icon-download>
Download Template
</button>
</div>
<input
type="file"
ref="fileInput"
accept=".csv,.xlsx,.xls"
@change="handleFileSelect"
class="d-none"
/>
<div class="supported-formats">
<small class="text-muted">
Supported formats: CSV (.csv), Excel (.xlsx, .xls)
</small>
</div>
<div class="mb-3">
<button class="btn btn-primary btn-lg me-2" @click="$refs.fileInput.click()">
<b-icon-folder-open class="me-2"></b-icon-folder-open>
Choose File
</button>
<button class="btn btn-outline-info" @click="downloadTemplate">
<b-icon-download class="me-2"></b-icon-download>
Download Template
</button>
</div>
</drag-drop-file-source>
<div class="supported-formats">
<small class="text-muted">
Supported formats: CSV (.csv), Excel (.xlsx, .xls)
</small>
</div>
</div>
<!-- File Info Display -->
<div v-else class="file-info">
@ -259,7 +264,6 @@
<script>
import * as BIcons from "bootstrap-icons-vue";
import { mapActions } from 'vuex';
import DragDropFileSource from "@/components/inputs/DragDropFileSource.vue";
// Implements every step of 'import-items'; steps 2-5,7 use a generic placeholder. See docs/implementation.md#bulk-item-import-workflow.
export default {
@ -295,7 +299,6 @@ export default {
}
},
components: {
DragDropFileSource,
...BIcons
},
props: {
@ -319,6 +322,7 @@ export default {
uploadTime: null,
processingFile: false,
processingStatus: '',
isDragOver: false,
uploadError: null,
fileAnalysis: null,
detectedColumns: [],
@ -368,9 +372,9 @@ export default {
...mapActions(['createInventoryItem']),
// --- Step 1: File upload ---
// DragDropFileSource already hashed/base64-encoded the dropped file to match the
// content-addressed file model - readAsText() below reverses that back to text.
handleFilesDropped(files) {
handleFileDrop(event) {
this.isDragOver = false;
const files = event.dataTransfer.files;
if (files.length > 0) {
this.processFile(files[0]);
}
@ -414,20 +418,10 @@ export default {
}
},
// Accepts either a browser File (from the "Choose File" input) or the hashed
// {name, size, mime_type, data, hash} descriptor DragDropFileSource emits on drop.
async readAsText(file) {
if (typeof file.text === 'function') {
return file.text();
}
const bytes = Uint8Array.from(atob(file.data), c => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
},
async analyzeFile(file) {
// Parsing happens entirely client-side; the backend only ever sees the resulting `items` list.
if (file.name.endsWith('.csv')) {
const text = await this.readAsText(file);
const text = await file.text();
this.parsedRows = this.parseCsv(text);
} else {
// Mock implementation; a real Excel parser would use a library like SheetJS.
@ -625,7 +619,7 @@ export default {
}
.upload-dropzone:hover,
.dragover .upload-dropzone {
.upload-dropzone.dragover {
border-color: #007bff;
background-color: #f8f9ff;
}

View file

@ -56,19 +56,4 @@
padding-left: 1.25rem;
}
}
}
.img-preview-fixed {
height: 160px;
object-fit: cover;
}
.img-preview-fluid {
width: 100%;
height: auto;
object-fit: cover;
}
.table-action {
white-space: nowrap;
}

View file

@ -77,12 +77,4 @@
.input-group-text:not(:last-child) {
border-right-width: 0;
}
.is-invalid input, .is-invalid select {
border: 1px solid var(--bs-danger);
}
.is-invalid .invalid-feedback {
display: block;
}

View file

@ -1,35 +0,0 @@
// Shared by any view rendering a Pinterest-style image grid (Inventory.vue,
// Search.vue, Files.vue) - keeps their column counts and breakpoints in sync.
.masonry-grid {
column-count: 2;
column-gap: 1rem;
}
.masonry-item {
break-inside: avoid;
margin-bottom: 1rem;
}
@include media-breakpoint-up(md) {
.masonry-grid {
column-count: 3;
}
}
@include media-breakpoint-up(lg) {
.masonry-grid {
column-count: 4;
}
}
@include media-breakpoint-up(xl) {
.masonry-grid {
column-count: 6;
}
}
@include media-breakpoint-up(xxl) {
.masonry-grid {
column-count: 12;
}
}

View file

@ -95,7 +95,6 @@ $body-color: $gray-700;
@import "tags";
@import "dropdown";
@import "label-fonts";
@import "masonry";
#root, body, html {
height: 100%;

View file

@ -12,7 +12,7 @@
<div class="masonry-item" v-for="file in only_images(files)" :key="file.id">
<deletable-wrapper confirm-message="Delete this file? This cannot be undone." @delete="deleteFile(file)">
<authenticated-image :src="thumbnailPathForHash(file.hash)" :owner="file.owner"
class="img-preview-fluid"/>
class="img-preview-masonry"/>
</deletable-wrapper>
</div>
</div>
@ -72,9 +72,51 @@ export default {
}
</script>
<style scoped>
<style scoped lang="scss">
@import "../scss/breakpoints";
.img-preview-masonry {
width: 100%;
height: auto;
object-fit: cover;
}
.masonry-item :deep(span) {
display: block !important;
width: 100%;
}
.masonry-grid {
column-count: 2;
column-gap: 1rem;
}
.masonry-item {
break-inside: avoid;
margin-bottom: 1rem;
}
@include media-breakpoint-up(md) {
.masonry-grid {
column-count: 3;
}
}
@include media-breakpoint-up(lg) {
.masonry-grid {
column-count: 4;
}
}
@include media-breakpoint-up(xl) {
.masonry-grid {
column-count: 6;
}
}
@include media-breakpoint-up(xxl) {
.masonry-grid {
column-count: 12;
}
}
</style>

View file

@ -83,7 +83,7 @@
<authenticated-image v-if="only_images(item.files).length > 0"
:src="only_images(item.files)[0].name"
:owner="only_images(item.files)[0].owner"
class="card-img-top img-preview-fluid"/>
class="card-img-top img-preview-masonry"/>
<div class="card-body">
<h5 class="card-title mb-0">
<router-link :to="itemRoute(item)">
@ -127,7 +127,7 @@
<authenticated-image v-if="only_images(item.files).length > 0"
:src="only_images(item.files)[0].name"
:owner="only_images(item.files)[0].owner"
class="card-img-top img-preview-fixed"/>
class="card-img-top img-preview-grid"/>
<div class="card-body d-flex flex-column flex-grow-1">
<h5 class="card-title mb-0">
<router-link :to="itemRoute(item)">
@ -299,7 +299,58 @@ export default {
}
</script>
<style scoped>
<style scoped lang="scss">
@import "../scss/breakpoints";
.img-preview-grid {
height: 160px;
object-fit: cover;
}
.img-preview-masonry {
width: 100%;
height: auto;
object-fit: cover;
}
.table-action {
white-space: nowrap;
}
.masonry-grid {
column-count: 2;
column-gap: 1rem;
}
.masonry-item {
break-inside: avoid;
margin-bottom: 1rem;
}
@include media-breakpoint-up(md) {
.masonry-grid {
column-count: 3;
}
}
@include media-breakpoint-up(lg) {
.masonry-grid {
column-count: 4;
}
}
@include media-breakpoint-up(xl) {
.masonry-grid {
column-count: 6;
}
}
@include media-breakpoint-up(xxl) {
.masonry-grid {
column-count: 12;
}
}
.unguarded.btn-danger,
.unguarded.btn-danger:hover,
.unguarded.btn-danger:focus {

View file

@ -222,6 +222,14 @@ export default {
</script>
<style scoped>
.is-invalid input, .is-invalid select {
border: 1px solid var(--bs-danger);
}
.is-invalid .invalid-feedback {
display: block;
}
.input-group-text svg {
overflow: visible;
}

View file

@ -196,6 +196,14 @@ export default {
</script>
<style scoped>
.is-invalid input, .is-invalid select {
border: 1px solid var(--bs-danger);
}
.is-invalid .invalid-feedback {
display: block;
}
.visually-hidden {
display: none;
}

View file

@ -6,19 +6,14 @@
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
<div class="card-header">
<h5 class="card-title mb-0">Results for "{{ query }}"</h5>
<div class="btn-group">
<button v-if="layout === 'grid'" @click="layout = 'masonry'" class="btn">
<b-icon-columns-gap></b-icon-columns-gap>
</button>
<button v-else-if="layout === 'masonry'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
</button>
</div>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
</button>
</div>
<table class="table table-striped" v-if="layout === 'table'">
<thead>
@ -43,29 +38,6 @@
</tr>
</tbody>
</table>
<div class="card-body" v-else-if="layout === 'masonry'">
<div class="masonry-grid">
<div class="masonry-item" v-for="item in search_results" :key="item.id">
<div class="card">
<authenticated-image v-if="only_images(item.files).length > 0"
:src="only_images(item.files)[0].name"
:owner="only_images(item.files)[0].owner"
class="card-img-top img-preview-fluid"/>
<div class="card-body">
<h5 class="card-title mb-0">
<router-link :to="item.route">
{{ item.name }}
</router-link></h5>
<div class="card-text text-black-50">
<user-name-tag :user="item" :width="24" :height="24"/>
<br>
<span class="float-right">{{ item.owned_quantity }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-body" v-else>
<div class="row">
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in search_results"
@ -74,7 +46,7 @@
<authenticated-image v-if="only_images(item.files).length > 0"
:src="only_images(item.files)[0].name"
:owner="only_images(item.files)[0].owner"
class="card-img-top img-preview-fixed"/>
class="card-img-top img-preview"/>
<div class="card-body">
<h5 class="card-title mb-0">
<router-link :to="item.route">
@ -99,7 +71,7 @@
</template>
<script>
import {mapActions, mapGetters} from 'vuex';
import {mapActions} from 'vuex';
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import SearchBox from "@/components/SearchBox.vue";
@ -124,23 +96,12 @@ export default {
},
data() {
return {
search_results: []
}
},
computed: {
...mapGetters(['getPreference']),
// Implicit preference: remembers the last-used view without a settings-page entry. See docs/implementation.md#preferences.
layout: {
get() {
return this.getPreference('ui.remembered.search_view_mode', 'grid')
},
set(value) {
this.setDevicePreference({key: 'ui.remembered.search_view_mode', value})
}
search_results: [],
layout: 'table'
}
},
methods: {
...mapActions(['fetchSearchResults', 'setDevicePreference']),
...mapActions(['fetchSearchResults']),
only_images(files) {
if (!files) return [];
return files.filter(file => file.mime_type.startsWith("image/"));
@ -169,3 +130,8 @@ export default {
}
</script>
<style scoped>
.img-preview {
object-fit: cover;
}
</style>

View file

@ -312,6 +312,11 @@ export default {
align-items: center;
}
/* The th widths above leave Actions only the leftover 5% - without this it wraps mid-icon. */
.table-action {
white-space: nowrap;
}
.btn-group.mt-auto {
width: 100%;
}