This commit is contained in:
andbi 2025-11-19 20:22:22 -06:00
parent aa7c30950c
commit 72716e1c28
41 changed files with 15461 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

14
frontend/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,14 @@
{
"github.copilot.nextEditSuggestions.enabled": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"files.autoSave": "onFocusChange",
"chat.agent.enabled": false,
"files.associations": {
"main.css": "tailwindcss"
},
"editor.accessibilitySupport": "off"
}

44
frontend/README.md Normal file
View file

@ -0,0 +1,44 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
npm install
```
## Spring Boot
Make sure the backend is running:
```bash
cd ../backend
mvn clean spring-boot:run
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
npm run dev
```
## Production
Build the application for production. We don't use the server-side rendering, so we don't do the `build` command
```bash
npm run generate
```
Locally preview production build:
```bash
npm run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

81
frontend/app/API.ts Normal file
View file

@ -0,0 +1,81 @@
import type { DataType, DataTypeConfiguration } from "./types";
enum APIMethod {
GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE",
}
export class API {
private readonly apiBaseUrl: string;
constructor(apiBaseUrl: string) {
this.apiBaseUrl = apiBaseUrl;
}
readConfiguration(): Promise<DataTypeConfiguration> {
return this.call(APIMethod.GET, "/data");
}
readDataType(id: string): Promise<DataType> {
return this.call(APIMethod.GET, "/data/" + id);
}
deleteDataType(type: DataType): Promise<void> {
return this.call(APIMethod.DELETE, "/data/" + type.id);
}
saveDataType(type: DataType): Promise<DataTypeConfiguration> {
return this.call(APIMethod.POST, "/data", type);
}
/*
Helper methods
*/
private fullUrl(url: string): string {
return this.apiBaseUrl + url;
}
private requestOptions<A>(method: APIMethod, obj?: A): RequestInit {
const headers: HeadersInit = new Headers();
const options: RequestInit = {
method: method,
headers: headers,
};
if (obj !== undefined) {
const objType = typeof obj;
if (objType === "object") {
options.body = JSON.stringify(obj);
headers.set("Content-Type", "application/json; charset=UTF-8");
} else {
throw new Error("Unsupported type: " + objType);
}
}
return options;
}
private async call<T, A>(
method: APIMethod,
url: string,
obj?: A
): Promise<T> {
const options = this.requestOptions(method, obj);
const response = await fetch(this.fullUrl(url), options);
const status = response.status;
const ok = status >= 200 && status < 400;
if (ok) {
return await response.json();
} else {
const errorText = await response.text();
return Promise.reject(errorText);
}
}
static instance(): API {
const runtimeConfig = useRuntimeConfig();
// See the `nuxt.config.ts` file
const apiBaseUrl = runtimeConfig.public.backend.baseUrl;
return new API(apiBaseUrl);
}
}

15
frontend/app/app.vue Normal file
View file

@ -0,0 +1,15 @@
<template>
<UApp>
<NuxtRouteAnnouncer />
<!-- The 'main' template from the app/layout folder -->
<NuxtLayout name="main">
<NuxtPage />
</NuxtLayout>
</UApp>
</template>
<script setup lang="ts">
// This is the 'main' script.
// Nothing is happening here, see the 'main' template for the initialization logic
console.log("app initialized...");
</script>

View file

@ -0,0 +1,7 @@
@import "tailwindcss";
@import "@nuxt/ui";
@plugin "@tailwindcss/typography"; /* Basic topography */
:root {
--ui-container: 72rem; /* make the main container smaller */
}

View file

@ -0,0 +1,48 @@
<template>
<u-modal
title="Confirmation"
description="Confirmation required for the selected action"
:dismissible="false"
>
<template #body>
<p class="my-3">
Type <strong>{{ message }}</strong> to confirm.
</p>
<div>
<u-field-group class="w-full">
<u-input
v-model="inputMessage"
name="user-input"
autocomplete="false"
type="text"
class="w-full"
/>
<u-button color="error" :disabled="!matched" @click="onSubmit">
Submit
</u-button>
</u-field-group>
</div>
</template>
</u-modal>
</template>
<script lang="ts" setup>
import { UModal, UInput, UButton } from "#components";
interface Props {
message: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{ close: [boolean] }>();
const inputMessage = ref("");
const matched = computed(() => {
return inputMessage.value === props.message;
});
function onSubmit() {
emit("close", true);
}
</script>

View file

@ -0,0 +1,96 @@
<template>
<div class="grid grid-cols-2 gap-5">
<u-card variant="soft">
<u-form-field label="Type Name">
<u-input v-model="type.name" class="w-full" />
</u-form-field>
<table class="w-full">
<thead>
<tr>
<th class="w-0">#</th>
<th>Property Name</th>
<th>Property Type</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<tr v-for="(property, idx) in type.properties" :key="idx">
<th class="align-top w-0 pt-4">{{ idx + 1 }}</th>
<td>
<u-input v-model="property.name" class="w-full" />
</td>
<td class="align-middle">
<u-radio-group
v-model="property.type"
:items="typeItems"
orientation="vertical"
/>
</td>
<td class="align-middle">
<u-button
icon="i-bi-trash"
size="xs"
color="error"
@click="deleteProperty(idx)"
/>
</td>
</tr>
</tbody>
</table>
<u-button
icon="i-bi-plus"
size="sm"
color="neutral"
variant="outline"
@click="addProperty"
>
New Property
</u-button>
<template #footer>
<p class="text-xs muted">
Add and delete type parameters, change their types, the dynamic form
on the right will be automatically updated.
</p>
</template>
</u-card>
<div v-if="allParametersAreValid">
<h4 style="margin-top: 0">Sample Form</h4>
<simple-form :type="type" />
</div>
</div>
</template>
<script setup lang="ts">
import type { DataType, UiPropertyType } from "~/types";
// Here we define our type as a model, meaning that any changes to this object will be published back to the parent view
const type = defineModel<DataType>({ required: true });
const typeItems = ref<UiPropertyType[]>(["number", "string", "text"]);
// Form won't be shown if there are empty parameters
const allParametersAreValid = computed(() => {
const typeProps = type.value.properties;
if (typeProps.length == 0) {
return false; // No properties at all
}
for (const p of typeProps) {
if (p.name.length === 0) {
return false; // Missing a name
}
}
return true;
});
function deleteProperty(index: number) {
const copy = type.value.properties;
copy.splice(index, 1);
type.value.properties = copy;
}
function addProperty() {
type.value.properties.push({ name: "", type: "string" });
}
</script>

View file

@ -0,0 +1,42 @@
<template>
<UNavigationMenu :items="items" class="w-full justify-start bg-neutral-50">
<template #list-trailing>
<div class="flex flex-1 justify-end mx-2">
<UBadge
icon="i-mdi-clock-outline"
size="md"
color="secondary"
variant="solid"
class="font-mono"
>
{{ currentTime }}
</UBadge>
</div>
</template>
</UNavigationMenu>
</template>
<script setup lang="ts">
import type { NavigationMenuItem } from "@nuxt/ui";
import { appState } from "#imports";
const state = appState();
const currentTime = computed(() => {
return state.value.time.toLocaleTimeString();
});
const items = ref<NavigationMenuItem[]>([
{
icon: "i-mdi-home",
to: "/",
active: false,
},
{
label: "Data Structures",
icon: "i-material-symbols-account-tree-outline-rounded",
to: "/data",
active: false,
},
]);
</script>

View file

@ -0,0 +1,41 @@
<template>
<u-breadcrumb :items="items" class="my-nav mb-3" />
</template>
<script setup lang="ts">
import type { BreadcrumbItem } from "@nuxt/ui";
import { UBreadcrumb } from "#components";
import type { NavRoute } from "~/ui";
interface Props {
previous: NavRoute[];
last: string;
}
const items = computed(() => {
const arr: BreadcrumbItem[] = [];
for (const h of props.previous) {
arr.push({
label: h.title,
to: h.path,
});
}
arr.push({
label: props.last,
disabled: true,
class: "text-muted",
});
return arr;
});
const props = defineProps<Props>();
</script>
<style>
/* Override default high margins for <ol> lists */
.my-nav ol {
margin-top: 0 !important;
margin-bottom: 0 !important;
padding-left: 0 !important;
}
</style>

View file

@ -0,0 +1,66 @@
<template>
<div>
<div class="p-2 bg-neutral-50 rounded-lg shadow-neutral-300 shadow-sm">
<table class="table">
<tbody>
<tr v-for="(prop, idx) in type.properties" :key="idx">
<td class="font-semibold w-0 text-nowrap px-2">
{{ prop.name }}
</td>
<td>
<u-input
v-if="prop.type === 'string'"
v-model="formData[prop.name]"
type="text"
class="w-full"
/>
<u-textarea
v-if="prop.type === 'text'"
v-model="formData[prop.name]"
class="w-full"
/>
<u-input
v-if="prop.type === 'number'"
v-model="formData[prop.name]"
type="number"
/>
</td>
</tr>
</tbody>
</table>
</div>
<div>
<h4 class="mx-0">Form Data</h4>
<pre class="mx-0 my-0">{{ formData }}</pre>
</div>
</div>
</template>
<script setup lang="ts">
import type { DataType } from "~/types";
interface Props {
type: DataType;
}
const props = defineProps<Props>();
const formData = ref<Record<string, string>>({});
watch(
props,
() => {
// Property value changed, resetting the form data
formData.value = {};
},
{ deep: true }
);
</script>
<style>
/* Override default high margins for <ol> lists */
.table {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
</style>

View file

@ -0,0 +1,10 @@
import { AppState } from "~/ui";
// Important: this exposes a global (per web app) state of type `AppState`.
// As with any other `Ref` variable, when the state changes, dependent views are automatically updated.
export const appState = (): Ref<AppState> => {
return useState(
"some-unique-state-key-1",
() => new AppState(new Date()) // Initial value
);
};

View file

@ -0,0 +1,29 @@
<template>
<div>
<!-- This is our custom navbar component. It has been automatically imported from the 'components' folder -->
<NavBar />
<UContainer>
<!-- the 'prose' class will apply basic typography for the whole app -->
<div class="w-full prose max-w-none py-5">
<!-- This is where all the pages will be rendered -->
<slot />
</div>
</UContainer>
</div>
</template>
<script setup lang="ts">
import { appState } from "#imports";
const state = appState();
// A demo
// This hook will be called when the layout has beed initialized
onMounted(async () => {
setInterval(() => {
state.value.time = new Date();
}, 1000);
});
console.log("app initialized...");
</script>

View file

@ -0,0 +1,84 @@
<template>
<div>
<div v-if="loadedType">
<!-- Show our basic breadcrumb view -->
<simple-breadcrumb
:last="loadedType.name"
:previous="[{ path: '/data', title: 'Data Structures' }]"
/>
<!-- Show our editor -->
<data-type-editor v-model="loadedType" />
<u-button
color="primary"
size="xl"
class="my-3"
@click="onSave(loadedType)"
>
<span class="px-3">Save</span>
</u-button>
<div
class="px-3 py-2 border-l-4 border-l-red-700 bg-red-100 mt-5 prose-sm"
>
<h2 style="margin: 0" class="text-red-700">Important</h2>
<ol class="list-decimal">
<li>
All dynamic changes are performed on a
<strong>local copy</strong> of the data that we received from the
backend during the page load event.
</li>
<li>
If a view requires additional data to be rendered, that data should
also be loaded, or a more complex data structure must be sent by the
backend.
</li>
<li>
Additional data can also be loaded dynamically in response to
various UI events.
</li>
<li>
<strong>But!</strong> The save operation is performed atomically in
a single <strong>ACID</strong> transaction on the server side. This
is the only way we can avoid partial saves or discrepancies in
business-critical data. See the architecture proposal for more
details.
</li>
</ol>
</div>
</div>
<div v-else>Loading ....</div>
</div>
</template>
<script setup lang="ts">
import { API } from "~/API";
import type { DataType } from "~/types";
// Current page's route (path)
const route = useRoute();
const api = API.instance();
// The loading state, initially undefined
const loadedType = ref<DataType | undefined>(undefined);
function onSave(type: DataType) {
api.saveDataType(type).then(() => {
// Get the fresh copy of the data
loadTypeFromUrlParameter();
});
}
onMounted(() => {
loadTypeFromUrlParameter();
});
function loadTypeFromUrlParameter() {
// Load the type by the page's [id] parameter
const dataId = route.params.id as string;
api.readDataType(dataId).then((response) => {
// Update the loaded type var
loadedType.value = response;
});
}
</script>

View file

@ -0,0 +1,139 @@
<template>
<div>
<h1>Data Structures</h1>
<table class="w-full lg:w-1/2">
<thead>
<tr>
<th class="w-0">ID</th>
<th>Name</th>
<th class="w-0">&nbsp;</th>
</tr>
</thead>
<tbody>
<tr v-for="type in types.dataTypes" :key="type.id">
<td class="w-0 text-nowrap">
{{ type.id }}
</td>
<td class="w-full">
<router-link :to="'/data/' + type.id">{{ type.name }}</router-link>
</td>
<td class="w-0">
<u-button
icon="i-bi-trash"
size="xs"
color="error"
@click="deleteType(type)"
/>
</td>
</tr>
</tbody>
</table>
<u-button color="primary" @click="showNewTypeModal">Create New</u-button>
<u-modal
v-model:open="newTypeDialogShow"
:dismissible="false"
title="New Type"
description="Create and save a new type"
>
<template #body>
<div class="grid grid-cols-1 gap-3">
<u-form-field label="ID">
<u-input v-model="newType.id" />
</u-form-field>
<u-form-field label="Name">
<u-input v-model="newType.name" />
</u-form-field>
</div>
</template>
<template #footer>
<u-button
:disabled="newType.id.length === 0 || newType.name.length === 0"
@click="saveNewType"
>
Save
</u-button>
</template>
</u-modal>
</div>
</template>
<script setup lang="ts">
import { API } from "~/API";
import { DataType, DataTypeConfiguration } from "~/types";
import { confirmationDialog } from "~/ui";
const types = ref<DataTypeConfiguration>(new DataTypeConfiguration([]));
// Var that holds our new type instance
const newType = ref<DataType>(DataType.empty());
const newTypeDialogShow = ref(false);
const api = API.instance();
// INIT !! This hook will be called when the layout has beed initialized
onMounted(readAllTypes);
function showNewTypeModal() {
newType.value = DataType.empty();
newTypeDialogShow.value = true;
}
function deleteType(type: DataType) {
confirmationDialog().show("delete", () => {
// This will be executed on confirmation
api.deleteDataType(type).finally(() => {
// The delete call doesn't return anything, updating the list...
readAllTypes();
});
});
}
function saveNewType() {
api
.saveDataType(newType.value)
.then((configuration) => {
// Update the types list
types.value = configuration;
toaster.add({
title: "Success !!!",
color: "success",
});
})
.catch((e) => {
// Just log the error
console.error("API response error", e);
toaster.add({
title: "Backend Error",
color: "error",
});
})
.finally(() => {
// Reset the new type states
newType.value = DataType.empty();
newTypeDialogShow.value = false;
});
}
const toaster = useToast();
function readAllTypes() {
// Call the backend to initialize the available data types
api
.readConfiguration()
.then((configuration) => {
// Save the configuration in the global state var
// (see the description in the state's declaration (app/composables/application.ts)
types.value = configuration;
})
.catch((e) => {
// Log & show the error
console.error("API response error", e);
toaster.add({
title: "Backend Error",
color: "error",
});
});
}
</script>

View file

@ -0,0 +1,52 @@
<template>
<div class="my-3">
<h1>Welcome!</h1>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- left -->
<div class="col-span-2">
<p>
This is a sample <strong>Nuxt</strong> web application that uses
<strong>Spring Boot</strong> as a RESTful backend. Launch the backend
from your IDE or use the following commands:
</p>
<pre>
cd backend
mvn clean spring-boot:run
</pre
>
<p>
I've left comments in the most important parts of the code. For more
details, use the links section.
</p>
</div>
<!-- right -->
<div class="bg-neutral-100 py-2 px-4 rounded-lg">
<h3 style="margin-top: 0 !important">Links</h3>
<ol>
<li>
<a href="https://nuxt.com" target="_blank">Nuxt</a> - the core
framework based on Vue.js.
</li>
<li>
<a href="https://ui.nuxt.com" target="_blank">Nuxt UI</a> - utility
components and styling.
</li>
<li>
<a href="https://spring.io/projects/spring-boot" target="_blank">
Spring Boot
</a>
- the most popular Java framework.
</li>
<li>
<a href="https://github.com/nvm-sh/nvm" target="_blank">
Node Version Manager
</a>
- an absolute must for any npm project.
</li>
</ol>
</div>
</div>
</div>
</template>
<script setup lang="ts"></script>

27
frontend/app/types.ts Normal file
View file

@ -0,0 +1,27 @@
// TypeScript variants of the Java classes.
// Typically, standalone DTOs (Data Transfer Objects) are used to communicate with the backend,
// but we'll keep it simple here.
export type UiPropertyType = "number" | "string" | "text";
export class TypeProperty {
constructor(public name: string, public type: UiPropertyType) {}
}
export class DataType {
constructor(
public id: string,
public name: string,
public properties: TypeProperty[]
) {}
static empty(): DataType {
return new DataType("", "", []);
}
}
export class DataTypeConfiguration {
constructor(public dataTypes: DataType[]) {}
}
export type Runnable = () => void;

30
frontend/app/ui.ts Normal file
View file

@ -0,0 +1,30 @@
import ConfirmationModal from "./components/ConfirmationModal.vue";
import type { Runnable } from "./types";
// A sample global state of the web app
export class AppState {
constructor(public time: Date) {}
}
export interface NavRoute {
title: string;
path: string;
}
export interface ConfirmationDialog {
show(message: string, onSuccess: Runnable): void;
}
export const confirmationDialog: () => ConfirmationDialog = () => {
const overlay = useOverlay();
const modal = overlay.create(ConfirmationModal);
return {
async show(message: string, onSuccess: Runnable) {
const instance = modal.open({ message: message });
const result = await instance.result;
if (result) {
onSuccess();
}
},
};
};

View file

@ -0,0 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)

27
frontend/nuxt.config.ts Normal file
View file

@ -0,0 +1,27 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: false },
modules: ["@nuxt/eslint", "@nuxt/ui", "@tailwindcss/typography"],
ssr: false,
pages: true,
css: ["~/assets/css/main.css"],
ui: {
colorMode: false,
},
devServer: {
port: 3000,
},
runtimeConfig: {
public: {
backend: {
baseUrl: "http://localhost:8081/v1",
},
},
},
});

14222
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
frontend/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "demo",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/eslint": "^1.10.0",
"@nuxt/ui": "^4.2.0",
"eslint": "^9.39.1",
"nuxt": "^4.2.1",
"typescript": "^5.9.3",
"vue": "^3.5.24",
"vue-router": "^4.6.3",
"@tailwindcss/typography": "0.5.19"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,2 @@
User-Agent: *
Disallow:

18
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}