Compare commits

...

39 commits
v1.0.0 ... main

Author SHA1 Message Date
57aeac59e6 1.4.0 2022-06-30 18:37:49 +02:00
34925dc266 Feature: Support for fetching data from amazon urls added
Signed-off-by: Benny <benny@hierl.dev>
2022-06-30 18:37:26 +02:00
f07085c211 1.3.0 2022-06-30 17:20:15 +02:00
9880406d61 pre-push adhusted
Signed-off-by: Benny <benny@hierl.dev>
2022-06-30 17:19:44 +02:00
42f0a6f63c pre commit adjusted
Signed-off-by: Benny <benny@hierl.dev>
2022-06-30 17:19:19 +02:00
54f869c7cd Bought Button adjusted 2022-06-30 17:17:32 +02:00
b4cd04a11a small adjustments for package.json
Signed-off-by: Benny <benny@hierl.dev>
2022-05-14 11:11:40 +02:00
caaef85d60 1.2.1 2022-05-14 11:09:51 +02:00
Benny
2e7ea74271
small change on readme 2022-03-10 21:24:33 +01:00
Benny
86e6fd4580
Create LICENSE 2022-03-08 22:54:43 +01:00
Benny
a0c7f33647
Update README.md 2022-03-08 22:52:12 +01:00
Benny
ecbeb4cef0
Update README.md 2022-03-08 22:39:56 +01:00
5ad1995f65 1.2.0 2022-03-08 22:30:23 +01:00
f319ee5185 new loading animation
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-03-08 22:29:45 +01:00
743f7c0f07 Favicon added
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-03-08 20:17:42 +01:00
c156a0ecea remove report export
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-03-08 20:14:44 +01:00
dc94e98aa0 ...
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-03-04 22:20:30 +01:00
abf16aa242 disable test report
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-03-04 22:17:41 +01:00
bc1458d5a0 ...
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-03-04 22:15:48 +01:00
0e04b91f15 ...
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-03-04 22:07:38 +01:00
2cbc0e2f8e reports added to unittest
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-03-04 22:04:56 +01:00
a2d81b0f14 1.1.0 2022-03-04 21:56:00 +01:00
38f865bbef not used import removed
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-03-04 21:52:15 +01:00
9a01d8147e junit reporter added
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-03-04 21:49:50 +01:00
7410fa6b34 additional unittests added
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-03-04 21:46:07 +01:00
4210053907 Open product page when clicking on image, title or description 2022-03-04 19:21:09 +01:00
78a8d39ea6 cleanup on the modal 2022-02-25 10:29:02 +01:00
59f2b7bdbf Merge branch 'main' of github.com:ThisIsBenny/wishlist-app 2022-02-21 20:09:48 +01:00
Benny
85582d21e6
Update README.md 2022-02-20 21:21:55 +01:00
946bd8b9d7 fix missing title bug
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-02-20 20:04:25 +01:00
53ec249f69 home icon removed
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-02-20 19:59:52 +01:00
fe7dcf68ca fix response design bug
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-02-20 19:58:38 +01:00
Benny
3fa309ce66
Update README.md 2022-02-20 16:14:55 +01:00
74c2f29cf5 Merge branch 'release/v1.0.0' 2022-02-20 15:59:31 +01:00
f54232972b add demo gif
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-02-20 15:56:11 +01:00
2637b65d28 fix issue with deleting wishlistitems
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-02-20 15:51:10 +01:00
71ab2c14fd Fix issue with deleting of a wishlist
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
2022-02-20 15:48:35 +01:00
Benny
f6cea9e41e
Update README.md 2022-02-20 15:28:51 +01:00
Benny
45da3ba0b0
Update README.md 2022-02-20 15:08:45 +01:00
43 changed files with 1370 additions and 270 deletions

BIN
.github/assets/demo-bookmark.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

1
.gitignore vendored
View file

@ -30,3 +30,4 @@ dist/
coverage
.env
data
reports/*

View file

@ -1,4 +1,7 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
npm run lint

View file

@ -1,4 +1,7 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
npm run test:unit:ci

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Benny
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,30 +1,43 @@
# wishlist
<p align="center">
<img src="https://raw.githubusercontent.com/ThisIsBenny/wishlist-app/main/public/logo-256.png" height="200">
</p>
<h1 align="center">
Wishlist App
</h1>
<p align="center">
<img src="https://img.shields.io/github/package-json/v/thisisbenny/wishlist-app" />
<img src="https://img.shields.io/github/workflow/status/thisisbenny/wishlist-app/CI" />
<a href="https://hub.docker.com/r/thisisbenny/wishlist-app"><img src="https://img.shields.io/docker/pulls/thisisbenny/wishlist-app" /></a>
<img src="https://img.shields.io/github/license/thisisbenny/wishlist-app" />
</p>
<p align="center">
A simple webapp to manage your wishlist.
<p>
<h3 align="center">
<a href="https://codesandbox.io/s/wishlist-app-ycygh3"><i>Demo</i></a>
</h3>
<br>
<br>
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.
[![Open in CodeSandbox](https://img.shields.io/badge/Open%20in-CodeSandbox-blue?style=flat-square&logo=codesandbox)](https://codesandbox.io/s/wishlist-app-h0htfc)
## Features
The app can be easily self-hosted via Docker (see docker-compose example below).
- Support of multiple wishlists
- Items can be removed from the wishlist by everyone (no registration needed for friends and family).
- Grab title, description and image-url from url via open graph meta tags
- i18n support
## Screenshots
![Overview Image](.github/assets/overview.jpg)
![Detail Image](.github/assets/details.jpg)
## Features
## Install
- Support of multiple wishlists
- Items can be removed from the wishlist by users
- i18n support
## Feature Roadmap
- Administrate wishlists
- Grab title, description and image-url from url via open graph meta tags
- Login
- Image upload
## Docker Setup
### Docker Setup
The app can be easily installed via Docker compose. During installation, only a password (API key) and a path for the SQLite database must be specified.
```yaml
version: '3.7'
@ -40,7 +53,24 @@ services:
- ./data:/app/data
```
## Development Setup
## Usage
When you open the app for the first time, you have to enter the API key. To do this, click the icon on the right in the header or open the `/login` page. If the API key is stored, a toggle for the edit mode appears in the header. The edit mode allows you to create new wishlists or edit existing ones.
If you want add new entries to the wishlist, open a wishlist and activate the edit mode. To more easily to add something to the wishlist, you can create a bookmark with the following content (replace [DOMAIN] with your own domain):
`javascript:window.location='[DOMAIN]/add-wishlist-item?url=' + window.location`
Now you can select the bookmark on a product page. This will redirect you to the app and pre-fill the form with the Open Graph data from the original page.
![Demo Bookmark adding](.github/assets/demo-bookmark.gif)
Once the wish list is ready, it can be shared with friends and family. They have the option to remove purchased items from the wish list so that they are not bought a second time.
Wishlists that have been set as non-public can only be opened with a deep link. They do not appear on the start page (unless the API key is set).
## Development Guide
```sh
npm install

1
components.d.ts vendored
View file

@ -14,7 +14,6 @@ declare module 'vue' {
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']

View file

@ -2,7 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<!-- <link rel="icon" href="/favicon.ico" /> -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wishlists</title>
</head>

1035
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
{
"version": "1.0.0",
"version": "1.4.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",
@ -10,10 +10,13 @@
"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",
"test:unit:ci": "vitest --environment jsdom --run --reporter=junit",
"coverage": "vitest run --environment jsdom --coverage",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"prepare": "husky install"
"prepare": "husky install",
"preversion": "git pull --ff-only",
"postversion": "git push --follow-tags"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
@ -23,6 +26,7 @@
"@tailwindcss/line-clamp": "^0.3.1",
"@vueuse/core": "^7.6.1",
"axios": "^0.25.0",
"cheerio": "^1.0.0-rc.12",
"fastify": "^3.27.1",
"fastify-compress": "^4.0.1",
"fastify-cors": "^6.0.2",
@ -45,6 +49,7 @@
"@vue/eslint-config-typescript": "^10.0.0",
"@vue/test-utils": "^2.0.0-rc.18",
"autoprefixer": "^10.4.2",
"c8": "^7.11.0",
"concurrently": "^7.0.0",
"dotenv": "^16.0.0",
"eslint": "^8.9.0",

View file

@ -0,0 +1,17 @@
-- 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 CASCADE 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;

View file

@ -27,6 +27,6 @@ model Item {
imageSrc String @default("")
description String
bought Boolean @default(false)
wishlist Wishlist @relation(fields: [wishlistId], references: [id])
wishlist Wishlist @relation(fields: [wishlistId], references: [id], onDelete: Cascade)
wishlistId String
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/logo-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/logo-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
public/logo-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

BIN
public/logo-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
public/logo-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

1
public/site.webmanifest Normal file
View file

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

0
reports/.gitkeep Normal file
View file

View file

@ -11,16 +11,17 @@
<IconError class="h-4 w-4 fill-red-500" />
<span>{{ t('errors.generic.text') }}</span>
</div>
<suspense v-else>
<suspense>
<template #default>
<component :is="Component"></component>
<component :is="Component" />
</template>
<template #fallback>
<div
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 class="m-20 flex flex-col items-center space-y-3">
<img
src="/logo-128.png"
:alt="t('common.loading.text')"
class="motion-safe:animate-pulse"
/>
</div>
</template>
</suspense>

View file

@ -0,0 +1,63 @@
import { FastifyRequest, FastifyReply, RouteOptions } from 'fastify'
import ogs, { OpenGraphImage } from 'open-graph-scraper'
import axios from 'axios'
import cheerio from 'cheerio'
interface fetchMetaDataRequest extends FastifyRequest {
query: {
url: string
}
}
export const fetchMetaData = <RouteOptions>{
method: 'GET',
url: '/fetch-meta-data-from-url',
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: fetchMetaDataRequest, reply: FastifyReply) => {
const url = request.query.url
const response = {
title: '',
description: '',
imageSrc: '',
}
if (url.includes('amazon.de')) {
const { data } = await axios.get(url)
const $ = cheerio.load(data)
response.title = $('#productTitle').text().trim() || ''
response.description = response.title
response.imageSrc = ($('#landingImage').attr('src') || '').trim()
} else {
const { result } = await ogs({
url: request.query.url,
})
request.log.debug(result)
if (result.success) {
response.imageSrc =
result.ogImage && (result.ogImage as OpenGraphImage).url
? (result.ogImage as OpenGraphImage).url
: ''
response.title = result.ogTitle || ''
response.description = result.ogDescription || ''
}
}
reply.send(response)
},
}

View file

@ -1,6 +1,6 @@
import { FastifyInstance } from 'fastify'
import { fetchOpenGraph } from './opengraph'
import { fetchMetaData } from './fetchmetadata'
export default async (app: FastifyInstance) => {
await app.route(fetchOpenGraph)
await app.route(fetchMetaData)
}

View file

@ -1,49 +0,0 @@
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,
})
}
},
}

View file

@ -1,6 +1,6 @@
<template>
<header
class="mb-4 flex flex-row items-center space-x-3 opacity-60 sm:justify-end"
class="mb-4 flex flex-row items-center justify-end space-x-3 opacity-60"
>
<div
v-if="isAuthenticated"
@ -11,9 +11,6 @@
<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>

View file

@ -8,48 +8,40 @@ const modal = useModal()
<div
v-if="modal.isShown"
data-test="modal"
class="fixed inset-0 z-10 overflow-y-auto"
class="fixed inset-0 z-10 overflow-y-auto p-4 pt-[70vh] sm:pt-[25vh]"
role="dialog"
>
<div class="fixed inset-0 bg-stone-700/75"></div>
<div
class="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0"
class="relative mx-auto rounded-md bg-white text-left shadow-xl ring-1 ring-black/5 dark:bg-stone-900 sm:max-w-xl"
>
<div class="fixed inset-0 bg-stone-700/75 transition-opacity"></div>
<span class="hidden sm:inline-block sm:h-screen sm:align-middle"
>&#8203;</span
>
<div
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="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 font-medium leading-6" data-test="modal-title">
{{ modal.title }}
</h3>
<div class="mt-2">
<p class="text-sm opacity-60" data-test="modal-body">
{{ modal.body }}
</p>
</div>
<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 font-medium leading-6" data-test="modal-title">
{{ modal.title }}
</h3>
<div class="mt-2">
<p class="text-sm opacity-60" data-test="modal-body">
{{ modal.body }}
</p>
</div>
</div>
</div>
<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 }}</ButtonBase
>
<ButtonBase
class="ml-2 w-full"
@click="modal.cancel"
data-test="modal-cancel-button"
>{{ modal.cancelText }}</ButtonBase
>
</div>
</div>
<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 }}</ButtonBase
>
<ButtonBase
class="ml-2 w-full"
@click="modal.cancel"
data-test="modal-cancel-button"
>{{ modal.cancelText }}</ButtonBase
>
</div>
</div>
</div>

View file

@ -2,10 +2,14 @@
import { useI18n } from 'vue-i18n'
import IconCart from './icons/IconCart.vue'
import { WishlistItem } from '@/types'
defineProps<{
const props = defineProps<{
item: WishlistItem
}>()
const { t } = useI18n()
const openUrl = (): void => {
props.item.url && window?.open(props.item.url, '_blank')?.focus()
}
</script>
<template>
@ -16,20 +20,38 @@ const { t } = useI18n()
class="max-h-44 flex-shrink-0 flex-grow-0 object-cover sm:w-1/4"
:src="item.imageSrc"
:alt="item.title"
:class="{ 'cursor-pointer': item.url }"
@click.prevent="openUrl()"
data-test="preview-image"
/>
<div class="flex flex-col justify-between p-2">
<div>
<h1 class="mb-1 text-lg font-bold">{{ item.title }}</h1>
<p class="text-sm sm:line-clamp-3">
<h1
@click.prevent="openUrl()"
class="mb-1 text-lg font-bold"
:class="{ 'cursor-pointer': item.url }"
data-test="title"
>
{{ item.title }}
</h1>
<p
@click.prevent="openUrl()"
class="text-sm sm:line-clamp-3"
:class="{ 'cursor-pointer': item.url }"
data-test="descriptions"
>
{{ item.description }}
</p>
</div>
<div class="flex flex-row items-baseline space-x-2">
<div
class="mt-4 flex w-full flex-row flex-wrap-reverse items-baseline sm:mt-2"
>
<ButtonBase
class="mt-4 text-xs sm:mt-2"
class="mr-4 mt-4 text-xs sm:mt-0"
:icon="IconCart"
@click="$emit('bought')"
data-test="bought-button"
>{{ t('components.wishlist-item.bought-button.text') }}</ButtonBase
>
<a
@ -37,7 +59,8 @@ const { t } = useI18n()
:href="item.url"
target="_blank"
rel="noopener"
class="mt-1 flex w-fit flex-row items-center text-sm text-stone-500 dark:text-white/60"
data-test="link"
class="flex w-fit flex-row items-center text-sm text-stone-500 dark:text-white/60"
>
<IconLink class="mr-1 h-4 w-4 fill-stone-500 dark:fill-white/60" />
<span>{{

View file

@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { shallowMount, VueWrapper } from '@vue/test-utils'
import ButtonBase from '../ButtonBase.vue'
describe('component: ButtonBase', () => {
let wrapper: VueWrapper
const createComponennt = (propsData: { mode: string }): void => {
wrapper = shallowMount(ButtonBase, {
propsData,
global: {
renderStubDefaultSlot: true,
},
})
}
it('shows primary button', () => {
createComponennt({ mode: 'primary' })
expect(wrapper.get('button')).toMatchSnapshot()
})
it('shows dange button', () => {
createComponennt({ mode: 'dange' })
expect(wrapper.get('button')).toMatchSnapshot()
})
it('shows secondary button', () => {
createComponennt({ mode: 'secondary' })
expect(wrapper.get('button')).toMatchSnapshot()
})
it('shows secondary button as default', () => {
createComponennt({ mode: '' })
expect(wrapper.get('button')).toMatchSnapshot()
})
})

View file

@ -0,0 +1,71 @@
import { describe, assert, it, afterEach } from 'vitest'
import { shallowMount, VueWrapper } from '@vue/test-utils'
import WishlistItem from '../WishlistItem.vue'
import i18n from '@/config/i18n'
import { WishlistItem as WishlistItemType } from '@/types'
const defaultWishlistItem: WishlistItemType = {
title: 'Item 1',
description: 'Description',
id: 1,
url: 'http://url',
imageSrc: 'http://imageurl',
bought: false,
}
describe('component: WishlistItem', () => {
let wrapper: VueWrapper
const createComponennt = (propsData: { item: WishlistItemType }): void => {
wrapper = shallowMount(WishlistItem, {
propsData,
global: {
renderStubDefaultSlot: true,
plugins: [i18n],
},
})
}
it('shows image if imageSrc is provided', () => {
createComponennt({ item: defaultWishlistItem })
assert.equal(wrapper.find('[data-test="preview-image"]').exists(), true)
})
it('shows fallback image if imageSrc is not providedn', () => {
const item = defaultWishlistItem
item.imageSrc = ''
createComponennt({ item })
assert.equal(wrapper.find('[data-test="preview-image"]').exists(), true)
})
it('shows title', () => {
createComponennt({ item: defaultWishlistItem })
assert.equal(wrapper.find('[data-test="title"]').exists(), true)
})
it('shows descriptions', () => {
createComponennt({ item: defaultWishlistItem })
assert.equal(wrapper.find('[data-test="descriptions"]').exists(), true)
})
it('shows bought button', () => {
createComponennt({ item: defaultWishlistItem })
assert.equal(wrapper.find('[data-test="bought-button"]').exists(), true)
})
it('shows link if url is provided', () => {
createComponennt({ item: defaultWishlistItem })
assert.equal(wrapper.find('[data-test="link"]').exists(), true)
})
it('shows no link if url is not provided', () => {
const item = defaultWishlistItem
item.url = ''
createComponennt({ item })
assert.equal(wrapper.find('[data-test="link"]').exists(), false)
})
afterEach(() => {
wrapper.unmount()
})
})

View file

@ -0,0 +1,61 @@
// Vitest Snapshot v1
exports[`component: ButtonBase > shows dange button 1`] = `
DOMWrapper {
"isDisabled": [Function],
"wrapperElement": <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"
>
<!--v-if-->
<span>
</span>
</button>,
}
`;
exports[`component: ButtonBase > shows primary button 1`] = `
DOMWrapper {
"isDisabled": [Function],
"wrapperElement": <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 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"
>
<!--v-if-->
<span>
</span>
</button>,
}
`;
exports[`component: ButtonBase > shows secondary button 1`] = `
DOMWrapper {
"isDisabled": [Function],
"wrapperElement": <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 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"
>
<!--v-if-->
<span>
</span>
</button>,
}
`;
exports[`component: ButtonBase > shows secondary button as default 1`] = `
DOMWrapper {
"isDisabled": [Function],
"wrapperElement": <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"
>
<!--v-if-->
<span>
</span>
</button>,
}
`;

View file

@ -1,5 +0,0 @@
<template>
<svg viewBox="0 0 24 24">
<path d="M10 20v-6h4v6h5v-8h3L12 3L2 12h3v8h5z"></path>
</svg>
</template>

View file

@ -41,7 +41,9 @@ const updateWishlist = async (updatedData: Wishlist): Promise<void> => {
}
const deleteWishlist = async (): Promise<void> => {
const { error } = await useFetch(`/wishlist/${state!.value!.id}`).delete()
const { error } = await useFetch(`/wishlist/${state!.value!.id}`)
.delete()
.json()
if (error.value) {
throw error.value
}
@ -92,7 +94,9 @@ const updateItem = async (
const itemBought = async (item: WishlistItem): Promise<void> => {
const { error } = await useFetch(
`/wishlist/${item.wishlistId}/item/${item.id}/bought`
).post()
)
.post()
.json()
if (error.value) {
throw error.value
}

View file

@ -130,7 +130,7 @@
"text": "Produktseite öffnen"
},
"bought-button": {
"text": "Gekauft"
"text": "Als gekauft markieren"
}
},
"form-wishlist": {

View file

@ -130,7 +130,7 @@
"text": "Open product page"
},
"bought-button": {
"text": "Bought"
"text": "Mark as purchased"
}
},
"form-wishlist": {

View file

@ -36,7 +36,7 @@ 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}`
`/utils/fetch-meta-data-from-url?url=${route.query.url}`
).json()
syncRef(opDataLoaded, isFinished)

View file

@ -16,6 +16,7 @@ const { isActive: editModeIsActive } = useEditMode()
const {
fetch,
state,
error,
isFinished,
updateWishlist,
deleteWishlist,
@ -112,7 +113,7 @@ const handleDeleteItem = async (item: WishlistItemType): Promise<void> => {
</script>
<template>
<div v-if="isFinished && state !== undefined" class="h-full">
<div v-if="isFinished && !error && state" class="h-full">
<div
class="flex flex-col items-center space-x-0 space-y-2 md:flex-row md:space-x-6 md:space-y-0"
>

View file

@ -1,44 +1,41 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useTitle } from '@vueuse/core'
import { useWishlistsStore, useEditMode } from '@/composables'
const { t } = useI18n()
useTitle(t('common.app-title.text'))
const { isActive: editModeIsActive } = useEditMode()
const { state, isFinished, fetch } = useWishlistsStore()
await fetch()
</script>
<template>
<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>
<h1 class="text-semibold text-center text-3xl">
{{ t('common.app-title.text') }}
</h1>
<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"
v-if="isFinished && state.length === 0 && !editModeIsActive"
class="flex h-1/2 w-full justify-center"
>
<span>{{ t('pages.home-view.main.empty-list.text') }}</span>
<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 in state"
:key="item.id"
:to="'/' + item.slugUrlText"
>
<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>
</div>
<div v-else class="flex flex-row flex-wrap justify-around p-10">
<router-link
v-for="item in state"
:key="item.id"
:to="'/' + item.slugUrlText"
>
<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>

View file

@ -20,4 +20,7 @@ export default defineConfig({
build: {
outDir: 'dist/static',
},
test: {
outputFile: 'reports/unittest.xml',
},
})