mirror of
https://github.com/hay-kot/homebox.git
synced 2025-08-03 16:20:27 +00:00
attachment download
This commit is contained in:
parent
c61ac295ba
commit
12dee6fcc5
13 changed files with 444 additions and 50 deletions
|
@ -224,7 +224,7 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v1/items/{id}/attachment": {
|
"/v1/items/{id}/attachments": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
|
@ -278,7 +278,44 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v1/items/{id}/attachment/{attachment_id}": {
|
"/v1/items/{id}/attachments/download": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"Bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/octet-stream"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Items"
|
||||||
|
],
|
||||||
|
"summary": "retrieves an attachment for an item",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Item ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Attachment token",
|
||||||
|
"name": "token",
|
||||||
|
"in": "query",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/items/{id}/attachments/{attachment_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
|
@ -310,7 +347,10 @@ const docTemplate = `{
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": ""
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/types.ItemAttachmentToken"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -980,6 +1020,14 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"types.ItemAttachmentToken": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"token": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"types.ItemCreate": {
|
"types.ItemCreate": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
@ -216,7 +216,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v1/items/{id}/attachment": {
|
"/v1/items/{id}/attachments": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
|
@ -270,7 +270,44 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v1/items/{id}/attachment/{attachment_id}": {
|
"/v1/items/{id}/attachments/download": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"Bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/octet-stream"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Items"
|
||||||
|
],
|
||||||
|
"summary": "retrieves an attachment for an item",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Item ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Attachment token",
|
||||||
|
"name": "token",
|
||||||
|
"in": "query",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/items/{id}/attachments/{attachment_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
|
@ -302,7 +339,10 @@
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": ""
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/types.ItemAttachmentToken"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -972,6 +1012,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"types.ItemAttachmentToken": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"token": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"types.ItemCreate": {
|
"types.ItemCreate": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
@ -60,6 +60,11 @@ definitions:
|
||||||
updatedAt:
|
updatedAt:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
types.ItemAttachmentToken:
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
types.ItemCreate:
|
types.ItemCreate:
|
||||||
properties:
|
properties:
|
||||||
description:
|
description:
|
||||||
|
@ -501,7 +506,7 @@ paths:
|
||||||
summary: updates a item
|
summary: updates a item
|
||||||
tags:
|
tags:
|
||||||
- Items
|
- Items
|
||||||
/v1/items/{id}/attachment:
|
/v1/items/{id}/attachments:
|
||||||
post:
|
post:
|
||||||
parameters:
|
parameters:
|
||||||
- description: Item ID
|
- description: Item ID
|
||||||
|
@ -536,7 +541,7 @@ paths:
|
||||||
summary: imports items into the database
|
summary: imports items into the database
|
||||||
tags:
|
tags:
|
||||||
- Items
|
- Items
|
||||||
/v1/items/{id}/attachment/{attachment_id}:
|
/v1/items/{id}/attachments/{attachment_id}:
|
||||||
get:
|
get:
|
||||||
parameters:
|
parameters:
|
||||||
- description: Item ID
|
- description: Item ID
|
||||||
|
@ -551,6 +556,31 @@ paths:
|
||||||
type: string
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/octet-stream
|
- application/octet-stream
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/types.ItemAttachmentToken'
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
summary: retrieves an attachment for an item
|
||||||
|
tags:
|
||||||
|
- Items
|
||||||
|
/v1/items/{id}/attachments/download:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: Item ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Attachment token
|
||||||
|
in: query
|
||||||
|
name: token
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/octet-stream
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: ""
|
description: ""
|
||||||
|
|
|
@ -54,6 +54,10 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
|
||||||
r.Post(v1Base("/users/register"), v1Ctrl.HandleUserRegistration())
|
r.Post(v1Base("/users/register"), v1Ctrl.HandleUserRegistration())
|
||||||
r.Post(v1Base("/users/login"), v1Ctrl.HandleAuthLogin())
|
r.Post(v1Base("/users/login"), v1Ctrl.HandleAuthLogin())
|
||||||
|
|
||||||
|
// Attachment download URl needs a `token` query param to be passed in the request.
|
||||||
|
// and also needs to be outside of the `auth` middleware.
|
||||||
|
r.Get(v1Base("/items/{id}/attachments/download"), v1Ctrl.HandleItemAttachmentDownload())
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(a.mwAuthToken)
|
r.Use(a.mwAuthToken)
|
||||||
r.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf())
|
r.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf())
|
||||||
|
@ -82,8 +86,8 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
|
||||||
r.Put(v1Base("/items/{id}"), v1Ctrl.HandleItemUpdate())
|
r.Put(v1Base("/items/{id}"), v1Ctrl.HandleItemUpdate())
|
||||||
r.Delete(v1Base("/items/{id}"), v1Ctrl.HandleItemDelete())
|
r.Delete(v1Base("/items/{id}"), v1Ctrl.HandleItemDelete())
|
||||||
|
|
||||||
r.Post(v1Base("/items/{id}/attachment"), v1Ctrl.HandleItemAttachmentCreate())
|
r.Post(v1Base("/items/{id}/attachments"), v1Ctrl.HandleItemAttachmentCreate())
|
||||||
r.Get(v1Base("/items/{id}/attachment/{attachment_id}"), v1Ctrl.HandleItemAttachmentGet())
|
r.Get(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentToken())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -205,7 +205,7 @@ func (ctrl *V1Controller) HandleItemsImport() http.HandlerFunc {
|
||||||
// @Param type formData string true "Type of file"
|
// @Param type formData string true "Type of file"
|
||||||
// @Param name formData string true "name of the file including extension"
|
// @Param name formData string true "name of the file including extension"
|
||||||
// @Success 200 {object} types.ItemOut
|
// @Success 200 {object} types.ItemOut
|
||||||
// @Router /v1/items/{id}/attachment [POST]
|
// @Router /v1/items/{id}/attachments [POST]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -262,11 +262,38 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc {
|
||||||
// @Tags Items
|
// @Tags Items
|
||||||
// @Produce application/octet-stream
|
// @Produce application/octet-stream
|
||||||
// @Param id path string true "Item ID"
|
// @Param id path string true "Item ID"
|
||||||
// @Param attachment_id path string true "Attachment ID"
|
// @Param token query string true "Attachment token"
|
||||||
// @Success 200
|
// @Success 200
|
||||||
// @Router /v1/items/{id}/attachment/{attachment_id} [GET]
|
// @Router /v1/items/{id}/attachments/download [GET]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleItemAttachmentGet() http.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemAttachmentDownload() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := server.GetParam(r, "token", "")
|
||||||
|
|
||||||
|
path, err := ctrl.svc.Items.GetAttachment(r.Context(), token)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to get attachment")
|
||||||
|
server.RespondServerError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(path)))
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
http.ServeFile(w, r, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleItemAttachmentToken godocs
|
||||||
|
// @Summary retrieves an attachment for an item
|
||||||
|
// @Tags Items
|
||||||
|
// @Produce application/octet-stream
|
||||||
|
// @Param id path string true "Item ID"
|
||||||
|
// @Param attachment_id path string true "Attachment ID"
|
||||||
|
// @Success 200 {object} types.ItemAttachmentToken
|
||||||
|
// @Router /v1/items/{id}/attachments/{attachment_id} [GET]
|
||||||
|
// @Security Bearer
|
||||||
|
func (ctrl *V1Controller) HandleItemAttachmentToken() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -280,7 +307,7 @@ func (ctrl *V1Controller) HandleItemAttachmentGet() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
path, err := ctrl.svc.Items.GetAttachment(r.Context(), user.GroupID, uid, attachmentId)
|
token, err := ctrl.svc.Items.NewAttachmentToken(r.Context(), user.GroupID, uid, attachmentId)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to get attachment")
|
log.Err(err).Msg("failed to get attachment")
|
||||||
|
@ -288,8 +315,9 @@ func (ctrl *V1Controller) HandleItemAttachmentGet() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(path)))
|
server.Respond(w, http.StatusOK, types.ItemAttachmentToken{
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
Token: token,
|
||||||
http.ServeFile(w, r, path)
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ func NewServices(repos *repo.AllRepos, root string) *AllServices {
|
||||||
Items: &ItemService{
|
Items: &ItemService{
|
||||||
repo: repos,
|
repo: repos,
|
||||||
filepath: root,
|
filepath: root,
|
||||||
|
at: attachmentTokens{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,25 +2,61 @@ package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/hay-kot/homebox/backend/ent/attachment"
|
"github.com/hay-kot/homebox/backend/ent/attachment"
|
||||||
"github.com/hay-kot/homebox/backend/internal/repo"
|
"github.com/hay-kot/homebox/backend/internal/repo"
|
||||||
"github.com/hay-kot/homebox/backend/internal/services/mappers"
|
"github.com/hay-kot/homebox/backend/internal/services/mappers"
|
||||||
"github.com/hay-kot/homebox/backend/internal/types"
|
"github.com/hay-kot/homebox/backend/internal/types"
|
||||||
|
"github.com/hay-kot/homebox/backend/pkgs/hasher"
|
||||||
"github.com/hay-kot/homebox/backend/pkgs/pathlib"
|
"github.com/hay-kot/homebox/backend/pkgs/pathlib"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: this isn't a scalable solution, tokens should be stored in the database
|
||||||
|
type attachmentTokens map[string]uuid.UUID
|
||||||
|
|
||||||
|
func (at attachmentTokens) Add(token string, id uuid.UUID) {
|
||||||
|
at[token] = id
|
||||||
|
|
||||||
|
log.Debug().Str("token", token).Str("uuid", id.String()).Msg("added token")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ch := time.After(1 * time.Minute)
|
||||||
|
<-ch
|
||||||
|
at.Delete(token)
|
||||||
|
log.Debug().Str("token", token).Msg("deleted token")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (at attachmentTokens) Get(token string) (uuid.UUID, bool) {
|
||||||
|
id, ok := at[token]
|
||||||
|
return id, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (at attachmentTokens) Delete(token string) {
|
||||||
|
delete(at, token)
|
||||||
|
}
|
||||||
|
|
||||||
type ItemService struct {
|
type ItemService struct {
|
||||||
repo *repo.AllRepos
|
repo *repo.AllRepos
|
||||||
|
|
||||||
// filepath is the root of the storage location that will be used to store all files from.
|
// filepath is the root of the storage location that will be used to store all files from.
|
||||||
filepath string
|
filepath string
|
||||||
|
|
||||||
|
// at is a map of tokens to attachment IDs. This is used to store the attachment ID
|
||||||
|
// for issued URLs
|
||||||
|
at attachmentTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *ItemService) GetOne(ctx context.Context, gid uuid.UUID, id uuid.UUID) (*types.ItemOut, error) {
|
func (svc *ItemService) GetOne(ctx context.Context, gid uuid.UUID, id uuid.UUID) (*types.ItemOut, error) {
|
||||||
|
@ -100,18 +136,28 @@ func (svc *ItemService) attachmentPath(gid, itemId uuid.UUID, filename string) s
|
||||||
return pathlib.Safe(path)
|
return pathlib.Safe(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *ItemService) GetAttachment(ctx context.Context, gid, itemId, attachmentId uuid.UUID) (string, error) {
|
func (svc *ItemService) NewAttachmentToken(ctx context.Context, gid, itemId, attachmentId uuid.UUID) (string, error) {
|
||||||
// Get the Item
|
|
||||||
item, err := svc.repo.Items.GetOne(ctx, itemId)
|
item, err := svc.repo.Items.GetOne(ctx, itemId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.Edges.Group.ID != gid {
|
if item.Edges.Group.ID != gid {
|
||||||
return "", ErrNotOwner
|
return "", ErrNotOwner
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the attachment
|
token := hasher.GenerateToken()
|
||||||
|
|
||||||
|
svc.at.Add(token.Raw, attachmentId)
|
||||||
|
|
||||||
|
return token.Raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *ItemService) GetAttachment(ctx context.Context, token string) (string, error) {
|
||||||
|
attachmentId, ok := svc.at.Get(token)
|
||||||
|
if !ok {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
attachment, err := svc.repo.Attachments.Get(ctx, attachmentId)
|
attachment, err := svc.repo.Attachments.Get(ctx, attachmentId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
|
@ -106,3 +106,7 @@ type ItemAttachment struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Document DocumentOut `json:"document"`
|
Document DocumentOut `json:"document"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ItemAttachmentToken struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
56
frontend/components/Item/AttachmentsList.vue
Normal file
56
frontend/components/Item/AttachmentsList.vue
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
<template>
|
||||||
|
<ul role="list" class="divide-y divide-gray-400 rounded-md border border-gray-400">
|
||||||
|
<li
|
||||||
|
v-for="attachment in attachments"
|
||||||
|
:key="attachment.id"
|
||||||
|
class="flex items-center justify-between py-3 pl-3 pr-4 text-sm"
|
||||||
|
>
|
||||||
|
<div class="flex w-0 flex-1 items-center">
|
||||||
|
<Icon name="mdi-paperclip" class="h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true" />
|
||||||
|
<span class="ml-2 w-0 flex-1 truncate"> {{ attachment.document.title }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex-shrink-0">
|
||||||
|
<button class="font-medium" @click="getAttachmentUrl(attachment)">Download</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ItemAttachment } from "~~/lib/api/types/data-contracts";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
attachments: {
|
||||||
|
type: Object as () => ItemAttachment[],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
itemId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = useUserApi();
|
||||||
|
const toast = useNotifier();
|
||||||
|
async function getAttachmentUrl(attachment: ItemAttachment) {
|
||||||
|
const url = await api.items.getAttachmentUrl(props.itemId, attachment.id);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
toast.error("Failed to get attachment url");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
window.open(url, "_blank");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.setAttribute("download", attachment.document.title);
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
|
@ -1,8 +1,10 @@
|
||||||
import { BaseAPI, route } from "../base";
|
import { BaseAPI, route } from "../base";
|
||||||
import { parseDate } from "../base/base-api";
|
import { parseDate } from "../base/base-api";
|
||||||
import { ItemCreate, ItemOut, ItemSummary, ItemUpdate } from "../types/data-contracts";
|
import { ItemAttachmentToken, ItemCreate, ItemOut, ItemSummary, ItemUpdate } from "../types/data-contracts";
|
||||||
import { Results } from "./types";
|
import { Results } from "./types";
|
||||||
|
|
||||||
|
export type AttachmentType = "photo" | "manual" | "warranty" | "attachment";
|
||||||
|
|
||||||
export class ItemsApi extends BaseAPI {
|
export class ItemsApi extends BaseAPI {
|
||||||
getAll() {
|
getAll() {
|
||||||
return this.http.get<Results<ItemOut>>({ url: route("/items") });
|
return this.http.get<Results<ItemOut>>({ url: route("/items") });
|
||||||
|
@ -45,6 +47,32 @@ export class ItemsApi extends BaseAPI {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("csv", file);
|
formData.append("csv", file);
|
||||||
|
|
||||||
return this.http.post<FormData, void>({ url: route("/items/import"), data: formData });
|
return this.http.post<FormData, void>({
|
||||||
|
url: route("/items/import"),
|
||||||
|
data: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addAttachment(id: string, file: File, type: AttachmentType) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("type", type);
|
||||||
|
|
||||||
|
return this.http.post<FormData, ItemOut>({
|
||||||
|
url: route(`/items/${id}/attachments`),
|
||||||
|
data: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttachmentUrl(id: string, attachmentId: string): Promise<string> {
|
||||||
|
const payload = await this.http.get<ItemAttachmentToken>({
|
||||||
|
url: route(`/items/${id}/attachments/${attachmentId}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!payload.data) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return route(`/items/${id}/attachments/download`, { token: payload.data.token });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,6 +49,10 @@ export interface ItemAttachment {
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ItemAttachmentToken {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ItemCreate {
|
export interface ItemCreate {
|
||||||
description: string;
|
description: string;
|
||||||
labelIds: string[];
|
labelIds: string[];
|
||||||
|
|
|
@ -97,11 +97,15 @@ export class Requests {
|
||||||
return {} as T;
|
return {} as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.headers.get("Content-Type")?.startsWith("application/json")) {
|
||||||
try {
|
try {
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {} as T;
|
return {} as T;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.body as unknown as T;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ItemAttachment } from "~~/lib/api/types/data-contracts";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "home",
|
layout: "home",
|
||||||
});
|
});
|
||||||
|
@ -23,6 +25,45 @@
|
||||||
refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type FilteredAttachments = {
|
||||||
|
photos: ItemAttachment[];
|
||||||
|
attachments: ItemAttachment[];
|
||||||
|
warranty: ItemAttachment[];
|
||||||
|
manuals: ItemAttachment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachments = computed<FilteredAttachments>(() => {
|
||||||
|
if (!item.value) {
|
||||||
|
return {
|
||||||
|
photos: [],
|
||||||
|
attachments: [],
|
||||||
|
manuals: [],
|
||||||
|
warranty: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.value.attachments.reduce(
|
||||||
|
(acc, attachment) => {
|
||||||
|
if (attachment.type === "photo") {
|
||||||
|
acc.photos.push(attachment);
|
||||||
|
} else if (attachment.type === "warranty") {
|
||||||
|
acc.warranty.push(attachment);
|
||||||
|
} else if (attachment.type === "manual") {
|
||||||
|
acc.manuals.push(attachment);
|
||||||
|
} else {
|
||||||
|
acc.attachments.push(attachment);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
photos: [] as ItemAttachment[],
|
||||||
|
attachments: [] as ItemAttachment[],
|
||||||
|
warranty: [] as ItemAttachment[],
|
||||||
|
manuals: [] as ItemAttachment[],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const itemSummary = computed(() => {
|
const itemSummary = computed(() => {
|
||||||
return {
|
return {
|
||||||
Description: item.value?.description || "",
|
Description: item.value?.description || "",
|
||||||
|
@ -31,10 +72,53 @@
|
||||||
Manufacturer: item.value?.manufacturer || "",
|
Manufacturer: item.value?.manufacturer || "",
|
||||||
Notes: item.value?.notes || "",
|
Notes: item.value?.notes || "",
|
||||||
Insured: item.value?.insured ? "Yes" : "No",
|
Insured: item.value?.insured ? "Yes" : "No",
|
||||||
Attachments: "", // TODO: Attachments
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showAttachments = computed(() => {
|
||||||
|
if (preferences.value?.showEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
attachments.value.photos.length > 0 ||
|
||||||
|
attachments.value.attachments.length > 0 ||
|
||||||
|
attachments.value.warranty.length > 0 ||
|
||||||
|
attachments.value.manuals.length > 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemAttachments = computed(() => {
|
||||||
|
const val: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (preferences.value.showEmpty) {
|
||||||
|
return {
|
||||||
|
Photos: "",
|
||||||
|
Manuals: "",
|
||||||
|
Warranty: "",
|
||||||
|
Attachments: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.value.photos.length > 0) {
|
||||||
|
val.Photos = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.value.manuals.length > 0) {
|
||||||
|
val.Manuals = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.value.warranty.length > 0) {
|
||||||
|
val.Warranty = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.value.attachments.length > 0) {
|
||||||
|
val.Attachments = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
const showWarranty = computed(() => {
|
const showWarranty = computed(() => {
|
||||||
if (preferences.value.showEmpty) {
|
if (preferences.value.showEmpty) {
|
||||||
return true;
|
return true;
|
||||||
|
@ -148,27 +232,36 @@
|
||||||
</template>
|
</template>
|
||||||
</BaseSectionHeader>
|
</BaseSectionHeader>
|
||||||
</template>
|
</template>
|
||||||
|
</BaseDetails>
|
||||||
|
<BaseDetails v-if="showAttachments" :details="itemAttachments">
|
||||||
|
<template #title> Attachments </template>
|
||||||
|
<template #Manuals>
|
||||||
|
<ItemAttachmentsList
|
||||||
|
v-if="attachments.manuals.length > 0"
|
||||||
|
:attachments="attachments.manuals"
|
||||||
|
:item-id="item.id"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<template #Attachments>
|
<template #Attachments>
|
||||||
<ul role="list" class="divide-y divide-gray-400 rounded-md border border-gray-400">
|
<ItemAttachmentsList
|
||||||
<li class="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
|
v-if="attachments.attachments.length > 0"
|
||||||
<div class="flex w-0 flex-1 items-center">
|
:attachments="attachments.attachments"
|
||||||
<Icon name="mdi-paperclip" class="h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true" />
|
:item-id="item.id"
|
||||||
<span class="ml-2 w-0 flex-1 truncate">User Guide.pdf</span>
|
/>
|
||||||
</div>
|
</template>
|
||||||
<div class="ml-4 flex-shrink-0">
|
<template #Warranty>
|
||||||
<a href="#" class="font-medium">Download</a>
|
<ItemAttachmentsList
|
||||||
</div>
|
v-if="attachments.warranty.length > 0"
|
||||||
</li>
|
:attachments="attachments.warranty"
|
||||||
<li class="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
|
:item-id="item.id"
|
||||||
<div class="flex w-0 flex-1 items-center">
|
/>
|
||||||
<Icon name="mdi-paperclip" class="h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true" />
|
</template>
|
||||||
<span class="ml-2 w-0 flex-1 truncate">Purchase Receipts.pdf</span>
|
<template #Photos>
|
||||||
</div>
|
<ItemAttachmentsList
|
||||||
<div class="ml-4 flex-shrink-0">
|
v-if="attachments.photos.length > 0"
|
||||||
<a href="#" class="font-medium">Download</a>
|
:attachments="attachments.photos"
|
||||||
</div>
|
:item-id="item.id"
|
||||||
</li>
|
/>
|
||||||
</ul>
|
|
||||||
</template>
|
</template>
|
||||||
</BaseDetails>
|
</BaseDetails>
|
||||||
<BaseDetails v-if="showPurchase" :details="purchaseDetails">
|
<BaseDetails v-if="showPurchase" :details="purchaseDetails">
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue