diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 22c23cb..264ebcc 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -78,4 +78,13 @@ input { } } +fieldset { + margin: $fieldset-margin; + padding: $fieldset-padding; + + border: $fieldset-border; + border-radius: $fieldset-border-radius; + + background-color: $fieldset-background-color; +} diff --git a/frontend/src/components/nodes/NodeDeleteConfirmationForm.vue b/frontend/src/components/nodes/NodeDeleteConfirmationForm.vue new file mode 100644 index 0000000..8f5e4b5 --- /dev/null +++ b/frontend/src/components/nodes/NodeDeleteConfirmationForm.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/frontend/src/components/nodes/NodeDeleteForm.vue b/frontend/src/components/nodes/NodeDeleteForm.vue new file mode 100644 index 0000000..28b4c45 --- /dev/null +++ b/frontend/src/components/nodes/NodeDeleteForm.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/src/components/nodes/NodeDeletedPanel.vue b/frontend/src/components/nodes/NodeDeletedPanel.vue new file mode 100644 index 0000000..bd43a73 --- /dev/null +++ b/frontend/src/components/nodes/NodeDeletedPanel.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/frontend/src/components/nodes/NodePreviewCard.vue b/frontend/src/components/nodes/NodePreviewCard.vue new file mode 100644 index 0000000..3d74ffe --- /dev/null +++ b/frontend/src/components/nodes/NodePreviewCard.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 24498ea..933a934 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -2,6 +2,7 @@ import {createRouter, createWebHistory, type LocationQueryRaw} from "vue-router" import AdminDashboardView from "@/views/AdminDashboardView.vue"; import AdminNodesView from "@/views/AdminNodesView.vue"; import HomeView from "@/views/HomeView.vue"; +import NodeDeleteView from "@/views/NodeDeleteView.vue"; import {isNodesFilter, isNodeSortField, isSortDirection, type SearchTerm} from "@/types"; export interface Route { @@ -11,6 +12,7 @@ export interface Route { export enum RouteName { HOME = "home", + NODE_DELETE = "node-delete", ADMIN = "admin", ADMIN_NODES = "admin-nodes", } @@ -30,6 +32,11 @@ const router = createRouter({ name: RouteName.HOME, component: HomeView, }, + { + path: "/node/delete", + name: RouteName.NODE_DELETE, + component: NodeDeleteView, + }, { path: "/admin", name: RouteName.ADMIN, diff --git a/frontend/src/scss/_variables.scss b/frontend/src/scss/_variables.scss index 846ba0d..46d743d 100644 --- a/frontend/src/scss/_variables.scss +++ b/frontend/src/scss/_variables.scss @@ -79,6 +79,8 @@ $link-hover-color: $variant-color-warning; // Form $label-font-weight: 600; +$fieldset-margin: 0; +$fieldset-padding: 0.5em 0.75em; $fieldset-border: 0.1em solid $gray; $fieldset-border-radius: 0.5em; $fieldset-background-color: $gray-darker; @@ -114,6 +116,20 @@ $error-card-font-weight: 600; $error-card-background-color: lighten($variant-color-danger, 10%); $error-card-color: $page-background-color; +// Node preview +$node-preview-card-padding: 0.75em 1em; +$node-preview-card-border: 0.2em solid $gray; +$node-preview-card-border-radius: 1em; +$node-preview-card-headline-font-size: 1.5em; +$node-preview-card-headline-margin: 0 0 0.5em; +$node-preview-card-field-margin: 0.25em 0; +$node-preview-card-field-code-font-size: 1.25em; + +// Deleted node summary +$node-deleted-summary-padding: $node-preview-card-padding; +$node-deleted-summary-border: $node-preview-card-border; +$node-deleted-summary-border-radius: $node-preview-card-border-radius; +$node-deleted-summary-font-size: 1.25em; // Navigation $nav-bar-background-color: $gray-dark; diff --git a/frontend/src/stores/node.ts b/frontend/src/stores/node.ts new file mode 100644 index 0000000..c84753a --- /dev/null +++ b/frontend/src/stores/node.ts @@ -0,0 +1,23 @@ +import {defineStore} from "pinia"; +import {isStoredNode, type StoredNode, type Token} from "@/types"; +import {api} from "@/utils/Api"; + +interface NodeStoreState { +} + +export const useNodeStore = defineStore({ + id: "node", + state(): NodeStoreState { + return {}; + }, + getters: {}, + actions: { + async fetchByToken(token: Token): Promise { + return await api.get(`node/${token}`, isStoredNode); + }, + + async deleteByToken(token: Token): Promise { + await api.delete(`node/${token}`); + } + }, +}); diff --git a/frontend/src/utils/Api.ts b/frontend/src/utils/Api.ts index effa6bc..2fcc040 100644 --- a/frontend/src/utils/Api.ts +++ b/frontend/src/utils/Api.ts @@ -4,6 +4,55 @@ import {parseInteger} from "@/utils/Numbers"; type Method = "GET" | "PUT" | "POST" | "DELETE"; +enum ApiErrorType { + REQUEST_FAILED = "request_failed", + UNEXPECTED_RESULT_TYPE = "unexpected_result_type", +} + +enum HttpStatusCode { + NOT_FOUND = 404, +} + +export class ApiError extends Error { + private constructor( + message: string, + private status: number, + private errorType: ApiErrorType, + ) { + super(message); + } + + static async requestFailed( + method: Method, + path: string, + response: Response, + ): Promise { + const body = await response.text(); + return new ApiError( + `API ${method} request failed: ${path} => ${response.status} - ${body}`, + response.status, + ApiErrorType.REQUEST_FAILED, + ); + } + + static async unexpectedResultType( + method: Method, + path: string, + response: Response, + json: any, + ): Promise { + return new ApiError( + `API ${method} request result has unexpected type. ${path} => ${json}`, + response.status, + ApiErrorType.UNEXPECTED_RESULT_TYPE, + ); + } + + isNotFoundError(): boolean { + return this.errorType === ApiErrorType.REQUEST_FAILED && this.status === HttpStatusCode.NOT_FOUND; + } +} + interface PagedListResult { entries: T[]; total: number; @@ -47,15 +96,14 @@ class Api { }); if (!response.ok) { - const body = await response.text(); - throw new Error(`API ${method} request failed: ${path} => ${response.status} - ${body}`); + throw await ApiError.requestFailed(method, path, response); } if (isT) { const json = await response.json(); if (isT && !isT(json)) { console.log(json); - throw new Error(`API ${method} request result has wrong type. ${path} => ${json}`); + throw await ApiError.unexpectedResultType(method, path, response, json); } return { diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 4c0ba03..f67082d 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -3,7 +3,8 @@ import ActionButton from "@/components/form/ActionButton.vue"; import ButtonGroup from "@/components/form/ButtonGroup.vue"; import PageContainer from "@/components/page/PageContainer.vue"; import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types"; - +import {route, RouteName} from "@/router"; +import RouteButton from "@/components/form/RouteButton.vue"; diff --git a/frontend/src/views/NodeDeleteView.vue b/frontend/src/views/NodeDeleteView.vue new file mode 100644 index 0000000..b46d830 --- /dev/null +++ b/frontend/src/views/NodeDeleteView.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/server/shared/validation/constraints.ts b/server/shared/validation/constraints.ts index ce0ad40..e6e85fd 100644 --- a/server/shared/validation/constraints.ts +++ b/server/shared/validation/constraints.ts @@ -3,14 +3,14 @@ // noinspection RegExpSimplifiable const CONSTRAINTS = { - id:{ + id: { type: 'string', regex: /^[1-9][0-9]*$/, optional: false }, - token:{ + token: { type: 'string', - regex: /^[0-9a-f]{16}$/i, + regex: /^[0-9a-fA-F]{16}$/, optional: false }, node: { diff --git a/shared/validation/constraints.js b/shared/validation/constraints.js index dc6fbd7..a4581e4 100644 --- a/shared/validation/constraints.js +++ b/shared/validation/constraints.js @@ -5,14 +5,14 @@ (function () { var constraints = { - id:{ + id: { type: 'string', regex: /^[1-9][0-9]*$/, optional: false }, - token:{ + token: { type: 'string', - regex: /^[0-9a-f]{16}$/i, + regex: /^[0-9a-fA-F]{16}$/, optional: false }, node: {