Compare commits
39 commits
Author | SHA1 | Date | |
---|---|---|---|
57aeac59e6 | |||
34925dc266 | |||
f07085c211 | |||
9880406d61 | |||
42f0a6f63c | |||
54f869c7cd | |||
b4cd04a11a | |||
caaef85d60 | |||
![]() |
2e7ea74271 | ||
![]() |
86e6fd4580 | ||
![]() |
a0c7f33647 | ||
![]() |
ecbeb4cef0 | ||
5ad1995f65 | |||
f319ee5185 | |||
743f7c0f07 | |||
c156a0ecea | |||
dc94e98aa0 | |||
abf16aa242 | |||
bc1458d5a0 | |||
0e04b91f15 | |||
2cbc0e2f8e | |||
a2d81b0f14 | |||
38f865bbef | |||
9a01d8147e | |||
7410fa6b34 | |||
4210053907 | |||
78a8d39ea6 | |||
59f2b7bdbf | |||
![]() |
85582d21e6 | ||
946bd8b9d7 | |||
53ec249f69 | |||
fe7dcf68ca | |||
![]() |
3fa309ce66 | ||
74c2f29cf5 | |||
f54232972b | |||
2637b65d28 | |||
71ab2c14fd | |||
![]() |
f6cea9e41e | ||
![]() |
45da3ba0b0 |
BIN
.github/assets/demo-bookmark.gif
vendored
Normal file
After Width: | Height: | Size: 426 KiB |
1
.gitignore
vendored
|
@ -30,3 +30,4 @@ dist/
|
|||
coverage
|
||||
.env
|
||||
data
|
||||
reports/*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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.
|
64
README.md
|
@ -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.
|
||||
|
||||
[](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
|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
|
||||
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
|
@ -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']
|
||||
|
|
|
@ -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
11
package.json
|
@ -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",
|
||||
|
|
|
@ -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;
|
|
@ -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
|
||||
}
|
||||
|
|
BIN
public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
public/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 52 KiB |
BIN
public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 640 B |
BIN
public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/logo-128.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
public/logo-256.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
public/logo-32.png
Normal file
After Width: | Height: | Size: 3 KiB |
BIN
public/logo-512.png
Normal file
After Width: | Height: | Size: 55 KiB |
BIN
public/logo-64.png
Normal file
After Width: | Height: | Size: 6 KiB |
1
public/site.webmanifest
Normal 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
15
src/App.vue
|
@ -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>
|
||||
|
|
63
src/api/routes/utils/fetchmetadata.ts
Normal 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)
|
||||
},
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
>​</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>
|
||||
|
|
|
@ -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>{{
|
||||
|
|
32
src/components/__tests__/ButtonBase.test.ts
Normal 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()
|
||||
})
|
||||
})
|
71
src/components/__tests__/WishlistItem.test.ts
Normal 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()
|
||||
})
|
||||
})
|
|
@ -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>,
|
||||
}
|
||||
`;
|
|
@ -1,5 +0,0 @@
|
|||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3L2 12h3v8h5z"></path>
|
||||
</svg>
|
||||
</template>
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -130,7 +130,7 @@
|
|||
"text": "Produktseite öffnen"
|
||||
},
|
||||
"bought-button": {
|
||||
"text": "Gekauft"
|
||||
"text": "Als gekauft markieren"
|
||||
}
|
||||
},
|
||||
"form-wishlist": {
|
||||
|
|
|
@ -130,7 +130,7 @@
|
|||
"text": "Open product page"
|
||||
},
|
||||
"bought-button": {
|
||||
"text": "Bought"
|
||||
"text": "Mark as purchased"
|
||||
}
|
||||
},
|
||||
"form-wishlist": {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -20,4 +20,7 @@ export default defineConfig({
|
|||
build: {
|
||||
outDir: 'dist/static',
|
||||
},
|
||||
test: {
|
||||
outputFile: 'reports/unittest.xml',
|
||||
},
|
||||
})
|
||||
|
|