first commit

Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
This commit is contained in:
Benny Samir Hierl 2022-01-29 11:04:24 +01:00
commit 38600ebacb
62 changed files with 13201 additions and 0 deletions

10
.dockerignore Normal file
View file

@ -0,0 +1,10 @@
dist/
.vscode
node_modules
Dockerfile
docker-compose.yml
.gitignore
.editorconfig
README.md
prisma/seed.ts
data/

13
.editorconfig Normal file
View file

@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false

3
.env.template Normal file
View file

@ -0,0 +1,3 @@
NODE_ENV=development
VITE_API_BASEURL=http://localhost:5000/api
DATABASE_URL="file:../data/data.db"

16
.eslintrc.cjs Normal file
View file

@ -0,0 +1,16 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript/recommended',
'@vue/eslint-config-prettier',
],
plugins: ['prettier'],
env: {
'vue/setup-compiler-macros': true,
},
}

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
dist/
.vscode
coverage
.env
data
prisma/seed.ts

6
.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": false,
"singleQuote": true
}

33
Dockerfile Normal file
View file

@ -0,0 +1,33 @@
# Build artifacts
FROM node:lts as builder
RUN mkdir /app
WORKDIR /app
COPY package.json package-lock.json /app/
RUN npm ci
ENV NODE_ENV=production
COPY . /app/
RUN npm run build
FROM node:lts
LABEL maintainer="github.com/thisisbenny"
ENV NODE_ENV=production
ENV PORT=5000
ENV DATABASE_URL="file:../data/data.db"
RUN mkdir /app
WORKDIR /app
RUN mkdir data
COPY package.json package-lock.json /app/
COPY ./prisma /app/prisma
RUN npm ci
COPY --from=builder /app/dist /app
EXPOSE 5000
ENTRYPOINT npx prisma migrate deploy && node api/server.js

52
README.md Normal file
View file

@ -0,0 +1,52 @@
# wishlist
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

9
docker-compose.yml Normal file
View file

@ -0,0 +1,9 @@
version: '3.7'
services:
wishlist:
build: ./
ports:
- '5000:5000'
volumes:
- ./data:/app/data

9
env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASEURL: string
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wunschlisten</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4
nodemon.json Normal file
View file

@ -0,0 +1,4 @@
{
"ignore": [".git", "node_modules/**/node_modules"],
"watch": ["src/api/"]
}

12118
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

56
package.json Normal file
View file

@ -0,0 +1,56 @@
{
"version": "0.0.0",
"scripts": {
"dev": "concurrently --kill-others \"npm run dev:frontend\" \"npm run dev:backend\"",
"dev:frontend": "vite",
"dev:backend": "nodemon -r dotenv/config ./src/api/server.ts",
"build": "npm run build:frontend && npm run build:backend",
"build:frontend": "vue-tsc --noEmit && vite build",
"build:backend": "tsc -p tsconfig.backend.json --outDir dist",
"preview": "vite preview --port 5050",
"test:unit": "vitest --environment jsdom",
"test:unit:ci": "vitest --environment jsdom --run",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^3.8.1",
"axios": "^0.25.0",
"fastify": "^3.27.0",
"fastify-compress": "^4.0.1",
"fastify-cors": "^6.0.2",
"fastify-helmet": "^7.0.1",
"fastify-static": "^4.5.0",
"vue": "^3.2.27",
"vue-router": "^4.0.12"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.1.0",
"@types/node": "^16.11.21",
"@vitejs/plugin-vue": "^2.0.1",
"@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",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^8.2.0",
"jsdom": "^19.0.0",
"nodemon": "^2.0.15",
"pino-pretty": "^7.5.1",
"postcss": "^8.4.5",
"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"
}
}

7
postcss.config.js Normal file
View file

@ -0,0 +1,7 @@
// eslint-disable-next-line no-undef
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "Wishlist" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"imageSrc" TEXT NOT NULL,
"slugUrlText" TEXT NOT NULL,
"description" TEXT
);
-- CreateTable
CREATE TABLE "Item" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"url" TEXT,
"image" TEXT,
"description" TEXT NOT NULL,
"comment" TEXT,
"bought" BOOLEAN NOT NULL DEFAULT false,
"wishlistId" TEXT,
CONSTRAINT "Item_wishlistId_fkey" FOREIGN KEY ("wishlistId") REFERENCES "Wishlist" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Wishlist_slugUrlText_key" ON "Wishlist"("slugUrlText");

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

32
prisma/schema.prisma Normal file
View file

@ -0,0 +1,32 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Wishlist {
id String @id @default(uuid())
title String
imageSrc String
slugUrlText String @unique
description String?
items Item[]
}
model Item {
id Int @id @default(autoincrement())
title String
url String?
image String?
description String
comment String?
bought Boolean @default(false)
wishlist Wishlist? @relation(fields: [wishlistId], references: [id])
wishlistId String?
}

BIN
public/benny.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/jonas.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
public/nadine.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

27
src/App.vue Normal file
View file

@ -0,0 +1,27 @@
<template>
<div class="app max-w-[900px] mx-auto p-10">
<main>
<router-view v-slot="{ Component }">
<template v-if="Component">
<keep-alive>
<suspense>
<component :is="Component"></component>
<template #fallback>
<div
class="flex flex-row space-x-2 items-center content-center justify-center m-20"
>
<IconSpinner class="w-4 h-4" />
<span> Lade Daten... </span>
</div>
</template>
</suspense>
</keep-alive>
</template>
</router-view>
</main>
<modal-overlay />
</div>
</template>
<script setup lang="ts">
import IconSpinner from '@/components/icons/IconSpinner.vue'
</script>

26
src/api/app.ts Normal file
View file

@ -0,0 +1,26 @@
import staticFiles from 'fastify-static'
import path from 'path'
import { initApp } from './config'
import routes from './routes'
const build = async (opts = {}) => {
const app = await initApp(opts)
routes.register(app)
app.register(staticFiles, {
root: path.join(__dirname, '..', 'static'),
})
app.setNotFoundHandler((req, res) => {
res.sendFile('index.html')
})
app.get('/healthz', async () => {
return { status: 'ok' }
})
// TODO: disconnet prisma client when server will be shutdown
return app
}
export default build

20
src/api/config/fastify.ts Normal file
View file

@ -0,0 +1,20 @@
let logLevel
switch (process.env.NODE_ENV) {
case 'development':
logLevel = 'debug'
break
case 'test':
logLevel = 'silent'
break
default:
logLevel = 'info'
break
}
export default {
logger: {
level: logLevel,
prettyPrint: process.env.NODE_ENV === 'development',
redact: ['err.stack'],
},
}

2
src/api/config/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { default as fastify } from './fastify'
export { default as initApp } from './initApp'

25
src/api/config/initApp.ts Normal file
View file

@ -0,0 +1,25 @@
import helmet from 'fastify-helmet'
import Fastify, { FastifyContextConfig } from 'fastify'
import compress from 'fastify-compress'
import cors from 'fastify-cors'
import { fastify as defaultConfig } from './'
export default async (opts: FastifyContextConfig = {}) => {
const app = Fastify({
...defaultConfig,
...opts,
})
await app.register(helmet, {
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
})
await app.register(cors, {
origin: true,
})
await app.register(compress)
return app
}

1
src/api/models/index.ts Normal file
View file

@ -0,0 +1 @@
export { default as wishlist } from './wishlist'

View file

@ -0,0 +1,32 @@
import { prisma } from '../../services'
import { Wishlist, WishlistItem } from '../../../types'
export default {
getAll: async (): Promise<any> => {
return await prisma.client.wishlist.findMany({
include: { items: false },
})
},
getBySlugUrlText: async (
value: string,
includeItems = false
): Promise<any> => {
return await prisma.client.wishlist.findUnique({
where: {
slugUrlText: value,
},
include: { items: includeItems },
})
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateItem: async (itemId: number, payload: any) => {
return await prisma.client.item.update({
where: {
id: itemId,
},
data: {
...payload,
},
})
},
}

13
src/api/routes/index.ts Normal file
View file

@ -0,0 +1,13 @@
import { FastifyInstance } from 'fastify'
import { default as wishlistRoute } from './wishlist/'
export default {
register: (app: FastifyInstance) => {
return app.register(
async (app) => {
await app.register(wishlistRoute, { prefix: '/wishlist' })
},
{ prefix: '/api' }
)
},
}

View file

@ -0,0 +1,9 @@
import { FastifyInstance } from 'fastify'
import { getAll, getBySlugUrl } from './read'
import { updateItem } from './update'
export default async (app: FastifyInstance) => {
await app.route(getAll)
await app.route(getBySlugUrl)
await app.route(updateItem)
}

View file

@ -0,0 +1,77 @@
import { FastifyRequest, FastifyReply, RouteOptions } from 'fastify'
import { wishlist } from '../../models'
export const getAll = <any>{
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' },
},
},
},
},
},
handler: async (request: FastifyRequest, reply: FastifyReply) => {
return await wishlist.getAll()
},
}
interface GetBySlugUrlTextRequest extends FastifyRequest {
params: {
slugText: string
}
}
export const getBySlugUrl = <RouteOptions>{
method: 'GET',
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' },
image: { type: 'string' },
description: { type: 'string' },
comment: { type: 'string' },
bought: { type: 'boolean' },
wishlistId: { type: 'string' },
},
},
},
},
},
},
},
handler: async (request: GetBySlugUrlTextRequest, reply: FastifyReply) => {
const list = await wishlist.getBySlugUrlText(request.params.slugText, true)
if (list) {
return list
} else {
return reply.code(404).send({
error: 'notFound',
http: 404,
})
}
},
}

View file

@ -0,0 +1,44 @@
import { FastifyRequest, FastifyReply, RouteOptions } from 'fastify'
import { wishlist } from '../../models'
interface GetBySlugUrlTextRequest extends FastifyRequest {
params: {
wishlistId: string
itemId: number
}
}
export const updateItem = <RouteOptions>{
method: 'PUT',
url: '/:wishlistId/item/:itemId',
schema: {
response: {
204: {
type: 'object',
properties: {
title: { type: 'string' },
url: { type: 'string' },
image: { type: 'string' },
description: { type: 'string' },
comment: { type: 'string' },
bought: { type: 'boolean' },
wishlistId: { type: 'string' },
},
},
},
},
handler: async (request: GetBySlugUrlTextRequest, reply: FastifyReply) => {
const item = await wishlist.updateItem(
Number(request.params.itemId),
request.body
)
if (item) {
return item
} else {
return reply.code(404).send({
error: 'notFound',
http: 404,
})
}
},
}

11
src/api/server.ts Normal file
View file

@ -0,0 +1,11 @@
import fastifyBuilder from './app'
fastifyBuilder().then((app) => {
app.listen(process.env.PORT || 5000, '0.0.0.0', (err, address) => {
if (err) {
console.error(err)
process.exit(1)
}
console.log(`Server listening at ${address}`)
})
})

View file

@ -0,0 +1 @@
export { default as prisma } from './prisma'

View file

@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client'
const client = new PrismaClient()
export default {
client,
}

3
src/assets/tailwind.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -0,0 +1,15 @@
<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>

50
src/components/Modal.vue Normal file
View file

@ -0,0 +1,50 @@
<script lang="ts" setup>
import BaseButton from './BaseButton.vue'
import { useModal } from '@/composables'
const modal = useModal()
</script>
<template>
<div
v-if="modal.isShown"
class="fixed z-10 inset-0 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"
>
<div class="fixed inset-0 bg-gray-500/75 transition-opacity"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen"
>&#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"
>
<div class="bg-white 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">
{{ modal.title }}
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
{{ modal.text }}
</p>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 flex flex-row">
<BaseButton class="w-full" @click="modal.confirm">{{
modal.confirmText
}}</BaseButton>
<BaseButton class="w-full ml-2" @click="modal.cancel">{{
modal.cancelText
}}</BaseButton>
</div>
</div>
</div>
</div>
</template>

18
src/components/Tile.vue Normal file
View file

@ -0,0 +1,18 @@
<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

@ -0,0 +1,55 @@
<script lang="ts" setup>
import IconLink from './icons/IconLink.vue'
import IconImagePlaceholder from './icons/IconImagePlaceholder.vue'
import BaseButton from './BaseButton.vue'
import IconCart from './icons/IconCart.vue'
defineProps<{
title: string
image: string
url?: string
description: string
comment?: string
}>()
</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"
>
<img
v-if="image"
:src="image"
:alt="title"
class="object-cover sm:aspect-[3/2] max-h-44 flex-shrink-0 flex-grow-0"
/>
<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>
<h1 class="text-lg mb-1 font-bold">{{ title }}</h1>
<p class="text-sm">{{ description }}</p>
<a
v-if="url"
:href="url"
target="_blank"
rel="noopener"
class="text-sm mt-1 text-stone-500 flex flex-row items-center w-fit"
>
<IconLink class="mr-1 w-4 h-4" />
<span>Produktseite öffnen</span>
</a>
</div>
<BaseButton
class="mt-4 sm:mt-2 text-xs"
:icon="IconCart"
@click="$emit('bought')"
>Gekauft</BaseButton
>
</div>
</div>
</template>

View file

@ -0,0 +1,8 @@
<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>
</template>

View file

@ -0,0 +1,8 @@
<template>
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M14,6L10.25,11L13.1,14.8L11.5,16C9.81,13.75 7,10 7,10L1,18H23L14,6Z"
/>
</svg>
</template>

View file

@ -0,0 +1,8 @@
<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>
</template>

View file

@ -0,0 +1,5 @@
<template>
<svg class="animate-spin" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</template>

3
src/composables/index.ts Normal file
View file

@ -0,0 +1,3 @@
export * from './useWishlistsStore'
export * from './useWishlistStore'
export * from './useModal'

View file

@ -0,0 +1,32 @@
import { reactive } from 'vue'
let _resolve: (confirmed: boolean) => void
const callback = (confirmed: boolean) => {
data.isShown = false
_resolve(confirmed)
}
const show = (title: string, text = '') => {
data.title = title
data.text = text
data.isShown = true
return new Promise((resolve) => {
_resolve = resolve
})
}
const data = reactive({
isShown: false,
show,
title: '',
text: '',
confirmText: 'Ja',
confirm: () => callback(true),
cancelText: 'Nein',
cancel: () => callback(false),
})
export const useModal = () => {
return data
}

View file

@ -0,0 +1,21 @@
import apiService from '@/services/apiService'
import { Wishlist, WishlistItem } from '@/types'
import { reactive } from 'vue'
const apiClient = apiService.getClient()
const getBySlugUrl = async (slugText: string): Promise<Wishlist> => {
const { data } = await apiClient.get(`/wishlist/${slugText}`)
return data
}
const updateItem = async (item: WishlistItem): Promise<void> => {
await apiClient.put(`/wishlist/${item.wishlistId}/item/${item.id}`, item)
}
export const useWishlistStore = async (slugText: string) => {
const list = reactive(await getBySlugUrl(slugText))
return reactive({
list,
updateItem,
})
}

View file

@ -0,0 +1,17 @@
import apiService from '@/services/apiService'
import { Wishlist } from '@/types'
import { reactive, ref } from 'vue'
const apiClient = apiService.getClient()
const prefix = '/wishlist'
export const getAll = async (): Promise<Wishlist[]> => {
const { data } = await apiClient.get(prefix)
return data
}
export const useWishlistsStore = async () => {
const lists = reactive(await getAll())
return reactive({
lists,
})
}

7
src/config/apiConfig.ts Normal file
View file

@ -0,0 +1,7 @@
interface apiConfig {
baseURL: string
}
export default <apiConfig>{
baseURL: import.meta.env.VITE_API_BASEURL || '/api',
}

1
src/config/index.ts Normal file
View file

@ -0,0 +1 @@
export { default as apiConfig } from './apiConfig'

13
src/main.ts Normal file
View file

@ -0,0 +1,13 @@
import { createApp } from 'vue'
import './assets/tailwind.css'
import App from './App.vue'
import router from './router'
import Modal from '@/components/Modal.vue'
const app = createApp(App)
app.use(router)
app.component('modalOverlay', Modal)
app.mount('#app')

26
src/router/index.ts Normal file
View file

@ -0,0 +1,26 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import DetailView from '../views/DetailView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/:slug',
name: 'detail',
component: DetailView,
},
{
name: 'notFound',
path: '/:pathMatch(.*)*',
component: () => import('../views/NotFound.vue'),
},
],
})
export default router

View file

@ -0,0 +1,14 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
import { apiConfig } from '@/config'
const config: AxiosRequestConfig = {
baseURL: apiConfig.baseURL,
}
const client: AxiosInstance = axios.create(config)
export default {
getClient: () => {
return client
},
}

22
src/types.ts Normal file
View file

@ -0,0 +1,22 @@
export interface WishlistItem {
id: string
title: string
url: string
image: string
description: string
comment: string
bought: boolean
wishlistId: boolean
}
export interface Wishlist {
id: string
title: string
description: string
imageSrc: string
slugUrlText: string
items: WishlistItem[]
}
export interface TileProp {
title: string
imageSrc: string
}

55
src/views/DetailView.vue Normal file
View file

@ -0,0 +1,55 @@
<script setup lang="ts">
import { WishlistItem as WishlistItemType } from '@/types'
import { useRoute } from 'vue-router'
import Tile from '@/components/Tile.vue'
import WishlistItem from '@/components/WishlistItem.vue'
import { useWishlistStore, useModal } from '@/composables'
import { computed } from 'vue'
const route = useRoute()
const modal = useModal()
const wishlistStore = await useWishlistStore(route.params.slug as string)
const list = wishlistStore.list
const notBoughtItems = computed(() => {
return list.items.filter((item: WishlistItemType) => item.bought === false)
})
const bought = async (item: WishlistItemType): Promise<void> => {
const confirmed = await modal.show(
'Möchten Sie den Gegenstand von der Liste nehmen?',
'Durch das das runternehmen von der Liste ist dieser Gegenstand nicht mehr andere sichtbar.'
)
if (confirmed) {
item.bought = true
wishlistStore.updateItem(item)
}
}
</script>
<template>
<div
class="flex flex-col md:flex-row space-x-0 md:space-x-6 space-y-2 md:space-y-0 items-center"
>
<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 }}
</h1>
<p v-if="list.description" class="text-lg">
{{ list.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.image"
:description="item.description"
:comment="item.comment"
@bought="bought(item)"
/>
</div>
</template>

23
src/views/HomeView.vue Normal file
View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import Tile from '@/components/Tile.vue'
import { useWishlistsStore } from '@/composables'
const wishlistStore = await useWishlistsStore()
const lists = wishlistStore.lists
</script>
<template>
<h1 class="text-3xl text-center">Wunschlisten</h1>
<div class="flex flex-row flex-wrap justify-around p-10">
<router-link
v-for="(item, index) in lists"
:key="index"
:to="'/' + item.slugUrlText"
>
<Tile
:title="item.title"
:image-src="item.imageSrc"
class="m-2 hover:ring-2 ring-slate-500"
/>
</router-link>
</div>
</template>

3
src/views/NotFound.vue Normal file
View file

@ -0,0 +1,3 @@
<template>
<h1>Oops, it looks like the page you're looking for doesn't exist.</h1>
</template>

8
tailwind.config.js Normal file
View file

@ -0,0 +1,8 @@
// eslint-disable-next-line no-undef
module.exports = {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}

7
tsconfig.backend.json Normal file
View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs"
},
"include": ["src/api/**/*"]
}

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"baseUrl": "./",
"target": "esnext",
"useDefineForClassFields": true,
"module": "esnext",
"moduleResolution": "node",
"isolatedModules": true,
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"paths": {
"@/*": ["src/*"]
},
"lib": ["esnext", "dom", "dom.iterable", "scripthost"],
"skipLibCheck": true
},
"ts-node": {
"compilerOptions": {
"module": "commonjs"
}
},
"include": ["vite.config.*", "env.d.ts", "src/**/*", "src/**/*.vue"]
}

17
vite.config.ts Normal file
View file

@ -0,0 +1,17 @@
import { fileURLToPath, URL } from 'url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: 'dist/static',
},
})