mirror of
https://github.com/ThisIsBenny/wishlist-app.git
synced 2025-04-19 15:27:41 +00:00
commit
87039c6e35
91 changed files with 5445 additions and 2417 deletions
|
@ -1,3 +1,4 @@
|
|||
NODE_ENV=development
|
||||
VITE_API_BASEURL=http://localhost:5000/api
|
||||
DATABASE_URL="file:../data/data.db"
|
||||
API_KEY=TOP_SECRET
|
||||
|
|
BIN
.github/assets/details.jpg
vendored
Normal file
BIN
.github/assets/details.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 149 KiB |
BIN
.github/assets/overview.jpg
vendored
Normal file
BIN
.github/assets/overview.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 107 KiB |
85
.github/workflows/ci-pre-release.yml
vendored
Normal file
85
.github/workflows/ci-pre-release.yml
vendored
Normal file
|
@ -0,0 +1,85 @@
|
|||
name: 'CI'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'release/**'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up NodeJs
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Linter
|
||||
run: npm run lint
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up NodeJs
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Unit tests
|
||||
run: npm run test:unit:ci
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- lint
|
||||
- test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
- name: Prepare
|
||||
id: prep
|
||||
run: |
|
||||
DOCKER_IMAGE=${{ secrets.DOCKER_USERNAME }}/${GITHUB_REPOSITORY#*/}
|
||||
VERSION=$(node -e "console.log(require('./package.json').version);")
|
||||
|
||||
TAGS="${DOCKER_IMAGE}:${VERSION}-pre"
|
||||
|
||||
echo ::set-output name=tags::${TAGS}
|
||||
echo ::set-output name=docker_image::${DOCKER_IMAGE}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@master
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@master
|
||||
|
||||
- name: Available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.prep.outputs.tags }}
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -30,5 +30,3 @@ dist/
|
|||
coverage
|
||||
.env
|
||||
data
|
||||
prisma/seed.ts
|
||||
public/*.jpeg
|
||||
|
|
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
v16.14.0
|
|
@ -32,4 +32,4 @@ COPY --from=builder /app/dist /app
|
|||
|
||||
EXPOSE 5000
|
||||
|
||||
ENTRYPOINT npx prisma migrate deploy && node server.js
|
||||
ENTRYPOINT npx prisma migrate deploy && node api/server.js
|
||||
|
|
15
README.md
15
README.md
|
@ -2,8 +2,15 @@
|
|||
|
||||
The wishlist app is a simple webapp for publishing wishlists. It allows to share wishlists for different people or occasions with friends and family. If something from the wishlist was bought, it can be removed from the list to prevent duplicate purchases.
|
||||
|
||||
[](https://codesandbox.io/s/wishlist-app-h0htfc)
|
||||
|
||||
The app can be easily self-hosted via Docker (see docker-compose example below).
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Support of multiple wishlists
|
||||
|
@ -25,6 +32,8 @@ version: '3.7'
|
|||
services:
|
||||
wishlist:
|
||||
image: thisisbenny/wishlist-app:latest
|
||||
environment:
|
||||
- API_KEY=TOP_SECRET
|
||||
ports:
|
||||
- '5000:5000'
|
||||
volumes:
|
||||
|
@ -35,6 +44,8 @@ services:
|
|||
|
||||
```sh
|
||||
npm install
|
||||
npx prisma generate
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
@ -66,3 +77,7 @@ npm run lint
|
|||
```sh
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Other stuff
|
||||
|
||||
[](https://www.buymeacoffee.com/hierlDev)
|
||||
|
|
40
components.d.ts
vendored
Normal file
40
components.d.ts
vendored
Normal file
|
@ -0,0 +1,40 @@
|
|||
// generated by unplugin-vue-components
|
||||
// We suggest you to commit this file into source control
|
||||
// Read more: https://github.com/vuejs/vue-next/pull/3399
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ButtonBase: typeof import('./src/components/ButtonBase.vue')['default']
|
||||
CreateWishlistTile: typeof import('./src/components/CreateWishlistTile.vue')['default']
|
||||
FormWishlist: typeof import('./src/components/FormWishlist.vue')['default']
|
||||
FormWishlistItem: typeof import('./src/components/FormWishlistItem.vue')['default']
|
||||
Header: typeof import('./src/components/Header.vue')['default']
|
||||
IconCart: typeof import('./src/components/icons/IconCart.vue')['default']
|
||||
IconCloudQuestion: typeof import('./src/components/icons/IconCloudQuestion.vue')['default']
|
||||
IconCreation: typeof import('./src/components/icons/IconCreation.vue')['default']
|
||||
IconDelete: typeof import('./src/components/icons/IconDelete.vue')['default']
|
||||
IconError: typeof import('./src/components/icons/IconError.vue')['default']
|
||||
IconHome: typeof import('./src/components/icons/IconHome.vue')['default']
|
||||
IconImagePlaceholder: typeof import('./src/components/icons/IconImagePlaceholder.vue')['default']
|
||||
IconLightDark: typeof import('./src/components/icons/IconLightDark.vue')['default']
|
||||
IconLink: typeof import('./src/components/icons/IconLink.vue')['default']
|
||||
IconLogin: typeof import('./src/components/icons/IconLogin.vue')['default']
|
||||
IconLogout: typeof import('./src/components/icons/IconLogout.vue')['default']
|
||||
IconNoGift: typeof import('./src/components/icons/IconNoGift.vue')['default']
|
||||
IconPencil: typeof import('./src/components/icons/IconPencil.vue')['default']
|
||||
IconSave: typeof import('./src/components/icons/IconSave.vue')['default']
|
||||
IconSpinner: typeof import('./src/components/icons/IconSpinner.vue')['default']
|
||||
IconToggleOff: typeof import('./src/components/icons/IconToggleOff.vue')['default']
|
||||
IconToggleOn: typeof import('./src/components/icons/IconToggleOn.vue')['default']
|
||||
ImagePreview: typeof import('./src/components/ImagePreview.vue')['default']
|
||||
ImageTile: typeof import('./src/components/ImageTile.vue')['default']
|
||||
InputFile: typeof import('./src/components/InputFile.vue')['default']
|
||||
InputText: typeof import('./src/components/InputText.vue')['default']
|
||||
InputTextArea: typeof import('./src/components/InputTextArea.vue')['default']
|
||||
InputToggle: typeof import('./src/components/InputToggle.vue')['default']
|
||||
Modal: typeof import('./src/components/Modal.vue')['default']
|
||||
WishlistItem: typeof import('./src/components/WishlistItem.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
export { }
|
|
@ -3,6 +3,8 @@ version: '3.7'
|
|||
services:
|
||||
wishlist:
|
||||
image: thisisbenny/wishlist-app:latest
|
||||
environment:
|
||||
- API_KEY=TOP_SECRET
|
||||
ports:
|
||||
- '5000:5000'
|
||||
volumes:
|
||||
|
|
80
examples.http
Normal file
80
examples.http
Normal file
|
@ -0,0 +1,80 @@
|
|||
@BASE_URL=http://localhost:5000/api
|
||||
@API_KEY=TOP_SECRET
|
||||
|
||||
|
||||
###
|
||||
# @name createWishlistFirst
|
||||
POST {{BASE_URL}}/wishlist
|
||||
Content-Type: application/json
|
||||
Authorization: API-Key {{API_KEY}}
|
||||
|
||||
{
|
||||
"title": "Junior",
|
||||
"imageSrc": "https://unsplash.com/photos/JZ51o_-UOY8/download?force=true&w=200",
|
||||
"slugUrlText": "junior"
|
||||
}
|
||||
|
||||
###
|
||||
# @name createWishlistSecond
|
||||
POST {{BASE_URL}}/wishlist
|
||||
Content-Type: application/json
|
||||
Authorization: API-Key {{API_KEY}}
|
||||
|
||||
{
|
||||
"title": "Wedding",
|
||||
"imageSrc": "https://unsplash.com/photos/8vaQKYnawHw/download?ixid=MnwxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNjQ0MDQ4MTIy&force=true&w=200",
|
||||
"description": "We are getting married",
|
||||
"slugUrlText": "wedding"
|
||||
}
|
||||
|
||||
###
|
||||
# @name getWishlists
|
||||
GET {{BASE_URL}}/wishlist
|
||||
|
||||
###
|
||||
# @name getFirstWishlist
|
||||
GET {{BASE_URL}}/wishlist/{{getWishlists.response.body.0.slugUrlText}}
|
||||
|
||||
###
|
||||
# @name updateFirstWishlist
|
||||
PUT {{BASE_URL}}/wishlist/{{getWishlists.response.body.0.id}}
|
||||
Content-Type: application/json
|
||||
Authorization: API-Key {{API_KEY}}
|
||||
|
||||
{
|
||||
"title": "Junior",
|
||||
"imageSrc": "https://unsplash.com/photos/JZ51o_-UOY8/download?force=true&w=200",
|
||||
"description": "Juniors Wishlist",
|
||||
"slugUrlText": "junior"
|
||||
}
|
||||
|
||||
###
|
||||
# @name addItemToFirstWishlist
|
||||
POST {{BASE_URL}}/wishlist/{{getWishlists.response.body.0.id}}/item
|
||||
Content-Type: application/json
|
||||
Authorization: API-Key {{API_KEY}}
|
||||
|
||||
{
|
||||
"title": "Goldfish 40442 | BrickHeadz",
|
||||
"url": "https://www.lego.com/en-de/product/goldfish-40442",
|
||||
"imageSrc": "https://www.lego.com/cdn/cs/set/assets/blt1fc37afef51cfa9f/40442.jpg?fit=bounds&format=jpg&quality=80&width=1500&height=1500&dpr=1",
|
||||
"description": "Cute goldfish and fry, build-and-display BrickHeadz™ model"
|
||||
}
|
||||
|
||||
###
|
||||
# @name updateItemOnFirstWishlist
|
||||
PUT {{BASE_URL}}/wishlist/{{getWishlists.response.body.0.id}}/item/1
|
||||
Content-Type: application/json
|
||||
Authorization: API-Key {{API_KEY}}
|
||||
|
||||
{
|
||||
"title": "Goldfish | BrickHeadz",
|
||||
"url": "https://www.lego.com/en-de/product/goldfish-40442",
|
||||
"imageSrc": "https://www.lego.com/cdn/cs/set/assets/blt1fc37afef51cfa9f/40442.jpg?fit=bounds&format=jpg&quality=80&width=1500&height=1500&dpr=1",
|
||||
"description": "Cute goldfish and fry, build-and-display BrickHeadz™ model"
|
||||
}
|
||||
|
||||
###
|
||||
# @name deleteItemToFirstWishlist
|
||||
DELETE {{BASE_URL}}/wishlist/{{getWishlists.response.body.0.id}}/item/2
|
||||
Authorization: API-Key {{API_KEY}}
|
|
@ -4,9 +4,9 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Wunschlisten</title>
|
||||
<title>Wishlists</title>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-white text-black dark:bg-stone-900 dark:text-white/75">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
|
4784
package-lock.json
generated
4784
package-lock.json
generated
File diff suppressed because it is too large
Load diff
50
package.json
50
package.json
|
@ -1,12 +1,13 @@
|
|||
{
|
||||
"version": "0.1.2",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "concurrently --kill-others \"npm run dev:frontend\" \"npm run dev:backend\"",
|
||||
"demo": "npm run build && prisma migrate deploy && prisma migrate reset -f && node dist/api/server.js",
|
||||
"dev:frontend": "vite",
|
||||
"dev:backend": "nodemon -r dotenv/config ./src/api/server.ts",
|
||||
"build": "npm run build:frontend && npm run build:backend",
|
||||
"build:frontend": "vite build",
|
||||
"build:backend": "npx prisma generate && tsc -p tsconfig.backend.json --outDir dist",
|
||||
"build:backend": "prisma generate && tsc -p tsconfig.backend.json --outDir dist",
|
||||
"preview": "vite preview --port 5050",
|
||||
"test:unit": "vitest --environment jsdom",
|
||||
"test:unit:ci": "vitest --environment jsdom --run",
|
||||
|
@ -18,42 +19,51 @@
|
|||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^3.8.1",
|
||||
"@prisma/client": "^3.9.2",
|
||||
"@tailwindcss/line-clamp": "^0.3.1",
|
||||
"@vueuse/core": "^7.6.1",
|
||||
"axios": "^0.25.0",
|
||||
"fastify": "^3.27.0",
|
||||
"fastify": "^3.27.1",
|
||||
"fastify-compress": "^4.0.1",
|
||||
"fastify-cors": "^6.0.2",
|
||||
"fastify-helmet": "^7.0.1",
|
||||
"fastify-static": "^4.5.0",
|
||||
"vue": "^3.2.27",
|
||||
"open-graph-scraper": "^4.11.0",
|
||||
"vee-validate": "^4.5.8",
|
||||
"vue": "^3.2.31",
|
||||
"vue-i18n": "^9.2.0-beta.30",
|
||||
"vue-router": "^4.0.12"
|
||||
"vue-router": "^4.0.12",
|
||||
"vue-toastification": "^2.0.0-rc.5",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.1.0",
|
||||
"@types/node": "^16.11.21",
|
||||
"@vitejs/plugin-vue": "^2.0.1",
|
||||
"@types/node": "^17.0.17",
|
||||
"@types/open-graph-scraper": "^4.8.1",
|
||||
"@vitejs/plugin-vue": "^2.2.0",
|
||||
"@vue/eslint-config-prettier": "^7.0.0",
|
||||
"@vue/eslint-config-typescript": "^10.0.0",
|
||||
"@vue/test-utils": "^2.0.0-rc.18",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"concurrently": "^7.0.0",
|
||||
"dotenv": "^14.3.2",
|
||||
"eslint": "^8.5.0",
|
||||
"dotenv": "^16.0.0",
|
||||
"eslint": "^8.9.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-vue": "^8.2.0",
|
||||
"eslint-plugin-vue": "^8.4.1",
|
||||
"husky": "^7.0.4",
|
||||
"jsdom": "^19.0.0",
|
||||
"nodemon": "^2.0.15",
|
||||
"pino-pretty": "^7.5.1",
|
||||
"postcss": "^8.4.5",
|
||||
"postcss": "^8.4.6",
|
||||
"prettier": "^2.5.1",
|
||||
"prisma": "^3.8.1",
|
||||
"tailwindcss": "^3.0.15",
|
||||
"ts-node": "^10.4.0",
|
||||
"typescript": "~4.5.4",
|
||||
"vite": "^2.7.13",
|
||||
"vitest": "^0.1.23",
|
||||
"vue-tsc": "^0.29.8",
|
||||
"husky": "^7.0.0"
|
||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||
"prisma": "^3.9.2",
|
||||
"tailwindcss": "^3.0.22",
|
||||
"ts-node": "^10.5.0",
|
||||
"typescript": "~4.5.5",
|
||||
"unplugin-vue-components": "^0.17.18",
|
||||
"vite": "^2.8.1",
|
||||
"vitest": "^0.3.4",
|
||||
"vue-tsc": "^0.31.3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,17 +4,17 @@ CREATE TABLE "Wishlist" (
|
|||
"title" TEXT NOT NULL,
|
||||
"imageSrc" TEXT NOT NULL,
|
||||
"slugUrlText" TEXT NOT NULL,
|
||||
"description" TEXT
|
||||
"description" TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Item" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"title" TEXT NOT NULL,
|
||||
"url" TEXT,
|
||||
"imageSrc" TEXT,
|
||||
"url" TEXT NOT NULL DEFAULT '',
|
||||
"imageSrc" TEXT NOT NULL DEFAULT '',
|
||||
"description" TEXT NOT NULL,
|
||||
"comment" TEXT,
|
||||
"comment" TEXT NOT NULL DEFAULT '',
|
||||
"bought" BOOLEAN NOT NULL DEFAULT false,
|
||||
"wishlistId" TEXT NOT NULL,
|
||||
CONSTRAINT "Item_wishlistId_fkey" FOREIGN KEY ("wishlistId") REFERENCES "Wishlist" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `comment` on the `Item` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Item" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"title" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL DEFAULT '',
|
||||
"imageSrc" TEXT NOT NULL DEFAULT '',
|
||||
"description" TEXT NOT NULL,
|
||||
"bought" BOOLEAN NOT NULL DEFAULT false,
|
||||
"wishlistId" TEXT NOT NULL,
|
||||
CONSTRAINT "Item_wishlistId_fkey" FOREIGN KEY ("wishlistId") REFERENCES "Wishlist" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Item" ("bought", "description", "id", "imageSrc", "title", "url", "wishlistId") SELECT "bought", "description", "id", "imageSrc", "title", "url", "wishlistId" FROM "Item";
|
||||
DROP TABLE "Item";
|
||||
ALTER TABLE "new_Item" RENAME TO "Item";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
|
@ -0,0 +1,16 @@
|
|||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Wishlist" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"public" BOOLEAN NOT NULL DEFAULT true,
|
||||
"title" TEXT NOT NULL,
|
||||
"imageSrc" TEXT NOT NULL,
|
||||
"slugUrlText" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
INSERT INTO "new_Wishlist" ("description", "id", "imageSrc", "slugUrlText", "title") SELECT "description", "id", "imageSrc", "slugUrlText", "title" FROM "Wishlist";
|
||||
DROP TABLE "Wishlist";
|
||||
ALTER TABLE "new_Wishlist" RENAME TO "Wishlist";
|
||||
CREATE UNIQUE INDEX "Wishlist_slugUrlText_key" ON "Wishlist"("slugUrlText");
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
|
@ -12,21 +12,21 @@ datasource db {
|
|||
|
||||
model Wishlist {
|
||||
id String @id @default(uuid())
|
||||
public Boolean @default(true)
|
||||
title String
|
||||
imageSrc String
|
||||
slugUrlText String @unique
|
||||
description String?
|
||||
description String @default("")
|
||||
items Item[]
|
||||
}
|
||||
|
||||
model Item {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
url String?
|
||||
imageSrc String?
|
||||
url String @default("")
|
||||
imageSrc String @default("")
|
||||
description String
|
||||
comment String?
|
||||
bought Boolean @default(false)
|
||||
wishlist Wishlist @relation(fields: [wishlistId], references: [id])
|
||||
wishlist Wishlist @relation(fields: [wishlistId], references: [id])
|
||||
wishlistId String
|
||||
}
|
||||
|
|
68
prisma/seed.ts
Normal file
68
prisma/seed.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { PrismaClient, Prisma } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const wishlistData: Prisma.WishlistCreateInput[] = [
|
||||
{
|
||||
title: 'Junior',
|
||||
imageSrc:
|
||||
'https://unsplash.com/photos/JZ51o_-UOY8/download?force=true&w=200',
|
||||
description:
|
||||
'Li Europan lingues es membres del sam familie. Lor separat existentie es un myth. Por scientie, musica, sport etc, litot',
|
||||
slugUrlText: 'junior',
|
||||
items: {
|
||||
create: [
|
||||
{
|
||||
title: 'Goldfish 40442 | BrickHeadz',
|
||||
url: 'https://www.lego.com/en-de/product/goldfish-40442',
|
||||
imageSrc:
|
||||
'https://www.lego.com/cdn/cs/set/assets/blt1fc37afef51cfa9f/40442.jpg?fit=bounds&format=jpg&quality=80&width=1500&height=1500&dpr=1',
|
||||
description:
|
||||
'Cute goldfish and fry, build-and-display BrickHeadz™ model',
|
||||
},
|
||||
{
|
||||
title: 'Goldfish 40442 | BrickHeadz',
|
||||
url: 'https://www.lego.com/en-de/product/goldfish-40442',
|
||||
imageSrc: '',
|
||||
description:
|
||||
'Cute goldfish and fry, build-and-display BrickHeadz™ model',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Wedding',
|
||||
public: false,
|
||||
imageSrc:
|
||||
'https://unsplash.com/photos/8vaQKYnawHw/download?ixid=MnwxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNjQ0MDQ4MTIy&force=true&w=200',
|
||||
description: 'We are getting married',
|
||||
slugUrlText: 'wedding',
|
||||
},
|
||||
{
|
||||
title: '40th birthday',
|
||||
imageSrc:
|
||||
'https://unsplash.com/photos/poH6OvcEeXE/download?ixid=MnwxMjA3fDB8MXxzZWFyY2h8NHx8YmlydGhkYXl8fDB8fHx8MTY0NDA1NDEzNA&force=true&w=200',
|
||||
description: 'We are getting married',
|
||||
slugUrlText: '40th-birthday',
|
||||
},
|
||||
]
|
||||
|
||||
async function main() {
|
||||
console.log(`Start seeding ...`)
|
||||
for (const u of wishlistData) {
|
||||
const wishlist = await prisma.wishlist.create({
|
||||
data: u,
|
||||
})
|
||||
console.log(`Created wishlist with id: ${wishlist.id}`)
|
||||
}
|
||||
console.log(`Seeding finished.`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
9
sandbox.config.json
Normal file
9
sandbox.config.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"infiniteLoopProtection": true,
|
||||
"hardReloadOnChange": false,
|
||||
"view": "browser",
|
||||
"container": {
|
||||
"startScript": "demo",
|
||||
"node": "16"
|
||||
}
|
||||
}
|
22
src/App.vue
22
src/App.vue
|
@ -1,13 +1,14 @@
|
|||
<template>
|
||||
<div class="app max-w-[900px] mx-auto p-10">
|
||||
<main>
|
||||
<div class="m-4 h-full">
|
||||
<Header />
|
||||
<main class="mx-auto h-full max-w-[900px]">
|
||||
<router-view v-slot="{ Component }">
|
||||
<template v-if="Component">
|
||||
<div
|
||||
v-if="error"
|
||||
class="flex flex-row space-x-2 items-center content-center justify-center m-20 text-red-500"
|
||||
class="m-20 flex flex-row content-center items-center justify-center space-x-2 text-red-500"
|
||||
>
|
||||
<IconError class="w-4 h-4" />
|
||||
<IconError class="h-4 w-4 fill-red-500" />
|
||||
<span>{{ t('errors.generic.text') }}</span>
|
||||
</div>
|
||||
<suspense v-else>
|
||||
|
@ -16,9 +17,9 @@
|
|||
</template>
|
||||
<template #fallback>
|
||||
<div
|
||||
class="flex flex-row space-x-2 items-center content-center justify-center m-20"
|
||||
class="m-20 flex flex-row content-center items-center justify-center space-x-2"
|
||||
>
|
||||
<IconSpinner class="w-4 h-4" />
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span> {{ t('common.loading.text') }} </span>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -30,14 +31,17 @@
|
|||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useTitle } from '@vueuse/core'
|
||||
import { onErrorCaptured, ref } from 'vue'
|
||||
import { IconSpinner, IconError } from '@/components/icons'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
|
||||
const error = ref(null)
|
||||
useTitle(t('common.app-title.text'))
|
||||
|
||||
onErrorCaptured((e: any) => {
|
||||
const error = ref()
|
||||
|
||||
onErrorCaptured((e: unknown) => {
|
||||
console.error(e)
|
||||
error.value = e
|
||||
return false
|
||||
})
|
||||
|
|
|
@ -7,9 +7,8 @@ const build = async (opts = {}) => {
|
|||
const app = await initApp(opts)
|
||||
|
||||
routes.register(app)
|
||||
|
||||
app.register(staticFiles, {
|
||||
root: path.join(__dirname, 'static'),
|
||||
root: path.join(__dirname, '..', 'static'),
|
||||
})
|
||||
app.setNotFoundHandler((req, res) => {
|
||||
res.sendFile('index.html')
|
||||
|
|
36
src/api/config/auth.ts
Normal file
36
src/api/config/auth.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { notAuthorized } from './errors'
|
||||
|
||||
const error = notAuthorized('Unauthorized')
|
||||
|
||||
export default {
|
||||
init: async (app: FastifyInstance) => {
|
||||
if (!process.env.API_KEY) {
|
||||
throw new Error('ENV API_KEY is not set!')
|
||||
}
|
||||
app.decorateRequest('isAuthenticated', false)
|
||||
app.addHook(
|
||||
'onRequest',
|
||||
(request: FastifyRequest, reply: FastifyReply, done) => {
|
||||
if (request.headers.authorization) {
|
||||
const authHeader = request.headers.authorization.split(' ')
|
||||
request.log.debug(authHeader)
|
||||
if (
|
||||
authHeader[0] &&
|
||||
authHeader[0].trim().toLowerCase() === 'api-key' &&
|
||||
authHeader[1]
|
||||
) {
|
||||
if (authHeader[1] === process.env.API_KEY) {
|
||||
request.isAuthenticated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reply.context.config.protected && !request.isAuthenticated) {
|
||||
done(error)
|
||||
} else {
|
||||
done()
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
61
src/api/config/errors/index.ts
Normal file
61
src/api/config/errors/index.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { FastifyRequest, FastifyReply, FastifyError } from 'fastify'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
const errorIs = (e: unknown, c: string) =>
|
||||
e instanceof Prisma.PrismaClientKnownRequestError && e.code === c
|
||||
|
||||
export const defaultErrorHandler = (
|
||||
error: FastifyError,
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) => {
|
||||
if (error.validation) {
|
||||
error.code = '400'
|
||||
reply.send(error)
|
||||
} else if (errorIs(error, 'P2002')) {
|
||||
reply.send(
|
||||
uniqueKeyError(
|
||||
// @ts-expect-error: Object is possibly 'undefined'
|
||||
`${error.meta.target[0] || 'One of the fields'} has to be unique`
|
||||
)
|
||||
)
|
||||
} else if (errorIs(error, 'P2025')) {
|
||||
reply.callNotFound()
|
||||
} else if (error instanceof httpError) {
|
||||
reply.send(error)
|
||||
} else {
|
||||
request.log.error(error)
|
||||
const e = new httpError('unexpected error', 500, '500')
|
||||
reply.send(e)
|
||||
}
|
||||
}
|
||||
|
||||
export const notFoundHandler = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) => {
|
||||
reply.send(notFoundError())
|
||||
}
|
||||
|
||||
class httpError extends Error {
|
||||
code: string
|
||||
statusCode: number
|
||||
constructor(message: string, statusCode: number, code: string) {
|
||||
super(message)
|
||||
this.name = this.constructor.name
|
||||
Error.captureStackTrace(this, this.constructor)
|
||||
this.statusCode = statusCode
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export const notFoundError = () => {
|
||||
return new httpError('Not Found', 404, '404')
|
||||
}
|
||||
|
||||
export const uniqueKeyError = (msg: string, code = '4001') => {
|
||||
return new httpError(msg, 422, code)
|
||||
}
|
||||
export const notAuthorized = (msg: string, code = '401') => {
|
||||
return new httpError(msg, 401, code)
|
||||
}
|
|
@ -3,6 +3,16 @@ import Fastify, { FastifyContextConfig } from 'fastify'
|
|||
import compress from 'fastify-compress'
|
||||
import cors from 'fastify-cors'
|
||||
import { fastify as defaultConfig } from './'
|
||||
import auth from './auth'
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
interface FastifyContextConfig {
|
||||
protected?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export default async (opts: FastifyContextConfig = {}) => {
|
||||
const app = Fastify({
|
||||
|
@ -23,6 +33,7 @@ export default async (opts: FastifyContextConfig = {}) => {
|
|||
})
|
||||
|
||||
await app.register(compress)
|
||||
await auth.init(app)
|
||||
|
||||
return app
|
||||
}
|
||||
|
|
1
src/api/config/schemas/index.ts
Normal file
1
src/api/config/schemas/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './wishlist'
|
53
src/api/config/schemas/wishlist.ts
Normal file
53
src/api/config/schemas/wishlist.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
export const wishlistItemRequestSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['title', 'description'],
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
url: { type: 'string' },
|
||||
imageSrc: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
bought: { type: 'boolean' },
|
||||
},
|
||||
}
|
||||
|
||||
export const wishlistItemResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
title: { type: 'string' },
|
||||
url: { type: 'string' },
|
||||
imageSrc: { type: 'string' },
|
||||
description: { type: 'string', maxLength: 300 },
|
||||
bought: { type: 'boolean' },
|
||||
wishlistId: { type: 'string' },
|
||||
},
|
||||
}
|
||||
|
||||
export const wishlistRequestSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['title', 'imageSrc', 'slugUrlText'],
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
public: { type: 'boolean' },
|
||||
imageSrc: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
slugUrlText: { type: 'string' },
|
||||
},
|
||||
}
|
||||
export const wishlistResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
public: { type: 'boolean' },
|
||||
imageSrc: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
slugUrlText: { type: 'string' },
|
||||
items: {
|
||||
type: 'array',
|
||||
items: wishlistItemResponseSchema,
|
||||
},
|
||||
},
|
||||
}
|
|
@ -1,15 +1,18 @@
|
|||
import { prisma } from '../../services'
|
||||
import { Wishlist, WishlistItem } from '@/types'
|
||||
|
||||
interface WishlistWhereInput {
|
||||
public?: boolean
|
||||
}
|
||||
|
||||
export default {
|
||||
getAll: async (): Promise<any> => {
|
||||
return await prisma.client.wishlist.findMany({
|
||||
getAll: async (where?: WishlistWhereInput): Promise<Wishlist[]> => {
|
||||
return (await prisma.client.wishlist.findMany({
|
||||
where,
|
||||
include: { items: false },
|
||||
})
|
||||
})) as Wishlist[]
|
||||
},
|
||||
getBySlugUrlText: async (
|
||||
value: string,
|
||||
includeItems = false
|
||||
): Promise<any> => {
|
||||
getBySlugUrlText: async (value: string, includeItems = false) => {
|
||||
return await prisma.client.wishlist.findUnique({
|
||||
where: {
|
||||
slugUrlText: value,
|
||||
|
@ -17,6 +20,44 @@ export default {
|
|||
include: { items: includeItems },
|
||||
})
|
||||
},
|
||||
create: async (payload: Wishlist) => {
|
||||
return await prisma.client.wishlist.create({
|
||||
data: payload,
|
||||
})
|
||||
},
|
||||
update: async (id: string, payload: Wishlist) => {
|
||||
return await prisma.client.wishlist.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
data: {
|
||||
...payload,
|
||||
},
|
||||
})
|
||||
},
|
||||
delete: async (id: string) => {
|
||||
return await prisma.client.wishlist.delete({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
})
|
||||
},
|
||||
createItem: async (wishlistId: string, payload: WishlistItem) => {
|
||||
const wishlist = await prisma.client.wishlist.update({
|
||||
where: {
|
||||
id: wishlistId,
|
||||
},
|
||||
data: {
|
||||
items: {
|
||||
create: {
|
||||
...payload,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { items: true },
|
||||
})
|
||||
return wishlist.items.pop()
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
updateItem: async (itemId: number, payload: any) => {
|
||||
return await prisma.client.item.update({
|
||||
|
@ -28,4 +69,11 @@ export default {
|
|||
},
|
||||
})
|
||||
},
|
||||
deleteItem: async (itemId: number) => {
|
||||
return await prisma.client.item.delete({
|
||||
where: {
|
||||
id: itemId,
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
import { FastifyInstance } from 'fastify'
|
||||
import { default as wishlistRoute } from './wishlist/'
|
||||
import { defaultErrorHandler, notFoundHandler } from '../config/errors'
|
||||
import { default as utilsRoute } from './utils/'
|
||||
|
||||
export default {
|
||||
register: (app: FastifyInstance) => {
|
||||
return app.register(
|
||||
async (app) => {
|
||||
await app.setNotFoundHandler(notFoundHandler)
|
||||
await app.setErrorHandler(defaultErrorHandler)
|
||||
await app.register(wishlistRoute, { prefix: '/wishlist' })
|
||||
await app.register(utilsRoute, { prefix: '/utils' })
|
||||
},
|
||||
{ prefix: '/api' }
|
||||
)
|
||||
|
|
6
src/api/routes/utils/index.ts
Normal file
6
src/api/routes/utils/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { FastifyInstance } from 'fastify'
|
||||
import { fetchOpenGraph } from './opengraph'
|
||||
|
||||
export default async (app: FastifyInstance) => {
|
||||
await app.route(fetchOpenGraph)
|
||||
}
|
49
src/api/routes/utils/opengraph.ts
Normal file
49
src/api/routes/utils/opengraph.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { FastifyRequest, FastifyReply, RouteOptions } from 'fastify'
|
||||
import ogs, { OpenGraphImage } from 'open-graph-scraper'
|
||||
|
||||
interface fetchOpenGraphRequest extends FastifyRequest {
|
||||
query: {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchOpenGraph = <RouteOptions>{
|
||||
method: 'GET',
|
||||
url: '/fetch-open-graph',
|
||||
schema: {
|
||||
querystring: {
|
||||
type: 'object',
|
||||
required: ['url'],
|
||||
properties: {
|
||||
url: { type: 'string', format: 'uri' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
imageSrc: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (request: fetchOpenGraphRequest, reply: FastifyReply) => {
|
||||
const { result } = await ogs({
|
||||
url: request.query.url,
|
||||
})
|
||||
request.log.debug(result)
|
||||
if (result.success) {
|
||||
const image =
|
||||
result.ogImage && (result.ogImage as OpenGraphImage).url
|
||||
? (result.ogImage as OpenGraphImage).url
|
||||
: ''
|
||||
reply.send({
|
||||
title: result.ogTitle || '',
|
||||
description: result.ogDescription || '',
|
||||
imageSrc: image,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
62
src/api/routes/wishlist/create.ts
Normal file
62
src/api/routes/wishlist/create.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { Wishlist, WishlistItem } from '@/types'
|
||||
import { FastifyRequest, FastifyReply, RouteOptions } from 'fastify'
|
||||
import { wishlist } from '../../models'
|
||||
import {
|
||||
wishlistItemRequestSchema,
|
||||
wishlistItemResponseSchema,
|
||||
wishlistRequestSchema,
|
||||
wishlistResponseSchema,
|
||||
} from '../../config/schemas'
|
||||
|
||||
interface createItemRequest extends FastifyRequest {
|
||||
params: {
|
||||
wishlistId: string
|
||||
}
|
||||
}
|
||||
|
||||
export const createList = <RouteOptions>{
|
||||
method: 'POST',
|
||||
url: '/',
|
||||
config: {
|
||||
protected: true,
|
||||
},
|
||||
schema: {
|
||||
body: wishlistRequestSchema,
|
||||
response: {
|
||||
201: wishlistResponseSchema,
|
||||
},
|
||||
},
|
||||
handler: async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
request.log.debug(request.body)
|
||||
const item = await wishlist.create(request.body as Wishlist)
|
||||
reply.code(201).send(item)
|
||||
},
|
||||
}
|
||||
|
||||
export const createItem = <RouteOptions>{
|
||||
method: 'POST',
|
||||
url: '/:wishlistId/item',
|
||||
config: {
|
||||
protected: true,
|
||||
},
|
||||
schema: {
|
||||
body: wishlistItemRequestSchema,
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
wishlistId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
201: wishlistItemResponseSchema,
|
||||
},
|
||||
},
|
||||
handler: async (request: createItemRequest, reply: FastifyReply) => {
|
||||
request.log.debug(request.body)
|
||||
const item = await wishlist.createItem(
|
||||
request.params.wishlistId,
|
||||
request.body as WishlistItem
|
||||
)
|
||||
reply.code(201).send(item)
|
||||
},
|
||||
}
|
56
src/api/routes/wishlist/delete.ts
Normal file
56
src/api/routes/wishlist/delete.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { FastifyRequest, FastifyReply, RouteOptions } from 'fastify'
|
||||
import { wishlist } from '../../models'
|
||||
|
||||
interface deleteRequest extends FastifyRequest {
|
||||
params: {
|
||||
wishlistId: string
|
||||
}
|
||||
}
|
||||
|
||||
interface deleteItemRequest extends FastifyRequest {
|
||||
params: {
|
||||
wishlistId: string
|
||||
itemId: number
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteList = <RouteOptions>{
|
||||
method: 'DELETE',
|
||||
url: '/:wishlistId',
|
||||
config: {
|
||||
protected: true,
|
||||
},
|
||||
schema: {
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
wishlistId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (request: deleteRequest, reply: FastifyReply) => {
|
||||
await wishlist.delete(request.params.wishlistId)
|
||||
reply.code(204).send()
|
||||
},
|
||||
}
|
||||
|
||||
export const deleteItem = <RouteOptions>{
|
||||
method: 'DELETE',
|
||||
url: '/:wishlistId/item/:itemId',
|
||||
config: {
|
||||
protected: true,
|
||||
},
|
||||
schema: {
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
wishlistId: { type: 'string' },
|
||||
itemId: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (request: deleteItemRequest, reply: FastifyReply) => {
|
||||
await wishlist.deleteItem(request.params.itemId)
|
||||
reply.code(204).send()
|
||||
},
|
||||
}
|
|
@ -1,9 +1,17 @@
|
|||
import { FastifyInstance } from 'fastify'
|
||||
import { getAll, getBySlugUrl } from './read'
|
||||
import { updateItem } from './update'
|
||||
import { updateList, updateItem, itemBought } from './update'
|
||||
import { createList, createItem } from './create'
|
||||
import { deleteList, deleteItem } from './delete'
|
||||
|
||||
export default async (app: FastifyInstance) => {
|
||||
await app.route(getAll)
|
||||
await app.route(getBySlugUrl)
|
||||
await app.route(createList)
|
||||
await app.route(createItem)
|
||||
await app.route(updateList)
|
||||
await app.route(updateItem)
|
||||
await app.route(itemBought)
|
||||
await app.route(deleteList)
|
||||
await app.route(deleteItem)
|
||||
}
|
||||
|
|
|
@ -1,27 +1,21 @@
|
|||
import { FastifyRequest, FastifyReply, RouteOptions } from 'fastify'
|
||||
import { wishlist } from '../../models'
|
||||
import { wishlistResponseSchema } from '../../config/schemas'
|
||||
|
||||
export const getAll = <any>{
|
||||
export const getAll = <RouteOptions>{
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
schema: {
|
||||
response: {
|
||||
200: {
|
||||
type: 'array',
|
||||
items: {
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
imageSrc: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
slugUrlText: { type: 'string' },
|
||||
},
|
||||
},
|
||||
items: wishlistResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async () => {
|
||||
return await wishlist.getAll()
|
||||
handler: async (request) => {
|
||||
const where = request.isAuthenticated ? {} : { public: true }
|
||||
return await wishlist.getAll(where)
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -36,31 +30,7 @@ export const getBySlugUrl = <RouteOptions>{
|
|||
url: '/:slugText',
|
||||
schema: {
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
imageSrc: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
slugUrlText: { type: 'string' },
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
title: { type: 'string' },
|
||||
url: { type: 'string' },
|
||||
imageSrc: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
comment: { type: 'string' },
|
||||
bought: { type: 'boolean' },
|
||||
wishlistId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
200: wishlistResponseSchema,
|
||||
},
|
||||
},
|
||||
handler: async (request: GetBySlugUrlTextRequest, reply: FastifyReply) => {
|
||||
|
@ -68,10 +38,7 @@ export const getBySlugUrl = <RouteOptions>{
|
|||
if (list) {
|
||||
return list
|
||||
} else {
|
||||
return reply.code(404).send({
|
||||
error: 'notFound',
|
||||
http: 404,
|
||||
})
|
||||
return reply.callNotFound()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,58 +1,97 @@
|
|||
import { Wishlist } from '@/types'
|
||||
import { FastifyRequest, FastifyReply, RouteOptions } from 'fastify'
|
||||
import { wishlist } from '../../models'
|
||||
import {
|
||||
wishlistRequestSchema,
|
||||
wishlistResponseSchema,
|
||||
wishlistItemRequestSchema,
|
||||
wishlistItemResponseSchema,
|
||||
} from '../../config/schemas'
|
||||
|
||||
interface GetBySlugUrlTextRequest extends FastifyRequest {
|
||||
interface updateRequest extends FastifyRequest {
|
||||
params: {
|
||||
wishlistId: string
|
||||
}
|
||||
}
|
||||
|
||||
interface updateItemRequest extends FastifyRequest {
|
||||
params: {
|
||||
wishlistId: string
|
||||
itemId: number
|
||||
}
|
||||
}
|
||||
|
||||
export const updateItem = <RouteOptions>{
|
||||
export const updateList = <RouteOptions>{
|
||||
method: 'PUT',
|
||||
url: '/:wishlistId/item/:itemId',
|
||||
url: '/:wishlistId',
|
||||
config: {
|
||||
protected: true,
|
||||
},
|
||||
schema: {
|
||||
body: {
|
||||
body: wishlistRequestSchema,
|
||||
params: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
url: { type: 'string' },
|
||||
image: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
comment: { type: 'string' },
|
||||
bought: { type: 'boolean' },
|
||||
wishlistId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
title: { type: 'string' },
|
||||
url: { type: 'string' },
|
||||
image: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
comment: { type: 'string' },
|
||||
bought: { type: 'boolean' },
|
||||
wishlistId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
200: wishlistResponseSchema,
|
||||
},
|
||||
},
|
||||
handler: async (request: GetBySlugUrlTextRequest, reply: FastifyReply) => {
|
||||
handler: async (request: updateRequest, reply: FastifyReply) => {
|
||||
request.log.debug(request.body)
|
||||
const item = await wishlist.updateItem(
|
||||
Number(request.params.itemId),
|
||||
request.body
|
||||
const item = await wishlist.update(
|
||||
request.params.wishlistId,
|
||||
request.body as Wishlist
|
||||
)
|
||||
reply.code(201).send(item)
|
||||
},
|
||||
}
|
||||
|
||||
export const updateItem = <RouteOptions>{
|
||||
method: 'PUT',
|
||||
url: '/:wishlistId/item/:itemId',
|
||||
config: {
|
||||
protected: true,
|
||||
},
|
||||
schema: {
|
||||
body: wishlistItemRequestSchema,
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
wishlistId: { type: 'string' },
|
||||
itemId: { type: 'number' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: wishlistItemResponseSchema,
|
||||
},
|
||||
},
|
||||
handler: async (request: updateItemRequest, reply: FastifyReply) => {
|
||||
request.log.debug(request.body)
|
||||
reply.send(await wishlist.updateItem(request.params.itemId, request.body))
|
||||
},
|
||||
}
|
||||
|
||||
export const itemBought = <RouteOptions>{
|
||||
method: 'POST',
|
||||
url: '/:wishlistId/item/:itemId/bought',
|
||||
schema: {
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
wishlistId: { type: 'string' },
|
||||
itemId: { type: 'number' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: wishlistItemResponseSchema,
|
||||
},
|
||||
},
|
||||
handler: async (request: updateItemRequest, reply: FastifyReply) => {
|
||||
reply.send(
|
||||
await wishlist.updateItem(request.params.itemId, { bought: true })
|
||||
)
|
||||
if (item) {
|
||||
return item
|
||||
} else {
|
||||
return reply.code(404).send({
|
||||
error: 'notFound',
|
||||
http: 404,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100% !important;
|
||||
height: 100%;
|
||||
}
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
icon?: any
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<button
|
||||
class="border-2 border-stone-200 text-stone-500 hover:bg-stone-100 rounded-md py-1 px-2 flex-row items-center w-fit inline-flex justify-center"
|
||||
>
|
||||
<component v-if="icon" :is="icon" class="w-4 h-4 mr-1" />
|
||||
<span>
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
37
src/components/ButtonBase.vue
Normal file
37
src/components/ButtonBase.vue
Normal file
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
icon: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'secondary',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex w-fit flex-row items-center justify-center rounded-md border-2 py-1 px-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="{
|
||||
'border-0 bg-cyan-600 text-white disabled:bg-cyan-600 disabled:hover:bg-cyan-600 dark:bg-cyan-600 dark:hover:bg-cyan-600 dark:disabled:hover:bg-cyan-600':
|
||||
mode === 'primary',
|
||||
'border-0 bg-rose-500 text-white disabled:bg-rose-500 disabled:hover:bg-rose-500 dark:bg-rose-500 dark:hover:bg-rose-500 dark:disabled:hover:bg-rose-500':
|
||||
mode === 'danger',
|
||||
'border-stone-200 text-stone-500 hover:bg-stone-100 disabled:hover:bg-white dark:border-stone-700 dark:text-white/70 dark:hover:bg-stone-700 disabled:hover:dark:bg-stone-900':
|
||||
mode === 'secondary',
|
||||
}"
|
||||
>
|
||||
<component
|
||||
v-if="icon"
|
||||
:is="icon"
|
||||
class="mr-1 h-4 w-4 fill-white"
|
||||
:class="{
|
||||
'fill-stone-500 dark:fill-white/70 ': mode === 'secondary',
|
||||
}"
|
||||
/>
|
||||
<span>
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
18
src/components/CreateWishlistTile.vue
Normal file
18
src/components/CreateWishlistTile.vue
Normal file
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-40 w-40 overflow-hidden rounded-full bg-stone-400">
|
||||
<div
|
||||
class="text-md font-semi-bold absolute inset-x-0 top-0 flex h-40 items-center justify-center space-x-2 p-6 text-center text-white dark:text-white/75"
|
||||
>
|
||||
<IconCreation class="h-6 w-6 shrink-0 fill-white dark:fill-white/75" />
|
||||
<span class="text-left"
|
||||
>{{ t('components.create-wishlist-title.text') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped></style>
|
120
src/components/FormWishlist.vue
Normal file
120
src/components/FormWishlist.vue
Normal file
|
@ -0,0 +1,120 @@
|
|||
<template>
|
||||
<div class="flex w-full flex-col justify-between space-y-2">
|
||||
<form @submit="onSubmit" class="w-full flex-col">
|
||||
<InputText
|
||||
name="title"
|
||||
type="text"
|
||||
required
|
||||
:value="wishlist?.title"
|
||||
:label="t('components.form-wishlist.title.label')"
|
||||
/>
|
||||
<InputToggle
|
||||
name="public"
|
||||
:value="wishlist?.public"
|
||||
:label="t('components.form-wishlist.public.label')"
|
||||
/>
|
||||
<InputTextArea
|
||||
name="description"
|
||||
type="text"
|
||||
:value="wishlist?.description"
|
||||
height-class="h-20"
|
||||
:label="t('components.form-wishlist.description.label')"
|
||||
/>
|
||||
<InputText
|
||||
name="imageSrc"
|
||||
type="text"
|
||||
required
|
||||
:value="wishlist?.imageSrc"
|
||||
:label="t('components.form-wishlist.image-src.label')"
|
||||
/>
|
||||
<InputFile
|
||||
name="imageFile"
|
||||
required
|
||||
:label="t('components.form-wishlist.image-file.label')"
|
||||
/>
|
||||
<InputText
|
||||
name="slugUrlText"
|
||||
type="text"
|
||||
required
|
||||
:value="wishlist?.slugUrlText"
|
||||
:label="t('components.form-wishlist.slug-text.label')"
|
||||
/>
|
||||
<ButtonBase
|
||||
class="h-12 w-full"
|
||||
mode="primary"
|
||||
:disabled="!meta.valid"
|
||||
:icon="IconSave"
|
||||
>{{ t('components.form-wishlist.submit.text') }}</ButtonBase
|
||||
>
|
||||
</form>
|
||||
<ButtonBase
|
||||
v-if="wishlist?.id"
|
||||
class="h-12 w-full"
|
||||
mode="danger"
|
||||
@click.prevent="() => emits('delete')"
|
||||
:icon="IconDelete"
|
||||
>{{ t('components.form-wishlist.delete-button.text') }}</ButtonBase
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Wishlist } from '@/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useForm } from 'vee-validate'
|
||||
import IconSave from '@/components/icons/IconSave.vue'
|
||||
import IconDelete from '@/components/icons/IconDelete.vue'
|
||||
import { object, string, boolean } from 'yup'
|
||||
|
||||
const props = defineProps<{
|
||||
wishlist?: Wishlist
|
||||
}>()
|
||||
|
||||
const emits = defineEmits(['create', 'update', 'delete'])
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const schema = object().shape(
|
||||
{
|
||||
title: string().required(
|
||||
t('components.form-wishlist.title.error-requried')
|
||||
),
|
||||
public: boolean(),
|
||||
description: string().max(
|
||||
300,
|
||||
t('components.form-wishlist.description.error-max')
|
||||
),
|
||||
slugUrlText: string()
|
||||
.required(t('components.form-wishlist.slug-text.error-requried'))
|
||||
.matches(/^[\w-]+$/, t('components.form-wishlist.slug-text.error-regex')),
|
||||
imageSrc: string().when('imageFile', {
|
||||
is: (imageFile: string) => !imageFile || imageFile.length === 0,
|
||||
then: string().required(
|
||||
t('components.form-wishlist.image-src.error-requried')
|
||||
),
|
||||
}),
|
||||
imageFile: string().when('imageSrc', {
|
||||
is: (imageSrc: string) => !imageSrc || imageSrc.length === 0,
|
||||
then: string().required(
|
||||
t('components.form-wishlist.image-file.error-requried')
|
||||
),
|
||||
}),
|
||||
},
|
||||
//@ts-expect-error ...
|
||||
['imageSrc', 'imageFile']
|
||||
)
|
||||
|
||||
const { handleSubmit, meta } = useForm({
|
||||
//@ts-expect-error ...
|
||||
validationSchema: schema,
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
values.imageSrc = values.imageFile || values.imageSrc
|
||||
if (props.wishlist?.id) {
|
||||
emits('update', values)
|
||||
} else {
|
||||
emits('create', values)
|
||||
}
|
||||
})
|
||||
</script>
|
114
src/components/FormWishlistItem.vue
Normal file
114
src/components/FormWishlistItem.vue
Normal file
|
@ -0,0 +1,114 @@
|
|||
<template>
|
||||
<div
|
||||
class="flex h-fit flex-col space-x-0 space-y-6 overflow-hidden sm:flex-row sm:space-x-2 sm:space-y-0"
|
||||
>
|
||||
<ImagePreview
|
||||
class="max-h-44 flex-shrink-0 flex-grow-0 object-cover sm:w-1/4"
|
||||
:src="item?.imageSrc"
|
||||
:alt="item?.title"
|
||||
/>
|
||||
<div class="flex w-full flex-col justify-between space-y-2 p-2">
|
||||
<div class="mb-4 flex flex-row items-center space-x-2 text-xl">
|
||||
<IconCreation v-if="!item?.id" class="h-6 w-6 fill-current" />
|
||||
<IconPencil v-else class="h-5 w-5 fill-current" />
|
||||
<h1 v-if="!item?.id">
|
||||
{{ t('components.form-wishlist-item.headline-new-item.text') }}
|
||||
</h1>
|
||||
<h1 v-else>
|
||||
{{ t('components.form-wishlist-item.headline-change-item.text') }}
|
||||
</h1>
|
||||
</div>
|
||||
<form @submit="onSubmit" class="w-full flex-col">
|
||||
<InputText
|
||||
name="title"
|
||||
type="text"
|
||||
required
|
||||
:value="item?.title"
|
||||
:label="t('components.form-wishlist-item.title.label')"
|
||||
/>
|
||||
<InputTextArea
|
||||
name="description"
|
||||
type="text"
|
||||
required
|
||||
:value="item?.description"
|
||||
height-class="h-20"
|
||||
:label="t('components.form-wishlist-item.description.label')"
|
||||
/>
|
||||
<InputText
|
||||
name="url"
|
||||
type="text"
|
||||
:value="item?.url"
|
||||
:label="t('components.form-wishlist-item.url.label')"
|
||||
/>
|
||||
<InputText
|
||||
name="imageSrc"
|
||||
type="text"
|
||||
:value="item?.imageSrc"
|
||||
:label="t('components.form-wishlist-item.image-src.label')"
|
||||
/>
|
||||
<InputToggle
|
||||
v-if="item?.id"
|
||||
name="bought"
|
||||
:value="item?.bought"
|
||||
:label="t('components.form-wishlist-item.bought.label')"
|
||||
/>
|
||||
<ButtonBase
|
||||
class="h-12 w-full"
|
||||
mode="primary"
|
||||
:disabled="!meta.valid"
|
||||
:icon="IconSave"
|
||||
>{{ t('components.form-wishlist-item.submit.text') }}</ButtonBase
|
||||
>
|
||||
</form>
|
||||
<ButtonBase
|
||||
v-if="item?.id"
|
||||
class="h-12 w-full"
|
||||
mode="danger"
|
||||
@click.prevent="() => emits('delete')"
|
||||
:icon="IconDelete"
|
||||
>{{ t('components.form-wishlist-item.delete-button.text') }}</ButtonBase
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { object, string, boolean } from 'yup'
|
||||
import { WishlistItem } from '@/types'
|
||||
import IconSave from '@/components/icons/IconSave.vue'
|
||||
import IconDelete from '@/components/icons/IconDelete.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
item?: WishlistItem
|
||||
}>()
|
||||
const emits = defineEmits(['update', 'create', 'delete'])
|
||||
const { t } = useI18n()
|
||||
|
||||
const schema = object({
|
||||
title: string().required(
|
||||
t('components.form-wishlist-item.title.error-requried')
|
||||
),
|
||||
description: string()
|
||||
.required(t('components.form-wishlist-item.description.error-requried'))
|
||||
.max(300, t('components.form-wishlist-item.description.error-max')),
|
||||
url: string().url(t('components.form-wishlist-item.url.error-url')),
|
||||
imageSrc: string().url(
|
||||
t('components.form-wishlist-item.image-src.error-url')
|
||||
),
|
||||
bought: boolean(),
|
||||
})
|
||||
|
||||
const { handleSubmit, resetForm, meta } = useForm({
|
||||
validationSchema: schema,
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
if (!props.item?.id) {
|
||||
emits('create', values)
|
||||
resetForm()
|
||||
} else {
|
||||
emits('update', values)
|
||||
}
|
||||
})
|
||||
</script>
|
39
src/components/Header.vue
Normal file
39
src/components/Header.vue
Normal file
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<header
|
||||
class="mb-4 flex flex-row items-center space-x-3 opacity-60 sm:justify-end"
|
||||
>
|
||||
<div
|
||||
v-if="isAuthenticated"
|
||||
class="mr-4 inline-flex grow cursor-pointer items-center space-x-1 sm:grow-0"
|
||||
@click="() => toggle()"
|
||||
>
|
||||
<IconToggleOn v-if="editMode" class="h-6 w-6 fill-emerald-700" />
|
||||
<IconToggleOff v-else class="h-6 w-6 cursor-pointer fill-current" />
|
||||
<span>{{ t('components.header.edit-mode.text') }}</span>
|
||||
</div>
|
||||
<router-link to="/">
|
||||
<IconHome class="h-6 w-6 cursor-pointer fill-current"></IconHome>
|
||||
</router-link>
|
||||
<div @click="() => toggleDark()">
|
||||
<IconLightDark class="h-6 w-6 cursor-pointer fill-current" />
|
||||
</div>
|
||||
<div v-if="isAuthenticated" @click="() => setToken('')">
|
||||
<IconLogout class="h-6 w-6 cursor-pointer fill-current"></IconLogout>
|
||||
</div>
|
||||
<router-link to="/login" v-else>
|
||||
<IconLogin class="h-6 w-6 cursor-pointer fill-current"></IconLogin>
|
||||
</router-link>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDark, useToggle } from '@vueuse/core'
|
||||
import { useAuth, useEditMode } from '@/composables'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isAuthenticated, setToken } = useAuth()
|
||||
const { state: editMode, toggle } = useEditMode()
|
||||
|
||||
const toggleDark = useToggle(useDark())
|
||||
</script>
|
19
src/components/ImagePreview.vue
Normal file
19
src/components/ImagePreview.vue
Normal file
|
@ -0,0 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img v-if="src" :src="src" :alt="alt" class="object-cover" />
|
||||
<div v-else class="flex items-center justify-center bg-stone-100">
|
||||
<IconImagePlaceholder class="h-36 w-36 fill-stone-300" />
|
||||
</div>
|
||||
</template>
|
23
src/components/ImageTile.vue
Normal file
23
src/components/ImageTile.vue
Normal file
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
title?: string
|
||||
imageSrc: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-40 w-40 overflow-hidden rounded-full">
|
||||
<img :src="imageSrc" class="h-full w-full object-cover" :alt="title" />
|
||||
<div
|
||||
v-if="title"
|
||||
class="text-background text-md absolute inset-x-0 top-0 flex h-40 items-center justify-center text-center font-bold text-white dark:text-white/75"
|
||||
>
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.text-background {
|
||||
background: linear-gradient(0deg, #00000088 30%, #ffffff44 100%);
|
||||
}
|
||||
</style>
|
123
src/components/InputFile.vue
Normal file
123
src/components/InputFile.vue
Normal file
|
@ -0,0 +1,123 @@
|
|||
<template>
|
||||
<div class="relative mb-8">
|
||||
<label class="mb-1 block w-full" :for="name"
|
||||
>{{ label }}<span v-if="required" class="text-red-500">*</span></label
|
||||
>
|
||||
<div
|
||||
class="flex h-24 w-full flex-row items-center space-x-10 rounded-md border-2 border-solid border-stone-300 bg-transparent px-2 outline-none dark:border-stone-700"
|
||||
:class="{
|
||||
'border-rose-500': !!errorMessage,
|
||||
'border-dotted bg-stone-200/20': showDropzone,
|
||||
}"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent="showDropzone = true"
|
||||
@dragleave.prevent="showDropzone = false"
|
||||
>
|
||||
<div class="h-20 w-20">
|
||||
<ImagePreview :src="value" class="h-full w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<i18n-t
|
||||
keypath="components.file.text-dropzone"
|
||||
tag="p"
|
||||
for="components.file.text-dropzone-link"
|
||||
>
|
||||
<button
|
||||
class="cursor-pointer text-cyan-600"
|
||||
@click.prevent="fileInput.click()"
|
||||
>
|
||||
{{ t('components.file.text-dropzone-link') }}
|
||||
</button>
|
||||
</i18n-t>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="hidden"
|
||||
:name="name"
|
||||
:id="name"
|
||||
type="file"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="absolute mt-2 text-sm text-rose-500" v-show="errorMessage">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useField } from 'vee-validate'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
type FileEventTarget = EventTarget & { files: FileList }
|
||||
const fileInput = ref()
|
||||
const showDropzone = ref(false)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
accept: {
|
||||
type: String,
|
||||
default: 'image/*',
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const { value, errorMessage, setErrors } = useField(props.name, undefined, {})
|
||||
|
||||
const convertBase64 = (file: File): Promise<string | null> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader()
|
||||
fileReader.readAsDataURL(file)
|
||||
|
||||
let image = new Image()
|
||||
fileReader.onload = () => {
|
||||
image.src = fileReader.result as string
|
||||
image.onload = function () {
|
||||
var height = image.height
|
||||
var width = image.width
|
||||
if (height > 200 || width > 200) {
|
||||
setErrors([t('components.form-wishlist.image-file.error-image-size')])
|
||||
return resolve('')
|
||||
}
|
||||
resolve(fileReader.result as string)
|
||||
}
|
||||
}
|
||||
|
||||
fileReader.onerror = (error) => {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
const base64String = await convertBase64(file)
|
||||
if (base64String) value.value = base64String
|
||||
}
|
||||
|
||||
const handleChange = async (event: Event) => {
|
||||
const file = (event.target as FileEventTarget).files[0]
|
||||
handleFile(file)
|
||||
}
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
showDropzone.value = false
|
||||
let droppedFiles = event.dataTransfer?.files
|
||||
if (!droppedFiles) return
|
||||
handleFile(droppedFiles[0])
|
||||
}
|
||||
</script>
|
62
src/components/InputText.vue
Normal file
62
src/components/InputText.vue
Normal file
|
@ -0,0 +1,62 @@
|
|||
<template>
|
||||
<div class="relative mb-8">
|
||||
<label class="mb-1 block w-full" :for="name"
|
||||
>{{ label }}<span v-if="required" class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
class="h-12 w-full rounded-md border-2 border-solid border-stone-300 bg-transparent px-2 outline-none dark:border-stone-700"
|
||||
:class="{ 'border-rose-500': !!errorMessage }"
|
||||
:name="name"
|
||||
:id="name"
|
||||
:type="type"
|
||||
:value="inputValue"
|
||||
:placeholder="placeholder"
|
||||
@input="handleChange"
|
||||
@blur="handleBlur"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
|
||||
<p class="absolute mt-2 text-sm text-rose-500" v-show="errorMessage">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useField } from 'vee-validate'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const {
|
||||
value: inputValue,
|
||||
errorMessage,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
} = useField(props.name, undefined, {
|
||||
initialValue: props.value,
|
||||
})
|
||||
</script>
|
66
src/components/InputTextArea.vue
Normal file
66
src/components/InputTextArea.vue
Normal file
|
@ -0,0 +1,66 @@
|
|||
<template>
|
||||
<div class="relative mb-8">
|
||||
<label class="mb-1 block w-full" :for="name"
|
||||
>{{ label }}<span v-if="required" class="text-red-500">*</span></label
|
||||
>
|
||||
<textarea
|
||||
class="w-full rounded-md border-2 border-solid border-stone-300 bg-transparent px-2 outline-none dark:border-stone-700"
|
||||
:class="[heightClass, !!errorMessage ? 'border-rose-500' : '']"
|
||||
:name="name"
|
||||
:id="name"
|
||||
:type="type"
|
||||
:value="inputValue"
|
||||
:placeholder="placeholder"
|
||||
@input="handleChange"
|
||||
@blur="handleBlur"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
|
||||
<p class="absolute mt-2 text-sm text-rose-500" v-show="errorMessage">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useField } from 'vee-validate'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
heightClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const {
|
||||
value: inputValue,
|
||||
errorMessage,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
} = useField(props.name, undefined, {
|
||||
initialValue: props.value,
|
||||
})
|
||||
</script>
|
84
src/components/InputToggle.vue
Normal file
84
src/components/InputToggle.vue
Normal file
|
@ -0,0 +1,84 @@
|
|||
<template>
|
||||
<div class="relative mb-8">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-bind="$attrs"
|
||||
type="checkbox"
|
||||
:checked="checked"
|
||||
class="input sr-only"
|
||||
@change="handleChange(!checked)"
|
||||
/>
|
||||
<span class="switch"></span>
|
||||
<span class="ml-3">{{ label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useField } from 'vee-validate'
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const { checked, handleChange } = useField(props.name, undefined, {
|
||||
type: 'checkbox',
|
||||
initialValue: props.value,
|
||||
checkedValue: true,
|
||||
uncheckedValue: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.switch {
|
||||
--switch-container-width: 50px;
|
||||
--switch-size: calc(var(--switch-container-width) / 2);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
height: var(--switch-size);
|
||||
flex-basis: var(--switch-container-width);
|
||||
border-radius: var(--switch-size);
|
||||
transition: background-color 0.25s ease-in-out;
|
||||
@apply bg-stone-500;
|
||||
}
|
||||
|
||||
.switch::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
height: calc(var(--switch-size) - 4px);
|
||||
width: calc(var(--switch-size) - 4px);
|
||||
border-radius: 9999px;
|
||||
@apply bg-white;
|
||||
transition: 0.375s transform ease-in-out;
|
||||
}
|
||||
|
||||
.input:checked + .switch {
|
||||
@apply bg-emerald-500;
|
||||
}
|
||||
|
||||
.input:checked + .switch::before {
|
||||
@apply border-emerald-500;
|
||||
transform: translateX(
|
||||
calc(var(--switch-container-width) - var(--switch-size))
|
||||
);
|
||||
}
|
||||
</style>
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts" setup>
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import { useModal } from '@/composables'
|
||||
|
||||
const modal = useModal()
|
||||
|
@ -9,49 +8,46 @@ const modal = useModal()
|
|||
<div
|
||||
v-if="modal.isShown"
|
||||
data-test="modal"
|
||||
class="fixed z-10 inset-0 overflow-y-auto"
|
||||
class="fixed inset-0 z-10 overflow-y-auto"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
|
||||
class="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500/75 transition-opacity"></div>
|
||||
<div class="fixed inset-0 bg-stone-700/75 transition-opacity"></div>
|
||||
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
<span class="hidden sm:inline-block sm:h-screen sm:align-middle"
|
||||
>​</span
|
||||
>
|
||||
<div
|
||||
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
|
||||
class="inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all dark:bg-stone-900 sm:my-8 sm:w-full sm:max-w-lg sm:align-middle"
|
||||
>
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3
|
||||
class="text-lg leading-6 font-medium text-gray-900"
|
||||
data-test="modal-title"
|
||||
>
|
||||
<h3 class="text-lg font-medium leading-6" data-test="modal-title">
|
||||
{{ modal.title }}
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500" data-test="modal-body">
|
||||
<p class="text-sm opacity-60" data-test="modal-body">
|
||||
{{ modal.body }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-3 sm:px-6 flex flex-row">
|
||||
<BaseButton
|
||||
<div class="flex flex-row px-4 py-3 sm:px-6">
|
||||
<ButtonBase
|
||||
class="w-full"
|
||||
@click="modal.confirm"
|
||||
data-test="modal-confirm-button"
|
||||
>{{ modal.confirmText }}</BaseButton
|
||||
>{{ modal.confirmText }}</ButtonBase
|
||||
>
|
||||
<BaseButton
|
||||
class="w-full ml-2"
|
||||
<ButtonBase
|
||||
class="ml-2 w-full"
|
||||
@click="modal.cancel"
|
||||
data-test="modal-cancel-button"
|
||||
>{{ modal.cancelText }}</BaseButton
|
||||
>{{ modal.cancelText }}</ButtonBase
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
title?: string
|
||||
imageSrc: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-40 h-40 rounded-full overflow-hidden">
|
||||
<img :src="imageSrc" class="object-cover w-full h-full" :alt="title" />
|
||||
<div
|
||||
v-if="title"
|
||||
class="absolute w-full py-2.5 bottom-0 inset-x-0 bg-white opacity-60 text-sm font-bold text-center leading-4"
|
||||
>
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,59 +1,50 @@
|
|||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import IconLink from './icons/IconLink.vue'
|
||||
import IconImagePlaceholder from './icons/IconImagePlaceholder.vue'
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import IconCart from './icons/IconCart.vue'
|
||||
import { WishlistItem } from '@/types'
|
||||
defineProps<{
|
||||
title: string
|
||||
image: string
|
||||
url?: string
|
||||
description: string
|
||||
comment?: string
|
||||
item: WishlistItem
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="h-fit sm:h-40 flex flex-col sm:flex-row space-x-0 sm:space-x-2 rounded-md border-stone-200 border-2 overflow-hidden"
|
||||
class="flex h-fit flex-col space-x-0 overflow-hidden rounded-md border-2 border-stone-200 dark:border-stone-700 sm:h-40 sm:flex-row sm:space-x-2"
|
||||
>
|
||||
<img
|
||||
v-if="image"
|
||||
:src="image"
|
||||
:alt="title"
|
||||
class="object-cover sm:aspect-[3/2] max-h-44 flex-shrink-0 flex-grow-0"
|
||||
<ImagePreview
|
||||
class="max-h-44 flex-shrink-0 flex-grow-0 object-cover sm:w-1/4"
|
||||
:src="item.imageSrc"
|
||||
:alt="item.title"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="sm:aspect-[3/2] max-h-44 flex-shrink-0 flex-grow-0 bg-stone-100 flex justify-center items-center"
|
||||
>
|
||||
<IconImagePlaceholder class="h-36 w-36 opacity-20" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col p-2 justify-between">
|
||||
<div class="flex flex-col justify-between p-2">
|
||||
<div>
|
||||
<h1 class="text-lg mb-1 font-bold">{{ title }}</h1>
|
||||
<p class="text-sm">{{ description }}</p>
|
||||
<h1 class="mb-1 text-lg font-bold">{{ item.title }}</h1>
|
||||
<p class="text-sm sm:line-clamp-3">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-row items-baseline space-x-2">
|
||||
<ButtonBase
|
||||
class="mt-4 text-xs sm:mt-2"
|
||||
:icon="IconCart"
|
||||
@click="$emit('bought')"
|
||||
>{{ t('components.wishlist-item.bought-button.text') }}</ButtonBase
|
||||
>
|
||||
<a
|
||||
v-if="url"
|
||||
:href="url"
|
||||
v-if="item.url"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-sm mt-1 text-stone-500 flex flex-row items-center w-fit"
|
||||
class="mt-1 flex w-fit flex-row items-center text-sm text-stone-500 dark:text-white/60"
|
||||
>
|
||||
<IconLink class="mr-1 w-4 h-4" />
|
||||
<IconLink class="mr-1 h-4 w-4 fill-stone-500 dark:fill-white/60" />
|
||||
<span>{{
|
||||
t('components.wishlist-item.external-product-page-link.text')
|
||||
}}</span>
|
||||
</a>
|
||||
</div>
|
||||
<BaseButton
|
||||
class="mt-4 sm:mt-2 text-xs"
|
||||
:icon="IconCart"
|
||||
@click="$emit('bought')"
|
||||
>{{ t('components.wishlist-item.bought-button.text') }}</BaseButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M17,18A2,2 0 0,1 19,20A2,2 0 0,1 17,22C15.89,22 15,21.1 15,20C15,18.89 15.89,18 17,18M1,2H4.27L5.21,4H20A1,1 0 0,1 21,5C21,5.17 20.95,5.34 20.88,5.5L17.3,11.97C16.96,12.58 16.3,13 15.55,13H8.1L7.2,14.63L7.17,14.75A0.25,0.25 0 0,0 7.42,15H19V17H7C5.89,17 5,16.1 5,15C5,14.65 5.09,14.32 5.24,14.04L6.6,11.59L3,4H1V2M7,18A2,2 0 0,1 9,20A2,2 0 0,1 7,22C5.89,22 5,21.1 5,20C5,18.89 5.89,18 7,18M16,11L18.78,6H6.14L8.5,11H16Z"
|
||||
/>
|
||||
</svg>
|
||||
|
|
7
src/components/icons/IconCloudQuestion.vue
Normal file
7
src/components/icons/IconCloudQuestion.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19.35 10.03A7.49 7.49 0 0 0 12 4C9.11 4 6.6 5.64 5.35 8.03A6.004 6.004 0 0 0 0 14a6 6 0 0 0 6 6h13a5 5 0 0 0 5-5c0-2.64-2.05-4.78-4.65-4.97M13 17h-2v-2h2v2m1.8-5.18c-.3.39-.67.68-1.13.93c-.26.16-.43.32-.52.51A1.7 1.7 0 0 0 13 14h-2c0-.55.11-.92.3-1.18c.2-.26.55-.57 1.07-.91c.26-.16.47-.35.63-.59c.15-.23.23-.51.23-.82c0-.32-.09-.56-.27-.74c-.18-.2-.46-.29-.76-.29c-.27 0-.49.08-.7.23c-.15.15-.25.38-.25.69H9.28c-.05-.75.22-1.39.78-1.8C10.6 8.2 11.31 8 12.2 8c.94 0 1.69.23 2.23.68c.54.45.81 1.07.81 1.82c0 .5-.15.91-.44 1.32z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
7
src/components/icons/IconCreation.vue
Normal file
7
src/components/icons/IconCreation.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19 1l-1.26 2.75L15 5l2.74 1.26L19 9l1.25-2.74L23 5l-2.75-1.25M9 4L6.5 9.5L1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5M19 15l-1.26 2.74L15 19l2.74 1.25L19 23l1.25-2.75L23 19l-2.75-1.26"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
7
src/components/icons/IconDelete.vue
Normal file
7
src/components/icons/IconDelete.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19 4h-3.5l-1-1h-5l-1 1H5v2h14M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12Z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
|
@ -1,7 +1,6 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11,15H13V17H11V15M11,7H13V13H11V7M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20Z"
|
||||
/>
|
||||
</svg>
|
||||
|
|
5
src/components/icons/IconHome.vue
Normal file
5
src/components/icons/IconHome.vue
Normal file
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3L2 12h3v8h5z"></path>
|
||||
</svg>
|
||||
</template>
|
|
@ -1,7 +1,6 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<svg viewBox="0 0 24 24" v-bind="$attrs">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14,6L10.25,11L13.1,14.8L11.5,16C9.81,13.75 7,10 7,10L1,18H23L14,6Z"
|
||||
/>
|
||||
</svg>
|
||||
|
|
7
src/components/icons/IconLightDark.vue
Normal file
7
src/components/icons/IconLightDark.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M7.5 2c-1.79 1.15-3 3.18-3 5.5s1.21 4.35 3.03 5.5C4.46 13 2 10.54 2 7.5A5.5 5.5 0 0 1 7.5 2m11.57 1.5l1.43 1.43L4.93 20.5L3.5 19.07L19.07 3.5m-6.18 2.43L11.41 5L9.97 6l.42-1.7L9 3.24l1.75-.12l.58-1.65L12 3.1l1.73.03l-1.35 1.13l.51 1.67m-3.3 3.61l-1.16-.73l-1.12.78l.34-1.32l-1.09-.83l1.36-.09l.45-1.29l.51 1.27l1.36.03l-1.05.87l.4 1.31M19 13.5a5.5 5.5 0 0 1-5.5 5.5c-1.22 0-2.35-.4-3.26-1.07l7.69-7.69c.67.91 1.07 2.04 1.07 3.26m-4.4 6.58l2.77-1.15l-.24 3.35l-2.53-2.2m4.33-2.7l1.15-2.77l2.2 2.54l-3.35.23m1.15-4.96l-1.14-2.78l3.34.24l-2.2 2.54M9.63 18.93l2.77 1.15l-2.53 2.19l-.24-3.34z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
|
@ -1,7 +1,6 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"
|
||||
/>
|
||||
</svg>
|
||||
|
|
7
src/components/icons/IconLogin.vue
Normal file
7
src/components/icons/IconLogin.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M10 17v-3H3v-4h7V7l5 5l-5 5m0-15h9a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2v-2h2v2h9V4h-9v2H8V4a2 2 0 0 1 2-2z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
7
src/components/icons/IconLogout.vue
Normal file
7
src/components/icons/IconLogout.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M16 17v-3H9v-4h7V7l5 5l-5 5M14 2a2 2 0 0 1 2 2v2h-2V4H5v16h9v-2h2v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
7
src/components/icons/IconNoGift.vue
Normal file
7
src/components/icons/IconNoGift.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M21 6h-3.17A3 3 0 0 0 18 5c0-1.66-1.34-3-3-3c-1 0-1.88.5-2.43 1.24v-.01L12 4l-.57-.77v.01A3.034 3.034 0 0 0 9 2c-1.03 0-1.94.5-2.5 1.32l1.53 1.51C8.12 4.36 8.5 4 9 4c.55 0 1 .45 1 1c0 .5-.36.88-.83.97L13 9.8V8h8v2h-7.8l2 2H20v4.8l2 2V12c.55 0 1-.45 1-1V8a2 2 0 0 0-2-2m-6 0c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1M1.11 3l3 3H3c-1.1 0-2 .9-2 2v3c0 .55.45 1 1 1v8a2 2 0 0 0 2 2h16.1l1.46 1.45l1.27-1.27L2.39 1.73L1.11 3M13 14.89L18.11 20H13v-5.11m-2-2V20H4v-8h6.11l.89.89M8.11 10H3V8h3.11l2 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
7
src/components/icons/IconPencil.vue
Normal file
7
src/components/icons/IconPencil.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M20.71 7.04c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-1.84 1.83l3.75 3.75M3 17.25V21h3.75L17.81 9.93l-3.75-3.75L3 17.25z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
7
src/components/icons/IconSave.vue
Normal file
7
src/components/icons/IconSave.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M17 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V7l-4-4m2 16H5V5h11.17L19 7.83V19m-7-7c-1.66 0-3 1.34-3 3s1.34 3 3 3s3-1.34 3-3s-1.34-3-3-3M6 6h9v4H6V6z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
7
src/components/icons/IconToggleOff.vue
Normal file
7
src/components/icons/IconToggleOff.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M17 7H7a5 5 0 0 0-5 5a5 5 0 0 0 5 5h10a5 5 0 0 0 5-5a5 5 0 0 0-5-5M7 15a3 3 0 0 1-3-3a3 3 0 0 1 3-3a3 3 0 0 1 3 3a3 3 0 0 1-3 3z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
7
src/components/icons/IconToggleOn.vue
Normal file
7
src/components/icons/IconToggleOn.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M17 7H7a5 5 0 0 0-5 5a5 5 0 0 0 5 5h10a5 5 0 0 0 5-5a5 5 0 0 0-5-5m0 8a3 3 0 0 1-3-3a3 3 0 0 1 3-3a3 3 0 0 1 3 3a3 3 0 0 1-3 3z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
|
@ -1,5 +0,0 @@
|
|||
export { default as IconError } from './IconError.vue'
|
||||
export { default as IconCart } from './IconCart.vue'
|
||||
export { default as IconImagePlaceholder } from './IconImagePlaceholder.vue'
|
||||
export { default as IconLink } from './IconLink.vue'
|
||||
export { default as IconSpinner } from './IconSpinner.vue'
|
|
@ -1,3 +1,7 @@
|
|||
export * from './useWishlistsStore'
|
||||
export * from './useWishlistStore'
|
||||
export * from './useModal'
|
||||
export * from './useAxios'
|
||||
export * from './useAuth'
|
||||
export * from './useEditMode'
|
||||
export * from './useFetch'
|
||||
|
|
20
src/composables/useAuth.ts
Normal file
20
src/composables/useAuth.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { computed, readonly } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
const state = useStorage('auth-token', '')
|
||||
|
||||
const setToken = (token: string): void => {
|
||||
state.value = token
|
||||
}
|
||||
|
||||
const isAuthenticated = computed(() => {
|
||||
return state.value !== ''
|
||||
})
|
||||
|
||||
export const useAuth = () => {
|
||||
return {
|
||||
setToken,
|
||||
isAuthenticated,
|
||||
token: readonly(state),
|
||||
}
|
||||
}
|
|
@ -1,9 +1,21 @@
|
|||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
} from 'axios'
|
||||
import { apiConfig } from '@/config'
|
||||
import { ref } from 'vue'
|
||||
import router from '../router'
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
export interface CustomAxiosError extends AxiosError {
|
||||
ignore: boolean
|
||||
}
|
||||
|
||||
const { token } = useAuth()
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
const error = ref<CustomAxiosError | null>(null)
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
baseURL: apiConfig.baseURL,
|
||||
|
@ -11,27 +23,40 @@ const config: AxiosRequestConfig = {
|
|||
|
||||
const client: AxiosInstance = axios.create(config)
|
||||
|
||||
client.interceptors.request.use(
|
||||
function (config) {
|
||||
export const requestInterceptor = client.interceptors.request.use(
|
||||
(config: AxiosRequestConfig): AxiosRequestConfig => {
|
||||
if (!config) {
|
||||
config = {}
|
||||
}
|
||||
if (!config.headers) {
|
||||
config.headers = {}
|
||||
}
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
config.headers.Authorization = token.value ? `API-Key ${token.value}` : ''
|
||||
|
||||
return config
|
||||
},
|
||||
function (err) {
|
||||
(err: CustomAxiosError): Promise<CustomAxiosError> => {
|
||||
isLoading.value = false
|
||||
error.value = err
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
client.interceptors.response.use(
|
||||
function (response) {
|
||||
export const responseInterceptor = client.interceptors.response.use(
|
||||
(response: AxiosResponse): AxiosResponse => {
|
||||
isLoading.value = false
|
||||
return response
|
||||
},
|
||||
function (err) {
|
||||
(err: CustomAxiosError): Promise<CustomAxiosError> => {
|
||||
isLoading.value = false
|
||||
error.value = err
|
||||
if (err.response?.status === 404) {
|
||||
router.push({ name: 'notFound' })
|
||||
err.ignore = true
|
||||
} else {
|
||||
error.value = err
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
|
29
src/composables/useEditMode.ts
Normal file
29
src/composables/useEditMode.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { useAuth } from './useAuth'
|
||||
import { ref, readonly, computed } from 'vue'
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const state = ref(false)
|
||||
|
||||
const activate = (): void => {
|
||||
state.value = true
|
||||
}
|
||||
|
||||
const deactivate = (): void => {
|
||||
state.value = false
|
||||
}
|
||||
|
||||
const toggle = (): void => {
|
||||
state.value = !state.value
|
||||
}
|
||||
|
||||
const isActive = computed(() => state.value && isAuthenticated.value)
|
||||
|
||||
export const useEditMode = () => {
|
||||
return {
|
||||
state: readonly(state),
|
||||
isActive,
|
||||
activate,
|
||||
deactivate,
|
||||
toggle,
|
||||
}
|
||||
}
|
29
src/composables/useFetch.ts
Normal file
29
src/composables/useFetch.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { apiConfig } from '@/config'
|
||||
import { useAuth } from './useAuth'
|
||||
import { createFetch } from '@vueuse/core'
|
||||
import router from '../router'
|
||||
|
||||
const { token } = useAuth()
|
||||
|
||||
export const useFetch = createFetch({
|
||||
baseUrl: apiConfig.baseURL,
|
||||
options: {
|
||||
beforeFetch({ options }) {
|
||||
if (token.value) {
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
Authorization: `API-Key ${token.value}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { options }
|
||||
},
|
||||
onFetchError(ctx) {
|
||||
if (ctx.data && ctx.data.statusCode === 404) {
|
||||
router.push({ name: 'notFound' })
|
||||
}
|
||||
|
||||
return ctx
|
||||
},
|
||||
},
|
||||
})
|
|
@ -1,23 +1,136 @@
|
|||
import useAxios from '@/composables/useAxios'
|
||||
import { computed, ref, unref } from 'vue'
|
||||
import { Wishlist, WishlistItem } from '@/types'
|
||||
import { ref } from 'vue'
|
||||
const { client } = useAxios()
|
||||
import { useEditMode } from './useEditMode'
|
||||
const { isActive: editModeIsActive } = useEditMode()
|
||||
import { useFetch } from './useFetch'
|
||||
|
||||
const list = ref<Wishlist | null>(null)
|
||||
const state = ref<Wishlist>()
|
||||
const isFinished = ref<boolean>(false)
|
||||
const error = ref<any>()
|
||||
|
||||
const fetch = async (slugText: string): Promise<void> => {
|
||||
const { data } = await client.get(`/wishlist/${slugText}`)
|
||||
list.value = data
|
||||
const fetch = async (slugText: string) => {
|
||||
const request = await useFetch(`/wishlist/${slugText}`).json()
|
||||
state.value = request.data.value
|
||||
isFinished.value = request.isFinished.value
|
||||
error.value = request.error.value
|
||||
}
|
||||
|
||||
const updateItem = async (item: WishlistItem): Promise<void> => {
|
||||
await client.put(`/wishlist/${item.wishlistId}/item/${item.id}`, item)
|
||||
const createWishlist = async (wishlist: Wishlist): Promise<void> => {
|
||||
const { data, error } = await useFetch('/wishlist/')
|
||||
.post(unref(wishlist))
|
||||
.json()
|
||||
if (error.value) {
|
||||
throw error.value
|
||||
}
|
||||
state.value = <Wishlist>data.value
|
||||
}
|
||||
const updateWishlist = async (updatedData: Wishlist): Promise<void> => {
|
||||
const id = state.value?.id
|
||||
const payload = {
|
||||
...state.value,
|
||||
...updatedData,
|
||||
}
|
||||
const { data, error } = await useFetch(`/wishlist/${id}`).put(payload).json()
|
||||
if (error.value) {
|
||||
throw error.value
|
||||
}
|
||||
state.value = {
|
||||
...state.value,
|
||||
...(<Wishlist>data.value),
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWishlist = async (): Promise<void> => {
|
||||
const { error } = await useFetch(`/wishlist/${state!.value!.id}`).delete()
|
||||
if (error.value) {
|
||||
throw error.value
|
||||
}
|
||||
}
|
||||
|
||||
const createItem = async (
|
||||
values: WishlistItem,
|
||||
wishlistId?: string
|
||||
): Promise<void> => {
|
||||
const id = wishlistId || state.value?.id
|
||||
const payload = {
|
||||
...values,
|
||||
}
|
||||
const { data, error } = await useFetch(`/wishlist/${id}/item`)
|
||||
.post(payload)
|
||||
.json()
|
||||
if (error.value) {
|
||||
throw error.value
|
||||
}
|
||||
state.value?.items?.push(unref(data))
|
||||
}
|
||||
|
||||
const updateItem = async (
|
||||
currentValues: WishlistItem,
|
||||
newValues: WishlistItem
|
||||
): Promise<void> => {
|
||||
const id = state.value?.id
|
||||
const payload = {
|
||||
...currentValues,
|
||||
...newValues,
|
||||
}
|
||||
|
||||
const { data, error } = await useFetch(
|
||||
`/wishlist/${id}/item/${currentValues.id}`
|
||||
)
|
||||
.put(payload)
|
||||
.json()
|
||||
if (error.value) {
|
||||
throw error.value
|
||||
}
|
||||
state.value?.items?.splice(
|
||||
state.value.items.indexOf(currentValues),
|
||||
1,
|
||||
unref(data)
|
||||
)
|
||||
}
|
||||
|
||||
const itemBought = async (item: WishlistItem): Promise<void> => {
|
||||
const { error } = await useFetch(
|
||||
`/wishlist/${item.wishlistId}/item/${item.id}/bought`
|
||||
).post()
|
||||
if (error.value) {
|
||||
throw error.value
|
||||
}
|
||||
item.bought = true
|
||||
}
|
||||
|
||||
const itemDelete = async (item: WishlistItem): Promise<void> => {
|
||||
const { error } = await useFetch(
|
||||
`/wishlist/${item.wishlistId}/item/${item.id}`
|
||||
).delete()
|
||||
if (error.value) {
|
||||
throw error.value
|
||||
}
|
||||
state.value?.items?.splice(state.value.items.indexOf(item), 1)
|
||||
}
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (!state.value || !state.value.items) {
|
||||
return []
|
||||
} else if (editModeIsActive.value) {
|
||||
return state.value.items
|
||||
}
|
||||
return state.value.items.filter((item: WishlistItem) => item.bought === false)
|
||||
})
|
||||
|
||||
export const useWishlistStore = () => {
|
||||
return {
|
||||
list,
|
||||
fetch,
|
||||
state,
|
||||
isFinished,
|
||||
error,
|
||||
createWishlist,
|
||||
updateWishlist,
|
||||
deleteWishlist,
|
||||
createItem,
|
||||
updateItem,
|
||||
itemBought,
|
||||
itemDelete,
|
||||
filteredItems,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,23 @@
|
|||
import useAxios from '@/composables/useAxios'
|
||||
import { Wishlist } from '@/types'
|
||||
import { ref } from 'vue'
|
||||
const { client } = useAxios()
|
||||
const prefix = '/wishlist'
|
||||
import { useFetch } from './useFetch'
|
||||
|
||||
const refState = ref<Wishlist[]>([])
|
||||
const state = ref<Wishlist[]>([])
|
||||
const isFinished = ref<boolean>(false)
|
||||
const error = ref<any>()
|
||||
|
||||
export const fetch = async (): Promise<void> => {
|
||||
const { data } = await client.get(prefix)
|
||||
refState.value = data
|
||||
const fetch = async () => {
|
||||
const request = await useFetch('/wishlist').json()
|
||||
state.value = request.data.value
|
||||
isFinished.value = request.isFinished.value
|
||||
error.value = request.error.value
|
||||
}
|
||||
|
||||
export const useWishlistsStore = () => {
|
||||
return {
|
||||
lists: refState,
|
||||
fetch,
|
||||
state,
|
||||
error,
|
||||
isFinished,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,26 +3,110 @@
|
|||
"app-title": {
|
||||
"text": "Wunschlisten"
|
||||
},
|
||||
"title": {
|
||||
"text": "Wunschliste: {title}"
|
||||
},
|
||||
"loading": {
|
||||
"text": "Lade..."
|
||||
},
|
||||
"saved": {
|
||||
"text": "Gespeichert"
|
||||
},
|
||||
"saving-failed": {
|
||||
"text": "Speichern ist fehlgeschlagen!"
|
||||
},
|
||||
"deleted": {
|
||||
"text": "Gelöscht"
|
||||
},
|
||||
"deleting-failed": {
|
||||
"text": "Löschen ist fehlgeschlagen!"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"not-found": {
|
||||
"text": "Ups, es sieht so aus, als ob die Seite, die du suchst, nicht existiert."
|
||||
"text": "Ups, die aufgerufene Seite existiert nicht."
|
||||
},
|
||||
"generic": {
|
||||
"text": "Es ist ein Fehler aufgetreten..."
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"login-view": {
|
||||
"main": {
|
||||
"title": {
|
||||
"text": "Anmelden"
|
||||
},
|
||||
"form": {
|
||||
"api-key": {
|
||||
"placeholder": "API-Key",
|
||||
"error-requried": "API-Key wird benötigt"
|
||||
},
|
||||
"submit": {
|
||||
"text": "Anmelden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"home-view": {
|
||||
"main": {
|
||||
"empty-list": {
|
||||
"text": "Keine Wunschlisten verfügbar."
|
||||
}
|
||||
}
|
||||
},
|
||||
"create-wishlist-view": {
|
||||
"title": {
|
||||
"text": "Wunschliste erstellen"
|
||||
},
|
||||
"headline": {
|
||||
"text": "Erstelle eine Wunschliste"
|
||||
}
|
||||
},
|
||||
"create-wishlist-item-view": {
|
||||
"title": {
|
||||
"text": "Eintrag hinzufügen"
|
||||
},
|
||||
"loading": {
|
||||
"text": "Lade Daten der übermittelten URL"
|
||||
},
|
||||
"headline-wishlist-selection": {
|
||||
"text": "Zu welcher Wunschliste möchten Sie etwas hinzufügen?"
|
||||
}
|
||||
},
|
||||
"detail-view": {
|
||||
"confirmation-modal": {
|
||||
"main": {
|
||||
"empty-list": {
|
||||
"text": "Diese Wunschliste ist leer."
|
||||
}
|
||||
},
|
||||
"modal-delete-wishlist": {
|
||||
"title": {
|
||||
"text": "Möchten Sie die Wunschliste löschen?"
|
||||
},
|
||||
"confirm-button": {
|
||||
"text": "Ja"
|
||||
},
|
||||
"cancel-button": {
|
||||
"text": "Nein"
|
||||
}
|
||||
},
|
||||
"modal-bought-item": {
|
||||
"title": {
|
||||
"text": "Möchten Sie den Gegenstand von der Liste nehmen?"
|
||||
},
|
||||
"body": {
|
||||
"text": "Durch das das runternehmen von der Liste ist dieser Gegenstand nicht mehr andere sichtbar."
|
||||
"text": "Durch das runternehmen von der Liste ist dieser Gegenstand nicht mehr für andere sichtbar."
|
||||
},
|
||||
"confirm-button": {
|
||||
"text": "Ja"
|
||||
},
|
||||
"cancel-button": {
|
||||
"text": "Nein"
|
||||
}
|
||||
},
|
||||
"modal-delete-item": {
|
||||
"title": {
|
||||
"text": "Möchten Sie den Gegenstand von der Liste löschen?"
|
||||
},
|
||||
"confirm-button": {
|
||||
"text": "Ja"
|
||||
|
@ -34,6 +118,13 @@
|
|||
}
|
||||
},
|
||||
"components": {
|
||||
"create-wishlist-title": {
|
||||
"text": "Wunschliste erstellen"
|
||||
},
|
||||
"file": {
|
||||
"text-dropzone-link": "hier",
|
||||
"text-dropzone": "Ziehen Sie ein beliebiges Bild hierher oder klicken Sie {0}, um den Dialog zu öffnen."
|
||||
},
|
||||
"wishlist-item": {
|
||||
"external-product-page-link": {
|
||||
"text": "Produktseite öffnen"
|
||||
|
@ -41,6 +132,80 @@
|
|||
"bought-button": {
|
||||
"text": "Gekauft"
|
||||
}
|
||||
},
|
||||
"form-wishlist": {
|
||||
"title": {
|
||||
"label": "Titel",
|
||||
"error-requried": "Titel wird benötigt."
|
||||
},
|
||||
"public": {
|
||||
"label": "Auf der Startseite anzeigen?"
|
||||
},
|
||||
"description": {
|
||||
"label": "Beschreibung",
|
||||
"error-max": "Die maximale Länge beträgt 300 Zeichen."
|
||||
},
|
||||
"slug-text": {
|
||||
"label": "URL Slug-Text",
|
||||
"error-requried": "URL Slug-Text wird benötigt.",
|
||||
"error-regex": "Unngültige URL Slug-Text."
|
||||
},
|
||||
"image-src": {
|
||||
"label": "Bild-URL",
|
||||
"error-requried": "Bild-URL wird benötigt."
|
||||
},
|
||||
"image-file": {
|
||||
"label": "Bild-Datei",
|
||||
"text-dropzone-link": "hier",
|
||||
"text-dropzone": "Ziehen Sie ein beliebiges Bild hierher oder klicken Sie {0}, um den Dialog zu öffnen.",
|
||||
"error-requried": "Bild-Datei wird benötigt.",
|
||||
"error-image-size": "Höhe und Breite dürfen 200px nicht überschreiten."
|
||||
},
|
||||
"submit": {
|
||||
"text": "Speichern"
|
||||
},
|
||||
"delete-button": {
|
||||
"text": "Löschen"
|
||||
}
|
||||
},
|
||||
"form-wishlist-item": {
|
||||
"headline-new-item": {
|
||||
"text": "Neuen Eintrag hinzufügen"
|
||||
},
|
||||
"headline-change-item": {
|
||||
"text": "Eintrag bearbeiten"
|
||||
},
|
||||
"title": {
|
||||
"label": "Titel",
|
||||
"error-requried": "Titel wird benötigt."
|
||||
},
|
||||
"description": {
|
||||
"label": "Beschreibung",
|
||||
"error-requried": "Beschreibung wird benötigt.",
|
||||
"error-max": "Die maximale Länge beträgt 300 Zeichen."
|
||||
},
|
||||
"url": {
|
||||
"label": "Produkt-URL",
|
||||
"error-url": "Keine gültige URL."
|
||||
},
|
||||
"image-src": {
|
||||
"label": "Bild-URL",
|
||||
"error-url": "Keine gültige URL."
|
||||
},
|
||||
"bought": {
|
||||
"label": "Gekauft?"
|
||||
},
|
||||
"submit": {
|
||||
"text": "Speichern"
|
||||
},
|
||||
"delete-button": {
|
||||
"text": "Löschen"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"edit-mode": {
|
||||
"text": "Bearbeitungsmodus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,21 +3,94 @@
|
|||
"app-title": {
|
||||
"text": "Wishlists"
|
||||
},
|
||||
"title": {
|
||||
"text": "Wishlist: {title}"
|
||||
},
|
||||
"loading": {
|
||||
"text": "Loading..."
|
||||
},
|
||||
"saved": {
|
||||
"text": "Saved"
|
||||
},
|
||||
"saving-failed": {
|
||||
"text": "Saving failed!"
|
||||
},
|
||||
"deleted": {
|
||||
"text": "Deleted"
|
||||
},
|
||||
"deleting-failed": {
|
||||
"text": "Deleting faild!"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"not-found": {
|
||||
"text": "Oops, it looks like the page you're looking for doesn't exist."
|
||||
"text": "Oops, the requested page does not exist."
|
||||
},
|
||||
"generic": {
|
||||
"text": "An error has occurred..."
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"login-view": {
|
||||
"main": {
|
||||
"title": {
|
||||
"text": "Login"
|
||||
},
|
||||
"form": {
|
||||
"api-key": {
|
||||
"placeholder": "API-Key",
|
||||
"error-requried": "API-Key is required"
|
||||
},
|
||||
"submit": {
|
||||
"text": "Login"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"home-view": {
|
||||
"main": {
|
||||
"empty-list": {
|
||||
"text": "No wishlists available."
|
||||
}
|
||||
}
|
||||
},
|
||||
"create-wishlist-item-view": {
|
||||
"title": {
|
||||
"text": "Add item"
|
||||
},
|
||||
"loading": {
|
||||
"text": "Loading data from provided URL"
|
||||
},
|
||||
"headline-wishlist-selection": {
|
||||
"text": "To which wish list would you like to add the new item?"
|
||||
}
|
||||
},
|
||||
"create-wishlist-view": {
|
||||
"title": {
|
||||
"text": "Create a wishlist"
|
||||
},
|
||||
"headline": {
|
||||
"text": "Create a wishlist"
|
||||
}
|
||||
},
|
||||
"detail-view": {
|
||||
"confirmation-modal": {
|
||||
"main": {
|
||||
"empty-list": {
|
||||
"text": "This wishlist is empty."
|
||||
}
|
||||
},
|
||||
"modal-delete-wishlist": {
|
||||
"title": {
|
||||
"text": "Do you want delete the wishlist?"
|
||||
},
|
||||
"confirm-button": {
|
||||
"text": "Yes"
|
||||
},
|
||||
"cancel-button": {
|
||||
"text": "No"
|
||||
}
|
||||
},
|
||||
"modal-bought-item": {
|
||||
"title": {
|
||||
"text": "Do you want to remove the item from the list?"
|
||||
},
|
||||
|
@ -30,10 +103,28 @@
|
|||
"cancel-button": {
|
||||
"text": "No"
|
||||
}
|
||||
},
|
||||
"modal-delete-item": {
|
||||
"title": {
|
||||
"text": "Do you want to delete the item from the list?"
|
||||
},
|
||||
"confirm-button": {
|
||||
"text": "Yes"
|
||||
},
|
||||
"cancel-button": {
|
||||
"text": "No"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"create-wishlist-title": {
|
||||
"text": "Create wishlist"
|
||||
},
|
||||
"file": {
|
||||
"text-dropzone-link": "click here",
|
||||
"text-dropzone": "drag and drop any image here or {0} to open dialog."
|
||||
},
|
||||
"wishlist-item": {
|
||||
"external-product-page-link": {
|
||||
"text": "Open product page"
|
||||
|
@ -41,6 +132,78 @@
|
|||
"bought-button": {
|
||||
"text": "Bought"
|
||||
}
|
||||
},
|
||||
"form-wishlist": {
|
||||
"title": {
|
||||
"label": "Title",
|
||||
"error-requried": "Titel is required."
|
||||
},
|
||||
"public": {
|
||||
"label": "Show on startpage?"
|
||||
},
|
||||
"description": {
|
||||
"label": "Description",
|
||||
"error-max": "The max. length is 300 chars."
|
||||
},
|
||||
"slug-text": {
|
||||
"label": "URL Slug-Text",
|
||||
"error-requried": "URL Slug-Text is required.",
|
||||
"error-regex": "Invalid URL Slug-Text."
|
||||
},
|
||||
"image-src": {
|
||||
"label": "Image-URL",
|
||||
"error-requried": "Image-URL is required."
|
||||
},
|
||||
"image-file": {
|
||||
"label": "Image-File",
|
||||
"error-requried": "Image-File is required.",
|
||||
"error-image-size": "Height and Width must not exceed 200px."
|
||||
},
|
||||
"submit": {
|
||||
"text": "Save"
|
||||
},
|
||||
"delete-button": {
|
||||
"text": "Delete"
|
||||
}
|
||||
},
|
||||
"form-wishlist-item": {
|
||||
"headline-new-item": {
|
||||
"text": "Add new item"
|
||||
},
|
||||
"headline-change-item": {
|
||||
"text": "Change item"
|
||||
},
|
||||
"title": {
|
||||
"label": "Title",
|
||||
"error-requried": "Title is required."
|
||||
},
|
||||
"description": {
|
||||
"label": "Description",
|
||||
"error-requried": "Description is required.",
|
||||
"error-max": "The max. length is 300 chars."
|
||||
},
|
||||
"url": {
|
||||
"label": "Produkt-URL",
|
||||
"error-url": "Invalid URL"
|
||||
},
|
||||
"image-src": {
|
||||
"label": "Bild-URL",
|
||||
"error-url": "Invalid URL"
|
||||
},
|
||||
"bought": {
|
||||
"label": "Bought?"
|
||||
},
|
||||
"submit": {
|
||||
"text": "Save"
|
||||
},
|
||||
"delete-button": {
|
||||
"text": "Delete"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"edit-mode": {
|
||||
"text": "Edit-Mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { createApp } from 'vue'
|
||||
import Toast from 'vue-toastification'
|
||||
import './assets/tailwind.css'
|
||||
import 'vue-toastification/dist/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
@ -10,6 +12,7 @@ const app = createApp(App)
|
|||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
app.use(Toast, {})
|
||||
app.component('modalOverlay', Modal)
|
||||
|
||||
app.mount('#app')
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import DetailView from '../views/DetailView.vue'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
import CreateWishlistView from '@/views/CreateWishlistView.vue'
|
||||
import AddWishlistItemView from '@/views/AddWishlistItemView.vue'
|
||||
import DetailView from '@/views/DetailView.vue'
|
||||
import { useAuth } from '@/composables'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
@ -9,18 +13,46 @@ const router = createRouter({
|
|||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView,
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/create-wishlist',
|
||||
name: 'create-wishlist',
|
||||
component: CreateWishlistView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/add-wishlist-item',
|
||||
name: 'add-wishlist--item',
|
||||
component: AddWishlistItemView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/:slug',
|
||||
name: 'detail',
|
||||
component: DetailView,
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
name: 'notFound',
|
||||
path: '/:pathMatch(.*)*',
|
||||
component: () => import('../views/NotFound.vue'),
|
||||
component: () => import('@/views/NotFoundView.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const { isAuthenticated } = useAuth()
|
||||
if (!isAuthenticated.value && to.meta.requiresAuth === true) {
|
||||
return { name: 'login' }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
10
src/types.ts
10
src/types.ts
|
@ -1,20 +1,20 @@
|
|||
export interface WishlistItem {
|
||||
id: string
|
||||
id?: number
|
||||
title: string
|
||||
url: string
|
||||
imageSrc: string
|
||||
description: string
|
||||
comment: string
|
||||
bought: boolean
|
||||
wishlistId: boolean
|
||||
wishlistId?: boolean
|
||||
}
|
||||
export interface Wishlist {
|
||||
id: string
|
||||
id?: string
|
||||
public: boolean
|
||||
title: string
|
||||
description: string
|
||||
imageSrc: string
|
||||
slugUrlText: string
|
||||
items: WishlistItem[]
|
||||
items?: WishlistItem[]
|
||||
}
|
||||
export interface TileProp {
|
||||
title: string
|
||||
|
|
103
src/views/AddWishlistItemView.vue
Normal file
103
src/views/AddWishlistItemView.vue
Normal file
|
@ -0,0 +1,103 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Wishlist, WishlistItem } from '@/types'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useWishlistsStore, useWishlistStore } from '@/composables'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useFetch } from '@/composables/useFetch'
|
||||
import { syncRef, useTitle, watchOnce } from '@vueuse/core'
|
||||
import { Ref, ref } from 'vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const isFinished = ref(true)
|
||||
const selectedWishlist: Ref<Wishlist | undefined> = ref()
|
||||
const prefillData = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
imageSrc: '',
|
||||
url: '',
|
||||
bought: false,
|
||||
})
|
||||
|
||||
const { createItem } = useWishlistStore()
|
||||
|
||||
const {
|
||||
state: wishlists,
|
||||
isFinished: loadWishlistsFinished,
|
||||
fetch,
|
||||
} = useWishlistsStore()
|
||||
|
||||
fetch()
|
||||
|
||||
if (route.query.url) {
|
||||
prefillData.value.url = route.query.url as string
|
||||
const { data: opData, isFinished: opDataLoaded } = useFetch(
|
||||
`/utils/fetch-open-graph?url=${route.query.url}`
|
||||
).json()
|
||||
syncRef(opDataLoaded, isFinished)
|
||||
|
||||
watchOnce(opData, () => {
|
||||
prefillData.value = {
|
||||
...prefillData.value,
|
||||
...opData.value,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useTitle(t('pages.create-wishlist-item-view.title.text'))
|
||||
|
||||
const handleCreateItem = async (values: WishlistItem): Promise<void> => {
|
||||
try {
|
||||
await createItem(values, selectedWishlist?.value?.id)
|
||||
toast.success(t('common.saved.text'))
|
||||
router.push(`/${selectedWishlist?.value?.slugUrlText}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error(t('common.saving-failed.text'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<div
|
||||
v-if="!isFinished || !loadWishlistsFinished"
|
||||
class="flex h-1/2 w-full flex-col justify-center"
|
||||
>
|
||||
<div
|
||||
class="m-20 flex flex-row content-center items-center justify-center space-x-2"
|
||||
>
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span> {{ t('pages.create-wishlist-item-view.loading.text') }} </span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loadWishlistsFinished && selectedWishlist === undefined">
|
||||
<h1 class="text-center text-xl font-bold">
|
||||
{{
|
||||
t('pages.create-wishlist-item-view.headline-wishlist-selection.text')
|
||||
}}
|
||||
</h1>
|
||||
<div
|
||||
class="m-8 flex flex-row flex-wrap content-center items-center justify-center sm:space-x-2"
|
||||
>
|
||||
<div
|
||||
v-for="item in wishlists"
|
||||
:key="item.id"
|
||||
@click="() => (selectedWishlist = item)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<ImageTile
|
||||
:title="item.title"
|
||||
:image-src="item.imageSrc"
|
||||
class="m-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormWishlistItem :item="prefillData" v-else @create="handleCreateItem" />
|
||||
</div>
|
||||
</template>
|
34
src/views/CreateWishlistView.vue
Normal file
34
src/views/CreateWishlistView.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Wishlist } from '@/types'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTitle } from '@vueuse/core'
|
||||
import { useWishlistStore } from '@/composables'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { createWishlist } = useWishlistStore()
|
||||
|
||||
useTitle(t('pages.create-wishlist-view.title.text'))
|
||||
|
||||
const handleCreateWishlist = async (wishlist: Wishlist): Promise<void> => {
|
||||
try {
|
||||
await createWishlist(wishlist)
|
||||
toast.success(t('common.saved.text'))
|
||||
router.push(`/${wishlist.slugUrlText}`)
|
||||
} catch (error) {
|
||||
toast.error(t('common.saving-failed.text'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<h1 class="mb-6 text-xl font-bold">
|
||||
{{ t('pages.create-wishlist-view.headline.text') }}
|
||||
</h1>
|
||||
<FormWishlist @create="handleCreateWishlist" />
|
||||
</div>
|
||||
</template>
|
|
@ -1,66 +1,182 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { WishlistItem as WishlistItemType } from '@/types'
|
||||
import { Wishlist, WishlistItem as WishlistItemType } from '@/types'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTitle } from '@vueuse/core'
|
||||
import { useWishlistStore, useModal, useEditMode } from '@/composables'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useWishlistStore, useModal } from '@/composables'
|
||||
import Tile from '@/components/Tile.vue'
|
||||
import WishlistItem from '@/components/WishlistItem.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const modal = useModal()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { list, fetch, updateItem } = useWishlistStore()
|
||||
const toast = useToast()
|
||||
const { isActive: editModeIsActive } = useEditMode()
|
||||
const {
|
||||
fetch,
|
||||
state,
|
||||
isFinished,
|
||||
updateWishlist,
|
||||
deleteWishlist,
|
||||
createItem,
|
||||
updateItem,
|
||||
itemBought,
|
||||
itemDelete,
|
||||
filteredItems,
|
||||
} = useWishlistStore()
|
||||
await fetch(route.params.slug as string)
|
||||
|
||||
const notBoughtItems = computed(() => {
|
||||
return list.value?.items.filter(
|
||||
(item: WishlistItemType) => item.bought === false
|
||||
)
|
||||
const title = computed(() => {
|
||||
return state.value?.title
|
||||
? t('common.title.text', { title: state.value.title })
|
||||
: t('common.loading.text')
|
||||
})
|
||||
|
||||
const bought = async (item: WishlistItemType): Promise<void> => {
|
||||
useTitle(title)
|
||||
|
||||
const handleUpdateWishlist = async (wishlist: Wishlist): Promise<void> => {
|
||||
try {
|
||||
await updateWishlist(wishlist)
|
||||
toast.success(t('common.saved.text'))
|
||||
router.push(`/${wishlist.slugUrlText}`)
|
||||
} catch (error) {
|
||||
toast.error(t('common.saving-failed.text'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
const confirmed = await modal.show(
|
||||
t('pages.detail-view.confirmation-modal.title.text'),
|
||||
t('pages.detail-view.confirmation-modal.confirm-button.text'),
|
||||
t('pages.detail-view.confirmation-modal.cancel-button.text'),
|
||||
t('pages.detail-view.confirmation-modal.body.text')
|
||||
t('pages.detail-view.modal-delete-wishlist.title.text'),
|
||||
t('pages.detail-view.modal-delete-wishlist.confirm-button.text'),
|
||||
t('pages.detail-view.modal-delete-wishlist.cancel-button.text')
|
||||
)
|
||||
if (confirmed) {
|
||||
item.bought = true
|
||||
updateItem(item)
|
||||
try {
|
||||
await deleteWishlist()
|
||||
toast.success(t('common.deleted.text'))
|
||||
router.push('/')
|
||||
} catch (error) {
|
||||
toast.error(t('common.deleting-failed.text'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateItem = async (values: WishlistItemType): Promise<void> => {
|
||||
try {
|
||||
await createItem(values)
|
||||
toast.success(t('common.saved.text'))
|
||||
} catch (error) {
|
||||
toast.error(t('common.saving-failed.text'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateItem = async (
|
||||
currentValues: WishlistItemType,
|
||||
newValues: WishlistItemType
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await updateItem(currentValues, newValues)
|
||||
toast.success(t('common.saved.text'))
|
||||
} catch (error) {
|
||||
toast.error(t('common.saving-failed.text'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBought = async (item: WishlistItemType): Promise<void> => {
|
||||
const confirmed = await modal.show(
|
||||
t('pages.detail-view.modal-bought-item.title.text'),
|
||||
t('pages.detail-view.modal-bought-item.confirm-button.text'),
|
||||
t('pages.detail-view.modal-bought-item.cancel-button.text'),
|
||||
t('pages.detail-view.modal-bought-item.body.text')
|
||||
)
|
||||
if (confirmed) {
|
||||
itemBought(item)
|
||||
}
|
||||
}
|
||||
const handleDeleteItem = async (item: WishlistItemType): Promise<void> => {
|
||||
const confirmed = await modal.show(
|
||||
t('pages.detail-view.modal-delete-item.title.text'),
|
||||
t('pages.detail-view.modal-delete-item.confirm-button.text'),
|
||||
t('pages.detail-view.modal-delete-item.cancel-button.text')
|
||||
)
|
||||
if (confirmed) {
|
||||
try {
|
||||
await itemDelete(item)
|
||||
toast.success(t('common.deleted.text'))
|
||||
} catch (error) {
|
||||
toast.error(t('common.deleting-failed.text'))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="list !== null">
|
||||
<div v-if="isFinished && state !== undefined" class="h-full">
|
||||
<div
|
||||
class="flex flex-col md:flex-row space-x-0 md:space-x-6 space-y-2 md:space-y-0 items-center"
|
||||
class="flex flex-col items-center space-x-0 space-y-2 md:flex-row md:space-x-6 md:space-y-0"
|
||||
>
|
||||
<Tile :image-src="list.imageSrc" class="shrink-0"></Tile>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-center md:text-left mb-2">
|
||||
{{ list.title }}
|
||||
<ImageTile :image-src="state.imageSrc" class="shrink-0"></ImageTile>
|
||||
<div v-if="!editModeIsActive">
|
||||
<h1 class="mb-2 text-center text-2xl font-bold md:text-left">
|
||||
{{ state.title }}
|
||||
</h1>
|
||||
<p v-if="list.description" class="text-lg">
|
||||
{{ list.description }}
|
||||
<p class="text-lg">
|
||||
{{ state.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-14 md:space-y-8 my-10">
|
||||
<WishlistItem
|
||||
v-for="(item, index) in notBoughtItems"
|
||||
:key="index"
|
||||
:title="item.title"
|
||||
:url="item.url"
|
||||
:image="item.imageSrc"
|
||||
:description="item.description"
|
||||
:comment="item.comment"
|
||||
@bought="bought(item)"
|
||||
<FormWishlist
|
||||
v-else
|
||||
:wishlist="state"
|
||||
@update="handleUpdateWishlist"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!editModeIsActive && filteredItems.length === 0"
|
||||
class="flex h-1/2 w-full justify-center"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col flex-wrap items-center justify-center text-center text-xl text-gray-600/75 dark:text-white/70 sm:flex-row sm:space-x-2 sm:text-left"
|
||||
>
|
||||
<IconNoGift class="h-10 w-10 fill-gray-600/75 dark:fill-white/70" />
|
||||
<span>{{ t('pages.detail-view.main.empty-list.text') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col py-10"
|
||||
:class="{
|
||||
'divide-y-2': editModeIsActive,
|
||||
'space-y-14 md:space-y-8': !editModeIsActive,
|
||||
}"
|
||||
>
|
||||
<FormWishlistItem
|
||||
v-if="editModeIsActive"
|
||||
@create="handleCreateItem"
|
||||
class="py-8 md:py-14"
|
||||
/>
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
:class="{
|
||||
'py-8 md:py-14': editModeIsActive,
|
||||
}"
|
||||
>
|
||||
<WishlistItem
|
||||
v-if="!editModeIsActive"
|
||||
:item="item"
|
||||
@bought="handleBought(item)"
|
||||
/>
|
||||
<FormWishlistItem
|
||||
v-else
|
||||
:item="item"
|
||||
@update="(updateValues) => handleUpdateItem(item, updateValues)"
|
||||
@delete="handleDeleteItem(item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,26 +1,44 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Tile from '@/components/Tile.vue'
|
||||
import { useWishlistsStore } from '@/composables'
|
||||
import { useWishlistsStore, useEditMode } from '@/composables'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { lists, fetch } = useWishlistsStore()
|
||||
const { isActive: editModeIsActive } = useEditMode()
|
||||
const { state, isFinished, fetch } = useWishlistsStore()
|
||||
await fetch()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1 class="text-3xl text-center">{{ t('common.app-title.text') }}</h1>
|
||||
<div v-if="lists" class="flex flex-row flex-wrap justify-around p-10">
|
||||
<h1 class="text-semibold text-center text-3xl">
|
||||
{{ t('common.app-title.text') }}
|
||||
</h1>
|
||||
<div
|
||||
v-if="!isFinished"
|
||||
class="m-20 flex flex-row content-center items-center justify-center space-x-2"
|
||||
>
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span> {{ t('common.loading.text') }} </span>
|
||||
</div>
|
||||
<div
|
||||
v-if="state.length === 0 && !editModeIsActive"
|
||||
class="flex h-1/2 w-full justify-center"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col flex-wrap items-center justify-center text-center text-xl text-gray-600/75 dark:text-white/70 sm:flex-row sm:space-x-2 sm:text-left"
|
||||
>
|
||||
<span>{{ t('pages.home-view.main.empty-list.text') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-row flex-wrap justify-around p-10">
|
||||
<router-link
|
||||
v-for="(item, index) in lists"
|
||||
:key="index"
|
||||
v-for="item in state"
|
||||
:key="item.id"
|
||||
:to="'/' + item.slugUrlText"
|
||||
>
|
||||
<Tile
|
||||
:title="item.title"
|
||||
:image-src="item.imageSrc"
|
||||
class="m-2 hover:ring-2 ring-slate-500"
|
||||
/>
|
||||
<ImageTile :title="item.title" :image-src="item.imageSrc" class="m-4" />
|
||||
</router-link>
|
||||
<router-link v-if="editModeIsActive" to="/create-wishlist">
|
||||
<CreateWishlistTile class="m-4" />
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
|
55
src/views/LoginView.vue
Normal file
55
src/views/LoginView.vue
Normal file
|
@ -0,0 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { object, string } from 'yup'
|
||||
import { useAuth } from '@/composables'
|
||||
import IconLogin from '@/components/icons/IconLogin.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { setToken } = useAuth()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const schema = object({
|
||||
'api-key': string().required(
|
||||
t('pages.login-view.main.form.api-key.error-requried')
|
||||
),
|
||||
})
|
||||
|
||||
const { handleSubmit, meta } = useForm({
|
||||
validationSchema: schema,
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
setToken(values['api-key'] as string)
|
||||
router.push('/')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full">
|
||||
<div
|
||||
class="m-auto rounded-md border-2 border-stone-200 px-6 py-10 dark:border-stone-700 sm:w-1/2"
|
||||
>
|
||||
<h1 class="text-semibold mb-8 text-center text-3xl">
|
||||
{{ t('pages.login-view.main.title.text') }}
|
||||
</h1>
|
||||
<form @submit="onSubmit" class="w-full flex-col space-y-3">
|
||||
<InputText
|
||||
name="api-key"
|
||||
type="text"
|
||||
:label="t('pages.login-view.main.form.api-key.placeholder')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<ButtonBase
|
||||
class="h-12 w-full"
|
||||
mode="primary"
|
||||
:icon="IconLogin"
|
||||
:disabled="!meta.dirty || !meta.valid"
|
||||
>{{ t('pages.login-view.main.form.submit.text') }}</ButtonBase
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,7 +0,0 @@
|
|||
<template>
|
||||
<h1>{{ t('errors.not-found.text') }}</h1>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
</script>
|
15
src/views/NotFoundView.vue
Normal file
15
src/views/NotFoundView.vue
Normal file
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { useTitle } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
useTitle(t('errors.not-found.text'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="mt-20 flex flex-col flex-wrap items-center justify-center text-center text-xl sm:flex-row sm:space-x-2 sm:text-left"
|
||||
>
|
||||
<IconCloudQuestion class="h-12 w-12 fill-current" />
|
||||
<h1>{{ t('errors.not-found.text') }}</h1>
|
||||
</div>
|
||||
</template>
|
|
@ -1,8 +1,9 @@
|
|||
// eslint-disable-next-line no-undef
|
||||
/* eslint-disable no-undef */
|
||||
module.exports = {
|
||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [require('@tailwindcss/line-clamp')],
|
||||
}
|
||||
|
|
|
@ -22,5 +22,11 @@
|
|||
"module": "commonjs"
|
||||
}
|
||||
},
|
||||
"include": ["vite.config.*", "env.d.ts", "src/**/*", "src/**/*.vue"]
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"env.d.ts",
|
||||
"src/**/*",
|
||||
"src/**/*.vue",
|
||||
"components.d.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,11 +1,17 @@
|
|||
import { fileURLToPath, URL } from 'url'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
Components({
|
||||
dts: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
|
Loading…
Add table
Reference in a new issue