mirror of
https://github.com/hay-kot/homebox.git
synced 2025-08-04 08:40:28 +00:00
expanded search for items
This commit is contained in:
parent
1b20a69c5e
commit
9e830610a2
17 changed files with 322 additions and 25 deletions
|
@ -70,6 +70,34 @@ const docTemplate = `{
|
||||||
"Items"
|
"Items"
|
||||||
],
|
],
|
||||||
"summary": "Get All Items",
|
"summary": "Get All Items",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "search string",
|
||||||
|
"name": "q",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"collectionFormat": "multi",
|
||||||
|
"description": "label Ids",
|
||||||
|
"name": "labels",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"collectionFormat": "multi",
|
||||||
|
"description": "location Ids",
|
||||||
|
"name": "locations",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
|
|
|
@ -62,6 +62,34 @@
|
||||||
"Items"
|
"Items"
|
||||||
],
|
],
|
||||||
"summary": "Get All Items",
|
"summary": "Get All Items",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "search string",
|
||||||
|
"name": "q",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"collectionFormat": "multi",
|
||||||
|
"description": "label Ids",
|
||||||
|
"name": "labels",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"collectionFormat": "multi",
|
||||||
|
"description": "location Ids",
|
||||||
|
"name": "locations",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
|
|
|
@ -426,6 +426,25 @@ paths:
|
||||||
- User
|
- User
|
||||||
/v1/items:
|
/v1/items:
|
||||||
get:
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: search string
|
||||||
|
in: query
|
||||||
|
name: q
|
||||||
|
type: string
|
||||||
|
- collectionFormat: multi
|
||||||
|
description: label Ids
|
||||||
|
in: query
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
name: labels
|
||||||
|
type: array
|
||||||
|
- collectionFormat: multi
|
||||||
|
description: location Ids
|
||||||
|
in: query
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
name: locations
|
||||||
|
type: array
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
|
|
|
@ -3,24 +3,51 @@ package v1
|
||||||
import (
|
import (
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/hay-kot/homebox/backend/internal/repo"
|
"github.com/hay-kot/homebox/backend/internal/repo"
|
||||||
"github.com/hay-kot/homebox/backend/internal/services"
|
"github.com/hay-kot/homebox/backend/internal/services"
|
||||||
"github.com/hay-kot/homebox/backend/pkgs/server"
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func uuidList(params url.Values, key string) []uuid.UUID {
|
||||||
|
var ids []uuid.UUID
|
||||||
|
for _, id := range params[key] {
|
||||||
|
uid, err := uuid.Parse(id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, uid)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractQuery(r *http.Request) repo.ItemQuery {
|
||||||
|
params := r.URL.Query()
|
||||||
|
|
||||||
|
return repo.ItemQuery{
|
||||||
|
Search: params.Get("q"),
|
||||||
|
LocationIDs: uuidList(params, "locations"),
|
||||||
|
LabelIDs: uuidList(params, "labels"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleItemsGetAll godoc
|
// HandleItemsGetAll godoc
|
||||||
// @Summary Get All Items
|
// @Summary Get All Items
|
||||||
// @Tags Items
|
// @Tags Items
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} server.Results{items=[]repo.ItemSummary}
|
// @Param q query string false "search string"
|
||||||
|
// @Param labels query []string false "label Ids" collectionFormat(multi)
|
||||||
|
// @Param locations query []string false "location Ids" collectionFormat(multi)
|
||||||
|
// @Success 200 {object} server.Results{items=[]repo.ItemSummary}
|
||||||
// @Router /v1/items [GET]
|
// @Router /v1/items [GET]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleItemsGetAll() http.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemsGetAll() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user := services.UseUserCtx(r.Context())
|
ctx := services.NewContext(r.Context())
|
||||||
items, err := ctrl.svc.Items.GetAll(r.Context(), user.GroupID)
|
items, err := ctrl.svc.Items.Query(ctx, extractQuery(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to get items")
|
log.Err(err).Msg("failed to get items")
|
||||||
server.RespondServerError(w)
|
server.RespondServerError(w)
|
||||||
|
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"github.com/hay-kot/homebox/backend/ent"
|
"github.com/hay-kot/homebox/backend/ent"
|
||||||
"github.com/hay-kot/homebox/backend/ent/group"
|
"github.com/hay-kot/homebox/backend/ent/group"
|
||||||
"github.com/hay-kot/homebox/backend/ent/item"
|
"github.com/hay-kot/homebox/backend/ent/item"
|
||||||
|
"github.com/hay-kot/homebox/backend/ent/label"
|
||||||
|
"github.com/hay-kot/homebox/backend/ent/location"
|
||||||
"github.com/hay-kot/homebox/backend/ent/predicate"
|
"github.com/hay-kot/homebox/backend/ent/predicate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -16,6 +18,12 @@ type ItemsRepository struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
ItemQuery struct {
|
||||||
|
Search string `json:"search"`
|
||||||
|
LocationIDs []uuid.UUID `json:"locationIds"`
|
||||||
|
LabelIDs []uuid.UUID `json:"labelIds"`
|
||||||
|
}
|
||||||
|
|
||||||
ItemCreate struct {
|
ItemCreate struct {
|
||||||
ImportRef string `json:"-"`
|
ImportRef string `json:"-"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
@ -206,6 +214,40 @@ func (e *ItemsRepository) GetOneByGroup(ctx context.Context, gid, id uuid.UUID)
|
||||||
return e.getOne(ctx, item.ID(id), item.HasGroupWith(group.ID(gid)))
|
return e.getOne(ctx, item.ID(id), item.HasGroupWith(group.ID(gid)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryByGroup returns a list of items that belong to a specific group based on the provided query.
|
||||||
|
func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q ItemQuery) ([]ItemSummary, error) {
|
||||||
|
qb := e.db.Item.Query().Where(item.HasGroupWith(group.ID(gid)))
|
||||||
|
|
||||||
|
if len(q.LabelIDs) > 0 {
|
||||||
|
labels := make([]predicate.Item, 0, len(q.LabelIDs))
|
||||||
|
for _, l := range q.LabelIDs {
|
||||||
|
labels = append(labels, item.HasLabelWith(label.ID(l)))
|
||||||
|
}
|
||||||
|
qb = qb.Where(item.Or(labels...))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(q.LocationIDs) > 0 {
|
||||||
|
locations := make([]predicate.Item, 0, len(q.LocationIDs))
|
||||||
|
for _, l := range q.LocationIDs {
|
||||||
|
locations = append(locations, item.HasLocationWith(location.ID(l)))
|
||||||
|
}
|
||||||
|
qb = qb.Where(item.Or(locations...))
|
||||||
|
}
|
||||||
|
|
||||||
|
if q.Search != "" {
|
||||||
|
qb.Where(
|
||||||
|
item.Or(
|
||||||
|
item.NameContainsFold(q.Search),
|
||||||
|
item.DescriptionContainsFold(q.Search),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapItemsSummaryErr(qb.WithLabel().
|
||||||
|
WithLocation().
|
||||||
|
All(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
// GetAll returns all the items in the database with the Labels and Locations eager loaded.
|
// GetAll returns all the items in the database with the Labels and Locations eager loaded.
|
||||||
func (e *ItemsRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]ItemSummary, error) {
|
func (e *ItemsRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]ItemSummary, error) {
|
||||||
return mapItemsSummaryErr(e.db.Item.Query().
|
return mapItemsSummaryErr(e.db.Item.Query().
|
||||||
|
|
|
@ -28,6 +28,10 @@ func (svc *ItemService) GetOne(ctx context.Context, gid uuid.UUID, id uuid.UUID)
|
||||||
return svc.repo.Items.GetOneByGroup(ctx, gid, id)
|
return svc.repo.Items.GetOneByGroup(ctx, gid, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svc *ItemService) Query(ctx Context, q repo.ItemQuery) ([]repo.ItemSummary, error) {
|
||||||
|
return svc.repo.Items.QueryByGroup(ctx, ctx.GID, q)
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *ItemService) GetAll(ctx context.Context, gid uuid.UUID) ([]repo.ItemSummary, error) {
|
func (svc *ItemService) GetAll(ctx context.Context, gid uuid.UUID) ([]repo.ItemSummary, error) {
|
||||||
return svc.repo.Items.GetAll(ctx, gid)
|
return svc.repo.Items.GetAll(ctx, gid)
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,10 @@
|
||||||
name: "Home",
|
name: "Home",
|
||||||
href: "/home",
|
href: "/home",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Items",
|
||||||
|
href: "/items",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Logout",
|
name: "Logout",
|
||||||
action: logout,
|
action: logout,
|
||||||
|
|
|
@ -11,7 +11,8 @@
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content mb-1 menu shadow border border-gray-400 rounded bg-base-100 w-full z-[9999] max-h-60 overflow-y-scroll scroll-bar"
|
style="display: inline"
|
||||||
|
class="dropdown-content mb-1 menu shadow border border-gray-400 rounded bg-base-100 w-full z-[9999] max-h-60 overflow-y-scroll"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="(obj, idx) in items"
|
v-for="(obj, idx) in items"
|
||||||
|
|
|
@ -3,13 +3,13 @@
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">{{ label }}</span>
|
<span class="label-text">{{ label }}</span>
|
||||||
</label>
|
</label>
|
||||||
<input ref="input" v-model="value" :type="type" class="input input-bordered w-full" />
|
<input ref="input" v-model="value" :placeholder="placeholder" :type="type" class="input input-bordered w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">{{ label }}</span>
|
<span class="label-text">{{ label }}</span>
|
||||||
</label>
|
</label>
|
||||||
<input v-model="value" class="input input-bordered col-span-3 w-full mt-2" />
|
<input v-model="value" :placeholder="placeholder" class="input input-bordered col-span-3 w-full mt-2" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -35,6 +35,10 @@
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = ref<HTMLElement | null>(null);
|
const input = ref<HTMLElement | null>(null);
|
||||||
|
|
32
frontend/composables/use-min-loader.ts
Normal file
32
frontend/composables/use-min-loader.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { WritableComputedRef } from "vue";
|
||||||
|
|
||||||
|
export function useMinLoader(ms = 500): WritableComputedRef<boolean> {
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const locked = ref(false);
|
||||||
|
|
||||||
|
const minLoading = computed({
|
||||||
|
get: () => loading.value,
|
||||||
|
set: value => {
|
||||||
|
if (value) {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
if (!locked.value) {
|
||||||
|
locked.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
locked.value = false;
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value && !locked.value) {
|
||||||
|
loading.value = false;
|
||||||
|
} else if (!value && locked.value) {
|
||||||
|
setTimeout(() => {
|
||||||
|
loading.value = false;
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return minLoading;
|
||||||
|
}
|
|
@ -8,12 +8,17 @@ import {
|
||||||
ItemSummary,
|
ItemSummary,
|
||||||
ItemUpdate,
|
ItemUpdate,
|
||||||
} from "../types/data-contracts";
|
} from "../types/data-contracts";
|
||||||
import { AttachmentTypes } from "../types/non-generated";
|
import { AttachmentTypes, Results } from "../types/non-generated";
|
||||||
import { Results } from "./types";
|
|
||||||
|
export type ItemsQuery = {
|
||||||
|
locations?: string[];
|
||||||
|
labels?: string[];
|
||||||
|
q?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export class ItemsApi extends BaseAPI {
|
export class ItemsApi extends BaseAPI {
|
||||||
getAll() {
|
getAll(q: ItemsQuery = {}) {
|
||||||
return this.http.get<Results<ItemSummary>>({ url: route("/items") });
|
return this.http.get<Results<ItemSummary>>({ url: route("/items", q) });
|
||||||
}
|
}
|
||||||
|
|
||||||
create(item: ItemCreate) {
|
create(item: ItemCreate) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { BaseAPI, route } from "../base";
|
import { BaseAPI, route } from "../base";
|
||||||
import { LabelCreate, LabelOut } from "../types/data-contracts";
|
import { LabelCreate, LabelOut } from "../types/data-contracts";
|
||||||
import { Results } from "./types";
|
import { Results } from "../types/non-generated";
|
||||||
|
|
||||||
export class LabelsApi extends BaseAPI {
|
export class LabelsApi extends BaseAPI {
|
||||||
getAll() {
|
getAll() {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { BaseAPI, route } from "../base";
|
import { BaseAPI, route } from "../base";
|
||||||
import { LocationOutCount, LocationCreate, LocationOut } from "../types/data-contracts";
|
import { LocationOutCount, LocationCreate, LocationOut } from "../types/data-contracts";
|
||||||
import { Results } from "./types";
|
import { Results } from "../types/non-generated";
|
||||||
|
|
||||||
export type LocationUpdate = LocationCreate;
|
export type LocationUpdate = LocationCreate;
|
||||||
|
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
export type Results<T> = {
|
|
||||||
items: T[];
|
|
||||||
};
|
|
|
@ -8,3 +8,7 @@ export enum AttachmentTypes {
|
||||||
export type Result<T> = {
|
export type Result<T> = {
|
||||||
item: T;
|
item: T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Results<T> = {
|
||||||
|
items: T[];
|
||||||
|
};
|
||||||
|
|
|
@ -157,13 +157,6 @@
|
||||||
</BaseCard>
|
</BaseCard>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
|
||||||
<BaseSectionHeader class="mb-5"> Labels </BaseSectionHeader>
|
|
||||||
<div class="flex gap-2 flex-wrap">
|
|
||||||
<LabelChip v-for="label in labels" :key="label.id" size="lg" :label="label" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<BaseSectionHeader class="mb-5"> Storage Locations </BaseSectionHeader>
|
<BaseSectionHeader class="mb-5"> Storage Locations </BaseSectionHeader>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 card md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 card md:grid-cols-3 gap-4">
|
||||||
|
@ -172,9 +165,9 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<BaseSectionHeader class="mb-5"> Items </BaseSectionHeader>
|
<BaseSectionHeader class="mb-5"> Labels </BaseSectionHeader>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="flex gap-2 flex-wrap">
|
||||||
<ItemCard v-for="item in items" :key="item.id" :item="item" />
|
<LabelChip v-for="label in labels" :key="label.id" size="lg" :label="label" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</BaseContainer>
|
</BaseContainer>
|
||||||
|
|
109
frontend/pages/items.vue
Normal file
109
frontend/pages/items.vue
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ItemSummary } from "~~/lib/api/types/data-contracts";
|
||||||
|
import { useLabelStore } from "~~/stores/labels";
|
||||||
|
import { useLocationStore } from "~~/stores/locations";
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: "home",
|
||||||
|
});
|
||||||
|
useHead({
|
||||||
|
title: "Homebox | Home",
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = useUserApi();
|
||||||
|
|
||||||
|
const query = ref("");
|
||||||
|
const loading = useMinLoader(2000);
|
||||||
|
const results = ref<ItemSummary[]>([]);
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const locations = selectedLocations.value.map(l => l.id);
|
||||||
|
const labels = selectedLabels.value.map(l => l.id);
|
||||||
|
|
||||||
|
const { data, error } = await api.items.getAll({ q: query.value, locations, labels });
|
||||||
|
if (error) {
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.value = data.items;
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
search();
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationsStore = useLocationStore();
|
||||||
|
const locations = computed(() => locationsStore.locations);
|
||||||
|
|
||||||
|
const labelStore = useLabelStore();
|
||||||
|
const labels = computed(() => labelStore.labels);
|
||||||
|
|
||||||
|
const advanced = ref(false);
|
||||||
|
const selectedLocations = ref([]);
|
||||||
|
const selectedLabels = ref([]);
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (!advanced.value) {
|
||||||
|
selectedLocations.value = [];
|
||||||
|
selectedLabels.value = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watchDebounced(query, search, { debounce: 250, maxWait: 1000 });
|
||||||
|
watchDebounced(selectedLocations, search, { debounce: 250, maxWait: 1000 });
|
||||||
|
watchDebounced(selectedLabels, search, { debounce: 250, maxWait: 1000 });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseContainer class="mb-16">
|
||||||
|
<FormTextField v-model="query" placeholder="Search" />
|
||||||
|
<div class="flex mt-1">
|
||||||
|
<label class="ml-auto label cursor-pointer">
|
||||||
|
<input v-model="advanced" type="checkbox" class="toggle toggle-primary" />
|
||||||
|
<span class="label-text text-neutral-content ml-2"> Filters </span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<BaseCard v-if="advanced" class="my-1 overflow-visible">
|
||||||
|
<template #title> Filters </template>
|
||||||
|
<template #subtitle>
|
||||||
|
Location and label filters use the 'OR' operation. If more than one is selected only one will be required for a
|
||||||
|
match
|
||||||
|
</template>
|
||||||
|
<div class="px-4 pb-4">
|
||||||
|
<FormMultiselect v-model="selectedLabels" label="Labels" :items="labels ?? []" />
|
||||||
|
<FormMultiselect v-model="selectedLocations" label="Labels" :items="locations ?? []" />
|
||||||
|
</div>
|
||||||
|
</BaseCard>
|
||||||
|
<section class="mt-10">
|
||||||
|
<BaseSectionHeader class="mb-5"> Items </BaseSectionHeader>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TransitionGroup name="list">
|
||||||
|
<ItemCard v-for="item in results" :key="item.id" :item="item" />
|
||||||
|
</TransitionGroup>
|
||||||
|
<div class="hidden first:inline text-xl">No Items Found</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</BaseContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css">
|
||||||
|
.list-move,
|
||||||
|
.list-enter-active,
|
||||||
|
.list-leave-active {
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-enter-from,
|
||||||
|
.list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
</style>
|
Loading…
Add table
Add a link
Reference in a new issue