Merge pull request #7 from ThisIsBenny/release/v1.0.0

Release/v1.0.0
This commit is contained in:
Benny 2022-02-20 14:46:32 +01:00 committed by GitHub
commit 87039c6e35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
91 changed files with 5445 additions and 2417 deletions

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

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
View 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
View file

@ -30,5 +30,3 @@ dist/
coverage
.env
data
prisma/seed.ts
public/*.jpeg

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
v16.14.0

View file

@ -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

View file

@ -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.
[![Open in CodeSandbox](https://img.shields.io/badge/Open%20in-CodeSandbox-blue?style=flat-square&logo=codesandbox)](https://codesandbox.io/s/wishlist-app-h0htfc)
The app can be easily self-hosted via Docker (see docker-compose example below).
## Screenshots
![Overview Image](.github/assets/overview.jpg)
![Detail Image](.github/assets/details.jpg)
## 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
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png)](https://www.buymeacoffee.com/hierlDev)

40
components.d.ts vendored Normal file
View 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 { }

View file

@ -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
View 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}}

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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
View 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
View file

@ -0,0 +1,9 @@
{
"infiniteLoopProtection": true,
"hardReloadOnChange": false,
"view": "browser",
"container": {
"startScript": "demo",
"node": "16"
}
}

View file

@ -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
})

View file

@ -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
View 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()
}
}
)
},
}

View 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)
}

View file

@ -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
}

View file

@ -0,0 +1 @@
export * from './wishlist'

View 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,
},
},
}

View file

@ -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,
},
})
},
}

View file

@ -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' }
)

View file

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

View 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,
})
}
},
}

View 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)
},
}

View 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()
},
}

View file

@ -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)
}

View file

@ -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()
}
},
}

View file

@ -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,
})
}
},
}

View file

@ -1,3 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#app {
min-height: 100% !important;
height: 100%;
}

View file

@ -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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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"
>&#8203;</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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View 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>

View file

@ -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>

View file

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

View file

@ -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>

View 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>

View file

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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'

View file

@ -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'

View 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),
}
}

View file

@ -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)
}
)

View 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,
}
}

View 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
},
},
})

View file

@ -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,
}
}

View file

@ -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,
}
}

View file

@ -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"
}
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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')

View file

@ -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

View file

@ -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

View 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>

View 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>

View file

@ -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>

View file

@ -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
View 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>

View file

@ -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>

View 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>

View file

@ -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')],
}

View file

@ -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"
]
}

View file

@ -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)),