Compare commits

..

7 Commits

Author SHA1 Message Date
Hayden
1279028d07 fix build injection 2022-10-13 17:01:18 -08:00
Hayden
84bf67079b feat: show app version (#60)
* add version and build to homepage view

* add version to profile
2022-10-13 16:49:29 -08:00
Hayden
889197994b fix: auto-select bug (#59) 2022-10-13 16:45:18 -08:00
Hayden
ae73b194c4 fix: block self-delete on demo site (#57) 2022-10-13 09:37:29 -08:00
Hayden
30014a77ca feat: expanded search for items (#46)
* expanded search for items

* range domain from email to example

* implement pagination for items
2022-10-12 21:13:07 -08:00
Hayden
1b20a69c5e fix: select-first to update on items change (#45) 2022-10-12 14:07:22 -08:00
Hayden
a8e1d2c447 fix: move datepicker buttons to bottom (#44) 2022-10-12 14:07:11 -08:00
38 changed files with 804 additions and 396 deletions

View File

@@ -60,6 +60,7 @@ jobs:
--tag ghcr.io/hay-kot/homebox:nightly \
--tag ghcr.io/hay-kot/homebox:latest \
--tag ghcr.io/hay-kot/homebox:${{ inputs.tag }} \
--build-arg VERSION=${{ inputs.tag }} \
--build-arg COMMIT=$(git rev-parse HEAD) \
--build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
--platform linux/amd64,linux/arm64,linux/arm/v7 .

View File

@@ -12,6 +12,7 @@ RUN pnpm build
FROM golang:alpine AS builder
ARG BUILD_TIME
ARG COMMIT
ARG VERSION
RUN apk update && \
apk upgrade && \
apk add --update git build-base gcc g++
@@ -22,7 +23,7 @@ RUN go get -d -v ./...
RUN rm -rf ./app/api/public
COPY --from=frontend-builder /app/.output/public ./app/api/public
RUN CGO_ENABLED=1 GOOS=linux go build \
-ldflags "-s -w -X main.Commit=$COMMIT -X main.BuildTime=$BUILD_TIME" \
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
-o /go/bin/api \
-v ./app/api/*.go

View File

@@ -21,7 +21,7 @@ func (a *app) SetupDemo() {
var (
registration = services.UserRegistration{
Email: "demo@email.com",
Email: "demo@example.com",
Name: "Demo",
Password: "demo",
}

View File

@@ -70,26 +70,51 @@ const docTemplate = `{
"Items"
],
"summary": "Get All Items",
"parameters": [
{
"type": "string",
"description": "search string",
"name": "q",
"in": "query"
},
{
"type": "integer",
"description": "page number",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "items per page",
"name": "pageSize",
"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": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Results"
},
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemSummary"
}
}
}
}
]
"$ref": "#/definitions/repo.PaginationResult-repo_ItemSummary"
}
}
}
@@ -153,7 +178,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -254,7 +279,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -354,7 +379,7 @@ const docTemplate = `{
],
"responses": {
"200": {
"description": ""
"description": "OK"
}
}
}
@@ -470,7 +495,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -634,7 +659,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -798,7 +823,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -846,7 +871,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -903,7 +928,7 @@ const docTemplate = `{
"summary": "User Logout",
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -922,7 +947,7 @@ const docTemplate = `{
"summary": "User Token Refresh",
"responses": {
"200": {
"description": ""
"description": "OK"
}
}
}
@@ -949,7 +974,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -1049,7 +1074,7 @@ const docTemplate = `{
"summary": "Deletes the user account",
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -1070,7 +1095,7 @@ const docTemplate = `{
"summary": "Update the current user's password // TODO:",
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -1488,6 +1513,26 @@ const docTemplate = `{
}
}
},
"repo.PaginationResult-repo_ItemSummary": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemSummary"
}
},
"page": {
"type": "integer"
},
"pageSize": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"repo.UserOut": {
"type": "object",
"properties": {
@@ -1541,9 +1586,7 @@ const docTemplate = `{
"server.Results": {
"type": "object",
"properties": {
"items": {
"type": "any"
}
"items": {}
}
},
"server.ValidationError": {

View File

@@ -62,26 +62,51 @@
"Items"
],
"summary": "Get All Items",
"parameters": [
{
"type": "string",
"description": "search string",
"name": "q",
"in": "query"
},
{
"type": "integer",
"description": "page number",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "items per page",
"name": "pageSize",
"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": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Results"
},
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemSummary"
}
}
}
}
]
"$ref": "#/definitions/repo.PaginationResult-repo_ItemSummary"
}
}
}
@@ -145,7 +170,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -246,7 +271,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -346,7 +371,7 @@
],
"responses": {
"200": {
"description": ""
"description": "OK"
}
}
}
@@ -462,7 +487,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -626,7 +651,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -790,7 +815,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -838,7 +863,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -895,7 +920,7 @@
"summary": "User Logout",
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -914,7 +939,7 @@
"summary": "User Token Refresh",
"responses": {
"200": {
"description": ""
"description": "OK"
}
}
}
@@ -941,7 +966,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -1041,7 +1066,7 @@
"summary": "Deletes the user account",
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -1062,7 +1087,7 @@
"summary": "Update the current user's password // TODO:",
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -1480,6 +1505,26 @@
}
}
},
"repo.PaginationResult-repo_ItemSummary": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemSummary"
}
},
"page": {
"type": "integer"
},
"pageSize": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"repo.UserOut": {
"type": "object",
"properties": {
@@ -1533,9 +1578,7 @@
"server.Results": {
"type": "object",
"properties": {
"items": {
"type": "any"
}
"items": {}
}
},
"server.ValidationError": {

View File

@@ -275,6 +275,19 @@ definitions:
updatedAt:
type: string
type: object
repo.PaginationResult-repo_ItemSummary:
properties:
items:
items:
$ref: '#/definitions/repo.ItemSummary'
type: array
page:
type: integer
pageSize:
type: integer
total:
type: integer
type: object
repo.UserOut:
properties:
email:
@@ -310,8 +323,7 @@ definitions:
type: object
server.Results:
properties:
items:
type: any
items: {}
type: object
server.ValidationError:
properties:
@@ -426,20 +438,40 @@ paths:
- User
/v1/items:
get:
parameters:
- description: search string
in: query
name: q
type: string
- description: page number
in: query
name: page
type: integer
- description: items per page
in: query
name: pageSize
type: integer
- 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:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/server.Results'
- properties:
items:
items:
$ref: '#/definitions/repo.ItemSummary'
type: array
type: object
$ref: '#/definitions/repo.PaginationResult-repo_ItemSummary'
security:
- Bearer: []
summary: Get All Items
@@ -477,7 +509,7 @@ paths:
- application/json
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: deletes a item
@@ -583,7 +615,7 @@ paths:
type: string
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: retrieves an attachment for an item
@@ -658,7 +690,7 @@ paths:
- application/octet-stream
responses:
"200":
description: ""
description: OK
security:
- Bearer: []
summary: retrieves an attachment for an item
@@ -676,7 +708,7 @@ paths:
- application/json
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: imports items into the database
@@ -735,7 +767,7 @@ paths:
- application/json
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: deletes a label
@@ -832,7 +864,7 @@ paths:
- application/json
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: deletes a location
@@ -899,7 +931,7 @@ paths:
$ref: '#/definitions/v1.ChangePassword'
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: Updates the users password
@@ -935,7 +967,7 @@ paths:
post:
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: User Logout
@@ -948,7 +980,7 @@ paths:
This does not validate that the user still exists within the database.
responses:
"200":
description: ""
description: OK
security:
- Bearer: []
summary: User Token Refresh
@@ -967,7 +999,7 @@ paths:
- application/json
responses:
"204":
description: ""
description: No Content
summary: Get the current user
tags:
- User
@@ -977,7 +1009,7 @@ paths:
- application/json
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: Deletes the user account
@@ -1032,7 +1064,7 @@ paths:
- application/json
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: 'Update the current user''s password // TODO:'

View File

@@ -20,9 +20,9 @@ import (
)
var (
Version = "0.1.0"
Commit = "HEAD"
BuildTime = "now"
version = "nightly"
commit = "HEAD"
buildTime = "now"
)
// @title Go API Templates

View File

@@ -47,9 +47,9 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode
)
r.Get(v1Base("/status"), v1Ctrl.HandleBase(func() bool { return true }, v1.Build{
Version: Version,
Commit: Commit,
BuildTime: BuildTime,
Version: version,
Commit: commit,
BuildTime: buildTime,
}))
r.Post(v1Base("/users/register"), v1Ctrl.HandleUserRegistration())

View File

@@ -3,30 +3,73 @@ package v1
import (
"encoding/csv"
"net/http"
"net/url"
"strconv"
"github.com/google/uuid"
"github.com/hay-kot/homebox/backend/internal/repo"
"github.com/hay-kot/homebox/backend/internal/services"
"github.com/hay-kot/homebox/backend/pkgs/server"
"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 intOrNegativeOne(s string) int {
i, err := strconv.Atoi(s)
if err != nil {
return -1
}
return i
}
func extractQuery(r *http.Request) repo.ItemQuery {
params := r.URL.Query()
page := intOrNegativeOne(params.Get("page"))
perPage := intOrNegativeOne(params.Get("perPage"))
return repo.ItemQuery{
Page: page,
PageSize: perPage,
Search: params.Get("q"),
LocationIDs: uuidList(params, "locations"),
LabelIDs: uuidList(params, "labels"),
}
}
// HandleItemsGetAll godoc
// @Summary Get All Items
// @Tags Items
// @Produce json
// @Success 200 {object} server.Results{items=[]repo.ItemSummary}
// @Param q query string false "search string"
// @Param page query int false "page number"
// @Param pageSize query int false "items per page"
// @Param labels query []string false "label Ids" collectionFormat(multi)
// @Param locations query []string false "location Ids" collectionFormat(multi)
// @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{}
// @Router /v1/items [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleItemsGetAll() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := services.UseUserCtx(r.Context())
items, err := ctrl.svc.Items.GetAll(r.Context(), user.GroupID)
ctx := services.NewContext(r.Context())
items, err := ctrl.svc.Items.Query(ctx, extractQuery(r))
if err != nil {
log.Err(err).Msg("failed to get items")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusOK, server.Results{Items: items})
server.Respond(w, http.StatusOK, items)
}
}

View File

@@ -110,6 +110,11 @@ func (ctrl *V1Controller) HandleUserUpdatePassword() http.HandlerFunc {
// @Security Bearer
func (ctrl *V1Controller) HandleUserSelfDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if ctrl.isDemo {
server.RespondError(w, http.StatusForbidden, nil)
return
}
actor := services.UseUserCtx(r.Context())
if err := ctrl.svc.User.DeleteSelf(r.Context(), actor.ID); err != nil {
server.RespondError(w, http.StatusInternalServerError, err)

View File

@@ -0,0 +1,12 @@
package repo
type PaginationResult[T any] struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int `json:"total"`
Items []T `json:"items"`
}
func calculateOffset(page, pageSize int) int {
return (page - 1) * pageSize
}

View File

@@ -8,6 +8,8 @@ import (
"github.com/hay-kot/homebox/backend/ent"
"github.com/hay-kot/homebox/backend/ent/group"
"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"
)
@@ -16,6 +18,14 @@ type ItemsRepository struct {
}
type (
ItemQuery struct {
Page int
PageSize int
Search string `json:"search"`
LocationIDs []uuid.UUID `json:"locationIds"`
LabelIDs []uuid.UUID `json:"labelIds"`
}
ItemCreate struct {
ImportRef string `json:"-"`
Name string `json:"name"`
@@ -206,6 +216,64 @@ func (e *ItemsRepository) GetOneByGroup(ctx context.Context, gid, id uuid.UUID)
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) (PaginationResult[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),
),
)
}
if q.Page != -1 || q.PageSize != -1 {
qb = qb.
Offset(calculateOffset(q.Page, q.PageSize)).
Limit(q.PageSize)
}
items, err := mapItemsSummaryErr(
qb.WithLabel().
WithLocation().
All(ctx),
)
if err != nil {
return PaginationResult[ItemSummary]{}, err
}
count, err := qb.Count(ctx)
if err != nil {
return PaginationResult[ItemSummary]{}, err
}
return PaginationResult[ItemSummary]{
Page: q.Page,
PageSize: q.PageSize,
Total: count,
Items: items,
}, nil
}
// 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) {
return mapItemsSummaryErr(e.db.Item.Query().

View File

@@ -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)
}
func (svc *ItemService) Query(ctx Context, q repo.ItemQuery) (repo.PaginationResult[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) {
return svc.repo.Items.GetAll(ctx, gid)
}

View File

@@ -33,7 +33,7 @@ func (f *Faker) Path() string {
}
func (f *Faker) Email() string {
return f.Str(10) + "@email.com"
return f.Str(10) + "@example.com"
}
func (f *Faker) Bool() bool {

View File

@@ -18,6 +18,10 @@
name: "Home",
href: "/home",
},
{
name: "Items",
href: "/items",
},
{
name: "Logout",
action: logout,

View File

@@ -11,7 +11,8 @@
</div>
<ul
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
v-for="(obj, idx) in items"

View File

@@ -42,54 +42,31 @@
default: null,
required: false,
},
selectFirst: {
type: Boolean,
default: false,
},
});
function syncSelect() {
if (!props.modelValue) {
if (props.selectFirst) {
selectedIdx.value = 0;
}
return;
}
// Check if we're already synced
if (props.value) {
if (props.modelValue[props.value] === props.items[selectedIdx.value][props.value]) {
return;
}
} else if (props.modelValue === props.items[selectedIdx.value]) {
return;
}
const selectedIdx = ref(-1);
const internalSelected = useVModel(props, "modelValue", emit);
const idx = props.items.findIndex(item => {
if (props.value) {
return item[props.value] === props.modelValue;
}
return item === props.modelValue;
watch(selectedIdx, newVal => {
internalSelected.value = props.items[newVal];
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function compare(a: any, b: any): boolean {
if (props.value != null) {
return a[props.value] === b[props.value];
}
return a === b;
}
watch(
internalSelected,
() => {
const idx = props.items.findIndex(item => compare(item, internalSelected.value));
selectedIdx.value = idx;
}
watch(
() => props.modelValue,
() => {
syncSelect();
}
);
const selectedIdx = ref(0);
watch(
() => selectedIdx.value,
() => {
if (props.value) {
emit("update:modelValue", props.items[selectedIdx.value][props.value]);
return;
}
emit("update:modelValue", props.items[selectedIdx.value]);
},
{
immediate: true,
}
);
</script>

View File

@@ -3,13 +3,13 @@
<label class="label">
<span class="label-text">{{ label }}</span>
</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 v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
<label class="label">
<span class="label-text">{{ label }}</span>
</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>
</template>
@@ -35,6 +35,10 @@
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: "",
},
});
const input = ref<HTMLElement | null>(null);

View File

@@ -2,7 +2,7 @@
<BaseModal v-model="modal">
<template #title> Create Item </template>
<form @submit.prevent="create">
<FormSelect v-model="form.location" label="Location" :items="locations ?? []" select-first />
<FormSelect v-model="form.location" label="Location" :items="locations ?? []" />
<FormTextField
ref="locationNameRef"
v-model="form.name"

View 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;
}

View File

@@ -8,12 +8,19 @@ import {
ItemSummary,
ItemUpdate,
} from "../types/data-contracts";
import { AttachmentTypes } from "../types/non-generated";
import { Results } from "./types";
import { AttachmentTypes, PaginationResult } from "../types/non-generated";
export type ItemsQuery = {
page?: number;
pageSize?: number;
locations?: string[];
labels?: string[];
q?: string;
};
export class ItemsApi extends BaseAPI {
getAll() {
return this.http.get<Results<ItemSummary>>({ url: route("/items") });
getAll(q: ItemsQuery = {}) {
return this.http.get<PaginationResult<ItemSummary>>({ url: route("/items", q) });
}
create(item: ItemCreate) {

View File

@@ -1,6 +1,6 @@
import { BaseAPI, route } from "../base";
import { LabelCreate, LabelOut } from "../types/data-contracts";
import { Results } from "./types";
import { Results } from "../types/non-generated";
export class LabelsApi extends BaseAPI {
getAll() {

View File

@@ -1,6 +1,6 @@
import { BaseAPI, route } from "../base";
import { LocationOutCount, LocationCreate, LocationOut } from "../types/data-contracts";
import { Results } from "./types";
import { Results } from "../types/non-generated";
export type LocationUpdate = LocationCreate;

View File

@@ -1,3 +0,0 @@
export type Results<T> = {
items: T[];
};

View File

@@ -187,6 +187,7 @@ export interface LocationSummary {
updatedAt: Date;
}
export interface UserOut {
email: string;
groupId: string;

View File

@@ -8,3 +8,14 @@ export enum AttachmentTypes {
export type Result<T> = {
item: T;
};
export type Results<T> = {
items: T[];
};
export interface PaginationResult<T> {
items: T[];
page: number;
pageSize: number;
total: number;
}

View File

@@ -157,13 +157,6 @@
</BaseCard>
</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>
<BaseSectionHeader class="mb-5"> Storage Locations </BaseSectionHeader>
<div class="grid grid-cols-1 sm:grid-cols-2 card md:grid-cols-3 gap-4">
@@ -172,9 +165,9 @@
</section>
<section>
<BaseSectionHeader class="mb-5"> Items </BaseSectionHeader>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ItemCard v-for="item in items" :key="item.id" :item="item" />
<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>
</BaseContainer>

View File

@@ -15,8 +15,7 @@
const { data } = await api.status();
if (data) {
console.log(data);
username.value = "demo@email.com";
username.value = "demo@example.com";
password.value = "demo";
}
return data;
@@ -24,7 +23,7 @@
whenever(status, status => {
if (status?.demo) {
email.value = "demo@email.com";
email.value = "demo@example.com";
loginPassword.value = "demo";
}
});
@@ -198,7 +197,7 @@
</h2>
<template v-if="status && status.demo">
<p class="text-xs italic text-center">This is a demo instance</p>
<p class="text-xs text-center"><b>Email</b> demo@email.com</p>
<p class="text-xs text-center"><b>Email</b> demo@example.com</p>
<p class="text-xs text-center"><b>Password</b> demo</p>
</template>
<FormTextField v-model="email" label="Email" />
@@ -223,6 +222,9 @@
</div>
</div>
</div>
<footer v-if="status" class="absolute text-center w-full bottom-0 pb-4">
<p class="text-center text-sm">Version: {{ status.build.version }} ~ Build: {{ status.build.commit }}</p>
</footer>
</div>
</template>

View File

@@ -30,6 +30,13 @@
return;
}
if (locations) {
const location = locations.value.find(l => l.id === data.location.id);
if (location) {
data.location = location;
}
}
return data;
});
onMounted(() => {
@@ -308,7 +315,7 @@
</template>
</BaseSectionHeader>
<div class="px-5 mb-6 grid md:grid-cols-2 gap-4">
<FormSelect v-model="item.location" label="Location" :items="locations ?? []" select-first />
<FormSelect v-if="item" v-model="item.location" label="Location" :items="locations ?? []" />
<FormMultiselect v-model="item.labels" label="Labels" :items="labels ?? []" />
</div>

109
frontend/pages/items.vue Normal file
View 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>

View File

@@ -10,6 +10,13 @@
title: "Homebox | Profile",
});
const pubApi = usePublicApi();
const { data: status } = useAsyncData(async () => {
const { data } = await pubApi.status();
return data;
});
const { setTheme } = useTheme();
type ThemeOption = {
@@ -341,6 +348,9 @@
</template>
</BaseCard>
</BaseContainer>
<footer v-if="status" class="text-center w-full bottom-0 pb-4">
<p class="text-center text-sm">Version: {{ status.build.version }} ~ Build: {{ status.build.commit }}</p>
</footer>
</div>
</template>

View File

@@ -22,6 +22,7 @@ def date_types(*names: list[str]) -> dict[re.Pattern, str]:
regex_replace: dict[re.Pattern, str] = {
re.compile(r" PaginationResultRepo"): "PaginationResult",
re.compile(r" Repo"): " ",
re.compile(r" Services"): " ",
re.compile(r" V1"): " ",