mirror of
https://github.com/ThisIsBenny/wishlist-app.git
synced 2025-04-19 15:27:41 +00:00
first commit
Signed-off-by: Benny Samir Hierl <bennysamir@posteo.de>
This commit is contained in:
commit
38600ebacb
62 changed files with 13201 additions and 0 deletions
10
.dockerignore
Normal file
10
.dockerignore
Normal 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
13
.editorconfig
Normal 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
3
.env.template
Normal 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
16
.eslintrc.cjs
Normal 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
33
.gitignore
vendored
Normal 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
6
.prettierrc
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
33
Dockerfile
Normal file
33
Dockerfile
Normal 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
52
README.md
Normal 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
9
docker-compose.yml
Normal 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
9
env.d.ts
vendored
Normal 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
13
index.html
Normal 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
4
nodemon.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"ignore": [".git", "node_modules/**/node_modules"],
|
||||
"watch": ["src/api/"]
|
||||
}
|
12118
package-lock.json
generated
Normal file
12118
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
56
package.json
Normal file
56
package.json
Normal 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
7
postcss.config.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
// eslint-disable-next-line no-undef
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
|
@ -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");
|
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
32
prisma/schema.prisma
Normal 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
BIN
public/benny.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
BIN
public/jonas.jpeg
Normal file
BIN
public/jonas.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 44 KiB |
BIN
public/nadine.jpeg
Normal file
BIN
public/nadine.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
27
src/App.vue
Normal file
27
src/App.vue
Normal 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
26
src/api/app.ts
Normal 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
20
src/api/config/fastify.ts
Normal 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
2
src/api/config/index.ts
Normal 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
25
src/api/config/initApp.ts
Normal 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
1
src/api/models/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as wishlist } from './wishlist'
|
32
src/api/models/wishlist/index.ts
Normal file
32
src/api/models/wishlist/index.ts
Normal 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
13
src/api/routes/index.ts
Normal 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' }
|
||||
)
|
||||
},
|
||||
}
|
9
src/api/routes/wishlist/index.ts
Normal file
9
src/api/routes/wishlist/index.ts
Normal 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)
|
||||
}
|
77
src/api/routes/wishlist/read.ts
Normal file
77
src/api/routes/wishlist/read.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
44
src/api/routes/wishlist/update.ts
Normal file
44
src/api/routes/wishlist/update.ts
Normal 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
11
src/api/server.ts
Normal 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}`)
|
||||
})
|
||||
})
|
1
src/api/services/index.ts
Normal file
1
src/api/services/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as prisma } from './prisma'
|
7
src/api/services/prisma/index.ts
Normal file
7
src/api/services/prisma/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const client = new PrismaClient()
|
||||
|
||||
export default {
|
||||
client,
|
||||
}
|
3
src/assets/tailwind.css
Normal file
3
src/assets/tailwind.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
15
src/components/BaseButton.vue
Normal file
15
src/components/BaseButton.vue
Normal 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
50
src/components/Modal.vue
Normal 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"
|
||||
>​</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
18
src/components/Tile.vue
Normal 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>
|
55
src/components/WishlistItem.vue
Normal file
55
src/components/WishlistItem.vue
Normal 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>
|
8
src/components/icons/IconCart.vue
Normal file
8
src/components/icons/IconCart.vue
Normal 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>
|
8
src/components/icons/IconImagePlaceholder.vue
Normal file
8
src/components/icons/IconImagePlaceholder.vue
Normal 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>
|
8
src/components/icons/IconLink.vue
Normal file
8
src/components/icons/IconLink.vue
Normal 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>
|
5
src/components/icons/IconSpinner.vue
Normal file
5
src/components/icons/IconSpinner.vue
Normal 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
3
src/composables/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from './useWishlistsStore'
|
||||
export * from './useWishlistStore'
|
||||
export * from './useModal'
|
32
src/composables/useModal.ts
Normal file
32
src/composables/useModal.ts
Normal 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
|
||||
}
|
21
src/composables/useWishlistStore.ts
Normal file
21
src/composables/useWishlistStore.ts
Normal 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,
|
||||
})
|
||||
}
|
17
src/composables/useWishlistsStore.ts
Normal file
17
src/composables/useWishlistsStore.ts
Normal 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
7
src/config/apiConfig.ts
Normal 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
1
src/config/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as apiConfig } from './apiConfig'
|
13
src/main.ts
Normal file
13
src/main.ts
Normal 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
26
src/router/index.ts
Normal 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
|
14
src/services/apiService.ts
Normal file
14
src/services/apiService.ts
Normal 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
22
src/types.ts
Normal 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
55
src/views/DetailView.vue
Normal 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
23
src/views/HomeView.vue
Normal 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
3
src/views/NotFound.vue
Normal 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
8
tailwind.config.js
Normal 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
7
tsconfig.backend.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
},
|
||||
"include": ["src/api/**/*"]
|
||||
}
|
26
tsconfig.json
Normal file
26
tsconfig.json
Normal 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
17
vite.config.ts
Normal 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',
|
||||
},
|
||||
})
|
Loading…
Add table
Reference in a new issue