forked from Wroclaw/WorkshopTasker
Initial commit
This commit is contained in:
commit
1e63e008af
48 changed files with 12715 additions and 0 deletions
26
.eslintrc.json
Normal file
26
.eslintrc.json
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"extends": ["@nuxtjs/eslint-config-typescript", "eslint:recommended"],
|
||||||
|
"ignorePatterns": ["*.md", "*.json"],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"comma-dangle": ["error", "always-multiline"],
|
||||||
|
"curly": ["error", "multi-or-nest", "consistent"],
|
||||||
|
"eol-last": ["error", "always"],
|
||||||
|
"no-console": "off",
|
||||||
|
"no-undef": "warn",
|
||||||
|
"quotes": "off",
|
||||||
|
"semi": ["error", "always"],
|
||||||
|
"space-before-function-paren": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"anonymous": "never",
|
||||||
|
"asyncArrow": "always",
|
||||||
|
"named": "never"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vue/html-indent": "error",
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
"vue/no-multiple-template-root": "off"
|
||||||
|
}
|
||||||
|
}
|
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
node_modules
|
||||||
|
*.log*
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
.output
|
||||||
|
.env
|
||||||
|
dist
|
||||||
|
.DS_Store
|
2
.npmrc
Normal file
2
.npmrc
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
shamefully-hoist=true
|
||||||
|
strict-peer-dependencies=false
|
50
.vscode/launch.json
vendored
Normal file
50
.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
{
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Launch Edge",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "msedge",
|
||||||
|
"url": "http://localhost:3000",
|
||||||
|
"webRoot": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Launch Vivaldi",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "chrome",
|
||||||
|
"url": "http://localhost:3000",
|
||||||
|
"webRoot": "${workspaceFolder}",
|
||||||
|
"runtimeExecutable": "C:\\Program Files\\Vivaldi\\Application\\vivaldi.exe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "firefox",
|
||||||
|
"request": "launch",
|
||||||
|
"reAttach": true,
|
||||||
|
"name": "Launch Firefox",
|
||||||
|
"url": "http://localhost:3000",
|
||||||
|
"webRoot": "${workspaceFolder}",
|
||||||
|
"pathMappings": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:3000/_nuxt/pages",
|
||||||
|
"path": "${workspaceFolder}/pages"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"name": "Nuxt: nuxi dev",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceFolder}/node_modules/nuxi/bin/nuxi.mjs",
|
||||||
|
"args": [
|
||||||
|
"dev"
|
||||||
|
],
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node-terminal",
|
||||||
|
"name": "Nuxt: npm run dev",
|
||||||
|
"request": "launch",
|
||||||
|
"command": "npm run dev",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
|
}
|
7
.vsls.json
Normal file
7
.vsls.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/vsls",
|
||||||
|
"gitignore": "none",
|
||||||
|
"excludeFiles": [
|
||||||
|
".env"
|
||||||
|
]
|
||||||
|
}
|
15
app.vue
Normal file
15
app.vue
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<template>
|
||||||
|
<VApp>
|
||||||
|
<NuxtLayout />
|
||||||
|
<VMain>
|
||||||
|
<VContainer fluid>
|
||||||
|
<NuxtErrorBoundary />
|
||||||
|
<NuxtPage />
|
||||||
|
</VContainer>
|
||||||
|
</VMain>
|
||||||
|
</VApp>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html { overflow-y: auto }
|
||||||
|
</style>
|
32
components/navigation/navigation.vue
Normal file
32
components/navigation/navigation.vue
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<script setup>
|
||||||
|
import { useDisplay } from "vuetify/lib/framework.mjs";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { navigateTo } from "#app";
|
||||||
|
|
||||||
|
const { mobile } = useDisplay();
|
||||||
|
const navOpen = ref(!mobile.value);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VAppBar v-if="mobile" order="5">
|
||||||
|
<VAppBarNavIcon @click.end="navOpen = !navOpen" />
|
||||||
|
<VToolbarTitle>Database Project</VToolbarTitle>
|
||||||
|
</VAppBar>
|
||||||
|
<VNavigationDrawer v-model="navOpen" :temporary="mobile">
|
||||||
|
<VList>
|
||||||
|
<VListItem
|
||||||
|
prepend-avatar="https://cdn.discordapp.com/avatars/317001025074757632/248f503c44bc95ac75f55f5835e6ff9f.png?size=512"
|
||||||
|
title="Anonymous"
|
||||||
|
/>
|
||||||
|
</VList>
|
||||||
|
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<VList density="compact" nav>
|
||||||
|
<VListItem prepend-icon="mdi-home" title="Home" value="/" @click="navigateTo('/')" />
|
||||||
|
<VListItem prepend-icon="mdi-login" title="Login" value="/login" @click="navigateTo('/login')" />
|
||||||
|
<VListItem prepend-icon="mdi-table" title="Table Example" value="/tableExample" @click="navigateTo('/tableExample')" />
|
||||||
|
<VListItem prepend-icon="mdi-form-textarea" title="Echo Form" value="/forms" @click="navigateTo('/forms')" />
|
||||||
|
</VList>
|
||||||
|
</VNavigationDrawer>
|
||||||
|
</template>
|
34
components/pagedList.vue
Normal file
34
components/pagedList.vue
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
records: Array<any>,
|
||||||
|
recordKey: string,
|
||||||
|
variant?: "default" | "inset" | "accordion" | "popout",
|
||||||
|
modelValue?: any,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// eslint-disable-next-line func-call-spacing
|
||||||
|
defineEmits<{
|
||||||
|
(e: "update:modelValue", value: typeof props.modelValue): void,
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VExpansionPanels
|
||||||
|
multiple
|
||||||
|
:v-model="modelValue"
|
||||||
|
@update:model-value="(e: any) => $emit('update:modelValue', e)"
|
||||||
|
>
|
||||||
|
<VExpansionPanel
|
||||||
|
v-for="record in records"
|
||||||
|
:key="record[recordKey]"
|
||||||
|
:variant="props.variant ?? 'default'"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<slot name="title" :record="record" />
|
||||||
|
</template>
|
||||||
|
<template #text>
|
||||||
|
<slot name="text" :record="record" />
|
||||||
|
</template>
|
||||||
|
</VExpansionPanel>
|
||||||
|
</VExpansionPanels>
|
||||||
|
</template>
|
56
components/pagedTable.vue
Normal file
56
components/pagedTable.vue
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
records: Array<any>,
|
||||||
|
recordKey: string,
|
||||||
|
fields: Array<string>,
|
||||||
|
buttons: boolean,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// eslint-disable-next-line func-call-spacing
|
||||||
|
defineEmits<{
|
||||||
|
(e: "click", recordKey: string): void,
|
||||||
|
(e: "click:edit", recordKey: string): void,
|
||||||
|
(e: "click:delete", recordKey: string): void,
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="record in props.records" :key="record[props.recordKey]">
|
||||||
|
<td
|
||||||
|
v-for="field in props.fields"
|
||||||
|
:key="field"
|
||||||
|
@click="$emit('click', record[recordKey])"
|
||||||
|
>
|
||||||
|
{{ record[field] }}
|
||||||
|
</td>
|
||||||
|
<td v-if="buttons" class="buttons">
|
||||||
|
<div>
|
||||||
|
<VBtn
|
||||||
|
icon="mdi-pencil"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('click:edit', record[recordKey])"
|
||||||
|
/>
|
||||||
|
<VBtn
|
||||||
|
icon="mdi-delete"
|
||||||
|
color="red"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('click:delete', record[recordKey])"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.buttons {
|
||||||
|
width: 0;
|
||||||
|
padding-right: 4px !important;
|
||||||
|
}
|
||||||
|
.buttons > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
11
components/test.vue
Normal file
11
components/test.vue
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const count = ref(0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VBtn @click="count++">
|
||||||
|
Clicky {{ count }} times
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
7
layouts/default.vue
Normal file
7
layouts/default.vue
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<script setup>
|
||||||
|
import Navigation from "~/components/navigation/navigation.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Navigation />
|
||||||
|
</template>
|
10
middleware/auth.ts
Normal file
10
middleware/auth.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineNuxtRouteMiddleware, navigateTo, useFetch } from "nuxt/app";
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||||
|
// FIXME: Check authorisation in another way (401 unauthorised) instead querying api directly
|
||||||
|
const me = await useFetch("/api/users/me", {});
|
||||||
|
|
||||||
|
if (!me.error.value) return;
|
||||||
|
if (process.client) console.log(me.error.value);
|
||||||
|
return navigateTo({ path: "/login", query: { redirect: to.fullPath } });
|
||||||
|
});
|
26
nuxt.config.ts
Normal file
26
nuxt.config.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { defineNuxtConfig } from "nuxt/config";
|
||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
pages: true,
|
||||||
|
build: {
|
||||||
|
transpile: ["vuetify"],
|
||||||
|
},
|
||||||
|
css: [
|
||||||
|
"vuetify/lib/styles/main.sass",
|
||||||
|
"@mdi/font/css/materialdesignicons.min.css",
|
||||||
|
],
|
||||||
|
ssr: true,
|
||||||
|
nitro: {
|
||||||
|
esbuild: {
|
||||||
|
options: {
|
||||||
|
target: "es2022",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
// devBundler: "legacy"
|
||||||
|
},
|
||||||
|
imports: {
|
||||||
|
autoImport: false,
|
||||||
|
},
|
||||||
|
});
|
11254
package-lock.json
generated
Normal file
11254
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
package.json
Normal file
27
package.json
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "<19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mdi/font": "^7.2.96",
|
||||||
|
"@nuxtjs/eslint-config-typescript": "^12.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.59.0",
|
||||||
|
"@typescript-eslint/parser": "^5.59.0",
|
||||||
|
"eslint": "^8.39.0",
|
||||||
|
"nuxt": "3.4.0",
|
||||||
|
"sass": "^1.62.0",
|
||||||
|
"vite-plugin-vuetify": "^1.0.2",
|
||||||
|
"vuetify": "^3.1.15"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mysql2": "^3.2.3"
|
||||||
|
}
|
||||||
|
}
|
74
pages/client/[id].vue
Normal file
74
pages/client/[id].vue
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRoute, useFetch, createError } from "nuxt/app";
|
||||||
|
import { Ref } from "vue";
|
||||||
|
|
||||||
|
import PagedList from "~/components/pagedList.vue";
|
||||||
|
import { client as clientType } from "~/utils/types/database";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const id = route.params.id;
|
||||||
|
|
||||||
|
const clientRequest = await useFetch(`/api/clients/${id}`);
|
||||||
|
if (clientRequest.error.value) throw createError(clientRequest.error.value?.data ?? "");
|
||||||
|
const client = clientRequest.data as Ref<clientType>;
|
||||||
|
console.log(client);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<div
|
||||||
|
class="text-h4"
|
||||||
|
:class="client.name === null ? ['font-italic'] : []"
|
||||||
|
>
|
||||||
|
{{ client.name ?? "[none]" }}
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
<VRow>
|
||||||
|
<VCol md="4" cols="12">
|
||||||
|
<VCard>
|
||||||
|
<VList>
|
||||||
|
<VListItem
|
||||||
|
v-if="client.address"
|
||||||
|
prepend-icon="mdi-map-marker"
|
||||||
|
>
|
||||||
|
<VListItemTitle class="text-wrap">
|
||||||
|
{{ client.address }}
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem
|
||||||
|
v-if="client.email"
|
||||||
|
prepend-icon="mdi-email"
|
||||||
|
>
|
||||||
|
<VListItemTitle class="text-wrap">
|
||||||
|
{{ client.email }}
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem
|
||||||
|
v-if="client.phone"
|
||||||
|
prepend-icon="mdi-phone"
|
||||||
|
>
|
||||||
|
<VListItemTitle class="text-wrap">
|
||||||
|
{{ client.phone }}
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="8">
|
||||||
|
<PagedList
|
||||||
|
:records="[{a: 'owo'}, {a: 'uwu'}, {a: 'qwq'}]"
|
||||||
|
record-key="a"
|
||||||
|
>
|
||||||
|
<template #text="i">
|
||||||
|
{{ i }}
|
||||||
|
</template>
|
||||||
|
<template #title="i">
|
||||||
|
{{ i }}
|
||||||
|
</template>
|
||||||
|
</PagedList>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</template>
|
124
pages/clients.backup.vue
Normal file
124
pages/clients.backup.vue
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/* global $fetch */
|
||||||
|
import { useFetch, createError } from "nuxt/app";
|
||||||
|
import { ref, Ref } from "vue";
|
||||||
|
import { definePageMeta } from "~/.nuxt/imports";
|
||||||
|
import { client as clientType } from "~/utils/types/database";
|
||||||
|
|
||||||
|
definePageMeta({ middleware: ["auth"] });
|
||||||
|
|
||||||
|
const clientsRequest = await useFetch("/api/clients");
|
||||||
|
if (clientsRequest.error.value) throw createError(clientsRequest.error.value?.data ?? "");
|
||||||
|
const clients = clientsRequest.data as Ref<NonNullable<typeof clientsRequest.data.value>>;
|
||||||
|
|
||||||
|
const countRequest = await useFetch("/api/clients/count");
|
||||||
|
if (countRequest.error.value) throw createError(countRequest.error.value?.data ?? "");
|
||||||
|
const count = countRequest.data as Ref<NonNullable<typeof countRequest.data.value>>;
|
||||||
|
|
||||||
|
function rowClicked(client: string, edit = false) {
|
||||||
|
console.log(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rowDelete(client: string) {
|
||||||
|
try {
|
||||||
|
await $fetch<clientType>(`/api/clients/${client}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
clients.value = clients.value.filter(e => e.id !== client);
|
||||||
|
count.value.count--;
|
||||||
|
} catch (e) {
|
||||||
|
// FIXME: show the error
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingMore = ref<boolean>(false);
|
||||||
|
async function loadBefore() {
|
||||||
|
loadingMore.value = true;
|
||||||
|
|
||||||
|
clients.value.push(...await $fetch("/api/clients", {
|
||||||
|
query: {
|
||||||
|
before: clients.value[clients.value.length - 1].id,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VOverlay
|
||||||
|
model-value
|
||||||
|
origin="top center"
|
||||||
|
:scrim="false"
|
||||||
|
height="fit-content"
|
||||||
|
persistent
|
||||||
|
no-click-animation
|
||||||
|
>
|
||||||
|
<VAlert class="alert">
|
||||||
|
owowowowowowowowo
|
||||||
|
</VAlert>
|
||||||
|
</VOverlay>
|
||||||
|
<VRow>
|
||||||
|
<VCol>
|
||||||
|
<VBreadcrumbs :items="['Clients']" />
|
||||||
|
<VSpacer />
|
||||||
|
<div class="text-h4">
|
||||||
|
There are {{ count?.count }} clients in the database.
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCard>
|
||||||
|
<VTable>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Address</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="client in clients" :key="client.id">
|
||||||
|
<td @click="() => rowClicked(client.id)">
|
||||||
|
{{ client.name }}
|
||||||
|
</td>
|
||||||
|
<td @click="() => rowClicked(client.id)">
|
||||||
|
{{ client.address }}
|
||||||
|
</td>
|
||||||
|
<td class="buttons">
|
||||||
|
<div>
|
||||||
|
<VBtn icon="mdi-pencil" variant="text" @click="() => rowClicked(client.id, true)" />
|
||||||
|
<VBtn icon="mdi-delete" color="red" variant="text" @click="() => rowDelete(client.id)" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
</VCard>
|
||||||
|
<VCol>
|
||||||
|
<VBtn
|
||||||
|
v-if="clients.length < count.count"
|
||||||
|
color="primary"
|
||||||
|
:loading="loadingMore"
|
||||||
|
@click="loadBefore"
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</VBtn>
|
||||||
|
</VCol>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.buttons {
|
||||||
|
width: 0;
|
||||||
|
padding-right: 4px !important;
|
||||||
|
}
|
||||||
|
.buttons > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
105
pages/clients.vue
Normal file
105
pages/clients.vue
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/* global $fetch */
|
||||||
|
import { useFetch, createError } from "nuxt/app";
|
||||||
|
import { ref, Ref } from "vue";
|
||||||
|
import { definePageMeta } from "~/.nuxt/imports";
|
||||||
|
import { client as clientType } from "~/utils/types/database";
|
||||||
|
import pagedTable from "~/components/pagedTable.vue";
|
||||||
|
|
||||||
|
definePageMeta({ middleware: ["auth"] });
|
||||||
|
|
||||||
|
const clientsRequest = await useFetch("/api/clients");
|
||||||
|
if (clientsRequest.error.value) throw createError(clientsRequest.error.value?.data ?? "");
|
||||||
|
const clients = clientsRequest.data as Ref<NonNullable<typeof clientsRequest.data.value>>;
|
||||||
|
|
||||||
|
const countRequest = await useFetch("/api/clients/count");
|
||||||
|
if (countRequest.error.value) throw createError(countRequest.error.value?.data ?? "");
|
||||||
|
const count = countRequest.data as Ref<NonNullable<typeof countRequest.data.value>>;
|
||||||
|
|
||||||
|
function rowClicked(client: string, edit = false) {
|
||||||
|
console.log(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rowDelete(client: string) {
|
||||||
|
try {
|
||||||
|
await $fetch<clientType>(`/api/clients/${client}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
clients.value = clients.value.filter(e => e.id !== client);
|
||||||
|
count.value.count--;
|
||||||
|
} catch (e) {
|
||||||
|
// FIXME: show the error
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingMore = ref<boolean>(false);
|
||||||
|
async function loadBefore() {
|
||||||
|
loadingMore.value = true;
|
||||||
|
|
||||||
|
clients.value.push(...await $fetch("/api/clients", {
|
||||||
|
query: {
|
||||||
|
before: clients.value[clients.value.length - 1].id,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VOverlay
|
||||||
|
model-value
|
||||||
|
origin="top center"
|
||||||
|
:scrim="false"
|
||||||
|
height="fit-content"
|
||||||
|
persistent
|
||||||
|
no-click-animation
|
||||||
|
>
|
||||||
|
<VAlert class="alert">
|
||||||
|
owowowowowowowowo
|
||||||
|
</VAlert>
|
||||||
|
</VOverlay>
|
||||||
|
<VRow>
|
||||||
|
<VCol>
|
||||||
|
<VBreadcrumbs :items="['Clients']" />
|
||||||
|
<VSpacer />
|
||||||
|
<div class="text-h4">
|
||||||
|
There are {{ count?.count }} clients in the database.
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCard>
|
||||||
|
<VTable>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Address</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<pagedTable
|
||||||
|
buttons
|
||||||
|
:records="clients"
|
||||||
|
:fields="['name', 'address']"
|
||||||
|
record-key="id"
|
||||||
|
@click="(id) => rowClicked(id, false)"
|
||||||
|
@click:edit="(id) => rowClicked(id, true)"
|
||||||
|
@click:delete="(id) => rowDelete(id)"
|
||||||
|
/>
|
||||||
|
</VTable>
|
||||||
|
</VCard>
|
||||||
|
<VCol>
|
||||||
|
<VBtn
|
||||||
|
v-if="clients.length < count.count"
|
||||||
|
color="primary"
|
||||||
|
:loading="loadingMore"
|
||||||
|
@click="loadBefore"
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</VBtn>
|
||||||
|
</VCol>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</template>
|
28
pages/forms.vue
Normal file
28
pages/forms.vue
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/* global $fetch */
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const question = ref();
|
||||||
|
const answer = ref();
|
||||||
|
|
||||||
|
async function getAnswer() {
|
||||||
|
const message = await $fetch("/api/echo", {
|
||||||
|
body: question.value,
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
answer.value = message;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VCard width="400px">
|
||||||
|
<template #text>
|
||||||
|
<VTextarea v-model="question" /><br>
|
||||||
|
<p>{{ answer }}</p>
|
||||||
|
<VBtn @click="getAnswer">
|
||||||
|
Send
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
22
pages/index.vue
Normal file
22
pages/index.vue
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<script setup>
|
||||||
|
import Test from '~/components/test.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1>Hi mom!</h1><br>
|
||||||
|
<Test />
|
||||||
|
<Test />
|
||||||
|
<Test />
|
||||||
|
<Test />
|
||||||
|
<Test /><br>
|
||||||
|
<VCard
|
||||||
|
text="..."
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h1 {
|
||||||
|
color: blue;
|
||||||
|
}
|
||||||
|
</style>
|
129
pages/login.vue
Normal file
129
pages/login.vue
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/* global $fetch */
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { VForm } from "vuetify/components";
|
||||||
|
import { navigateTo, useCookie, useFetch, useRoute } from "nuxt/app";
|
||||||
|
import { cookieSettings } from "~/utils/cookieSettings";
|
||||||
|
import { definePageMeta } from "~/.nuxt/imports";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const login = ref("");
|
||||||
|
const password = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<true | string>(true);
|
||||||
|
const form = ref<VForm | null>(null);
|
||||||
|
const loggedIn = ref<boolean>(useCookie("token", cookieSettings).value != null);
|
||||||
|
|
||||||
|
const redirectTo = ref(route.redirectedFrom);
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await $fetch("/api/login", {
|
||||||
|
body: { login: login.value, password: password.value },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
console.log(result);
|
||||||
|
loggedIn.value = true;
|
||||||
|
password.value = "";
|
||||||
|
} catch (e) {
|
||||||
|
console.log(typeof e);
|
||||||
|
console.error(e);
|
||||||
|
console.log(e);
|
||||||
|
error.value = (e as any).data.message;
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
if (redirectTo.value) navigateTo(redirectTo.value.fullPath);
|
||||||
|
if (form.value) form.value.validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await $fetch("/api/logout");
|
||||||
|
loggedIn.value = false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = ref(JSON.stringify(useFetch("/api/users/me").data));
|
||||||
|
watch(loggedIn, updateUserInfo);
|
||||||
|
async function updateUserInfo() {
|
||||||
|
if (loggedIn.value) {
|
||||||
|
try {
|
||||||
|
userInfo.value = JSON.stringify(await $fetch("/api/users/me"));
|
||||||
|
} catch (e) {
|
||||||
|
// expected if the user is not logged in
|
||||||
|
userInfo.value = "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
userInfo.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUserInfo();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VCard max-width="450px" class="mx-auto mt-16" variant="outlined">
|
||||||
|
<VProgressLinear
|
||||||
|
absolute
|
||||||
|
:active="loading"
|
||||||
|
:indeterminate="loading"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<template #title>
|
||||||
|
Log in
|
||||||
|
</template>
|
||||||
|
<template #text>
|
||||||
|
<VForm v-if="!loggedIn" ref="form" class="form" :disabled="loading">
|
||||||
|
<VTextField
|
||||||
|
v-model="login"
|
||||||
|
label="Login"
|
||||||
|
:rules="[() => error]"
|
||||||
|
:disabled="loggedIn"
|
||||||
|
autocomplete="username"
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<VTextField
|
||||||
|
v-model="password"
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
:rules="[() => error]"
|
||||||
|
:disabled="loggedIn"
|
||||||
|
autocomplete="current-password"
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
class="pt-4"
|
||||||
|
/>
|
||||||
|
</VForm>
|
||||||
|
<p v-if="loggedIn">
|
||||||
|
Logged in<br>{{ userInfo }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<VBtn
|
||||||
|
v-if="!loggedIn"
|
||||||
|
color="primary"
|
||||||
|
:disabled="loggedIn || loading"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
v-if="loggedIn"
|
||||||
|
color="primary"
|
||||||
|
:disabled="!loggedIn"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
50
pages/tableExample.vue
Normal file
50
pages/tableExample.vue
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useFetch } from '#app';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
const tableContent = ref(await useFetch("/api/dbtest").data);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-card width="min-content">
|
||||||
|
<v-table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>id</th>
|
||||||
|
<th>nazwa</th>
|
||||||
|
<th>adres</th>
|
||||||
|
<th>kontakt</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in tableContent" :key="row.id">
|
||||||
|
<td>{{ row.id }}</td>
|
||||||
|
<td>{{ row.nazwa }}</td>
|
||||||
|
<td>{{ row.adres }}</td>
|
||||||
|
<td>{{ row.kontakt }}</td>
|
||||||
|
<td>
|
||||||
|
<v-btn-group variant="plain">
|
||||||
|
<v-btn icon="mdi-pencil" color="main" />
|
||||||
|
<v-btn icon="mdi-delete" color="red" />
|
||||||
|
</v-btn-group>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="last">
|
||||||
|
<td>*</td>
|
||||||
|
<td><v-text-field label="nazwa" density="compact" /></td>
|
||||||
|
<td><v-text-field label="adres" density="compact" /></td>
|
||||||
|
<td><v-text-field label="kontakt" density="compact" /></td>
|
||||||
|
<td><v-btn icon="mdi-plus" variant="plain" class="rounded" /></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.last td:not(:last-child):not(:first-child) {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
16
plugins/vuetify.ts
Normal file
16
plugins/vuetify.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { createVuetify } from 'vuetify';
|
||||||
|
import * as components from 'vuetify/components';
|
||||||
|
import * as directives from 'vuetify/directives';
|
||||||
|
import { defineNuxtPlugin } from '#app';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
const vuetify = createVuetify({
|
||||||
|
ssr: true,
|
||||||
|
components,
|
||||||
|
directives,
|
||||||
|
theme: {
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
nuxtApp.vueApp.use(vuetify);
|
||||||
|
});
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
112
server/api/clients.get.ts
Normal file
112
server/api/clients.get.ts
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
/* global defineEventHandler getQuery, createError */
|
||||||
|
import { QueryObject } from "ufo";
|
||||||
|
|
||||||
|
import { data, database } from "../utils/database";
|
||||||
|
import { isString } from "../utils/isString";
|
||||||
|
import { client } from "~/utils/types/database";
|
||||||
|
|
||||||
|
type queryType = {
|
||||||
|
type: "before" | "after" | "around",
|
||||||
|
id: string
|
||||||
|
} | {
|
||||||
|
type: null
|
||||||
|
};
|
||||||
|
|
||||||
|
function getLocationParameterType(query: QueryObject): queryType {
|
||||||
|
const before = query.before;
|
||||||
|
const after = query.after;
|
||||||
|
const around = query.around;
|
||||||
|
|
||||||
|
let setLocationParametersCount = 0;
|
||||||
|
let rvalue: queryType = { type: null };
|
||||||
|
|
||||||
|
if (isString(before)) {
|
||||||
|
setLocationParametersCount++;
|
||||||
|
rvalue = { type: "before", id: before };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isString(after)) {
|
||||||
|
setLocationParametersCount++;
|
||||||
|
rvalue = { type: "after", id: after };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isString(around)) {
|
||||||
|
setLocationParametersCount++;
|
||||||
|
rvalue = { type: "around", id: around };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setLocationParametersCount > 1) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "multiple location parameters not allowed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rvalue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getResults(
|
||||||
|
queryType: queryType,
|
||||||
|
limit = 50,
|
||||||
|
) {
|
||||||
|
switch (queryType.type) {
|
||||||
|
case "before": {
|
||||||
|
const [data] = await database.query(
|
||||||
|
"SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` WHERE `id` < ? ORDER BY `id` DESC LIMIT ?",
|
||||||
|
[queryType.id, limit],
|
||||||
|
) as unknown as data<client>;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
case "after": {
|
||||||
|
const [data] = await database.query(
|
||||||
|
"SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` WHERE `id` > ? ORDER BY `id` DESC LIMIT ?",
|
||||||
|
[queryType.id, limit],
|
||||||
|
) as unknown as data<client>;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
case "around": {
|
||||||
|
const [data] = await database.query(
|
||||||
|
"(SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` WHERE `id` >= ? ORDER BY `id` ASC LIMIT ?)\n" +
|
||||||
|
"UNION ALL\n" +
|
||||||
|
"(SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` WHERE `id` < ? ORDER BY `id` DESC LIMIT ?)\n" +
|
||||||
|
"ORDER BY `id` DESC",
|
||||||
|
[queryType.id, Math.ceil(limit / 2), queryType.id, Math.floor(limit / 2)],
|
||||||
|
) as unknown as data<client>;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
case null: {
|
||||||
|
const [data] = await database.query(
|
||||||
|
"SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` ORDER BY `id` DESC LIMIT ?",
|
||||||
|
[limit],
|
||||||
|
) as unknown as data<client>;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw createError("Not implemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const query = getQuery(e);
|
||||||
|
|
||||||
|
let limit = 50;
|
||||||
|
if (query.limit) limit = Number(query.limit);
|
||||||
|
if (limit > 200) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Cannot retrieve more than 200 records",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (limit <= 0) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Tried to retireve 0 or less records",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryData = getLocationParameterType(query);
|
||||||
|
|
||||||
|
const result = await getResults(queryData, limit);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
67
server/api/clients.post.ts
Normal file
67
server/api/clients.post.ts
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
/* global defineEventHandler, createError, readBody */
|
||||||
|
|
||||||
|
import { database } from "../utils/database";
|
||||||
|
import Snowflake from "../utils/snowflake";
|
||||||
|
import { client } from "~/utils/types/database";
|
||||||
|
|
||||||
|
const clientKeys = [
|
||||||
|
"name",
|
||||||
|
"address",
|
||||||
|
"phone",
|
||||||
|
"email",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function checkIsClient(
|
||||||
|
value: any,
|
||||||
|
required = true,
|
||||||
|
): value is Partial<Omit<client, "id">> {
|
||||||
|
const errors = new Map<string, string>();
|
||||||
|
|
||||||
|
if (typeof value !== "object") {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Invalid body",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(typeof value.name === "string" || value.name === null || (!required && value.name === undefined))) errors.set("name", "is not string or null");
|
||||||
|
if (!(typeof value.address === "string" || value.address === null || (!required && value.address === undefined))) errors.set("address", "is not string or null");
|
||||||
|
if (!(typeof value.phone === "string" || value.phone === null || (!required && value.phone === undefined))) errors.set("phone", "is not string or null");
|
||||||
|
if (!(typeof value.email === "string" || value.email === null || (!required && value.email === undefined))) errors.set("email", "is not string or null");
|
||||||
|
|
||||||
|
for (const i in value)
|
||||||
|
if (!clientKeys.includes(i)) errors.set(i, `excessive property`);
|
||||||
|
|
||||||
|
if (errors.size !== 0) {
|
||||||
|
let message = "Invalid Parameters: ";
|
||||||
|
for (const i in errors)
|
||||||
|
message += i + ", ";
|
||||||
|
message = message.slice(0, -2);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message,
|
||||||
|
data: {
|
||||||
|
errors: Object.fromEntries(errors),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const body = await readBody(e);
|
||||||
|
const id = new Snowflake().toString();
|
||||||
|
|
||||||
|
if (!checkIsClient(body)) return; // checkIsClient already throws an detailed error
|
||||||
|
|
||||||
|
await database.query(
|
||||||
|
"INSERT INTO `clients` VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[id, body.name, body.address, body.phone, body.email],
|
||||||
|
);
|
||||||
|
|
||||||
|
// FIXME: data may be turncated in the database
|
||||||
|
// either throw an error when data is too large or
|
||||||
|
// reply with turncated data
|
||||||
|
return { id, ...body };
|
||||||
|
});
|
17
server/api/clients/[id].delete.ts
Normal file
17
server/api/clients/[id].delete.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
/* global defineEventHandler, createError */
|
||||||
|
import { ResultSetHeader } from "mysql2";
|
||||||
|
|
||||||
|
import { database } from "~/server/utils/database";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const id = e.context.params?.id as string;
|
||||||
|
|
||||||
|
const [result] = await database.query(
|
||||||
|
"DELETE FROM `clients` WHERE `id` = ?",
|
||||||
|
[id],
|
||||||
|
) as unknown as [ResultSetHeader];
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) throw createError({ statusCode: 404 });
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
20
server/api/clients/[id].get.ts
Normal file
20
server/api/clients/[id].get.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
/* global defineEventHandler, createError */
|
||||||
|
|
||||||
|
import { database, data } from "~/server/utils/database";
|
||||||
|
import { client } from "~/utils/types/database";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const id = e.context.params?.id;
|
||||||
|
const [data] = await database.query(
|
||||||
|
"SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` WHERE `id` = ?",
|
||||||
|
[id],
|
||||||
|
) as unknown as data<client>;
|
||||||
|
|
||||||
|
if (!data[0]) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return data[0];
|
||||||
|
});
|
36
server/api/clients/[id].patch.ts
Normal file
36
server/api/clients/[id].patch.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
/* global defineEventHandler, readBody, createError */
|
||||||
|
|
||||||
|
import { ResultSetHeader } from "mysql2";
|
||||||
|
|
||||||
|
import { checkIsClient } from "../clients.post";
|
||||||
|
import { client } from "~/utils/types/database";
|
||||||
|
import { database, data } from "~/server/utils/database";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const body = await readBody(e);
|
||||||
|
const id = e.context.params?.id as string;
|
||||||
|
|
||||||
|
if (!checkIsClient(body, false)) return; // checkIsClient already throws an detailed error
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(body)) {
|
||||||
|
const [res] = await database.query(
|
||||||
|
// I believe it is safe to put key in the template
|
||||||
|
// because it is limited to 4 values here
|
||||||
|
`UPDATE \`clients\` SET \`${k}\` = ? WHERE \`id\` = ?`,
|
||||||
|
[v, id],
|
||||||
|
) as unknown as [ResultSetHeader];
|
||||||
|
|
||||||
|
if (res.affectedRows !== 1) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data] = await database.query(
|
||||||
|
"SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` WHERE `id` = ?",
|
||||||
|
[id],
|
||||||
|
) as unknown as data<client>;
|
||||||
|
|
||||||
|
return data[0];
|
||||||
|
});
|
12
server/api/clients/count.get.ts
Normal file
12
server/api/clients/count.get.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
/* global defineEventHandler, createError */
|
||||||
|
|
||||||
|
import { database, data } from "~/server/utils/database";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const [[data]] = await database.query(
|
||||||
|
"SELECT COUNT(*) as `count` FROM `clients`",
|
||||||
|
) as unknown as data<{count: number}>;
|
||||||
|
|
||||||
|
if (!data) throw createError("Database returned no rows");
|
||||||
|
return data;
|
||||||
|
});
|
9
server/api/dbtest.get.ts
Normal file
9
server/api/dbtest.get.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
/* global defineEventHandler */
|
||||||
|
|
||||||
|
import { database } from "../utils/database";
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
const [owo] = await database.execute("SELECT * FROM `sch_baza_smartfony`.`lombardy`");
|
||||||
|
|
||||||
|
return owo as {id: number, nazwa: string, adres: string, kontakt: string}[];
|
||||||
|
});
|
26
server/api/dbtest.post.ts
Normal file
26
server/api/dbtest.post.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
/* global defineEventHandler, readBody */
|
||||||
|
|
||||||
|
import { RowDataPacket } from "mysql2";
|
||||||
|
|
||||||
|
import { database } from "../utils/database";
|
||||||
|
import { isString } from "../utils/isString";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const data = await readBody(e);
|
||||||
|
|
||||||
|
const nazwa = data.nazwa;
|
||||||
|
const adres = data.adres;
|
||||||
|
const kontakt = data.kontakt;
|
||||||
|
|
||||||
|
if (!isString(nazwa)) throw new Error("nazwa is not string");
|
||||||
|
if (!isString(adres)) throw new Error("adres is not string");
|
||||||
|
if (!isString(kontakt)) throw new Error("kontakt is not string");
|
||||||
|
|
||||||
|
const [inserted] = await database.query("INSERT INTO `sch_baza_smartfony`.`lombardy` (`nazwa`, `adres`, `kontakt`) VALUES (?, ?, ?);", [nazwa, adres, kontakt]) as RowDataPacket[];
|
||||||
|
return {
|
||||||
|
id: inserted.insertId as number,
|
||||||
|
nazwa,
|
||||||
|
adres,
|
||||||
|
kontakt,
|
||||||
|
};
|
||||||
|
});
|
9
server/api/dbtest/[id].delete.ts
Normal file
9
server/api/dbtest/[id].delete.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
/* global defineEventHandler */
|
||||||
|
|
||||||
|
import { database } from "~/server/utils/database";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
if (!e.context.params?.id) return Error("id is not provided");
|
||||||
|
const rowID = e.context.params.id;
|
||||||
|
await database.execute("DELETE FROM `sch_baza_smartfony`.`lombardy` WHERE `id` = ?", [rowID]);
|
||||||
|
});
|
6
server/api/echo.post.ts
Normal file
6
server/api/echo.post.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
/* global defineEventHandler */
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const message = event.node.req.read();
|
||||||
|
return message;
|
||||||
|
});
|
5
server/api/hi.ts
Normal file
5
server/api/hi.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
/* global defineEventHandler */
|
||||||
|
|
||||||
|
export default defineEventHandler(() => {
|
||||||
|
return "Hi mom!";
|
||||||
|
});
|
40
server/api/login.post.ts
Normal file
40
server/api/login.post.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
/* global defineEventHandler, getCookie, setCookie, readBody, createError */
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
import { database, data } from "../utils/database";
|
||||||
|
import { isString } from "../utils/isString";
|
||||||
|
import Snowflake from "../utils/snowflake";
|
||||||
|
import { cookieSettings } from "../utils/rootUtils";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
if (getCookie(e, "token"))
|
||||||
|
throw createError({ statusCode: 501, message: "Case not implemented: logging in while cookie is set" });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 420));
|
||||||
|
const data = await readBody(e);
|
||||||
|
|
||||||
|
const login = data.login;
|
||||||
|
const password = data.password;
|
||||||
|
|
||||||
|
if (!isString(login)) throw createError({ statusCode: 400, message: "Login is not string." });
|
||||||
|
if (!isString(password)) throw createError({ statusCode: 400, message: "Password is not string." });
|
||||||
|
|
||||||
|
const hashedPassword = crypto.createHmac("sha512", "42")
|
||||||
|
.update(password)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
const [account] = await database.query(
|
||||||
|
"SELECT CONVERT(`id`, CHAR(32)) AS `id` from `users` WHERE `username` = ? AND LOWER(HEX(`password`)) = ? LIMIT 1",
|
||||||
|
[login, hashedPassword],
|
||||||
|
)as unknown as data<{id: string}>;
|
||||||
|
|
||||||
|
if (account.length === 0) throw createError({ statusCode: 400, message: "Invalid username or password." });
|
||||||
|
|
||||||
|
const sessionId = new Snowflake().toString();
|
||||||
|
|
||||||
|
await database.query(
|
||||||
|
"INSERT INTO `sessions` (`id`, `user`) VALUES ( ? , ? )",
|
||||||
|
[sessionId, account[0].id],
|
||||||
|
);
|
||||||
|
setCookie(e, "token", sessionId, cookieSettings);
|
||||||
|
return { message: "Login successful", token: sessionId };
|
||||||
|
});
|
29
server/api/logout.ts
Normal file
29
server/api/logout.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
/* global defineEventHandler, createError, getCookie, deleteCookie */
|
||||||
|
|
||||||
|
import { isAuthorised } from "../middleware/auth";
|
||||||
|
import { database } from "../utils/database";
|
||||||
|
import { cookieSettings } from "../utils/rootUtils";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const token = getCookie(e, "token");
|
||||||
|
if (token === undefined) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
data: "You can't log out if you're already logged out (no session cookie)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCookie(e, "token", cookieSettings);
|
||||||
|
if (!await isAuthorised(token)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: "You can't log out if you're already logged out (session expired or never existed)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
database.query(
|
||||||
|
"DELETE FROM `sessions` WHERE `id` = ?",
|
||||||
|
[token],
|
||||||
|
);
|
||||||
|
return { message: "Logged out" };
|
||||||
|
});
|
14
server/api/users/me.get.ts
Normal file
14
server/api/users/me.get.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
/* global defineEventHandler, getCookie */
|
||||||
|
|
||||||
|
import { database, data } from "~/server/utils/database";
|
||||||
|
import { user } from "~/utils/types/database";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const token = getCookie(e, "token");
|
||||||
|
const [[userData]] = await database.query(
|
||||||
|
"SELECT CONVERT(`users`.`id`, CHAR(32)) as `id`, `users`.`username` as `username`, `users`.`email` as `email`, `users`.`display_name` as `display_name` FROM `sessions` LEFT JOIN `users` ON `sessions`.`user` = `users`.`id` WHERE `sessions`.`id` = ?",
|
||||||
|
[token],
|
||||||
|
) as unknown as data<user>;
|
||||||
|
|
||||||
|
return userData;
|
||||||
|
});
|
40
server/middleware/auth.ts
Normal file
40
server/middleware/auth.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
/* global defineEventHandler, createError, getCookie */
|
||||||
|
|
||||||
|
import { database, data } from "~/server/utils/database";
|
||||||
|
|
||||||
|
const endpointsWithoutAuth: string[] = [
|
||||||
|
"/dbtest",
|
||||||
|
"/echo",
|
||||||
|
"/hi",
|
||||||
|
"/login",
|
||||||
|
"/logout",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const endpoint = e.path?.match(/^\/api(\/.*)/)?.[1];
|
||||||
|
|
||||||
|
// if client does not access api
|
||||||
|
if (!endpoint) return;
|
||||||
|
|
||||||
|
for (const i of endpointsWithoutAuth)
|
||||||
|
// if accessed endpoint doesn't require auth
|
||||||
|
if (endpoint.startsWith(i)) return;
|
||||||
|
|
||||||
|
const token = getCookie(e, "token");
|
||||||
|
if (!await isAuthorised(token))
|
||||||
|
throw createError({ statusCode: 401, message: "Unauthorized" });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the token is authorised
|
||||||
|
* @param token the token to ckeck
|
||||||
|
*/
|
||||||
|
export async function isAuthorised(token: string | undefined): Promise<boolean> {
|
||||||
|
if (!token) return false;
|
||||||
|
const [[session]] = await database.query(
|
||||||
|
"SELECT EXISTS(SELECT `id` FROM `sessions` WHERE `id` = ? AND `expiry_date` >= NOW()) as `logged_in`",
|
||||||
|
[token],
|
||||||
|
) as unknown as data<{logged_in: number}>;
|
||||||
|
|
||||||
|
return session.logged_in === 1;
|
||||||
|
}
|
20
server/utils/baaPagination.ts
Normal file
20
server/utils/baaPagination.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
|
||||||
|
type queryType = {
|
||||||
|
type: "before" | "after" | "around",
|
||||||
|
id: string
|
||||||
|
} | {
|
||||||
|
type: null
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before, around, after pagination wrapper
|
||||||
|
*/
|
||||||
|
export default class baaPagination<T> {
|
||||||
|
readonly table: string;
|
||||||
|
readonly key: string;
|
||||||
|
|
||||||
|
constructor(table: string, key: string) {
|
||||||
|
this.table = table;
|
||||||
|
this.key = key;
|
||||||
|
}
|
||||||
|
}
|
11
server/utils/database.ts
Normal file
11
server/utils/database.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
|
||||||
|
export const database = await mysql.createConnection({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_SCHEMA,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type data<T> = [T[], mysql.FieldPacket[]];
|
3
server/utils/isString.ts
Normal file
3
server/utils/isString.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export function isString(value: unknown): value is string {
|
||||||
|
return typeof value === "string";
|
||||||
|
}
|
1
server/utils/rootUtils.ts
Normal file
1
server/utils/rootUtils.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from "../../utils/cookieSettings";
|
43
server/utils/snowflake.ts
Normal file
43
server/utils/snowflake.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
export default class Snowflake {
|
||||||
|
static current_increment = 0n;
|
||||||
|
|
||||||
|
public static increment() {
|
||||||
|
this.current_increment = BigInt.asUintN(12, this.current_increment + 1n);
|
||||||
|
return this.current_increment;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = 0n;
|
||||||
|
public set_timestamp(value: number | bigint) {
|
||||||
|
value = BigInt.asUintN(64 - 22, BigInt(value));
|
||||||
|
const state = BigInt.asUintN(22, this.state);
|
||||||
|
this.state = state + (value << 22n);
|
||||||
|
}
|
||||||
|
|
||||||
|
public set_machineid(value: number | bigint) {
|
||||||
|
value = BigInt.asUintN(12 - 17, BigInt(value));
|
||||||
|
const state = BigInt.asUintN(17, this.state) + (this.state >> 22n) << 22n;
|
||||||
|
this.state = state + (value << 12n);
|
||||||
|
}
|
||||||
|
|
||||||
|
public set_processid(value: number | bigint) {
|
||||||
|
value = BigInt.asUintN(17 - 12, BigInt(value));
|
||||||
|
const state = BigInt.asUintN(12, this.state) + (this.state >> 17n) << 17n;
|
||||||
|
this.state = state + (value << 12n);
|
||||||
|
}
|
||||||
|
|
||||||
|
public set_increment(value: number | bigint) {
|
||||||
|
value = BigInt.asUintN(12 - 0, BigInt(value));
|
||||||
|
const state = (this.state >> 12n) << 12n;
|
||||||
|
this.state = state + (value << 0n);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.set_timestamp(Date.now());
|
||||||
|
this.set_processid(1);
|
||||||
|
this.set_increment(Snowflake.increment());
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString() {
|
||||||
|
return this.state.toString();
|
||||||
|
}
|
||||||
|
}
|
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"extends": "./.nuxt/tsconfig.json",
|
||||||
|
}
|
6
utils/cookieSettings.ts
Normal file
6
utils/cookieSettings.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { CookieSerializeOptions } from "cookie-es";
|
||||||
|
|
||||||
|
export const cookieSettings: CookieSerializeOptions = {
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
};
|
68
utils/types/database.ts
Normal file
68
utils/types/database.ts
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
export interface client {
|
||||||
|
id: string,
|
||||||
|
name: string | null,
|
||||||
|
address: string | null,
|
||||||
|
phone: string | null,
|
||||||
|
email: string | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface user {
|
||||||
|
id: string,
|
||||||
|
username: string,
|
||||||
|
email: string,
|
||||||
|
display_name?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface session {
|
||||||
|
id: string,
|
||||||
|
user: string,
|
||||||
|
expiry_date: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface imported_product {
|
||||||
|
id: string,
|
||||||
|
// order: string,
|
||||||
|
name?: string,
|
||||||
|
link: string,
|
||||||
|
price_imported: string,
|
||||||
|
price: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface offer {
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
description?: string,
|
||||||
|
recommended_price?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface work {
|
||||||
|
id: string,
|
||||||
|
// order: string,
|
||||||
|
offer: string|offer,
|
||||||
|
price: string,
|
||||||
|
notes: string,
|
||||||
|
is_fulfilled: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface order {
|
||||||
|
id: string,
|
||||||
|
client: client|string,
|
||||||
|
user: user|string,
|
||||||
|
is_draft: boolean,
|
||||||
|
imported_products: imported_product[],
|
||||||
|
work: work[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface work_template {
|
||||||
|
id: string,
|
||||||
|
// order_template: string,
|
||||||
|
offer: string|offer,
|
||||||
|
price: string,
|
||||||
|
notes?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface order_template {
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
description?: string,
|
||||||
|
}
|
Loading…
Reference in a new issue