move to nuxt
|
@ -323,6 +323,39 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/types.LocationSummary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/locations/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Locations"
|
||||
],
|
||||
"summary": "Gets a location and fields",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Location ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
|
@ -331,6 +364,65 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Locations"
|
||||
],
|
||||
"summary": "updates a location",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Location ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/types.LocationOut"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Locations"
|
||||
],
|
||||
"summary": "deletes a location",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Location ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/users/login": {
|
||||
|
@ -993,6 +1085,29 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"types.ItemSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"locationId": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"types.LocationCreate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -1005,6 +1120,35 @@ const docTemplate = `{
|
|||
}
|
||||
},
|
||||
"types.LocationOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"groupId": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/types.ItemSummary"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"types.LocationSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
|
|
|
@ -315,6 +315,39 @@
|
|||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/types.LocationSummary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/locations/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Locations"
|
||||
],
|
||||
"summary": "Gets a location and fields",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Location ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
|
@ -323,6 +356,65 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Locations"
|
||||
],
|
||||
"summary": "updates a location",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Location ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/types.LocationOut"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Locations"
|
||||
],
|
||||
"summary": "deletes a location",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Location ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/users/login": {
|
||||
|
@ -985,6 +1077,29 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"types.ItemSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"locationId": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"types.LocationCreate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -997,6 +1112,35 @@
|
|||
}
|
||||
},
|
||||
"types.LocationOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"groupId": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/types.ItemSummary"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"types.LocationSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
|
|
|
@ -338,6 +338,21 @@ definitions:
|
|||
type: string
|
||||
type: array
|
||||
type: object
|
||||
types.ItemSummary:
|
||||
properties:
|
||||
createdAt:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
locationId:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
updatedAt:
|
||||
type: string
|
||||
type: object
|
||||
types.LocationCreate:
|
||||
properties:
|
||||
description:
|
||||
|
@ -346,6 +361,25 @@ definitions:
|
|||
type: string
|
||||
type: object
|
||||
types.LocationOut:
|
||||
properties:
|
||||
createdAt:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
groupId:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/types.ItemSummary'
|
||||
type: array
|
||||
name:
|
||||
type: string
|
||||
updatedAt:
|
||||
type: string
|
||||
type: object
|
||||
types.LocationSummary:
|
||||
properties:
|
||||
createdAt:
|
||||
type: string
|
||||
|
@ -584,12 +618,68 @@ paths:
|
|||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/types.LocationOut'
|
||||
$ref: '#/definitions/types.LocationSummary'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Create a new location
|
||||
tags:
|
||||
- Locations
|
||||
/v1/locations/{id}:
|
||||
delete:
|
||||
parameters:
|
||||
- description: Location ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: ""
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: deletes a location
|
||||
tags:
|
||||
- Locations
|
||||
get:
|
||||
parameters:
|
||||
- description: Location ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/types.LocationOut'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Gets a location and fields
|
||||
tags:
|
||||
- Locations
|
||||
put:
|
||||
parameters:
|
||||
- description: Location ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/types.LocationOut'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: updates a location
|
||||
tags:
|
||||
- Locations
|
||||
/v1/users/login:
|
||||
post:
|
||||
consumes:
|
||||
|
|
|
@ -52,6 +52,9 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
|
|||
|
||||
r.Get(v1Base("/locations"), v1Handlers.HandleLocationGetAll())
|
||||
r.Post(v1Base("/locations"), v1Handlers.HandleLocationCreate())
|
||||
r.Get(v1Base("/locations/{id}"), v1Handlers.HandleLocationGet())
|
||||
r.Put(v1Base("/locations/{id}"), v1Handlers.HandleLocationUpdate())
|
||||
r.Delete(v1Base("/locations/{id}"), v1Handlers.HandleLocationDelete())
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
|
|
|
@ -3,12 +3,15 @@ package v1
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/hay-kot/content/backend/internal/services"
|
||||
"github.com/hay-kot/content/backend/internal/types"
|
||||
"github.com/hay-kot/content/backend/pkgs/logger"
|
||||
"github.com/hay-kot/content/backend/pkgs/server"
|
||||
)
|
||||
|
||||
// HandleUserSelf godoc
|
||||
// HandleLocationGetAll godoc
|
||||
// @Summary Get All Locations
|
||||
// @Tags Locations
|
||||
// @Produce json
|
||||
|
@ -29,12 +32,12 @@ func (ctrl *V1Controller) HandleLocationGetAll() http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// HandleUserSelf godoc
|
||||
// HandleLocationCreate godoc
|
||||
// @Summary Create a new location
|
||||
// @Tags Locations
|
||||
// @Produce json
|
||||
// @Param payload body types.LocationCreate true "Location Data"
|
||||
// @Success 200 {object} types.LocationOut
|
||||
// @Success 200 {object} types.LocationSummary
|
||||
// @Router /v1/locations [POST]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleLocationCreate() http.HandlerFunc {
|
||||
|
@ -57,3 +60,101 @@ func (ctrl *V1Controller) HandleLocationCreate() http.HandlerFunc {
|
|||
server.Respond(w, http.StatusCreated, location)
|
||||
}
|
||||
}
|
||||
|
||||
func (ctrl *V1Controller) partialParseIdAndUser(w http.ResponseWriter, r *http.Request) (uuid.UUID, *types.UserOut, error) {
|
||||
uid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
ctrl.log.Debug(err.Error(), logger.Props{
|
||||
"details": "failed to convert id to valid UUID",
|
||||
})
|
||||
server.RespondError(w, http.StatusBadRequest, err)
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
|
||||
user := services.UseUserCtx(r.Context())
|
||||
return uid, user, nil
|
||||
}
|
||||
|
||||
// HandleLocationDelete godocs
|
||||
// @Summary deletes a location
|
||||
// @Tags Locations
|
||||
// @Produce json
|
||||
// @Param id path string true "Location ID"
|
||||
// @Success 204
|
||||
// @Router /v1/locations/{id} [DELETE]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleLocationDelete() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = ctrl.svc.Location.Delete(r.Context(), user.GroupID, uid)
|
||||
if err != nil {
|
||||
ctrl.log.Error(err, nil)
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusNoContent, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLocationGet godocs
|
||||
// @Summary Gets a location and fields
|
||||
// @Tags Locations
|
||||
// @Produce json
|
||||
// @Param id path string true "Location ID"
|
||||
// @Success 200 {object} types.LocationOut
|
||||
// @Router /v1/locations/{id} [GET]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleLocationGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
location, err := ctrl.svc.Location.GetOne(r.Context(), user.GroupID, uid)
|
||||
if err != nil {
|
||||
ctrl.log.Error(err, nil)
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusOK, location)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLocationUpdate godocs
|
||||
// @Summary updates a location
|
||||
// @Tags Locations
|
||||
// @Produce json
|
||||
// @Param id path string true "Location ID"
|
||||
// @Success 200 {object} types.LocationOut
|
||||
// @Router /v1/locations/{id} [PUT]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleLocationUpdate() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body := types.LocationUpdate{}
|
||||
if err := server.Decode(r, &body); err != nil {
|
||||
ctrl.log.Error(err, nil)
|
||||
server.RespondError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
body.ID = uid
|
||||
|
||||
result, err := ctrl.svc.Location.Update(r.Context(), user.GroupID, body)
|
||||
if err != nil {
|
||||
ctrl.log.Error(err, nil)
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,11 @@ type EntLocationRepository struct {
|
|||
}
|
||||
|
||||
func (r *EntLocationRepository) Get(ctx context.Context, ID uuid.UUID) (*ent.Location, error) {
|
||||
return r.db.Location.Get(ctx, ID)
|
||||
return r.db.Location.Query().
|
||||
Where(location.ID(ID)).
|
||||
WithGroup().
|
||||
WithItems().
|
||||
Only(ctx)
|
||||
}
|
||||
|
||||
func (r *EntLocationRepository) GetAll(ctx context.Context, groupId uuid.UUID) ([]*ent.Location, error) {
|
||||
|
@ -37,10 +41,16 @@ func (r *EntLocationRepository) Create(ctx context.Context, groupdId uuid.UUID,
|
|||
}
|
||||
|
||||
func (r *EntLocationRepository) Update(ctx context.Context, data types.LocationUpdate) (*ent.Location, error) {
|
||||
return r.db.Location.UpdateOneID(data.ID).
|
||||
_, err := r.db.Location.UpdateOneID(data.ID).
|
||||
SetName(data.Name).
|
||||
SetDescription(data.Description).
|
||||
Save(ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.Get(ctx, data.ID)
|
||||
}
|
||||
|
||||
func (r *EntLocationRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
|
|
9
backend/internal/services/mappers/helpers.go
Normal file
|
@ -0,0 +1,9 @@
|
|||
package mappers
|
||||
|
||||
func MapEach[T any, U any](items []T, fn func(T) U) []U {
|
||||
result := make([]U, len(items))
|
||||
for i, item := range items {
|
||||
result[i] = fn(item)
|
||||
}
|
||||
return result
|
||||
}
|
17
backend/internal/services/mappers/items.go
Normal file
|
@ -0,0 +1,17 @@
|
|||
package mappers
|
||||
|
||||
import (
|
||||
"github.com/hay-kot/content/backend/ent"
|
||||
"github.com/hay-kot/content/backend/internal/types"
|
||||
)
|
||||
|
||||
func ToItemSummary(item *ent.Item) *types.ItemSummary {
|
||||
return &types.ItemSummary{
|
||||
ID: item.ID,
|
||||
LocationID: item.Edges.Location.ID,
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
}
|
||||
}
|
32
backend/internal/services/mappers/locations.go
Normal file
|
@ -0,0 +1,32 @@
|
|||
package mappers
|
||||
|
||||
import (
|
||||
"github.com/hay-kot/content/backend/ent"
|
||||
"github.com/hay-kot/content/backend/internal/types"
|
||||
)
|
||||
|
||||
func ToLocationSummary(location *ent.Location) *types.LocationSummary {
|
||||
return &types.LocationSummary{
|
||||
ID: location.ID,
|
||||
GroupID: location.Edges.Group.ID,
|
||||
Name: location.Name,
|
||||
Description: location.Description,
|
||||
CreatedAt: location.CreatedAt,
|
||||
UpdatedAt: location.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ToLocationSummaryErr(location *ent.Location, err error) (*types.LocationSummary, error) {
|
||||
return ToLocationSummary(location), err
|
||||
}
|
||||
|
||||
func ToLocationOut(location *ent.Location) *types.LocationOut {
|
||||
return &types.LocationOut{
|
||||
LocationSummary: *ToLocationSummary(location),
|
||||
Items: MapEach(location.Edges.Items, ToItemSummary),
|
||||
}
|
||||
}
|
||||
|
||||
func ToLocationOutErr(location *ent.Location, err error) (*types.LocationOut, error) {
|
||||
return ToLocationOut(location), err
|
||||
}
|
|
@ -2,44 +2,75 @@ package services
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hay-kot/content/backend/ent"
|
||||
"github.com/hay-kot/content/backend/internal/repo"
|
||||
"github.com/hay-kot/content/backend/internal/services/mappers"
|
||||
"github.com/hay-kot/content/backend/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotOwner = errors.New("not owner")
|
||||
)
|
||||
|
||||
type LocationService struct {
|
||||
repos *repo.AllRepos
|
||||
}
|
||||
|
||||
func ToLocationOut(location *ent.Location, err error) (*types.LocationOut, error) {
|
||||
return &types.LocationOut{
|
||||
ID: location.ID,
|
||||
GroupID: location.Edges.Group.ID,
|
||||
Name: location.Name,
|
||||
Description: location.Description,
|
||||
CreatedAt: location.CreatedAt,
|
||||
UpdatedAt: location.UpdatedAt,
|
||||
}, err
|
||||
func (svc *LocationService) GetOne(ctx context.Context, groupId uuid.UUID, id uuid.UUID) (*types.LocationOut, error) {
|
||||
location, err := svc.repos.Locations.Get(ctx, id)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (svc *LocationService) Create(ctx context.Context, groupId uuid.UUID, data types.LocationCreate) (*types.LocationOut, error) {
|
||||
location, err := svc.repos.Locations.Create(ctx, groupId, data)
|
||||
return ToLocationOut(location, err)
|
||||
if location.Edges.Group.ID != groupId {
|
||||
return nil, ErrNotOwner
|
||||
}
|
||||
|
||||
func (svc *LocationService) GetAll(ctx context.Context, groupId uuid.UUID) ([]*types.LocationOut, error) {
|
||||
return mappers.ToLocationOut(location), nil
|
||||
}
|
||||
|
||||
func (svc *LocationService) GetAll(ctx context.Context, groupId uuid.UUID) ([]*types.LocationSummary, error) {
|
||||
locations, err := svc.repos.Locations.GetAll(ctx, groupId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
locationsOut := make([]*types.LocationOut, len(locations))
|
||||
locationsOut := make([]*types.LocationSummary, len(locations))
|
||||
for i, location := range locations {
|
||||
locationOut, _ := ToLocationOut(location, nil)
|
||||
locationsOut[i] = locationOut
|
||||
locationsOut[i] = mappers.ToLocationSummary(location)
|
||||
}
|
||||
|
||||
return locationsOut, nil
|
||||
}
|
||||
|
||||
func (svc *LocationService) Create(ctx context.Context, groupId uuid.UUID, data types.LocationCreate) (*types.LocationSummary, error) {
|
||||
location, err := svc.repos.Locations.Create(ctx, groupId, data)
|
||||
return mappers.ToLocationSummaryErr(location, err)
|
||||
}
|
||||
|
||||
func (svc *LocationService) Delete(ctx context.Context, groupId uuid.UUID, id uuid.UUID) error {
|
||||
location, err := svc.repos.Locations.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if location.Edges.Group.ID != groupId {
|
||||
return ErrNotOwner
|
||||
}
|
||||
|
||||
return svc.repos.Locations.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (svc *LocationService) Update(ctx context.Context, groupId uuid.UUID, data types.LocationUpdate) (*types.LocationOut, error) {
|
||||
location, err := svc.repos.Locations.Get(ctx, data.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if location.Edges.Group.ID != groupId {
|
||||
return nil, ErrNotOwner
|
||||
}
|
||||
|
||||
return mappers.ToLocationOutErr(svc.repos.Locations.Update(ctx, data))
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ type LocationUpdate struct {
|
|||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type LocationOut struct {
|
||||
type LocationSummary struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GroupID uuid.UUID `json:"groupId"`
|
||||
Name string `json:"name"`
|
||||
|
@ -25,3 +25,17 @@ type LocationOut struct {
|
|||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ItemSummary struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
LocationID uuid.UUID `json:"locationId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type LocationOut struct {
|
||||
LocationSummary
|
||||
Items []*ItemSummary `json:"items"`
|
||||
}
|
||||
|
|
14
frontend/.gitignore
vendored
|
@ -1,10 +1,8 @@
|
|||
node_modules
|
||||
.DS_Store
|
||||
*.log*
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
.output
|
||||
.env
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.*-debug.log
|
||||
*.log
|
||||
.vercel
|
||||
.vite-ssg-temp
|
||||
.idea
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2021 Christopher Reeve
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -1,137 +1,42 @@
|
|||
<p align='center'>
|
||||
<img src='https://user-images.githubusercontent.com/45350572/138070856-731c849a-466b-41a2-b39d-c5b5e76e94fa.png' alt='Vitailse - Opinionated Vite Starter Template with TailwindCSS' width='300'/>
|
||||
</p>
|
||||
# Nuxt 3 Minimal Starter
|
||||
|
||||
Opinionated Vite starter template with [TailwindCSS](https://tailwindcss.com/)
|
||||
Look at the [nuxt 3 documentation](https://v3.nuxtjs.org) to learn more.
|
||||
|
||||
Inspired by [Vitesse](https://github.com/antfu/vitesse) ❤
|
||||
## Setup
|
||||
|
||||
## Features
|
||||
|
||||
- ⚡️ [Vue 3](https://github.com/vuejs/vue-next), [Vite 2](https://github.com/vitejs/vite), [pnpm](https://pnpm.js.org/), [ESBuild](https://github.com/evanw/esbuild) - born with fastness
|
||||
|
||||
- 🗂 [File based routing](./src/pages)
|
||||
|
||||
- 📦 [Components auto importing](./src/components)
|
||||
|
||||
- 🍍 [State Management via Pinia](https://pinia.esm.dev/)
|
||||
|
||||
- 📑 [Layout system](./src/layouts)
|
||||
|
||||
- 📲 [PWA](https://github.com/antfu/vite-plugin-pwa)
|
||||
|
||||
- 🌍 [I18n ready](./locales)
|
||||
|
||||
- 🎨 [Tailwind CSS](https://tailwindcss.com/) - Rapidly build modern websites without ever leaving your HTML.
|
||||
|
||||
- 😃 [Use icons from any icon sets, with no compromise](https://github.com/antfu/unplugin-icons)
|
||||
|
||||
- 🔥 Use the [new `<script setup>` syntax](https://github.com/vuejs/rfcs/pull/227)
|
||||
|
||||
- 📥 [APIs auto importing](https://github.com/antfu/unplugin-auto-import) - use Composition API and others directly
|
||||
|
||||
- 🖨 Server-side generation (SSG) via [vite-ssg](https://github.com/antfu/vite-ssg)
|
||||
|
||||
- 🦔 Critical CSS via [critters](https://github.com/GoogleChromeLabs/critters)
|
||||
|
||||
- 🦾 TypeScript, of course
|
||||
|
||||
## Pre-packed
|
||||
|
||||
### UI Frameworks
|
||||
|
||||
- [TailwindCSS](https://tailwindcss.com/)
|
||||
- [TailwindCSS Typography](https://github.com/tailwindlabs/tailwindcss-typography)
|
||||
- [TailwindCSS Forms](https://github.com/tailwindlabs/tailwindcss-forms)
|
||||
- [TailwindCSS Aspect Ratio](https://github.com/tailwindlabs/tailwindcss-aspect-ratio)
|
||||
|
||||
### Icons
|
||||
|
||||
- [Iconify](https://iconify.design) - use icons from any icon sets
|
||||
- [`unplugin-icons`](https://github.com/antfu/unplugin-icons) - icons as Vue components
|
||||
|
||||
### Plugins
|
||||
|
||||
- [Vue Router](https://github.com/vuejs/vue-router)
|
||||
- [`vite-plugin-pages`](https://github.com/hannoeru/vite-plugin-pages) - file system based routing
|
||||
- [`vite-plugin-vue-layouts`](https://github.com/JohnCampionJr/vite-plugin-vue-layouts) - layouts for pages
|
||||
- [Pinia](https://pinia.esm.dev) - Intuitive, type safe, light and flexible Store for Vue using the composition api
|
||||
- [`unplugin-vue-components`](https://github.com/antfu/unplugin-vue-components) - components auto import
|
||||
- [`unplugin-auto-import`](https://github.com/antfu/unplugin-auto-import) - Directly use Vue Composition API and others without importing
|
||||
- [VueUse](https://github.com/antfu/vueuse) - collection of useful composition APIs
|
||||
- [`@vueuse/head`](https://github.com/vueuse/head) - manipulate document head reactively
|
||||
- [Vue I18n](https://github.com/intlify/vue-i18n-next) - Internationalization
|
||||
- [`vite-plugin-vue-i18n`](https://github.com/intlify/vite-plugin-vue-i18n) - Vite plugin for Vue I18n
|
||||
- [`vite-plugin-pwa`](https://github.com/antfu/vite-plugin-pwa) - PWA
|
||||
|
||||
### Coding Style
|
||||
|
||||
- Use Composition API with [`<script setup>` SFC syntax](https://github.com/vuejs/rfcs/pull/227)
|
||||
|
||||
### Dev tools
|
||||
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [pnpm](https://pnpm.js.org/) - fast, disk space efficient package manager
|
||||
- [`vite-ssg`](https://github.com/antfu/vite-ssg) - Server-side generation
|
||||
- [critters](https://github.com/GoogleChromeLabs/critters) - Critical CSS
|
||||
- [VS Code Extensions](./.vscode/extensions.json)
|
||||
- [Vite](https://marketplace.visualstudio.com/items?itemName=antfu.vite) - Fire up Vite server automatically
|
||||
- [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) - Vue 3 `<script setup>` IDE support
|
||||
- [Iconify IntelliSense](https://marketplace.visualstudio.com/items?itemName=antfu.iconify) - Icon inline display and autocomplete
|
||||
- [TailwindCSS Intellisense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) - IDE support for Tailwind CSS
|
||||
- [i18n Ally](https://marketplace.visualstudio.com/items?itemName=lokalise.i18n-ally) - All in one i18n support
|
||||
## Try it now!
|
||||
### GitHub Template
|
||||
|
||||
[Create a repo from this template on GitHub](https://github.com/zynth17/vitailse/generate).
|
||||
|
||||
### Clone to local
|
||||
|
||||
If you prefer to do it manually with the cleaner git history
|
||||
Make sure to install the dependencies:
|
||||
|
||||
```bash
|
||||
npx degit zynth17/vitailse my-vitailse-app
|
||||
cd my-vitailse-app
|
||||
pnpm i # If you don't have pnpm installed, run: npm install -g pnpm
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install --shamefully-hoist
|
||||
```
|
||||
|
||||
## Checklist
|
||||
## Development Server
|
||||
|
||||
When you use this template, try follow the checklist to update your info properly
|
||||
|
||||
- [ ] Rename `name` field in `package.json`
|
||||
- [ ] Change the author name in `LICENSE`
|
||||
- [ ] Change the title in `App.vue`
|
||||
- [ ] Change the favicon in `public`
|
||||
- [ ] Remove the `.github` folder which contains the funding info
|
||||
- [ ] Clean up the READMEs and remove routes
|
||||
|
||||
And, enjoy :)
|
||||
|
||||
## Usage
|
||||
|
||||
### Development
|
||||
|
||||
Just run and visit http://localhost:3000
|
||||
Start the development server on http://localhost:3000
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Preview in Https
|
||||
## Production
|
||||
|
||||
Just run and visit https://localhost
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
pnpm build && pnpm run https-preview
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
To build the App, run
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
And you will see the generated file in `dist` that ready to be served.
|
||||
Checkout the [deployment documentation](https://v3.nuxtjs.org/guide/deploy/presets) for more information.
|
||||
|
|
6
frontend/app.vue
Normal file
|
@ -0,0 +1,6 @@
|
|||
<template>
|
||||
<NuxtLayout>
|
||||
<Html lang="en" data-theme="garden" />
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
252
frontend/auto-imports.d.ts
vendored
|
@ -1,252 +0,0 @@
|
|||
// Generated by 'unplugin-auto-import'
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const computedAsync: typeof import('@vueuse/core')['computedAsync']
|
||||
const computedEager: typeof import('@vueuse/core')['computedEager']
|
||||
const computedInject: typeof import('@vueuse/core')['computedInject']
|
||||
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
|
||||
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
|
||||
const controlledRef: typeof import('@vueuse/core')['controlledRef']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createEventHook: typeof import('@vueuse/core')['createEventHook']
|
||||
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
|
||||
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
|
||||
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
|
||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
||||
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
|
||||
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const extendRef: typeof import('@vueuse/core')['extendRef']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isDefined: typeof import('@vueuse/core')['isDefined']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const logicAnd: typeof import('@vueuse/core')['logicAnd']
|
||||
const logicNot: typeof import('@vueuse/core')['logicNot']
|
||||
const logicOr: typeof import('@vueuse/core')['logicOr']
|
||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
|
||||
const onLongPress: typeof import('@vueuse/core')['onLongPress']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactify: typeof import('@vueuse/core')['reactify']
|
||||
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
|
||||
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
|
||||
const reactivePick: typeof import('@vueuse/core')['reactivePick']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
|
||||
const refDebounced: typeof import('@vueuse/core')['refDebounced']
|
||||
const refDefault: typeof import('@vueuse/core')['refDefault']
|
||||
const refThrottled: typeof import('@vueuse/core')['refThrottled']
|
||||
const refWithControl: typeof import('@vueuse/core')['refWithControl']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolveRef: typeof import('@vueuse/core')['resolveRef']
|
||||
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const syncRef: typeof import('@vueuse/core')['syncRef']
|
||||
const syncRefs: typeof import('@vueuse/core')['syncRefs']
|
||||
const templateRef: typeof import('@vueuse/core')['templateRef']
|
||||
const throttledRef: typeof import('@vueuse/core')['throttledRef']
|
||||
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toReactive: typeof import('@vueuse/core')['toReactive']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
|
||||
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
|
||||
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
|
||||
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
|
||||
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const unrefElement: typeof import('@vueuse/core')['unrefElement']
|
||||
const until: typeof import('@vueuse/core')['until']
|
||||
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
|
||||
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
|
||||
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useBase64: typeof import('@vueuse/core')['useBase64']
|
||||
const useBattery: typeof import('@vueuse/core')['useBattery']
|
||||
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
|
||||
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
|
||||
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
|
||||
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
|
||||
const useCached: typeof import('@vueuse/core')['useCached']
|
||||
const useClamp: typeof import('@vueuse/core')['useClamp']
|
||||
const useClipboard: typeof import('@vueuse/core')['useClipboard']
|
||||
const useColorMode: typeof import('@vueuse/core')['useColorMode']
|
||||
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
|
||||
const useCounter: typeof import('@vueuse/core')['useCounter']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVar: typeof import('@vueuse/core')['useCssVar']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
|
||||
const useCycleList: typeof import('@vueuse/core')['useCycleList']
|
||||
const useDark: typeof import('@vueuse/core')['useDark']
|
||||
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
|
||||
const useDebounce: typeof import('@vueuse/core')['useDebounce']
|
||||
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
|
||||
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
|
||||
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
|
||||
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
|
||||
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
|
||||
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
|
||||
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
|
||||
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
|
||||
const useDraggable: typeof import('@vueuse/core')['useDraggable']
|
||||
const useDropZone: typeof import('@vueuse/core')['useDropZone']
|
||||
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
|
||||
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
|
||||
const useElementHover: typeof import('@vueuse/core')['useElementHover']
|
||||
const useElementSize: typeof import('@vueuse/core')['useElementSize']
|
||||
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
|
||||
const useEventBus: typeof import('@vueuse/core')['useEventBus']
|
||||
const useEventListener: typeof import('@vueuse/core')['useEventListener']
|
||||
const useEventSource: typeof import('@vueuse/core')['useEventSource']
|
||||
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
|
||||
const useFavicon: typeof import('@vueuse/core')['useFavicon']
|
||||
const useFetch: typeof import('@vueuse/core')['useFetch']
|
||||
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
|
||||
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
|
||||
const useFocus: typeof import('@vueuse/core')['useFocus']
|
||||
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
|
||||
const useFps: typeof import('@vueuse/core')['useFps']
|
||||
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
||||
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
||||
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
|
||||
const useHead: typeof import('@vueuse/head')['useHead']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
const useIdle: typeof import('@vueuse/core')['useIdle']
|
||||
const useImage: typeof import('@vueuse/core')['useImage']
|
||||
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
|
||||
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
|
||||
const useInterval: typeof import('@vueuse/core')['useInterval']
|
||||
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
|
||||
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
|
||||
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
|
||||
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
|
||||
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
|
||||
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
|
||||
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
|
||||
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
|
||||
const useMemoize: typeof import('@vueuse/core')['useMemoize']
|
||||
const useMemory: typeof import('@vueuse/core')['useMemory']
|
||||
const useMounted: typeof import('@vueuse/core')['useMounted']
|
||||
const useMouse: typeof import('@vueuse/core')['useMouse']
|
||||
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
|
||||
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
|
||||
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
|
||||
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
|
||||
const useNetwork: typeof import('@vueuse/core')['useNetwork']
|
||||
const useNow: typeof import('@vueuse/core')['useNow']
|
||||
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
|
||||
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
|
||||
const useOnline: typeof import('@vueuse/core')['useOnline']
|
||||
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
|
||||
const useParallax: typeof import('@vueuse/core')['useParallax']
|
||||
const usePermission: typeof import('@vueuse/core')['usePermission']
|
||||
const usePointer: typeof import('@vueuse/core')['usePointer']
|
||||
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
|
||||
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
|
||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
||||
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
|
||||
const useRafFn: typeof import('@vueuse/core')['useRafFn']
|
||||
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
|
||||
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
|
||||
const useRoute: typeof import('@vue-router')['useRoute']
|
||||
const useRouter: typeof import('@vue-router')['useRouter']
|
||||
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
|
||||
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
|
||||
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
|
||||
const useScroll: typeof import('@vueuse/core')['useScroll']
|
||||
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
|
||||
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
|
||||
const useShare: typeof import('@vueuse/core')['useShare']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
|
||||
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
|
||||
const useStepper: typeof import('@vueuse/core')['useStepper']
|
||||
const useStorage: typeof import('@vueuse/core')['useStorage']
|
||||
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
|
||||
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
|
||||
const useSwipe: typeof import('@vueuse/core')['useSwipe']
|
||||
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
|
||||
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
|
||||
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
|
||||
const useThrottle: typeof import('@vueuse/core')['useThrottle']
|
||||
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
|
||||
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
|
||||
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
|
||||
const useTimeout: typeof import('@vueuse/core')['useTimeout']
|
||||
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
|
||||
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
|
||||
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
|
||||
const useTitle: typeof import('@vueuse/core')['useTitle']
|
||||
const useToggle: typeof import('@vueuse/core')['useToggle']
|
||||
const useTransition: typeof import('@vueuse/core')['useTransition']
|
||||
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
|
||||
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
|
||||
const useVModel: typeof import('@vueuse/core')['useVModel']
|
||||
const useVModels: typeof import('@vueuse/core')['useVModels']
|
||||
const useVibrate: typeof import('@vueuse/core')['useVibrate']
|
||||
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
|
||||
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
|
||||
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
|
||||
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
|
||||
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
|
||||
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
|
||||
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
|
||||
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
|
||||
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchArray: typeof import('@vueuse/core')['watchArray']
|
||||
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
|
||||
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
|
||||
const watchOnce: typeof import('@vueuse/core')['watchOnce']
|
||||
const watchPausable: typeof import('@vueuse/core')['watchPausable']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
|
||||
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
|
||||
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
|
||||
const whenever: typeof import('@vueuse/core')['whenever']
|
||||
}
|
19
frontend/components.d.ts
vendored
|
@ -1,19 +0,0 @@
|
|||
// generated by unplugin-vue-components
|
||||
// We suggest you to commit this file into source control
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
import '@vue/runtime-core'
|
||||
|
||||
export {}
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
export interface GlobalComponents {
|
||||
AppHeader: typeof import('./src/components/AppHeader.vue')['default']
|
||||
AppToast: typeof import('./src/components/App/Toast.vue')['default']
|
||||
FormTextField: typeof import('./src/components/Form/TextField.vue')['default']
|
||||
'Icon:bx:bxMoon': typeof import('~icons/bx/bx-moon')['default']
|
||||
'Icon:bx:bxsMoon': typeof import('~icons/bx/bxs-moon')['default']
|
||||
'IconAkarIcons:githubFill': typeof import('~icons/akar-icons/github-fill')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
139
frontend/components/App/Header.vue
Normal file
|
@ -0,0 +1,139 @@
|
|||
<script lang="ts" setup>
|
||||
import { useAuthStore } from '~~/stores/auth';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const api = useUserApi();
|
||||
|
||||
async function logout() {
|
||||
const { error } = await authStore.logout(api);
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigateTo('/');
|
||||
}
|
||||
|
||||
const links = [
|
||||
{
|
||||
name: 'Home',
|
||||
href: '/home',
|
||||
},
|
||||
{
|
||||
name: 'Logout',
|
||||
action: logout,
|
||||
last: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropdown = [
|
||||
{
|
||||
name: 'Location',
|
||||
action: () => {
|
||||
modal.value = true;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Item / Asset',
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
name: 'Label',
|
||||
action: () => {},
|
||||
},
|
||||
];
|
||||
|
||||
// ----------------------------
|
||||
// Location Stuff
|
||||
// Should move to own component
|
||||
const locationLoading = ref(false);
|
||||
const locationForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const locationNameRef = ref(null);
|
||||
const triggerFocus = ref(false);
|
||||
const modal = ref(false);
|
||||
|
||||
whenever(
|
||||
() => modal.value,
|
||||
() => {
|
||||
triggerFocus.value = true;
|
||||
}
|
||||
);
|
||||
|
||||
async function createLocation() {
|
||||
locationLoading.value = true;
|
||||
const { data } = await api.locations.create(locationForm);
|
||||
|
||||
if (data) {
|
||||
navigateTo(`/location/${data.id}`);
|
||||
}
|
||||
|
||||
locationLoading.value = false;
|
||||
modal.value = false;
|
||||
locationForm.name = '';
|
||||
locationForm.description = '';
|
||||
triggerFocus.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalConfirm />
|
||||
<BaseModal v-model="modal">
|
||||
<template #title> Create Location </template>
|
||||
<form @submit.prevent="createLocation">
|
||||
<FormTextField
|
||||
:trigger-focus="triggerFocus"
|
||||
ref="locationNameRef"
|
||||
:autofocus="true"
|
||||
label="Location Name"
|
||||
v-model="locationForm.name"
|
||||
/>
|
||||
<FormTextField label="Location Description" v-model="locationForm.description" />
|
||||
<div class="modal-action">
|
||||
<BaseButton type="submit" :loading="locationLoading"> Create </BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
<BaseContainer is="header" class="py-6">
|
||||
<h2 class="mt-1 text-4xl font-bold tracking-tight text-base-content sm:text-5xl lg:text-6xl">Homebox</h2>
|
||||
<div class="ml-1 mt-2 text-lg text-base-content/50 space-x-2">
|
||||
<template v-for="link in links">
|
||||
<NuxtLink
|
||||
v-if="!link.action"
|
||||
class="hover:text-base-content transition-color duration-200 italic"
|
||||
:to="link.href"
|
||||
>
|
||||
{{ link.name }}
|
||||
</NuxtLink>
|
||||
<button
|
||||
for="location-form-modal"
|
||||
v-else
|
||||
@click="link.action"
|
||||
class="hover:text-base-content transition-color duration-200 italic"
|
||||
>
|
||||
{{ link.name }}
|
||||
</button>
|
||||
<span v-if="!link.last"> / </span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex mt-6">
|
||||
<div class="dropdown">
|
||||
<label tabindex="0" class="btn btn-sm">
|
||||
<span>
|
||||
<Icon name="mdi-plus" class="w-5 h-5 mr-2" />
|
||||
</span>
|
||||
Create
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li v-for="btn in dropdown">
|
||||
<button @click="btn.action">
|
||||
{{ btn.name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BaseContainer>
|
||||
</template>
|
58
frontend/components/App/Toast.vue
Normal file
|
@ -0,0 +1,58 @@
|
|||
<template>
|
||||
<div class="force-above fixed top-2 right-2 w-[300px]">
|
||||
<TransitionGroup name="notify" tag="div">
|
||||
<div
|
||||
v-for="(notify, index) in notifications.slice(0, 4)"
|
||||
:key="notify.id"
|
||||
class="my-2 w-[300px] rounded-md p-3 text-sm text-white opacity-75"
|
||||
:class="{
|
||||
'bg-primary': notify.type === 'info',
|
||||
'bg-red-600': notify.type === 'error',
|
||||
'bg-green-600': notify.type === 'success',
|
||||
}"
|
||||
@click="dropNotification(index)"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<template v-if="notify.type == 'success'">
|
||||
<Icon name="heroicons-check" class="h-5 w-5" />
|
||||
</template>
|
||||
<template v-if="notify.type == 'info'">
|
||||
<Icon name="heroicons-information-circle" class="h-5 w-5" />
|
||||
</template>
|
||||
|
||||
<template v-if="notify.type == 'error'">
|
||||
<Icon name="heroicons-bell-alert" class="h-5 w-5" />
|
||||
</template>
|
||||
{{ notify.message }}
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useNotifications } from '@/composables/use-notifier';
|
||||
|
||||
const { notifications, dropNotification } = useNotifications();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.force-above {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.notify-move,
|
||||
.notify-enter-active,
|
||||
.notify-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
.notify-enter-from,
|
||||
.notify-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
.notify-leave-active {
|
||||
position: absolute;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
</style>
|
21
frontend/components/Base/ActionsDivider.vue
Normal file
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t border-primary" />
|
||||
</div>
|
||||
<div class="relative flex justify-center">
|
||||
<span class="isolate inline-flex -space-x-px rounded-md shadow-sm">
|
||||
<div class="btn-group">
|
||||
<button @click="$emit('edit')" name="options" class="btn btn-sm btn-primary">
|
||||
<Icon name="heroicons-pencil" class="h-5 w-5 mr-1" aria-hidden="true" />
|
||||
<span> Edit </span>
|
||||
</button>
|
||||
<button @click="$emit('delete')" name="options" class="btn btn-sm btn-primary">
|
||||
<Icon name="heroicons-trash" class="h-5 w-5 mr-1" aria-hidden="true" />
|
||||
<span> Delete </span>
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
24
frontend/components/Base/Button.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<button
|
||||
:disabled="disabled || loading"
|
||||
class="btn"
|
||||
:class="{
|
||||
loading: loading,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
14
frontend/components/Base/Container.vue
Normal file
|
@ -0,0 +1,14 @@
|
|||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
is: {
|
||||
type: String,
|
||||
default: 'div',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="is" class="container max-w-6xl mx-auto px-4">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
37
frontend/components/Base/Details.vue
Normal file
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<div class="overflow-hidden card bg-base-100 shadow-xl sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6 bg-neutral">
|
||||
<h3 class="text-lg font-medium leading-6 text-neutral-content">
|
||||
<slot name="title"></slot>
|
||||
</h3>
|
||||
<p v-if="$slots.subtitle" class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
<slot name="subtitle"></slot>
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-300 px-4 py-5 sm:p-0">
|
||||
<dl class="sm:divide-y sm:divide-gray-300">
|
||||
<div v-for="(dValue, dKey) in details" class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">
|
||||
{{ dKey }}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||
{{ dValue }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type StringLike = string | number | boolean;
|
||||
|
||||
defineProps({
|
||||
details: {
|
||||
type: Object as () => Record<string, StringLike>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
45
frontend/components/Base/Modal.vue
Normal file
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<div class="z-[9999]">
|
||||
<input type="checkbox" :id="modalId" class="modal-toggle" v-model="modal" />
|
||||
<div class="modal">
|
||||
<div class="modal-box relative">
|
||||
<button @click="close" :for="modalId" class="btn btn-sm btn-circle absolute right-2 top-2">✕</button>
|
||||
|
||||
<h3 class="font-bold text-lg">
|
||||
<slot name="title"></slot>
|
||||
</h3>
|
||||
<slot> </slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits(['cancel', 'update:modelValue']);
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
/**
|
||||
* in readonly mode the modal only `emits` a "cancel" event to indicate
|
||||
* that the modal was closed via the "x" button. The parent component is
|
||||
* responsible for closing the modal.
|
||||
*/
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
function close() {
|
||||
if (props.readonly) {
|
||||
emit('cancel');
|
||||
return;
|
||||
}
|
||||
modal.value = false;
|
||||
}
|
||||
|
||||
const modalId = useId();
|
||||
const modal = useVModel(props, 'modelValue', emit);
|
||||
</script>
|
10
frontend/components/Base/SectionHeader.vue
Normal file
|
@ -0,0 +1,10 @@
|
|||
<template>
|
||||
<div class="border-b border-base-200 pb-3">
|
||||
<h3 class="text-xl font-medium leading-4 text-base-content">
|
||||
<slot />
|
||||
</h3>
|
||||
<p v-if="$slots.description" class="mt-2 max-w-4xl text-sm text-gray-500">
|
||||
<slot name="description" />
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
42
frontend/components/Form/TextField.vue
Normal file
|
@ -0,0 +1,42 @@
|
|||
<template>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
</label>
|
||||
<input ref="input" :type="type" v-model="value" class="input input-bordered w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
triggerFocus: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const input = ref<HTMLElement | null>(null);
|
||||
|
||||
whenever(
|
||||
() => props.triggerFocus,
|
||||
() => {
|
||||
if (input.value) {
|
||||
input.value.focus();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const value = useVModel(props, 'modelValue');
|
||||
</script>
|
31
frontend/components/Icon.vue
Normal file
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
import type { Ref } from 'vue';
|
||||
import type { IconifyIcon } from '@iconify/vue';
|
||||
import { Icon as Iconify, loadIcon } from '@iconify/vue';
|
||||
|
||||
const nuxtApp = useNuxtApp();
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const icon: Ref<IconifyIcon | null> = ref(null);
|
||||
const component = computed(() => nuxtApp.vueApp.component(props.name));
|
||||
|
||||
icon.value = await loadIcon(props.name).catch(_ => null);
|
||||
|
||||
watch(
|
||||
() => props.name,
|
||||
async () => {
|
||||
icon.value = await loadIcon(props.name).catch(_ => null);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Iconify v-if="icon" :icon="icon" class="inline-block w-5 h-5" />
|
||||
<Component :is="component" v-else-if="component" />
|
||||
<span v-else>{{ name }}</span>
|
||||
</template>
|
15
frontend/components/ModalConfirm.vue
Normal file
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<BaseModal @cancel="cancel(false)" v-model="isRevealed" readonly>
|
||||
<template #title> Confirm </template>
|
||||
<div>
|
||||
<p>{{ text }}</p>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<BaseButton type="submit" @click="confirm(true)"> Confirm </BaseButton>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { text, isRevealed, confirm, cancel } = useConfirm();
|
||||
</script>
|
23
frontend/composables/use-api.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { PublicApi } from "~~/lib/api/public";
|
||||
import { UserApi } from "~~/lib/api/user";
|
||||
import { Requests } from "~~/lib/requests";
|
||||
import { useAuthStore } from "~~/stores/auth";
|
||||
|
||||
async function ApiDebugger(r: Response) {
|
||||
console.table({
|
||||
"Request Url": r.url,
|
||||
"Response Status": r.status,
|
||||
"Response Status Text": r.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublicApi(): PublicApi {
|
||||
const requests = new Requests("", "", {}, ApiDebugger);
|
||||
return new PublicApi(requests);
|
||||
}
|
||||
|
||||
export function useUserApi(): UserApi {
|
||||
const authStore = useAuthStore();
|
||||
const requests = new Requests("", () => authStore.token, {}, ApiDebugger);
|
||||
return new UserApi(requests);
|
||||
}
|
40
frontend/composables/use-confirm.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { UseConfirmDialogReturn } from '@vueuse/core';
|
||||
import { Ref } from 'vue';
|
||||
|
||||
type Store = UseConfirmDialogReturn<any, Boolean, Boolean> & {
|
||||
text: Ref<string>;
|
||||
setup: boolean;
|
||||
};
|
||||
|
||||
const store: Partial<Store> = {
|
||||
text: ref('Are you sure you want to delete this item? '),
|
||||
setup: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* This function is used to wrap the ModalConfirmation which is a "Singleton" component
|
||||
* that is used to confirm actions. It's mounded once on the root of the page and reused
|
||||
* for every confirmation action that is required.
|
||||
*
|
||||
* This is in an experimental phase of development and may have unknown or unexpected side effects.
|
||||
*/
|
||||
export function useConfirm(): Store {
|
||||
if (!store.setup) {
|
||||
store.setup = true;
|
||||
const { isRevealed, reveal, confirm, cancel } = useConfirmDialog<any, Boolean, Boolean>();
|
||||
store.isRevealed = isRevealed;
|
||||
store.reveal = reveal;
|
||||
store.confirm = confirm;
|
||||
store.cancel = cancel;
|
||||
}
|
||||
|
||||
async function openDialog(msg: string) {
|
||||
store.text.value = msg;
|
||||
return await store.reveal();
|
||||
}
|
||||
|
||||
return {
|
||||
...(store as Store),
|
||||
reveal: openDialog,
|
||||
};
|
||||
}
|
23
frontend/composables/use-preferences.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { Ref } from 'vue';
|
||||
|
||||
export type LocationViewPreferences = {
|
||||
showDetails: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* useLocationViewPreferences loads the view preferences from local storage and hydrates
|
||||
* them. These are reactive and will update the local storage when changed.
|
||||
*/
|
||||
export function useLocationViewPreferences(): Ref<LocationViewPreferences> {
|
||||
const results = useLocalStorage(
|
||||
'homebox/preferences/location',
|
||||
{
|
||||
showDetails: true,
|
||||
},
|
||||
{ mergeDefaults: true }
|
||||
);
|
||||
|
||||
// casting is required because the type returned is removable, however since we
|
||||
// use `mergeDefaults` the result _should_ always be present.
|
||||
return results as unknown as Ref<LocationViewPreferences>;
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="garden">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vitailse | Opinionated vite starter template with TailwindCSS</title>
|
||||
|
||||
<meta name="description" content="Opinionated vite starter template with TailwindCSS" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
<link rel="alternate icon" href="/favicon.ico" type="image/png" sizes="16x16" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
|
||||
<link rel="mask-icon" href="/favicon.png" color="#076AE0" />
|
||||
<meta name="theme-color" content="#076AE0" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
5
frontend/layouts/404.vue
Normal file
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<main class="w-full min-h-screen bg-blue-100 grid place-items-center">
|
||||
<slot></slot>
|
||||
</main>
|
||||
</template>
|
10
frontend/layouts/default.vue
Normal file
|
@ -0,0 +1,10 @@
|
|||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<div>
|
||||
<AppToast />
|
||||
<AppHeader />
|
||||
<main class="p-8 dark:bg-gray-800 dark:text-white bg-white text-gray-800 min-h-screen">
|
||||
<slot></slot>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
7
frontend/layouts/empty.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<div>
|
||||
<AppToast />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
9
frontend/layouts/home.vue
Normal file
|
@ -0,0 +1,9 @@
|
|||
<template>
|
||||
<div>
|
||||
<AppToast />
|
||||
<AppHeader />
|
||||
<main>
|
||||
<slot></slot>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
|
@ -1,4 +1,4 @@
|
|||
import { Requests } from '../../lib/requests';
|
||||
import { Requests } from '../../requests';
|
||||
// <
|
||||
// TGetResult,
|
||||
// TPostData,
|
|
@ -13,6 +13,8 @@ export type Location = LocationCreate & {
|
|||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type LocationUpdate = LocationCreate;
|
||||
|
||||
export class LocationsApi extends BaseAPI {
|
||||
async getAll() {
|
||||
return this.http.get<Results<Location>>(UrlBuilder('/locations'));
|
||||
|
@ -21,4 +23,15 @@ export class LocationsApi extends BaseAPI {
|
|||
async create(location: LocationCreate) {
|
||||
return this.http.post<LocationCreate, Location>(UrlBuilder('/locations'), location);
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
return this.http.get<Location>(UrlBuilder(`/locations/${id}`));
|
||||
}
|
||||
async delete(id: string) {
|
||||
return this.http.delete<void>(UrlBuilder(`/locations/${id}`));
|
||||
}
|
||||
|
||||
async update(id: string, location: LocationUpdate) {
|
||||
return this.http.put<LocationUpdate, Location>(UrlBuilder(`/locations/${id}`), location);
|
||||
}
|
||||
}
|
39
frontend/lib/api/public.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { BaseAPI, UrlBuilder } from "./base";
|
||||
|
||||
export type LoginResult = {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type LoginPayload = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type RegisterPayload = {
|
||||
user: {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
};
|
||||
groupName: string;
|
||||
};
|
||||
|
||||
export class PublicApi extends BaseAPI {
|
||||
public login(username: string, password: string) {
|
||||
return this.http.post<LoginPayload, LoginResult>(
|
||||
UrlBuilder("/users/login"),
|
||||
{
|
||||
username,
|
||||
password,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public register(payload: RegisterPayload) {
|
||||
return this.http.post<RegisterPayload, LoginResult>(
|
||||
UrlBuilder("/users/register"),
|
||||
payload
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { Requests } from '@/lib/requests';
|
||||
import { BaseAPI, UrlBuilder } from './base';
|
||||
import { LocationsApi } from './classes/locations';
|
||||
import { Requests } from "~~/lib/requests";
|
||||
import { BaseAPI, UrlBuilder } from "./base";
|
||||
import { LocationsApi } from "./classes/locations";
|
||||
|
||||
export type Result<T> = {
|
||||
item: T;
|
||||
|
@ -25,10 +25,10 @@ export class UserApi extends BaseAPI {
|
|||
}
|
||||
|
||||
public self() {
|
||||
return this.http.get<Result<User>>(UrlBuilder('/users/self'));
|
||||
return this.http.get<Result<User>>(UrlBuilder("/users/self"));
|
||||
}
|
||||
|
||||
public logout() {
|
||||
return this.http.post<object, void>(UrlBuilder('/users/logout'), {});
|
||||
return this.http.post<object, void>(UrlBuilder("/users/logout"), {});
|
||||
}
|
||||
}
|
|
@ -65,7 +65,6 @@ export class Requests {
|
|||
|
||||
const token = this.token();
|
||||
if (token !== '' && args.headers !== undefined) {
|
||||
// @ts-expect-error -- headers is always defined at this point
|
||||
args.headers['Authorization'] = token;
|
||||
}
|
||||
|
||||
|
@ -80,6 +79,10 @@ export class Requests {
|
|||
}
|
||||
|
||||
const data: T = await (async () => {
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (e) {
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"pages": {
|
||||
"home": "Home",
|
||||
"other": {
|
||||
"menu": "Other Page",
|
||||
"desc": "An example of other pages"
|
||||
},
|
||||
"not-found": "Page not found"
|
||||
},
|
||||
"app": {
|
||||
"offline": "App ready to work offline",
|
||||
"new-content": "New content available, click on reload button to update."
|
||||
},
|
||||
"intro": {
|
||||
"desc": "Welcome to Vitailse, Vite starter template with {tailwindurl}",
|
||||
"github": "Please give stars and report any issues on our {githuburl}"
|
||||
},
|
||||
"button": {
|
||||
"reload": "Reload",
|
||||
"close": "Close"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"pages": {
|
||||
"home": "Beranda",
|
||||
"other": {
|
||||
"menu": "Halaman lain",
|
||||
"desc": "Contoh untuk halaman lain"
|
||||
},
|
||||
"not-found": "Laman tidak ditemukan"
|
||||
},
|
||||
"app": {
|
||||
"offline": "Aplikasi siap digunakan tanpa jaringan internet",
|
||||
"new-content": "Konten baru ditemukan, Tekan tombol perbarui untuk memperbarui laman."
|
||||
},
|
||||
"intro": {
|
||||
"desc": "Selamat datang di Vitailse, Template awal vite dengan ",
|
||||
"github": "Mohon berikan bintang dan laporkan masalah pada "
|
||||
},
|
||||
"button": {
|
||||
"reload": "Perbarui",
|
||||
"close": "Tutup"
|
||||
}
|
||||
}
|
14
frontend/nuxt.config.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { defineNuxtConfig } from 'nuxt';
|
||||
|
||||
// https://v3.nuxtjs.org/api/configuration/nuxt.config
|
||||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt', '@vueuse/nuxt'],
|
||||
vite: {
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:7745',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,71 +1,29 @@
|
|||
{
|
||||
"name": "@zynth/vitailse",
|
||||
"description": "Vite starter template with TailwindCSS",
|
||||
"version": "0.1.0",
|
||||
"main": "src/main.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/zynth17/vitailse.git"
|
||||
},
|
||||
"keywords": [
|
||||
"vitailse",
|
||||
"tailwindcss",
|
||||
"vite",
|
||||
"vitesse"
|
||||
],
|
||||
"author": "Christopher Reeeve",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/zynth17/vitailse/issues"
|
||||
},
|
||||
"homepage": "https://github.com/zynth17/vitailse#readme",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite-ssg build",
|
||||
"serve": "vite preview",
|
||||
"test:watch": "vitest --watch",
|
||||
"https-preview": "serve dist"
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nuxt": "3.0.0-rc.8",
|
||||
"vitest": "^0.22.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/vue": "^3.2.1",
|
||||
"@nuxtjs/tailwindcss": "^5.3.2",
|
||||
"@pinia/nuxt": "^0.4.1",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.0",
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
"@tailwindcss/typography": "^0.5.4",
|
||||
"@types/node": "^18.0.4",
|
||||
"@vueuse/components": "^8.9.3",
|
||||
"@vueuse/core": "^8.9.3",
|
||||
"@vueuse/head": "^0.7.6",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"@vueuse/nuxt": "^9.1.1",
|
||||
"autoprefixer": "^10.4.8",
|
||||
"daisyui": "^2.24.0",
|
||||
"pinia": "^2.0.16",
|
||||
"postcss": "^8.4.14",
|
||||
"tailwindcss": "^3.1.6",
|
||||
"vue": "^3.2.37",
|
||||
"vue-i18n": "^9.1.10",
|
||||
"vue-router": "^4.1.2",
|
||||
"workbox": "^0.0.0",
|
||||
"workbox-window": "^6.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/json": "^2.1.78",
|
||||
"@iconify/vue": "^3.2.1",
|
||||
"@intlify/vite-plugin-vue-i18n": "^5.0.0",
|
||||
"@vitejs/plugin-vue": "^3.0.0",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"@vue/server-renderer": "^3.2.37",
|
||||
"critters": "^0.0.16",
|
||||
"https-localhost": "^4.7.1",
|
||||
"typescript": "^4.7.4",
|
||||
"unplugin-auto-import": "^0.9.3",
|
||||
"unplugin-icons": "^0.14.7",
|
||||
"unplugin-vue-components": "0.21.1",
|
||||
"unplugin-vue-router": "^0.0.21",
|
||||
"vite": "^3.0.0",
|
||||
"vite-plugin-pwa": "^0.12.3",
|
||||
"vite-plugin-vue-layouts": "^0.7.0",
|
||||
"vite-plugin-vue-type-imports": "^0.2.0",
|
||||
"vite-ssg": "^0.20.2",
|
||||
"vite-ssg-sitemap": "^0.3.2",
|
||||
"vitest": "^0.18.0",
|
||||
"vue-tsc": "^0.38.5"
|
||||
"pinia": "^2.0.21",
|
||||
"postcss": "^8.4.16",
|
||||
"tailwindcss": "^3.1.8",
|
||||
"vue": "^3.2.38"
|
||||
}
|
||||
}
|
15
frontend/pages/[...all].vue
Normal file
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: "404. Not Found",
|
||||
});
|
||||
definePageMeta({
|
||||
layout: "404",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1 class="text-blue-500 font-extrabold flex flex-col text-center">
|
||||
<span class="text-7xl">404.</span>
|
||||
<span class="text-5xl mt-5"> Page Not Found </span>
|
||||
</h1>
|
||||
</template>
|
39
frontend/pages/home.vue
Normal file
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { type Location } from '~~/lib/api/classes/locations';
|
||||
definePageMeta({
|
||||
layout: 'home',
|
||||
});
|
||||
useHead({
|
||||
title: 'Homebox | Home',
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
const locations = ref<Location[]>([]);
|
||||
onMounted(async () => {
|
||||
const { data } = await api.locations.getAll();
|
||||
if (data) {
|
||||
locations.value = data.items;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseContainer>
|
||||
<BaseSectionHeader class="mb-5"> Storage Locations </BaseSectionHeader>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<NuxtLink
|
||||
:to="`/location/${l.id}`"
|
||||
class="card bg-primary text-primary-content hover:-translate-y-1 focus:-translate-y-1 transition duration-300"
|
||||
v-for="l in locations"
|
||||
>
|
||||
<div class="card-body p-4">
|
||||
<h2 class="flex items-center gap-2">
|
||||
<Icon name="heroicons-map-pin" class="h-5 w-5 text-white" height="25" />
|
||||
{{ l.name }}
|
||||
<!-- <span class="badge badge-accent badge-lg ml-auto text-accent-content text-lg">0</span> -->
|
||||
</h2>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</BaseContainer>
|
||||
</template>
|
190
frontend/pages/index.vue
Normal file
|
@ -0,0 +1,190 @@
|
|||
<script setup lang="ts">
|
||||
import TextField from '@/components/Form/TextField.vue';
|
||||
import { useNotifier } from '@/composables/use-notifier';
|
||||
import { usePublicApi } from '@/composables/use-api';
|
||||
import { useAuthStore } from '~~/stores/auth';
|
||||
useHead({
|
||||
title: 'Homebox | Organize and Tag Your Stuff',
|
||||
});
|
||||
|
||||
definePageMeta({
|
||||
layout: 'empty',
|
||||
});
|
||||
|
||||
const registerFields = [
|
||||
{
|
||||
label: "What's your name?",
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: "What's your email?",
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: 'Name your group',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: 'Set your password',
|
||||
value: '',
|
||||
type: 'password',
|
||||
},
|
||||
{
|
||||
label: 'Confirm your password',
|
||||
value: '',
|
||||
type: 'password',
|
||||
},
|
||||
];
|
||||
|
||||
const api = usePublicApi();
|
||||
|
||||
async function registerUser() {
|
||||
loading.value = true;
|
||||
// Print Values of registerFields
|
||||
|
||||
const { data, error } = await api.register({
|
||||
user: {
|
||||
name: registerFields[0].value,
|
||||
email: registerFields[1].value,
|
||||
password: registerFields[3].value,
|
||||
},
|
||||
groupName: registerFields[2].value,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error('Problem registering user');
|
||||
} else {
|
||||
toast.success('User registered');
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const loginFields = [
|
||||
{
|
||||
label: 'Email',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: 'Password',
|
||||
value: '',
|
||||
type: 'password',
|
||||
},
|
||||
];
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const toast = useNotifier();
|
||||
const loading = ref(false);
|
||||
|
||||
async function login() {
|
||||
loading.value = true;
|
||||
const { data, error } = await api.login(loginFields[0].value, loginFields[1].value);
|
||||
|
||||
if (error) {
|
||||
toast.error('Invalid email or password');
|
||||
} else {
|
||||
toast.success('Logged in successfully');
|
||||
|
||||
authStore.$patch({
|
||||
token: data.token,
|
||||
expires: data.expiresAt,
|
||||
});
|
||||
|
||||
navigateTo('/home');
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const registerForm = ref(false);
|
||||
function toggleLogin() {
|
||||
registerForm.value = !registerForm.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="sm:px-6 py-2 lg:p-14 sm:py-6">
|
||||
<h2 class="mt-1 text-4xl font-bold tracking-tight text-base-content sm:text-5xl lg:text-6xl">Homebox</h2>
|
||||
<p class="ml-1 text-lg text-base-content/50">Track, Organize, and Manage your Shit.</p>
|
||||
</header>
|
||||
<div class="grid p-6 sm:place-items-center min-h-[50vh]">
|
||||
<Transition name="slide-fade">
|
||||
<form v-if="registerForm" @submit.prevent="registerUser">
|
||||
<div class="card w-max-[500px] md:w-[500px] bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Register</h2>
|
||||
<TextField
|
||||
v-for="field in registerFields"
|
||||
v-model="field.value"
|
||||
:label="field.label"
|
||||
:key="field.label"
|
||||
:type="field.type"
|
||||
/>
|
||||
<div class="card-actions justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary mt-2"
|
||||
:class="loading ? 'loading' : ''"
|
||||
:disabled="loading"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form v-else @submit.prevent="login">
|
||||
<div class="card w-max-[500px] md:w-[500px] bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Login</h2>
|
||||
<TextField
|
||||
v-for="field in loginFields"
|
||||
v-model="field.value"
|
||||
:label="field.label"
|
||||
:key="field.label"
|
||||
:type="field.type"
|
||||
/>
|
||||
<div class="card-actions justify-end mt-2">
|
||||
<button type="submit" class="btn btn-primary" :class="loading ? 'loading' : ''" :disabled="loading">
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Transition>
|
||||
<div class="text-center mt-2">
|
||||
<button @click="toggleLogin">
|
||||
{{ registerForm ? 'Already a User? Login' : 'Not a User? Register' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-full absolute bottom-0 z-[-1]">
|
||||
<svg class="fill-primary" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" viewBox="0 0 1440 320">
|
||||
<path
|
||||
fill-opacity="1"
|
||||
d="M0,32L30,42.7C60,53,120,75,180,80C240,85,300,75,360,80C420,85,480,107,540,128C600,149,660,171,720,160C780,149,840,107,900,90.7C960,75,1020,85,1080,122.7C1140,160,1200,224,1260,234.7C1320,245,1380,203,1410,181.3L1440,160L1440,320L1410,320C1380,320,1320,320,1260,320C1200,320,1140,320,1080,320C1020,320,960,320,900,320C840,320,780,320,720,320C660,320,600,320,540,320C480,320,420,320,360,320C300,320,240,320,180,320C120,320,60,320,30,320L0,320Z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="bg-primary flex-col flex min-h-[32vh]">
|
||||
<div class="mt-auto mx-auto mb-8">
|
||||
<p class="text-center text-gray-200">© 2022 Contents. All Rights Reserved. Haybytes LLC</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
position: absolute;
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
136
frontend/pages/location/[id].vue
Normal file
|
@ -0,0 +1,136 @@
|
|||
<script setup lang="ts">
|
||||
import { Location } from '~~/lib/api/classes/locations';
|
||||
import ActionsDivider from '../../components/Base/ActionsDivider.vue';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'home',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const api = useUserApi();
|
||||
const toast = useNotifier();
|
||||
|
||||
const preferences = useLocationViewPreferences();
|
||||
|
||||
const location = ref<Location | null>(null);
|
||||
const locationId = computed<string>(() => route.params.id as string);
|
||||
|
||||
function maybeTimeAgo(date?: string): string {
|
||||
if (!date) {
|
||||
return '??';
|
||||
}
|
||||
|
||||
const time = new Date(date);
|
||||
|
||||
return `${useTimeAgo(time).value} (${useDateFormat(time, 'MM-DD-YYYY').value})`;
|
||||
}
|
||||
|
||||
const details = computed(() => {
|
||||
const dt = {
|
||||
Name: location.value?.name || '',
|
||||
Description: location.value?.description || '',
|
||||
};
|
||||
|
||||
if (preferences.value.showDetails) {
|
||||
dt['Created At'] = maybeTimeAgo(location.value?.createdAt);
|
||||
dt['Updated At'] = maybeTimeAgo(location.value?.updatedAt);
|
||||
dt['Database ID'] = location.value?.id || '';
|
||||
dt['Group Id'] = location.value?.groupId || '';
|
||||
}
|
||||
|
||||
return dt;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const { data, error } = await api.locations.get(locationId.value);
|
||||
|
||||
if (error) {
|
||||
toast.error('Failed to load location');
|
||||
navigateTo('/home');
|
||||
return;
|
||||
}
|
||||
location.value = data;
|
||||
});
|
||||
|
||||
const { reveal } = useConfirm();
|
||||
|
||||
async function confirmDelete() {
|
||||
const { isCanceled } = await reveal('Are you sure you want to delete this location? This action cannot be undone.');
|
||||
|
||||
if (isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await api.locations.delete(locationId.value);
|
||||
|
||||
if (error) {
|
||||
toast.error('Failed to delete location');
|
||||
return;
|
||||
}
|
||||
toast.success('Location deleted');
|
||||
navigateTo('/home');
|
||||
}
|
||||
|
||||
const updateModal = ref(false);
|
||||
const updating = ref(false);
|
||||
const updateData = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
function openUpdate() {
|
||||
updateData.name = location.value?.name || '';
|
||||
updateData.description = location.value?.description || '';
|
||||
updateModal.value = true;
|
||||
}
|
||||
|
||||
async function update() {
|
||||
updating.value = true;
|
||||
const { error, data } = await api.locations.update(locationId.value, updateData);
|
||||
|
||||
if (error) {
|
||||
toast.error('Failed to update location');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Location updated');
|
||||
console.log(data);
|
||||
location.value = data;
|
||||
updateModal.value = false;
|
||||
updating.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseContainer>
|
||||
<BaseModal v-model="updateModal">
|
||||
<template #title> Update Location </template>
|
||||
<form v-if="location" @submit.prevent="update">
|
||||
<FormTextField :autofocus="true" label="Location Name" v-model="updateData.name" />
|
||||
<FormTextField label="Location Description" v-model="updateData.description" />
|
||||
<div class="modal-action">
|
||||
<BaseButton type="submit" :loading="updating"> Update </BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
<section>
|
||||
<BaseSectionHeader class="mb-5">
|
||||
{{ location ? location.name : '' }}
|
||||
</BaseSectionHeader>
|
||||
<BaseDetails class="mb-2" :details="details">
|
||||
<template #title> Location Details </template>
|
||||
</BaseDetails>
|
||||
<div class="form-control ml-auto mr-2 max-w-[130px]">
|
||||
<label class="label cursor-pointer">
|
||||
<input type="checkbox" v-model.checked="preferences.showDetails" class="checkbox" />
|
||||
<span class="label-text"> Detailed View </span>
|
||||
</label>
|
||||
</div>
|
||||
<ActionsDivider @delete="confirmDelete" @edit="openUpdate" />
|
||||
</section>
|
||||
|
||||
<!-- <section>
|
||||
<BaseSectionHeader> Items </BaseSectionHeader>
|
||||
</section> -->
|
||||
</BaseContainer>
|
||||
</template>
|
Before Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 647 B |
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 8.7 KiB |
Before Width: | Height: | Size: 22 KiB |
|
@ -1,2 +0,0 @@
|
|||
User-agent: *
|
||||
Allow: /
|
|
@ -1 +0,0 @@
|
|||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
|
@ -1,8 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import Toast from './components/App/Toast.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
<router-view />
|
||||
</template>
|
|
@ -1,7 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('tests', () => {
|
||||
it('should works', () => {
|
||||
expect(1 + 1).toEqual(2)
|
||||
})
|
||||
})
|
|
@ -1,39 +0,0 @@
|
|||
import { BaseAPI, UrlBuilder } from './base';
|
||||
|
||||
export type LoginResult = {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type LoginPayload = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type RegisterPayload = {
|
||||
user: {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
};
|
||||
groupName: string;
|
||||
};
|
||||
|
||||
export class PublicApi extends BaseAPI {
|
||||
public login(username: string, password: string) {
|
||||
return this.http.post<LoginPayload, LoginResult>(
|
||||
UrlBuilder('/users/login'),
|
||||
{
|
||||
username,
|
||||
password,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public register(payload: RegisterPayload) {
|
||||
return this.http.post<RegisterPayload, LoginResult>(
|
||||
UrlBuilder('/users/register'),
|
||||
payload
|
||||
);
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 35 KiB |
|
@ -1,71 +0,0 @@
|
|||
<template>
|
||||
<div class="force-above fixed top-2 right-2 w-[300px]">
|
||||
<TransitionGroup name="notify" tag="div">
|
||||
<div
|
||||
v-for="(notify, index) in notifications.slice(0, 4)"
|
||||
:key="notify.id"
|
||||
class="my-2 w-[300px] rounded-md p-3 text-sm text-white opacity-75"
|
||||
:class="{
|
||||
'bg-primary': notify.type === 'info',
|
||||
'bg-red-600': notify.type === 'error',
|
||||
'bg-green-600': notify.type === 'success',
|
||||
}"
|
||||
@click="dropNotification(index)"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<template v-if="notify.type == 'info'">
|
||||
<Icon
|
||||
icon="mdi-information-outline"
|
||||
class="h-5 w-5"
|
||||
height="25"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="notify.type == 'success'">
|
||||
<Icon
|
||||
icon="mdi-check-circle-outline"
|
||||
class="h-5 w-5"
|
||||
height="25"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="notify.type == 'error'">
|
||||
<Icon
|
||||
icon="mdi-alert-circle-outline"
|
||||
class="h-5 w-5"
|
||||
height="25"
|
||||
/>
|
||||
</template>
|
||||
{{ notify.message }}
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { useNotifications } from '@/composables/use-notifier';
|
||||
|
||||
const { notifications, dropNotification } = useNotifications();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.force-above {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.notify-move,
|
||||
.notify-enter-active,
|
||||
.notify-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
.notify-enter-from,
|
||||
.notify-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
.notify-leave-active {
|
||||
position: absolute;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
</style>
|
|
@ -1,80 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineEmits(['toggleSidebar']);
|
||||
|
||||
const { availableLocales } = useI18n();
|
||||
|
||||
const preferedDark = usePreferredDark();
|
||||
const isDark = useStorage('isDark', preferedDark.value);
|
||||
const body = ref<HTMLBodyElement | null>(null);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
if (body.value) {
|
||||
if (isDark.value) {
|
||||
body.value.classList.remove('dark');
|
||||
} else {
|
||||
body.value.classList.add('dark');
|
||||
}
|
||||
}
|
||||
isDark.value = !isDark.value;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
|
||||
body.value = document.querySelector('body') as HTMLBodyElement;
|
||||
if (body.value) {
|
||||
if (isDark.value) body.value.classList.add('dark');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<nav
|
||||
class="
|
||||
w-full
|
||||
bg-white
|
||||
text-gray-800
|
||||
dark:bg-gray-800 dark:text-white
|
||||
py-4
|
||||
px-8
|
||||
shadow-md
|
||||
dark:shadow-md
|
||||
flex
|
||||
items-center
|
||||
border-b border-gray-400/50
|
||||
"
|
||||
>
|
||||
<router-link :to="{ name: 'home' }">
|
||||
<div class="font-bold lg:text-xl md:text-lg text-md">Vitailse</div>
|
||||
</router-link>
|
||||
<div class="ml-auto flex items-center h-full">
|
||||
<select
|
||||
id="language"
|
||||
v-model="$i18n.locale"
|
||||
class="py-1 focus:outline-none rounded dark:text-gray-800"
|
||||
>
|
||||
<option
|
||||
v-for="locale in availableLocales"
|
||||
:key="locale"
|
||||
:value="locale"
|
||||
>
|
||||
{{ locale }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="mx-5 cursor-pointer focus:outline-none"
|
||||
@click="toggleDarkMode"
|
||||
>
|
||||
<icon:bx:bx-moon class="w-6 h-6" v-if="!isDark" />
|
||||
<icon:bx:bxs-moon class="w-6 h-6" v-else />
|
||||
</button>
|
||||
<a href="https://github.com/zynth17/vitailse">
|
||||
<icon-akar-icons:github-fill />
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,31 +0,0 @@
|
|||
<template>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
</label>
|
||||
<input
|
||||
:type="type"
|
||||
v-model="value"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
});
|
||||
|
||||
const value = useVModel(props, 'modelValue');
|
||||
</script>
|
|
@ -1,23 +0,0 @@
|
|||
import { PublicApi } from '@/api/public';
|
||||
import { UserApi } from '@/api/user';
|
||||
import { Requests } from '@/lib/requests';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
|
||||
async function ApiDebugger(r: Response) {
|
||||
console.table({
|
||||
'Request Url': r.url,
|
||||
'Response Status': r.status,
|
||||
'Response Status Text': r.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublicApi(): PublicApi {
|
||||
const requests = new Requests('', '', {}, ApiDebugger);
|
||||
return new PublicApi(requests);
|
||||
}
|
||||
|
||||
export function useUserApi(): UserApi {
|
||||
const authStore = useAuthStore();
|
||||
const requests = new Requests('', () => authStore.token, {}, ApiDebugger);
|
||||
return new UserApi(requests);
|
||||
}
|
8
frontend/src/env.d.ts
vendored
|
@ -1,8 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
<template>
|
||||
<main class="w-full min-h-screen bg-blue-100 grid place-items-center">
|
||||
<router-view />
|
||||
</main>
|
||||
</template>
|
|
@ -1,15 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import Toast from '@/components/App/Toast.vue';
|
||||
</script>
|
||||
<template>
|
||||
<Toast />
|
||||
<header>
|
||||
<app-header />
|
||||
</header>
|
||||
|
||||
<main
|
||||
class="p-8 dark:bg-gray-800 dark:text-white bg-white text-gray-800 min-h-screen"
|
||||
>
|
||||
<router-view />
|
||||
</main>
|
||||
</template>
|
|
@ -1,19 +0,0 @@
|
|||
import App from '@/App.vue';
|
||||
import { ViteSSG } from 'vite-ssg';
|
||||
|
||||
import '@/styles/index.css';
|
||||
import { ViteSetupModule } from './types/ViteSetupModule';
|
||||
import { extendedRoutes } from '@/router';
|
||||
|
||||
export const createApp = ViteSSG(
|
||||
App,
|
||||
{ routes: extendedRoutes },
|
||||
async ctx => {
|
||||
Object.values(
|
||||
import.meta.glob<{ install: ViteSetupModule }>('./modules/*.ts', {
|
||||
eager: true,
|
||||
})
|
||||
).map(i => i.install?.(ctx));
|
||||
},
|
||||
{}
|
||||
);
|
|
@ -1,29 +0,0 @@
|
|||
import { ViteSetupModule } from '@/types/ViteSetupModule';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
// Import i18n resources
|
||||
// https://vitejs.dev/guide/features.html#glob-import
|
||||
|
||||
// Don't need this? Try vitesse-lite: https://github.com/antfu/vitesse-lite
|
||||
const messages = Object.fromEntries(
|
||||
Object.entries(
|
||||
import.meta.glob<{ default: any }>('../../locales/*.{y(a)?ml,json}', {
|
||||
eager: true,
|
||||
})
|
||||
).map(([key, value]) => {
|
||||
const isYamlOrJson = key.endsWith('.yaml') || key.endsWith('.json');
|
||||
|
||||
return [key.slice(14, isYamlOrJson ? -5 : -4), value.default];
|
||||
})
|
||||
);
|
||||
|
||||
export const install: ViteSetupModule = ({ app }) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages,
|
||||
globalInjection: true,
|
||||
});
|
||||
|
||||
app.use(i18n);
|
||||
};
|
|
@ -1,14 +0,0 @@
|
|||
import { ViteSetupModule } from '@/types/ViteSetupModule';
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
// Setup Pinia
|
||||
// https://pinia.esm.dev/
|
||||
export const install: ViteSetupModule = ({ isClient, initialState, app }) => {
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
// Refer to
|
||||
// https://github.com/antfu/vite-ssg/blob/main/README.md#state-serialization
|
||||
// for other serialization strategies.
|
||||
if (isClient) pinia.state.value = initialState.pinia || {};
|
||||
else initialState.pinia = pinia.state.value;
|
||||
};
|
|
@ -1,10 +0,0 @@
|
|||
import { ViteSetupModule } from '@/types/ViteSetupModule';
|
||||
|
||||
export const install: ViteSetupModule = ({ isClient, router }) => {
|
||||
if (!isClient) return;
|
||||
|
||||
router.isReady().then(async () => {
|
||||
const { registerSW } = await import('virtual:pwa-register');
|
||||
registerSW({ immediate: true });
|
||||
});
|
||||
};
|
|
@ -1,19 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: '404. Not Found',
|
||||
});
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1 class="text-blue-500 font-extrabold flex flex-col text-center">
|
||||
<span class="text-7xl">404.</span>
|
||||
<span class="text-5xl mt-5">{{ t('pages.not-found') }}</span>
|
||||
</h1>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
name : not-found
|
||||
meta:
|
||||
layout: 404
|
||||
</route>
|
|
@ -1,137 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
import { type Location } from '@/api/classes/locations';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { useUserApi } from '@/composables/use-api';
|
||||
useHead({
|
||||
title: 'Homebox | Home',
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const user = ref({});
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.self();
|
||||
|
||||
if (data) {
|
||||
user.value = data.item;
|
||||
}
|
||||
});
|
||||
|
||||
const locations = ref<Location[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.locations.getAll();
|
||||
|
||||
if (data) {
|
||||
console.log(data);
|
||||
locations.value = data.items;
|
||||
}
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
async function logout() {
|
||||
const { error } = await authStore.logout(api);
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
const links = [
|
||||
{
|
||||
name: 'Home',
|
||||
href: '/home',
|
||||
},
|
||||
{
|
||||
name: 'Logout',
|
||||
action: logout,
|
||||
last: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropdown = [
|
||||
{
|
||||
name: 'Location',
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
name: 'Item / Asset',
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
name: 'Label',
|
||||
action: () => {},
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-6xl mx-auto">
|
||||
<header class="sm:px-6 py-2 lg:px-14 sm:py-6">
|
||||
<h2 class="mt-1 text-4xl font-bold tracking-tight text-base-content sm:text-5xl lg:text-6xl">Homebox</h2>
|
||||
<div class="ml-1 mt-2 text-lg text-base-content/50 space-x-2">
|
||||
<template v-for="link in links">
|
||||
<router-link
|
||||
v-if="!link.action"
|
||||
class="hover:text-base-content transition-color duration-200 italic"
|
||||
:to="link.href"
|
||||
>
|
||||
{{ link.name }}
|
||||
</router-link>
|
||||
<button v-else @click="link.action" class="hover:text-base-content transition-color duration-200 italic">
|
||||
{{ link.name }}
|
||||
</button>
|
||||
<span v-if="!link.last"> / </span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex mt-6">
|
||||
<div class="dropdown">
|
||||
<label tabindex="0" class="btn btn-sm">
|
||||
<span>
|
||||
<Icon icon="mdi-plus" class="w-5 h-5 mr-2" />
|
||||
</span>
|
||||
Create
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li v-for="btn in dropdown">
|
||||
<button @click="btn.action">
|
||||
{{ btn.name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</section>
|
||||
<section class="max-w-6xl mx-auto sm:px-6 lg:px-14">
|
||||
<div class="border-b border-gray-600 pb-3 mb-3">
|
||||
<h3 class="text-lg text-base-content font-medium leading-6">Storage Locations</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<a
|
||||
:href="`#${l.id}`"
|
||||
class="card bg-primary text-primary-content hover:-translate-y-1 focus:-translate-y-1 transition duration-300"
|
||||
v-for="l in locations"
|
||||
>
|
||||
<div class="card-body p-4">
|
||||
<h2 class="flex items-center gap-2">
|
||||
<Icon icon="mdi-light:home" class="h-5 w-5" height="25" />
|
||||
{{ l.name }}
|
||||
<span class="badge badge-accent badge-lg ml-auto text-accent-content text-lg">0</span>
|
||||
</h2>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
name: home
|
||||
</route>
|
|
@ -1,188 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import TextField from '@/components/Form/TextField.vue';
|
||||
import { useNotifier } from '@/composables/use-notifier';
|
||||
import { usePublicApi } from '@/composables/use-api';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
useHead({
|
||||
title: 'Homebox | Organize and Tag Your Stuff',
|
||||
});
|
||||
|
||||
const registerFields = [
|
||||
{
|
||||
label: "What's your name?",
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: "What's your email?",
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: 'Name your group',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: 'Set your password',
|
||||
value: '',
|
||||
type: 'password',
|
||||
},
|
||||
{
|
||||
label: 'Confirm your password',
|
||||
value: '',
|
||||
type: 'password',
|
||||
},
|
||||
];
|
||||
|
||||
const api = usePublicApi();
|
||||
|
||||
async function registerUser() {
|
||||
loading.value = true;
|
||||
// Print Values of registerFields
|
||||
|
||||
const { data, error } = await api.register({
|
||||
user: {
|
||||
name: registerFields[0].value,
|
||||
email: registerFields[1].value,
|
||||
password: registerFields[3].value,
|
||||
},
|
||||
groupName: registerFields[2].value,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error('Problem registering user');
|
||||
} else {
|
||||
toast.success('User registered');
|
||||
}
|
||||
|
||||
console.log(data);
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const loginFields = [
|
||||
{
|
||||
label: 'Email',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: 'Password',
|
||||
value: '',
|
||||
type: 'password',
|
||||
},
|
||||
];
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const toast = useNotifier();
|
||||
const loading = ref(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function login() {
|
||||
loading.value = true;
|
||||
const { data, error } = await api.login(loginFields[0].value, loginFields[1].value);
|
||||
|
||||
if (error) {
|
||||
toast.error('Invalid email or password');
|
||||
} else {
|
||||
toast.success('Logged in successfully');
|
||||
|
||||
console.log(data);
|
||||
|
||||
authStore.$patch({
|
||||
token: data.token,
|
||||
expires: data.expiresAt,
|
||||
});
|
||||
|
||||
router.push({ name: 'home' });
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const registerForm = ref(false);
|
||||
function toggleLogin() {
|
||||
registerForm.value = !registerForm.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sm:px-6 py-2 lg:p-14 sm:py-6">
|
||||
<h2 class="mt-1 text-4xl font-bold tracking-tight text-base-content sm:text-5xl lg:text-6xl">Homebox</h2>
|
||||
<p class="ml-1 text-lg text-base-content/50">Track, Organize, and Manage your Shit.</p>
|
||||
</header>
|
||||
<div class="grid p-6 sm:place-items-center min-h-[50vh]">
|
||||
<Transition name="slide-fade">
|
||||
<form v-if="registerForm" @submit.prevent="registerUser">
|
||||
<div class="card w-max-[500px] md:w-[500px] bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Register</h2>
|
||||
<TextField
|
||||
v-for="field in registerFields"
|
||||
v-model="field.value"
|
||||
:label="field.label"
|
||||
:key="field.label"
|
||||
:type="field.type"
|
||||
/>
|
||||
<div class="card-actions justify-end">
|
||||
<button type="submit" class="btn btn-primary mt-2" :class="loading ? 'loading' : ''" :disabled="loading">
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form v-else @submit.prevent="login">
|
||||
<div class="card w-max-[500px] md:w-[500px] bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Login</h2>
|
||||
<TextField
|
||||
v-for="field in loginFields"
|
||||
v-model="field.value"
|
||||
:label="field.label"
|
||||
:key="field.label"
|
||||
:type="field.type"
|
||||
/>
|
||||
<div class="card-actions justify-end mt-2">
|
||||
<button type="submit" class="btn btn-primary" :class="loading ? 'loading' : ''" :disabled="loading">
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Transition>
|
||||
<div class="text-center mt-2">
|
||||
<button @click="toggleLogin">
|
||||
{{ registerForm ? 'Already a User? Login' : 'Not a User? Register' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-full absolute bottom-0 z-[-1]">
|
||||
<svg class="fill-primary" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" viewBox="0 0 1440 320">
|
||||
<path
|
||||
fill-opacity="1"
|
||||
d="M0,32L30,42.7C60,53,120,75,180,80C240,85,300,75,360,80C420,85,480,107,540,128C600,149,660,171,720,160C780,149,840,107,900,90.7C960,75,1020,85,1080,122.7C1140,160,1200,224,1260,234.7C1320,245,1380,203,1410,181.3L1440,160L1440,320L1410,320C1380,320,1320,320,1260,320C1200,320,1140,320,1080,320C1020,320,960,320,900,320C840,320,780,320,720,320C660,320,600,320,540,320C480,320,420,320,360,320C300,320,240,320,180,320C120,320,60,320,30,320L0,320Z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="bg-primary flex-col flex min-h-[32vh]">
|
||||
<div class="mt-auto mx-auto mb-8">
|
||||
<p class="text-center text-gray-200">© 2022 Contents. All Rights Reserved. Haybytes LLC</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
name: login
|
||||
</route>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
position: absolute;
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
|
@ -1,17 +0,0 @@
|
|||
import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
createMemoryHistory,
|
||||
} from '@vue-router';
|
||||
|
||||
import { setupLayouts } from 'virtual:generated-layouts';
|
||||
export let extendedRoutes: any = null;
|
||||
export const router = createRouter({
|
||||
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
|
||||
// You don't need to pass the routes anymore,
|
||||
// the plugin writes it for you 🤖
|
||||
extendRoutes: routes => {
|
||||
extendedRoutes = routes;
|
||||
return setupLayouts(routes);
|
||||
},
|
||||
});
|
|
@ -1,36 +0,0 @@
|
|||
import { UserApi } from '@/api/user';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: useLocalStorage('pinia/auth/token', ''),
|
||||
expires: useLocalStorage('pinia/auth/expires', ''),
|
||||
}),
|
||||
getters: {
|
||||
isTokenExpired: state => {
|
||||
if (!state.expires) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof state.expires === 'string') {
|
||||
return new Date(state.expires) < new Date();
|
||||
}
|
||||
|
||||
return state.expires < new Date();
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async logout(api: UserApi) {
|
||||
const result = await api.logout();
|
||||
|
||||
if (result.error) {
|
||||
return result;
|
||||
}
|
||||
|
||||
this.token = '';
|
||||
this.expires = '';
|
||||
|
||||
return result;
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,7 +0,0 @@
|
|||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useStore = defineStore('store', {
|
||||
state: () => ({
|
||||
count: 0,
|
||||
}),
|
||||
});
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
|
@ -1,3 +0,0 @@
|
|||
import { ViteSSGContext } from 'vite-ssg';
|
||||
|
||||
export type ViteSetupModule = (ctx: ViteSSGContext) => void;
|
37
frontend/stores/auth.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { UserApi } from "~~/lib/api/user";
|
||||
import { defineStore } from "pinia";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
|
||||
export const useAuthStore = defineStore("auth", {
|
||||
state: () => ({
|
||||
token: useLocalStorage("pinia/auth/token", ""),
|
||||
expires: useLocalStorage("pinia/auth/expires", ""),
|
||||
}),
|
||||
getters: {
|
||||
isTokenExpired: (state) => {
|
||||
if (!state.expires) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof state.expires === "string") {
|
||||
return new Date(state.expires) < new Date();
|
||||
}
|
||||
|
||||
return state.expires < new Date();
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async logout(api: UserApi) {
|
||||
const result = await api.logout();
|
||||
|
||||
if (result.error) {
|
||||
return result;
|
||||
}
|
||||
|
||||
this.token = "";
|
||||
this.expires = "";
|
||||
|
||||
return result;
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
module.exports = {
|
||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
content: ['./app.vue', './{components,pages,layouts}/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
darkMode: 'class', // or 'media' or 'class'
|
||||
theme: {
|
||||
extend: {},
|
||||
|
|
|
@ -1,34 +1,4 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["esnext", "dom"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": [
|
||||
"vite/client",
|
||||
"vite-plugin-vue-layouts/client",
|
||||
"unplugin-icons/types/vue",
|
||||
"vite-plugin-pwa/client",
|
||||
"@intlify/vite-plugin-vue-i18n/client"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"components.d.ts",
|
||||
"auto-imports.d.ts",
|
||||
"typed-router.d.ts"
|
||||
]
|
||||
// https://v3.nuxtjs.org/concepts/typescript
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
|
|
96
frontend/typed-router.d.ts
vendored
|
@ -1,96 +0,0 @@
|
|||
|
||||
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
|
||||
// It's recommended to commit this file.
|
||||
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
|
||||
|
||||
/// <reference types="unplugin-vue-router/client" />
|
||||
|
||||
import type {
|
||||
// type safe route locations
|
||||
RouteLocationTypedList,
|
||||
RouteLocationResolvedTypedList,
|
||||
RouteLocationNormalizedTypedList,
|
||||
RouteLocationNormalizedLoadedTypedList,
|
||||
|
||||
// helper types
|
||||
// route definitions
|
||||
RouteRecordInfo,
|
||||
ParamValue,
|
||||
ParamValueOneOrMore,
|
||||
ParamValueZeroOrMore,
|
||||
ParamValueZeroOrOne,
|
||||
|
||||
// vue-router extensions
|
||||
_RouterTyped,
|
||||
RouterLinkTyped,
|
||||
NavigationGuard,
|
||||
UseLinkFnTyped,
|
||||
} from 'unplugin-vue-router'
|
||||
|
||||
declare module '@vue-router/routes' {
|
||||
export interface RouteNamedMap {
|
||||
'login': RouteRecordInfo<'login', '/', Record<never, never>, Record<never, never>>,
|
||||
'not-found': RouteRecordInfo<'not-found', '/:all(.*)', { all: ParamValue<true> }, { all: ParamValue<false> }>,
|
||||
'home': RouteRecordInfo<'home', '/home', Record<never, never>, Record<never, never>>,
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@vue-router' {
|
||||
import type { RouteNamedMap } from '@vue-router/routes'
|
||||
|
||||
export type RouterTyped = _RouterTyped<RouteNamedMap>
|
||||
|
||||
/**
|
||||
* Type safe version of `RouteLocationNormalized` (the type of `to` and `from` in navigation guards).
|
||||
* Allows passing the name of the route to be passed as a generic.
|
||||
*/
|
||||
export type RouteLocationNormalized<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationNormalizedTypedList<RouteNamedMap>[Name]
|
||||
|
||||
/**
|
||||
* Type safe version of `RouteLocationNormalizedLoaded` (the return type of `useRoute()`).
|
||||
* Allows passing the name of the route to be passed as a generic.
|
||||
*/
|
||||
export type RouteLocationNormalizedLoaded<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationNormalizedLoadedTypedList<RouteNamedMap>[Name]
|
||||
|
||||
/**
|
||||
* Type safe version of `RouteLocationResolved` (the returned route of `router.resolve()`).
|
||||
* Allows passing the name of the route to be passed as a generic.
|
||||
*/
|
||||
export type RouteLocationResolved<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationResolvedTypedList<RouteNamedMap>[Name]
|
||||
|
||||
/**
|
||||
* Type safe version of `RouteLocation` . Allows passing the name of the route to be passed as a generic.
|
||||
*/
|
||||
export type RouteLocation<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationTypedList<RouteNamedMap>[Name]
|
||||
|
||||
/**
|
||||
* Generate a type safe params for a route location. Requires the name of the route to be passed as a generic.
|
||||
*/
|
||||
export type RouteParams<Name extends keyof RouteNamedMap> = RouteNamedMap[Name]['params']
|
||||
/**
|
||||
* Generate a type safe raw params for a route location. Requires the name of the route to be passed as a generic.
|
||||
*/
|
||||
export type RouteParamsRaw<Name extends keyof RouteNamedMap> = RouteNamedMap[Name]['paramsRaw']
|
||||
|
||||
export function useRouter(): RouterTyped
|
||||
export function useRoute<Name extends keyof RouteNamedMap = keyof RouteNamedMap>(name?: Name): RouteLocationNormalizedLoadedTypedList<RouteNamedMap>[Name]
|
||||
|
||||
export const useLink: UseLinkFnTyped<RouteNamedMap>
|
||||
|
||||
export function onBeforeRouteLeave(guard: NavigationGuard<RouteNamedMap>): void
|
||||
export function onBeforeRouteUpdate(guard: NavigationGuard<RouteNamedMap>): void
|
||||
}
|
||||
|
||||
declare module 'vue-router' {
|
||||
import type { RouteNamedMap } from '@vue-router/routes'
|
||||
|
||||
export interface TypesConfig {
|
||||
beforeRouteUpdate: NavigationGuard<RouteNamedMap>
|
||||
beforeRouteLeave: NavigationGuard<RouteNamedMap>
|
||||
|
||||
$route: RouteLocationNormalizedLoadedTypedList<RouteNamedMap>[keyof RouteNamedMap]
|
||||
$router: _RouterTyped<RouteNamedMap>
|
||||
|
||||
RouterLink: RouterLinkTyped<RouteNamedMap>
|
||||
}
|
||||
}
|
|
@ -1,133 +0,0 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { resolve } from 'path';
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import AutoImport from 'unplugin-auto-import/vite';
|
||||
import Icons from 'unplugin-icons/vite';
|
||||
import IconsResolver from 'unplugin-icons/resolver';
|
||||
import Layouts from 'vite-plugin-vue-layouts';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import VueI18n from '@intlify/vite-plugin-vue-i18n';
|
||||
import generateSitemap from 'vite-ssg-sitemap';
|
||||
import VueRouter from 'unplugin-vue-router/vite';
|
||||
import { VueRouterExports } from 'unplugin-vue-router';
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VueRouter({
|
||||
dts: true,
|
||||
routesFolder: 'src/pages',
|
||||
}),
|
||||
Components({
|
||||
dts: true,
|
||||
dirs: ['src/components'],
|
||||
directoryAsNamespace: true,
|
||||
resolvers: [
|
||||
IconsResolver({
|
||||
prefix: 'icon',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Icons({
|
||||
compiler: 'vue3',
|
||||
}),
|
||||
AutoImport({
|
||||
dts: true,
|
||||
// targets to transform
|
||||
include: [
|
||||
/\.[tj]sx?$/, // .ts, .tsx, .js, .jsx
|
||||
/\.vue\??/, // .vue
|
||||
],
|
||||
dirs: ['./composables'],
|
||||
|
||||
// global imports to register
|
||||
imports: [
|
||||
// presets
|
||||
'vue',
|
||||
{ '@vue-router': VueRouterExports },
|
||||
'vue-i18n',
|
||||
'@vueuse/core',
|
||||
'@vueuse/head',
|
||||
// custom
|
||||
],
|
||||
|
||||
// custom resolvers
|
||||
// see https://github.com/antfu/unplugin-auto-import/pull/23/
|
||||
resolvers: [],
|
||||
}),
|
||||
Layouts(),
|
||||
VitePWA({
|
||||
includeAssets: ['favicon-16x16.png', 'favicon-32x32.png', 'favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
|
||||
manifest: {
|
||||
name: 'Vitailse',
|
||||
short_name: 'Vitailse',
|
||||
description: 'Opinionated vite template with TailwindCSS',
|
||||
theme_color: '#076AE0',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
VueI18n({
|
||||
runtimeOnly: true,
|
||||
compositionOnly: true,
|
||||
include: [resolve(__dirname, 'locales/**')],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
strict: true,
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:7745',
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['vue', 'vue-router', '@vueuse/core', '@vueuse/head'],
|
||||
},
|
||||
// @ts-ignore
|
||||
ssgOptions: {
|
||||
script: 'async',
|
||||
formatting: 'minify',
|
||||
format: 'cjs',
|
||||
onFinished() {
|
||||
generateSitemap();
|
||||
},
|
||||
mock: true,
|
||||
},
|
||||
// https://github.com/vitest-dev/vitest
|
||||
test: {
|
||||
include: ['src/__test__/**/*.test.ts', 'src/**/*.test.ts', 'src/__test__/**/*.spec.ts'],
|
||||
environment: 'jsdom',
|
||||
deps: {
|
||||
inline: ['@vue', '@vueuse', 'vue-demi'],
|
||||
},
|
||||
},
|
||||
ssr: {
|
||||
// TODO: workaround until they support native ESM
|
||||
noExternal: ['workbox-window', /vue-i18n/],
|
||||
},
|
||||
});
|