🎉 Init

This commit is contained in:
Andreas Dinauer 2026-02-05 19:42:00 +01:00
commit c8cfb6eeab
51 changed files with 11966 additions and 0 deletions

24
.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

39
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,39 @@
pipeline {
agent any
stages {
stage('Set Image Name') {
steps {
script {
env.IMAGE = "harbor.dinauer.dev/kubooboo/frontend:${env.BUILD_NUMBER}";
}
}
}
stage('Build Image') {
steps {
script {
sh "docker build --no-cache -t ${env.IMAGE} ."
}
}
}
stage('Push Image to Docker Hub') {
steps {
script {
withCredentials([usernamePassword(credentialsId: 'harbor', usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) {
sh 'echo ${PASSWORD} | docker login harbor.dinauer.dev -u ${USERNAME} --password-stdin'
sh "docker push ${env.IMAGE}"
sh "docker logout harbor.dinauer.dev"
}
}
}
}
stage('Remove image from host') {
steps {
script {
sh "docker image rm --force ${env.IMAGE}"
}
}
}
}
}

75
README.md Normal file
View File

@ -0,0 +1,75 @@
# 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
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

22
app/app.vue Normal file
View File

@ -0,0 +1,22 @@
<template>
<div class="app">
<NuxtPage></NuxtPage>
<PopupTemplate v-if="popup" :heading="popup.config.heading" :size="popup.config.size">
<component :is="popup.component"></component>
</PopupTemplate>
</div>
</template>
<script setup lang="ts">
import {usePopup} from "~/components/ui/popup/Popup";
import PopupTemplate from "~/components/ui/popup/PopupTemplate.vue";
const popup = computed(() => usePopup().get());
</script>
<style>
.app {
height: 100vh;
width: 100vw;
}
</style>

256
app/assets/base-style.css Normal file
View File

@ -0,0 +1,256 @@
.center {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.left-center {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
}
.right-center {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
.stretch-center {
display: flex;
align-items: center;
justify-content: stretch;
gap: 0.5rem;
}
.left-top {
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 0.5rem;
}
.center-top {
display: flex;
align-items: flex-start;
justify-content: center;
}
.center-bottom {
display: flex;
align-items: flex-end;
justify-content: center;
}
.top-center {
display: flex;
align-items: center;
justify-content: flex-start;
}
.spaced-top {
display: flex;
align-items: start;
justify-content: space-between;
}
.spaced-center {
display: flex;
align-items: center;
justify-content: space-between;
}
.pointer {
cursor: pointer;
}
.expand {
height: 100%;
width: 100%;
}
.content, *[class^='content-'], *[class*=' content-'] {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.content, *[class^='content-'], *[class*=' content-'] > *:is(div, form, header, footer) {
width: 100%;
justify-self: stretch;
}
.content .full {
width: 100%;
}
.content-s {
gap: 0.25rem;
}
.content-m {
gap: 0.5rem;
}
.content-l {
gap: 1rem;
}
.content-xl {
display: grid;
gap: 2rem;
}
.content-2xl {
gap: 3rem;
}
.content-3xl {
gap: 4rem;
}
.padding-m {
padding: 0.5rem;
}
.padding-l {
padding: 1rem;
}
.padding-xl {
padding: 2rem;
}
.narrow {
width: min(100%, 1340px);
padding: 0 0.5rem;
}
.narrow.s {
width: min(100%, 320px);
}
.narrow.m {
width: min(100%, 540px);
}
.narrow-b {
width: min(100%, 740px);
}
.tile, *[class^='tile-'], *[class*=' tile-'] {
background-color: var(--tile-color);
border-radius: 0.25rem;
overflow: hidden;
width: 100%;
border: 1px solid #cddaff;
}
.tile-s {
padding: 0.25rem;
}
.tile-m {
padding: 0.5rem;
}
.tile-l {
padding: 1rem;
}
.tile-xl {
padding: 2rem;
}
.col-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.col-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.col-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
.col-5 {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1rem;
}
.grayed-out {
color: #777777;
font-size: 0.85rem;
}
.row_auto-1fr {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
}
.height_100 {
height: 100%;
}
.gap-0 {
gap: 0;
}
.gap-m {
gap: 0.5rem;
}
.gap-l {
gap: 1rem;
}
.column {
flex-direction: column;
}
@media (max-width: 768px) {
.col-1-m {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.col-2-m {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.col-3-m {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
}
.center-m {
display: flex;
justify-content: center;
align-items: center;
}
}
.full_20rem {
display: grid;
grid-template-columns: 1fr 20rem;
gap: 1rem;
}
.base-shape {
height: 2.25rem;
padding: 0.5rem;
}
.width-6rem {
width: 6rem;
}
.nowrap {
white-space: nowrap
}

BIN
app/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

27
app/assets/style.css Normal file
View File

@ -0,0 +1,27 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap');
* {
padding: 0;
margin: 0;
font-family: "Outfit", sans-serif;
font-optical-sizing: auto;
box-sizing: border-box;
text-decoration: none;
color: black;
}
html, body, #__nuxt {
height: 100vh;
width: 100vw;
}
html {
--tile-color: #eef6ff;
--primary-color: #3c74ff;
}
h3 {
font-weight: 550;
}

43
app/auth/Account.ts Normal file
View File

@ -0,0 +1,43 @@
import axios from "axios";
import {AuthApi} from "~/utils/AuthApi";
export class Account
{
firstname?: string;
lastname?: string;
username?: string;
email?: string;
password?: string;
roles?: string[];
initial?: boolean;
static get(id: string, token: string, onSuccess: (account: Account) => void)
{
AuthApi.get(StringUtils.format('/accounts/%s', id), {
headers: {
Authorization: StringUtils.format("Bearer %s", token)
}
})
.then((response) => {
onSuccess(response.data);
});
}
static create(user: AccountCreation, onSuccess: () => void)
{
AuthApi.post('/accounts', user)
.then(() => {
onSuccess();
});
}
}
export class AccountCreation
{
firstname?: string;
lastname?: string;
email?: string;
password?: string;
username?: string;
role: string = "USER";
}

32
app/auth/Session.ts Normal file
View File

@ -0,0 +1,32 @@
import {type AxiosResponse} from "axios";
import {AuthApi} from "~/utils/AuthApi";
import type {Account} from "~/auth/Account";
export class Session
{
static COOKIE = "session";
constructor (
public user: Account,
public token: string
) {}
static create(sessionCreation: SessionCreation, onSuccess: (token: string) => void, onError: () => void)
{
AuthApi.post<string>("/sessions", sessionCreation)
.then((response: AxiosResponse) => {
onSuccess(response.data);
})
.catch(() => {
onError();
});
}
}
export class SessionCreation
{
constructor (
public email?: string,
public password?: string
) {}
}

44
app/auth/Token.ts Normal file
View File

@ -0,0 +1,44 @@
import {MavenApi} from "~/utils/MavenApi";
export class Token
{
constructor(
public name: string,
public createdAt: Date,
public expiresAt: Date
) {}
static get(onSuccess: (tokens: Token[]) => void)
{
MavenApi.get<Token[]>("/tokens")
.then((response) => {
onSuccess(response.data);
});
}
}
export class TokenCreation
{
constructor(
public name: string,
public expiresAt: Date
) {}
static create(tokenCreation: TokenCreation, onSuccess: (token: TokenSecret) => void)
{
MavenApi.post<TokenSecret>("/tokens", tokenCreation)
.then((response) => {
onSuccess(response.data);
});
}
}
export class TokenSecret
{
constructor(
public name: string,
public expiresAt: Date,
public createdAt: Date,
public token: string
) {}
}

View File

@ -0,0 +1,58 @@
import axios from "axios";
export class Artifact
{
constructor (
public id: string,
public groupId: string,
public artifactId: string,
public updatedAt: string,
public versions: Version[],
public totalPullCount: number
) {}
static get(onSuccess: (artifacts: Artifact[]) => void)
{
axios.get<Artifact[]>("http://localhost:8080/artifacts")
.then((response) => {
onSuccess(response.data)
});
}
static getById(id: string, onSuccess: (artifact: Artifact) => void)
{
axios.get<Artifact>("http://localhost:8080/artifacts/" + id)
.then((response) => {
onSuccess(response.data)
});
}
}
export class Version
{
constructor (
public id: string,
public groupId: string,
public artifactId: string,
public version: string,
public jars?: Jar[],
public pom?: Pom
) {}
}
export class Jar
{
constructor (
public filename: string,
public url: string
) {}
}
export class Pom
{
constructor (
public filename: string,
public url: string
) {}
}

View File

@ -0,0 +1,26 @@
<template>
<ContentRow class="artifact pointer" @click="() => useRouter().push('artifacts/' + artifact.id)">
<ContentCell>{{ artifact.groupId }}</ContentCell>
<ContentCell>{{ artifact.artifactId }}</ContentCell>
<ContentCell v-for="latest in [artifact.versions.at(0)]"><span v-if="latest">{{ latest.version }}</span></ContentCell>
<ContentCell>{{ artifact.totalPullCount }}</ContentCell>
<ContentCell>{{ dayjs(artifact.updatedAt).format("DD.MM.YYYY HH:mm") + " Uhr" }}</ContentCell>
</ContentRow>
</template>
<script setup lang="ts">
import type {Artifact} from "~/components/artifact/Artifact";
import dayjs from "dayjs";
import ContentRow from "~/components/ui/table/ContentRow.vue";
import ContentCell from "~/components/ui/table/ContentCell.vue";
defineProps<{
artifact: Artifact
}>()
</script>
<style scoped>
.artifact {
display: contents;
}
</style>

View File

@ -0,0 +1,32 @@
<template>
<Table v-if="artifacts" columns="auto auto auto 1fr auto">
<HeaderRow>
<HeaderCell>Group ID</HeaderCell>
<HeaderCell>Artifact ID</HeaderCell>
<HeaderCell>Latest Version</HeaderCell>
<HeaderCell>Pull Count</HeaderCell>
<HeaderCell>Updated At</HeaderCell>
</HeaderRow>
<ArtifactComponent :artifact="artifact" v-for="artifact in artifacts"></ArtifactComponent>
</Table>
</template>
<script setup lang="ts">
import {Artifact} from "~/components/artifact/Artifact";
import ArtifactComponent from "~/components/artifact/ArtifactComponent.vue";
import Table from "~/components/ui/table/Table.vue";
import HeaderCell from "~/components/ui/table/HeaderCell.vue";
import HeaderRow from "~/components/ui/table/HeaderRow.vue";
const artifacts: Ref<Artifact[] | undefined> = ref(undefined);
onMounted(() => {
Artifact.get((_artifacts: Artifact[]) => {
artifacts.value = _artifacts;
});
})
</script>
<style scoped>
</style>

View File

@ -0,0 +1,20 @@
<template>
<div class="codebox">
<p v-html="content"></p>
</div>
</template>
<script setup lang="ts">
defineProps<{
content: string
}>()
</script>
<style scoped>
.codebox * {
font-family: "Roboto Mono", monospace;
font-optical-sizing: auto;
white-space: preserve;
tab-size: 2;
}
</style>

View File

@ -0,0 +1,18 @@
<template>
<div>
<p v-if="account && account.firstname && account.firstname">{{ account.firstname }}, {{ account.lastname }}</p>
<p v-else>-</p>
</div>
</template>
<script setup lang="ts">
import type {Account} from "~/auth/Account";
defineProps<{
account?: Account
}>()
</script>
<style scoped>
</style>

View File

@ -0,0 +1,43 @@
<template>
<div class="login-page">
<h1>Login</h1>
<UiInput label="E-Mail">
<input type="text" v-model="sessionCreation.email">
</UiInput>
<UiInput label="Passwort">
<input type="password" v-model="sessionCreation.password">
</UiInput>
<UiButton @click="login">Login</UiButton>
</div>
</template>
<script setup lang="ts">
import {Session, SessionCreation} from "~/auth/Session";
import {jwtDecode} from "jwt-decode";
import {Account} from "~/auth/Account";
const sessionCreation = ref(function() {
if (StringUtils.equals("development", process.env.NODE_ENV))
{
return new SessionCreation("andreas.j.dinauer@gmail.com", "pw")
}
return new SessionCreation();
}());
function login()
{
Session.create(sessionCreation.value, (token: string) => {
const decode = jwtDecode(token) as any;
Account.get(decode.upn, token, (user: Account) => {
useCookie<Session>(Session.COOKIE).value = new Session(user, token);
useRouter().push('/app/artifacts');
});
}, () => {})
}
</script>
<style scoped>
.login-page {
max-width: 540px;
}
</style>

View File

@ -0,0 +1,35 @@
import axios from "axios";
import type {Account} from "~/auth/Account";
export class Event
{
constructor (
public type: EventType,
public timestamp: Date,
public resource: Resource,
public account: Account
) {}
static get(onSuccess: (events: Event[]) => void)
{
axios.get<Event[]>("http://localhost:8080/events")
.then((response) => {
onSuccess(response.data)
});
}
}
export class Resource
{
constructor (
public groupId: string,
public artifactId: string,
public version: string
) {}
}
export enum EventType
{
UPLOAD = "UPLOAD",
DELETE = "DELETE"
}

View File

@ -0,0 +1,30 @@
<template>
<ContentRow>
<ContentCell>
<UiIcon v-if="event.type === EventType.UPLOAD">upload</UiIcon>
</ContentCell>
<ContentCell> {{ event.resource.groupId }}</ContentCell>
<ContentCell>{{ event.resource.artifactId }}</ContentCell>
<ContentCell>{{ event.resource.version }}</ContentCell>
<ContentCell>
<DisplayNameComponent :account="event.account"></DisplayNameComponent>
</ContentCell>
<ContentCell>{{ Age.calc(event.timestamp) }}</ContentCell>
</ContentRow>
</template>
<script setup lang="ts">
import ContentRow from "~/components/ui/table/ContentRow.vue";
import ContentCell from "~/components/ui/table/ContentCell.vue";
import {Event, EventType} from "~/components/events/Event";
import {Age} from "~/utils/Age";
import DisplayNameComponent from "~/components/auth/DisplayNameComponent.vue";
defineProps<{
event: Event
}>()
</script>
<style scoped>
</style>

View File

@ -0,0 +1,33 @@
<template>
<Table columns="auto auto auto auto 1fr auto" v-if="events">
<HeaderRow>
<HeaderCell></HeaderCell>
<HeaderCell>Group ID</HeaderCell>
<HeaderCell>Artifact ID</HeaderCell>
<HeaderCell>Version</HeaderCell>
<HeaderCell>User</HeaderCell>
<HeaderCell></HeaderCell>
</HeaderRow>
<EventComponent :event="event" v-for="event in events"></EventComponent>
</Table>
</template>
<script setup lang="ts">
import Table from "~/components/ui/table/Table.vue";
import HeaderRow from "~/components/ui/table/HeaderRow.vue";
import EventComponent from "~/components/events/EventComponent.vue";
import HeaderCell from "~/components/ui/table/HeaderCell.vue";
import {Event} from "~/components/events/Event";
const events: Ref<Event[] | undefined> = ref(undefined);
onMounted(() => {
Event.get((_events: Event[]) => {
events.value = _events;
})
})
</script>
<style scoped>
</style>

View File

@ -0,0 +1,16 @@
import axios from "axios";
export class Group
{
constructor (
public groupId: string
) {}
static get(onSuccess: (groups: Group[]) => void)
{
axios.get<Group[]>("http://localhost:8080/groups")
.then((response) => {
onSuccess(response.data)
});
}
}

View File

@ -0,0 +1,11 @@
<template>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

View File

@ -0,0 +1,49 @@
<template>
<div>
<div v-if="!token" class="content-l">
<div class="col-2">
<UiInput label="Name" required>
<input type="text" v-model="name">
</UiInput>
<UiInput label="Expires At">
<input type="date" v-model="expiresAt">
</UiInput>
</div>
<div class="center">
<UiButton @click="create" :disabled="name.length === 0">Create</UiButton>
</div>
</div>
<div v-else class="content-l">
<p class="tile-m">{{ token.token }}</p>
<div class="center">
<UiButton class="width-6rem">Copy</UiButton>
<UiButton class="width-6rem" @click="usePopup().close()">Close</UiButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {TokenCreation, TokenSecret} from "~/auth/Token";
import {usePopup} from "~/components/ui/popup/Popup";
const name: Ref<string> = ref('');
const expiresAt = ref(new Date().toISOString().substring(0, 10));
const token: Ref<TokenSecret | undefined> = ref(undefined);
function create()
{
if (!name.value)
{
return false;
}
TokenCreation.create(new TokenCreation(name.value, new Date(expiresAt.value)), (_token: TokenSecret) => {
token.value = _token;
usePopup().get()!.config.callback?.(_token);
})
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,61 @@
<template>
<button @click="() => click()" class="base-shape button pointer center" :class="{ loading: loading, reverse: reverse, disabled: disabled }"><UiIcon v-if="icon && !loading">{{ icon }}</UiIcon><slot></slot></button>
</template>
<script setup lang="ts">
import UiIcon from '@/components/ui/UiIcon.vue';
function click() {
if(!props.disabled) {
if(props.onclick != null) {
props.onclick()
}
if(props.to != null) {
useRouter().push(props.to);
}
}
}
const props = defineProps<{
icon?: string,
reverse?: boolean,
loading?: boolean | undefined,
disabled?: boolean,
onclick?: () => void
to?: string
}>();
</script>
<style>
.button {
background-color: var(--primary-color);
color: white;
outline: none;
gap: 0.5rem;
white-space: nowrap;
border-radius: 0.25rem;
font-weight: 500;
font-size: 1rem;
border: none;
}
.button * {
color: white;
fill: white;
}
.button.reverse {
flex-direction: row-reverse;
}
.button.loading {
background-color: #5c5c5c;
border-color: #5c5c5c;
cursor: default;
}
.button.disabled {
background-color: #7a7a7a;
border-color: #7a7a7a;
cursor: default;
}
.reverse {
flex-direction: row-reverse;
}
</style>

View File

@ -0,0 +1,14 @@
<template>
<span class="material-symbols-outlined"><slot></slot></span>
</template>
<style scoped>
.material-symbols-outlined {
font-variation-settings:
'FILL' 0,
'wght' 400,
'GRAD' 0,
'opsz' 24;
font-size: 1.25rem;
}
</style>

View File

@ -0,0 +1,52 @@
<template>
<div class="field">
<div class="left-center">
<label v-if="label">{{ label }} <span class="required" v-if="required">*</span></label>
<slot name="label"></slot>
</div>
<slot></slot>
</div>
</template>
<script setup lang="ts">
defineProps<{
label?: string,
required?: boolean
}>();
</script>
<style scoped>
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
</style>
<style>
.field input, .field .input, .field textarea, .field select {
min-height: 2.5rem;
border-radius: 0.25rem;
background-color: rgb(255, 255, 255);
width: 100%;
border: 2px solid #444444;
outline: none;
padding: 0.25rem;
font-size: 1rem;
}
.field textarea {
min-height: 5rem;
padding: 0.25rem;
}
.field input:focus, .field textarea:focus, .field select:focus {
border-color: var(--primary-color);
}
.required {
color: #e63515;
font-weight: bold;
}
.field input:disabled, textarea:disabled, select:disabled {
border: 2px solid #7a7a7a;
background-color: #f1f1f1;
}
</style>

View File

@ -0,0 +1,71 @@
export class Popup
{
constructor (
public component: Component,
public config: PopupConfig,
public payload?: any
) {}
}
export interface PopupConfig
{
heading: string;
size: PopupSize;
callback?: (payload?: any) => void;
}
export enum PopupSize
{
SMALL = "small", MEDIUM = "medium", LARGE = "large", FULL = "full"
}
export const usePopup = defineStore('popup', {
state: () => ({
popup: shallowRef<Popup | undefined>(undefined)
}),
getters: {
get: (state) => {
return () => {
return state.popup;
}
},
require: (state) => {
return () => {
if (state.popup)
{
return state.popup;
}
throw new Error('Expected to be in open popup state.');
}
}
},
actions: {
open(popup: Popup) {
this.popup = popup;
disableScrolling();
},
close() {
this.popup = undefined;
enableScrolling();
}
}
})
function disableScrolling()
{
const body = document.getElementsByTagName('body');
for(const element of body)
{
element.style.overflow = "hidden";
}
}
function enableScrolling()
{
const body = document.getElementsByTagName('body');
for(const element of body)
{
element.style.overflow = "visible";
}
}

View File

@ -0,0 +1,80 @@
<template>
<div class="overlay center" @click="usePopup().close()">
<div class="popup" :class="size" @click.stop>
<div class="popup__header">
<h2>{{ heading }}</h2>
<UiButton icon="close" @click="usePopup().close()" class="square"></UiButton>
</div>
<div class="popup__body">
<slot></slot>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {PopupSize, usePopup} from "~/components/ui/popup/Popup";
defineProps<{
heading: string,
size: PopupSize
}>()
document.addEventListener('keydown', (event) => {
if(event.key === 'Escape')
{
usePopup().close();
}
});
</script>
<style scoped>
.overlay {
background-color: rgba(54, 54, 54, 0.514);
backdrop-filter: blur(0.1rem);
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
padding: 0.5rem;
z-index: 2;
}
.popup {
display: grid;
grid-template-rows: auto 1fr;
align-content: flex-start;
width: 100%;
height: 100%;
padding: 1rem;
background-color: rgb(255, 255, 255);
border-radius: 0.5rem;
}
.popup__header {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
}
.popup__body {
height: 100%;
overflow: hidden;
}
.medium {
height: auto;
width: 740px;
}
.small {
height: auto;
width: 540px;
}
.large {
height: auto;
width: 740px;
}
.full {
height: auto;
width: 540px;
}
</style>

View File

@ -0,0 +1,15 @@
<template>
<div class="cell left-center">
<slot></slot>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.cell {
padding: 0.75rem 1rem;
}
</style>

View File

@ -0,0 +1,24 @@
<template>
<div class="content-row display-contents">
<slot></slot>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.display-contents {
display: contents;
}
.content-row > * {
border-bottom: 1px solid #c1c1c1;
}
.content-row:hover > * {
background-color: var(--tile-color);
}
.content-row:last-of-type * {
border-bottom: 0;
}
</style>

View File

@ -0,0 +1,20 @@
<template>
<div class="header">
<slot></slot>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.header {
border-bottom: 1px solid #c1c1c1;
padding: 0.75rem 1rem;
background-color: #efefef;
}
.header, .header * {
font-weight: 600;
}
</style>

View File

@ -0,0 +1,15 @@
<template>
<div class="display-contents">
<slot></slot>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.display-contents {
display: contents;
}
</style>

View File

@ -0,0 +1,30 @@
<template>
<div class="table">
<slot></slot>
</div>
</template>
<script setup lang="ts">
defineProps<{
columns: string
}>()
</script>
<style scoped>
.table {
display: grid;
grid-template-columns: v-bind(columns);
border: 1px solid #c1c1c1;
border-radius: 0.5rem;
overflow: hidden;
}
.header * {
font-weight: 600;
}
.artifact-list .artifact:hover * {
background-color: var(--tile-color);
}
.artifact-list .artifact:last-of-type * {
border-bottom: 0;
}
</style>

View File

@ -0,0 +1,14 @@
import {Session} from "~/auth/Session";
export default defineNuxtRouteMiddleware((to) => {
const authenticated = useCookie<Session>(Session.COOKIE).value != null;
const path = to.path;
if (StringUtils.startsWith(path, '/app') && !authenticated)
{
return navigateTo('/')
}
if (StringUtils.equals(path, '/') && authenticated)
{
return navigateTo('/app')
}
})

42
app/pages/app.vue Normal file
View File

@ -0,0 +1,42 @@
<template>
<div class="page">
<div class="sidebar">
<div>
<NuxtLink class="link left-center" :class="{ active: useRoute().fullPath === '/app' }" to="/app"><UiIcon>home</UiIcon>Home</NuxtLink>
<NuxtLink class="link left-center" :class="{ active: useRoute().fullPath.startsWith('/app/artifacts') }" to="/app/artifacts"><UiIcon>box</UiIcon>Artifacts</NuxtLink>
</div>
<NuxtLink class="link left-center" :class="{ active: useRoute().fullPath.startsWith('/app/settings') }" to="/app/settings"><UiIcon>settings</UiIcon>Settings</NuxtLink>
</div>
<NuxtPage></NuxtPage>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.page {
display: grid;
grid-template-columns: auto 1fr;
width: 100%;
min-height: 100%;
}
.page > * {
padding: 1rem;
}
.sidebar {
background-color: var(--tile-color);
border-right: 1px solid #cddaff;
display: grid;
grid-template-rows: 1fr auto;
}
.link {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
}
.link.active, .link.active * {
background-color: #3c74ff;
color: white;
}
</style>

View File

@ -0,0 +1,75 @@
<template>
<div v-if="artifact" class="content-l">
<h1>Artifact</h1>
<div class="col-2">
<p class="tile-m">{{ artifact.groupId }}</p>
<p class="tile-m">{{ artifact.artifactId }}</p>
</div>
<div class="artifact-page">
<div class="content-m" v-if="selectedVersion">
<h2>Dependency</h2>
<div class="tile-m" v-if="dependency">
<Codebox :content="dependency"></Codebox>
</div>
<div class="content-m">
<h2>Files</h2>
<p class="tile-m" v-if="selectedVersion.jars" v-for="jar in selectedVersion.jars" @click="Download.download(jar.url)">{{ jar.filename }}</p>
<a class="tile-m" v-if="selectedVersion.pom" :href="MavenApi.defaults.baseURL + '/maven2/' + selectedVersion.pom.url" target="_blank">{{ selectedVersion.pom.filename }}</a>
</div>
</div>
<div class="content-m">
<h2>Versions</h2>
<p class="tile-m pointer" v-for="version in artifact.versions" :class="{ active: (selectedVersion != null && version.id === selectedVersion.id) }" @click="() => { selectedVersion = version }">{{ version.version }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {Artifact, Version} from "~/components/artifact/Artifact";
import Codebox from "~/components/artifact/Codebox.vue";
import {Download} from "~/utils/Download";
import {MavenApi} from "~/utils/MavenApi";
const artifact: Ref<Artifact | undefined> = ref(undefined);
const selectedVersion: Ref<Version | undefined> = ref(undefined);
onMounted(() => {
const id = useRoute().params.id as string;
Artifact.getById(id, (_artifact: Artifact) => {
artifact.value = _artifact;
const _version = _artifact.versions.at(0);
if (_version != null)
{
selectedVersion.value = _version;
}
})
})
const dependency = computed(() => {
if (selectedVersion.value)
{
const template = [
"<dependency>",
"\t<groupId>" + selectedVersion.value.groupId + "</groupId>",
"\t<artifactId>" + selectedVersion.value.artifactId + "</artifactId>",
"\t<version>" + selectedVersion.value.version + "</version>",
"</dependency>"
]
return template.join("\n").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
return undefined;
});
</script>
<style scoped>
.artifact-page {
display: grid;
grid-template-columns: 1fr 10rem;
gap: 1rem;
}
.active {
background-color: #3c74ff;
color: white;
border: none;
}
</style>

View File

@ -0,0 +1,14 @@
<template>
<div class="content-l">
<h1>Artifacts</h1>
<ArtifactList></ArtifactList>
</div>
</template>
<script setup lang="ts">
import ArtifactList from "~/components/artifact/ArtifactList.vue";
</script>
<style scoped>
</style>

15
app/pages/app/index.vue Normal file
View File

@ -0,0 +1,15 @@
<template>
<div class="content-l">
<h1>Home</h1>
<h2>Recent Events</h2>
<EventList></EventList>
</div>
</template>
<script setup lang="ts">
import EventList from "~/components/events/EventList.vue";
</script>
<style scoped>
</style>

View File

@ -0,0 +1,66 @@
<template>
<div class="content-l">
<h1>Account Settings</h1>
<div class="col-3">
<div class="tile-m">
<h3>Username</h3>
<DisplayNameComponent :account="session.user"></DisplayNameComponent>
</div>
<div class="tile-m">
<h3>E-Mail</h3>
<p>andreas.j.dinauer@gmail.com</p>
</div>
<div class="tile-m">
<h3>Password</h3>
<p>************</p>
</div>
</div>
<h1>Access Tokens</h1>
<div v-if="tokens">
<p v-if="tokens.length === 0" class="tile-m">No tokens created yet.</p>
<UiTable v-else columns="auto 1fr auto auto">
<HeaderRow>
<HeaderCell>Name</HeaderCell>
<HeaderCell>Created At</HeaderCell>
<HeaderCell>Expires At</HeaderCell>
<HeaderCell></HeaderCell>
</HeaderRow>
<ContentRow v-for="token in tokens">
<ContentCell>{{ token.name }}</ContentCell>
<ContentCell>{{ dayjs(token.createdAt).format("DD.MM.YYYY") }}</ContentCell>
<ContentCell>{{ dayjs(token.expiresAt).format("DD.MM.YYYY") }}</ContentCell>
<ContentCell style="padding: 0.25rem"><UiButton icon="delete"></UiButton></ContentCell>
</ContentRow>
</UiTable>
</div>
<div>
<UiButton @click="usePopup().open(new Popup(TokenCreationComponent, { size: PopupSize.MEDIUM, heading: 'Create Token', callback: (token: TokenSecret) => { tokens?.push(new Token(token.name, token.createdAt, token.expiresAt)) } }))">Create</UiButton>
</div>
</div>
</template>
<script setup lang="ts">
import DisplayNameComponent from "~/components/auth/DisplayNameComponent.vue";
import {Session} from "~/auth/Session";
import TokenCreationComponent from "~/components/settings/token/TokenCreationComponent.vue";
import {Popup, PopupSize, usePopup} from "~/components/ui/popup/Popup";
import {Token, type TokenSecret} from "~/auth/Token";
import HeaderCell from "~/components/ui/table/HeaderCell.vue";
import HeaderRow from "~/components/ui/table/HeaderRow.vue";
import ContentRow from "~/components/ui/table/ContentRow.vue";
import ContentCell from "~/components/ui/table/ContentCell.vue";
import dayjs from "dayjs";
const session = useCookie<Session>(Session.COOKIE);
const tokens: Ref<Token[] | undefined> = ref(undefined);
onMounted(() => {
Token.get((_tokens: Token[]) => {
tokens.value = _tokens;
})
})
</script>
<style scoped>
</style>

12
app/pages/index.vue Normal file
View File

@ -0,0 +1,12 @@
<template>
<LoginComponent></LoginComponent>
</template>
<script setup lang="ts">
import LoginComponent from "~/components/auth/LoginComponent.vue";
</script>
<style scoped>
</style>

17
app/server/download.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineEventHandler } from 'h3'
export default defineEventHandler(async (event) => {
if (event.context.params)
{
const filename = event.context.params.path;
const url = `http://localhost:8080/${filename}`
const res = await fetch(url)
const data = await res.arrayBuffer()
event.res.setHeader('Content-Type', res.headers.get('content-type') || 'application/octet-stream')
event.res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
return Buffer.from(data)
}
})

83
app/utils/Age.ts Normal file
View File

@ -0,0 +1,83 @@
export class Age
{
static calc(date: Date)
{
const today = new Date().getTime() / 1000;
const createdAt = new Date(date).getTime() / 1000;
const dif = today - createdAt;
if (dif < 60)
{
return Age.format(dif, ChronoUnit.SECOND) + " ago";
}
if (dif < 60 * 60)
{
const minutes = Math.floor((dif / 60));
return Age.format(minutes, ChronoUnit.MINUTE) + " ago";
}
if (dif < 60 * 60 * 24)
{
const hours = Math.floor(dif / (60 * 60));
return Age.format(hours, ChronoUnit.HOUR) + " ago";
}
else
{
const days = Math.floor(dif / (60 * 60 * 24));
return Age.format(days, ChronoUnit.DAY) + " ago";
}
}
static format(count: number, unit: ChronoUnit): string
{
if (unit === ChronoUnit.SECOND)
{
if (count != 1)
{
return count.toFixed(0) + " seconds";
}
else
{
return count.toFixed(0) + " second";
}
}
if (unit === ChronoUnit.MINUTE)
{
if (count != 1)
{
return count.toFixed(0) + " minutes";
}
else
{
return count.toFixed(0) + " minute";
}
}
if (unit === ChronoUnit.HOUR)
{
if (count != 1)
{
return count.toFixed(0) + " hours";
}
else
{
return count.toFixed(0) + " hour";
}
}
if (unit === ChronoUnit.DAY)
{
if (count != 1)
{
return count.toFixed(0) + " days";
}
else
{
return count.toFixed(0) + " day";
}
}
return "-";
}
}
enum ChronoUnit
{
SECOND, MINUTE, HOUR, DAY
}

19
app/utils/AuthApi.ts Normal file
View File

@ -0,0 +1,19 @@
import axios, {type AxiosInstance} from "axios";
import {Session} from "~/auth/Session";
export const AuthApi: AxiosInstance = function()
{
const instance: AxiosInstance = axios.create({
baseURL: 'http://localhost:8089/api/iam-backend'
});
instance.interceptors.request.use((config) =>
{
const session: Session = useCookie<Session>(Session.COOKIE).value
if (session)
{
config.headers["Authorization"] = StringUtils.format("Bearer %s", session.token)
}
return config;
})
return instance;
}();

12
app/utils/Download.ts Normal file
View File

@ -0,0 +1,12 @@
export class Download
{
static download(url: string)
{
const a = document.createElement("a");
a.style.display = "none";
a.href = "http://localhost:8080/maven2/" + url;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
}

19
app/utils/MavenApi.ts Normal file
View File

@ -0,0 +1,19 @@
import axios, {type AxiosInstance} from "axios";
import {Session} from "~/auth/Session";
export const MavenApi: AxiosInstance = function()
{
const instance: AxiosInstance = axios.create({
baseURL: 'http://localhost:8080'
});
instance.interceptors.request.use((config) =>
{
const session: Session = useCookie<Session>(Session.COOKIE).value
if (session)
{
config.headers["Authorization"] = StringUtils.format("Bearer %s", session.token)
}
return config;
})
return instance;
}();

36
app/utils/StringUtils.ts Normal file
View File

@ -0,0 +1,36 @@
export class StringUtils
{
static format(template: string, ...varContext: string[]): string
{
const context = Array.from(varContext);
let contextIndex = 0;
while (template.includes("%s"))
{
const currentContext = context.at(contextIndex);
if (currentContext != null)
{
template = template.replace("%s", currentContext);
contextIndex++;
}
else
{
return template;
}
}
return template;
}
static equals(first: string | null | undefined, second: string | null | undefined): boolean
{
return first === second;
}
static startsWith(input: string | null | undefined, start: string | null | undefined)
{
if (input != null && start != null)
{
return input.startsWith(start);
}
return undefined;
}
}

20
nuxt.config.ts Normal file
View File

@ -0,0 +1,20 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
css: [
'@/assets/style.css',
'@/assets/base-style.css'
],
app: {
head: {
link: [
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200' }
]
}
},
modules: ['@pinia/nuxt']
})

10162
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "frontend",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@pinia/nuxt": "^0.11.3",
"axios": "^1.13.2",
"dayjs": "^1.11.19",
"jwt-decode": "^4.0.0",
"nuxt": "^4.3.0",
"pinia": "^3.0.4",
"vue": "^3.5.27",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^25.1.0"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt Normal file
View File

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

18
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"
}
]
}