This commit is contained in:
purifetchi 2025-01-13 22:22:56 +01:00
parent 35ffaf3e48
commit 04a01788f8
22 changed files with 1814 additions and 76 deletions

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="">
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,11 @@
"type-check": "vue-tsc --build"
},
"dependencies": {
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"tailwindcss-primeui": "^0.4.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
@ -19,7 +24,10 @@
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.20",
"npm-run-all2": "^7.0.2",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.3",
"vite": "^6.0.5",
"vite-plugin-vue-devtools": "^7.6.8",

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -1,8 +1,10 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import Toast from 'primevue/toast';
</script>
<template>
<Toast />
<RouterView />
</template>

View file

@ -0,0 +1,14 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
@import 'primeicons/primeicons.css';
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
html, body, #app {
width: 100%;
height: 100%;
}
* {
font-family: "Inter", serif;
}

View file

@ -1,23 +1,46 @@
<script setup lang="ts">
import {onBeforeMount, ref} from "vue";
import Panel from 'primevue/panel';
import ProgressSpinner from 'primevue/progressspinner';
import type FileDto from "@/dto/file-dto.ts";
import FileListItem from "@/components/FileListItem.vue";
import {useToast} from "primevue/usetoast";
const files = ref<FileDto[]>([]);
const toast = useToast();
onBeforeMount(async () => {
await refresh();
});
const refresh = async () => {
files.value = [];
const items = await fetch('/api/file/all')
.then(i => i.json())
.then(j => j as FileDto[]);
files.value = items;
});
};
const onDeleted = async () => {
toast.add({
severity: "success",
detail: "File successfully deleted."
});
await refresh();
};
</script>
<template>
<div v-if="files.length > 0">
<FileListItem v-for="file of files" :item="file" :key="file.filename" />
</div>
<Panel header="Uploaded files">
<div v-if="files.length > 0">
<div class="inline-block m-3" v-for="file of files" :key="file.filename">
<FileListItem :item="file" @deleted="onDeleted" />
</div>
</div>
<ProgressSpinner v-else />
</Panel>
</template>
<style scoped>

View file

@ -1,11 +1,15 @@
<script setup lang="ts">
import type FileDto from "@/dto/file-dto.ts";
import Card from 'primevue/card';
import Button from 'primevue/button';
import {computed} from "vue";
const props = defineProps<{
item: FileDto
}>();
const emit = defineEmits(["deleted"]);
const url = computed(() => {
return `http://localhost:5118/f/${props.item.filename}`;
})
@ -15,15 +19,29 @@
method: 'GET',
credentials: 'include'
});
window.location.reload();
emit("deleted");
}
const download = () => {
location.href = url.value;
}
</script>
<template>
<div>{{ item.filename }} - {{ item.originalFilename }}</div>
<a :href="url">Download</a><br/>
<a href="#" v-on:click.prevent="removeItem">Delete</a>
<Card style="width: 25rem; overflow: hidden;">
<template #title>{{ item.originalFilename }}</template>
<template #content>
<p>Uploaded as {{ item.filename }}</p>
<p>Size in bytes: {{ item.sizeInBytes }}B</p>
</template>
<template #footer>
<div class="flex gap-4 mt-1">
<Button v-on:click.prevent="removeItem" label="Delete" severity="secondary" outlined class="w-full" />
<Button v-on:click.prevent="download" label="Download" class="w-full" />
</div>
</template>
</Card>
</template>
<style scoped>

View file

@ -0,0 +1,72 @@
<script setup lang="ts">
import Menubar from "primevue/menubar";
import Popover from "primevue/popover";
import Button from "primevue/button";
import Avatar from "primevue/avatar";
import Divider from "primevue/divider";
import {computed, onMounted, ref} from "vue";
import type {MenuItem} from "primevue/menuitem";
import type {UserInfoDto} from "@/dto/user-info-dto.ts";
import {getUserInfo} from "@/helpers/api.ts";
const info = ref<UserInfoDto>({
username: "[loading]",
avatar: null
});
onMounted(async () => {
info.value = await getUserInfo();
})
const letter = computed(() => {
return info.value.username[0].toLowerCase();
});
const logout = async () => {
await fetch('/api/user/logout');
window.location.reload();
};
const onAvatarClicked = (event: MouseEvent) => {
op.value.toggle(event);
}
const op = ref();
const items: MenuItem[] = [
{
label: "Home",
url: "/",
icon: "pi pi-cloud-upload"
},
{
label: "Settings",
url: "/settings",
icon: "pi pi-folder-open"
}
];
</script>
<template>
<Menubar :model="items">
<template #start>
<div class="font-black">Mizuki</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<Avatar @click="onAvatarClicked" shape="circle" :label="letter" />
<Popover ref="op">
<p>Logged in as {{ info.username }}</p>
<Divider />
<Button @click="logout" severity="secondary" label="Log out" icon="pi pi-sign-out" />
</Popover>
</div>
</template>
</Menubar>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,80 @@
<script setup lang="ts">
import Message from "primevue/message";
import Password from "primevue/password";
import {Form, type FormResolverOptions, type FormSubmitEvent} from "@primevue/forms";
import Button from "primevue/button";
import Divider from "primevue/divider";
const formResolver = (ev: FormResolverOptions): Record<string, any> => {
const resp = {
errors: {
CurrentPassword: [],
Password: [],
RepeatPassword: []
}
};
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@#$%^&*!])[A-Za-z\d@#$%^&*!]{8,}$/;
if ("CurrentPassword" in ev.values && ev.values["CurrentPassword"] === undefined || !ev.values["CurrentPassword"].match(regex)) {
resp.errors["Password"] = ["Current password doesn't meet requirements."];
}
if ("Password" in ev.values && ev.values["Password"] === undefined || !ev.values["Password"].match(regex)) {
resp.errors["Password"] = ["New password doesn't meet requirements."];
}
if ("RepeatPassword" in ev.values && ev.values["RepeatPassword"] === undefined || ev.values["RepeatPassword"] != ev.values["Password"]) {
resp.errors["RepeatPassword"] = ["Invalid repeat password."];
}
return resp;
};
const onFormSubmit = (event: FormSubmitEvent) => {
if (!event.valid) {
} else {
}
};
</script>
<template>
<div>
<Form v-slot="$form" :resolver="formResolver" :validateOnValueUpdate="true" :validateOnBlur="true" @submit="onFormSubmit" class="flex flex-col space-y-2">
<div class="flex flex-col gap-1">
<Password name="CurrentPassword" type="text" placeholder="Current Password" toggleMask required />
<Message v-if="$form.CurrentPassword?.invalid" severity="error" size="small" variant="simple">{{ $form.CurrentPassword.error }}</Message>
</div>
<div class="flex flex-col gap-1">
<Password name="Password" type="text" placeholder="Password" toggleMask required>
<template #header>
<h6>Pick a password</h6>
</template>
<template #footer>
<Divider />
<p class="mt-2">Requirements</p>
<ul class="pl-2 ml-2 mt-0" style="line-height: 1.5">
<li>At least one lowercase</li>
<li>At least one uppercase</li>
<li>At least one numeric</li>
<li>At least one of @#$%^&*!</li>
<li>Minimum 8 characters</li>
</ul>
</template>
</Password>
<Message v-if="$form.Password?.invalid" severity="error" size="small" variant="simple">{{ $form.Password.error }}</Message>
</div>
<div class="flex flex-col gap-1">
<Password name="RepeatPassword" type="text" placeholder="Repeat Password" toggleMask required />
<Message v-if="$form.RepeatPassword?.invalid" severity="error" size="small" variant="simple">{{ $form.RepeatPassword.error }}</Message>
</div>
<Button type="submit" severity="secondary" label="Change password" />
</Form>
</div>
</template>
<style scoped>
</style>

View file

@ -1,35 +1,138 @@
<script setup lang="ts">
const sendData = async () => {
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
// noinspection JSIncompatibleTypesComparison
if (input.files === null || input.files.length < 1) {
return;
}
const data = new FormData();
data.append('formFile', input.files[0]);
const resp = await fetch('/api/file/upload', {
method: 'POST',
body: data,
credentials: 'include'
import FileUpload, {type FileUploadUploadEvent} from 'primevue/fileupload';
import Button from 'primevue/button';
import Badge from 'primevue/badge';
import Message from 'primevue/message';
import Dialog from 'primevue/dialog';
import {ref} from "vue";
import {useToast} from "primevue/usetoast";
import ProgressBar from 'primevue/progressbar';
import type {FileCreationResultDto} from "@/dto/file-creation-result-dto.ts";
import type {FileCreationErrorDto} from "@/dto/file-creation-error-dto.ts";
const totalSize = ref(0);
const totalSizePercent = ref(0);
const files = ref([]);
const visible = ref(false);
const toast = useToast();
const filename = ref<string>("");
const onRemoveTemplatingFile = (file, removeFileCallback, index) => {
removeFileCallback(index);
totalSize.value -= parseInt(formatSize(file.size));
totalSizePercent.value = totalSize.value / 10;
};
const onClearTemplatingUpload = (clear) => {
clear();
totalSize.value = 0;
totalSizePercent.value = 0;
};
const onSelectedFiles = (event) => {
files.value = event.files;
files.value.forEach((file) => {
totalSize.value += parseInt(formatSize(file.size));
});
const json = resp.json();
if ('filename' in json) {
alert(`Your file is able to be downloaded at https://localhost:5881/f/${json.filename}!`);
} else if ('reason' in json) {
alert(json.reason);
};
const uploadEvent = (callback) => {
totalSizePercent.value = totalSize.value / 10;
callback();
};
const onTemplatedUpload = (ev : FileUploadUploadEvent) => {
const resp = JSON.parse(ev.xhr.responseText) as FileCreationResultDto | FileCreationErrorDto;
if ('filename' in resp) {
filename.value = resp.filename;
visible.value = true;
} else if ('reason' in resp) {
toast.add({
severity: "error",
detail: `There was an error uploading your file: ${resp.reason}`,
})
}
};
const formatSize = (bytes) => {
const k = 1024;
const dm = 3;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
if (bytes === 0) {
return `0 ${sizes[0]}`;
}
const i = Math.floor(Math.log(bytes) / Math.log(k));
const formattedSize = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
return `${formattedSize} ${sizes[i]}`;
};
</script>
<template>
<div>
<input type="file" name="formFile" />
<input type="submit" v-on:click.prevent="sendData" />
</div>
<FileUpload name="formFile" url="/api/file/upload" @upload="onTemplatedUpload($event)" :multiple="false" :maxFileSize="52428800">
<template #header="{ chooseCallback, uploadCallback, clearCallback, files }">
<div class="flex flex-wrap justify-between items-center flex-1 gap-4">
<div class="flex gap-2">
<Button @click="chooseCallback()" icon="pi pi-images" rounded outlined severity="secondary"></Button>
<Button @click="uploadEvent(uploadCallback)" icon="pi pi-cloud-upload" rounded outlined severity="success" :disabled="!files || files.length === 0"></Button>
<Button @click="clearCallback()" icon="pi pi-times" rounded outlined severity="danger" :disabled="!files || files.length === 0"></Button>
</div>
<ProgressBar :value="totalSizePercent" :showValue="false" class="md:w-20rem h-1 w-full md:ml-auto">
<span class="whitespace-nowrap">{{ totalSize }}B / 1Mb</span>
</ProgressBar>
</div>
</template>
<template #content="{ files, uploadedFiles, removeUploadedFileCallback, removeFileCallback, messages }">
<div class="flex flex-col gap-8 pt-4">
<Message v-for="message of messages" :key="message" :class="{ 'mb-8': !files.length && !uploadedFiles.length}" severity="error">
{{ message }}
</Message>
<div v-if="files.length > 0">
<h5>Pending</h5>
<div class="flex flex-wrap gap-4">
<div v-for="(file, index) of files" :key="file.name + file.type + file.size" class="p-8 rounded-border flex flex-col border border-surface items-center gap-4">
<span class="font-semibold text-ellipsis max-w-60 whitespace-nowrap overflow-hidden">{{ file.name }}</span>
<div>{{ formatSize(file.size) }}</div>
<Badge value="Pending" severity="warn" />
<Button icon="pi pi-times" @click="onRemoveTemplatingFile(file, removeFileCallback, index)" outlined rounded severity="danger" />
</div>
</div>
</div>
<div v-if="uploadedFiles.length > 0">
<h5>Completed</h5>
<div class="flex flex-wrap gap-4">
<div v-for="(file, index) of uploadedFiles" :key="file.name + file.type + file.size" class="p-8 rounded-border flex flex-col border border-surface items-center gap-4">
<span class="font-semibold text-ellipsis max-w-60 whitespace-nowrap overflow-hidden">{{ file.name }}</span>
<div>{{ formatSize(file.size) }}</div>
<Badge value="Completed" class="mt-4" severity="success" />
<Button icon="pi pi-times" @click="removeUploadedFileCallback(index)" outlined rounded severity="danger" />
</div>
</div>
</div>
</div>
</template>
<template #empty>
<div class="flex items-center justify-center flex-col">
<i class="pi pi-cloud-upload !border-2 !rounded-full !p-8 !text-4xl !text-muted-color" />
<p class="mt-6 mb-0">Drag and drop files to here to upload.</p>
</div>
</template>
</FileUpload>
<Dialog v-model:visible="visible" modal header="Success!" :style="{ width: '30rem' }">
<div class="space-y-3">
<p>Your file has been uploaded and is now accessible at:</p>
<div class="p-5 bg-neutral-950 text-gray-50 rounded-2xl font-bold ">
https://localhost:5118/f/{{ filename }}
</div>
<Button label="Click to copy" class="float-right"/>
</div>
</Dialog>
</template>
<style scoped>

View file

@ -0,0 +1,3 @@
export interface FileCreationErrorDto {
reason: string
}

View file

@ -0,0 +1,3 @@
export interface FileCreationResultDto {
filename: string
}

View file

@ -0,0 +1,4 @@
export interface UserInfoDto {
username: string,
avatar: string | null
}

View file

@ -1,3 +1,5 @@
import type {UserInfoDto} from "@/dto/user-info-dto.ts";
export const checkIfLoggedIn = async () => {
try {
const resp = await fetch("/api/user/check", {
@ -8,4 +10,12 @@ export const checkIfLoggedIn = async () => {
} catch {
return false;
}
};
export const getUserInfo = async () => {
const resp = await fetch("/api/user/info", {
redirect: 'error',
credentials: 'include'
});
return await resp.json() as unknown as UserInfoDto;
};

View file

@ -3,9 +3,21 @@ import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import PrimeVue from 'primevue/config';
import ToastService from 'primevue/toastservice';
import Aura from '@primevue/themes/aura';
const app = createApp(App)
app.use(router)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: '.my-app-dark',
}
}
});
app.use(ToastService);
app.mount('#app')

View file

@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import LoginView from "@/views/LoginView.vue";
import RegisterView from "@/views/RegisterView.vue";
import SettingsView from "@/views/SettingsView.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -11,6 +12,11 @@ const router = createRouter({
name: 'home',
component: HomeView,
},
{
path: '/settings',
name: 'settings',
component: SettingsView,
},
{
path: '/login',
name: 'login',

View file

@ -1,30 +1,32 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { onBeforeMount } from "vue";
import { checkIfLoggedIn } from "@/helpers/api.ts";
import { useRouter } from "vue-router";
import Uploader from "@/components/Uploader.vue";
import FileList from "@/components/FileList.vue";
import Menu from "@/components/Menu.vue"
const router = useRouter();
onMounted(async () => {
onBeforeMount(async () => {
const loggedIn = await checkIfLoggedIn();
if (!loggedIn) {
await router.push('/login');
return;
}
});
const logout = async () => {
await fetch('/api/user/logout');
window.location.reload();
};
</script>
<template>
<FileList />
<Uploader />
<a href="#" v-on:click.prevent="logout">Log out</a>
<div class="p-5 space-y-5 h-full">
<Menu />
<div>
<Uploader />
</div>
<div>
<FileList />
</div>
</div>
</template>
<style scoped>

View file

@ -1,11 +1,19 @@
<script setup lang="ts">
import { onBeforeMount, ref } from "vue";
import {onBeforeMount, onMounted, ref} from "vue";
import { checkIfLoggedIn } from "@/helpers/api.ts";
import { useRouter } from "vue-router";
import {useRoute, useRouter} from "vue-router";
import Button from "primevue/button";
import Password from 'primevue/password';
import InputText from 'primevue/inputtext';
import {Form, type FormSubmitEvent} from '@primevue/forms';
import {useToast} from "primevue/usetoast";
const router = useRouter();
const loggedIn = ref<boolean>(true);
const toast = useToast();
const route = useRoute();
onBeforeMount(async () => {
loggedIn.value = await checkIfLoggedIn();
if (loggedIn.value) {
@ -13,14 +21,37 @@ onBeforeMount(async () => {
return;
}
});
onMounted(() => {
if ("error" in route.query) {
toast.add({
severity: "error",
detail: route.query.error
});
}
});
const onFormSubmit = (ev: FormSubmitEvent) => {
if (ev.valid) {
const form = ev.originalEvent.target as HTMLFormElement;
form.method = "POST";
form.action = "/api/user/login";
form.submit();
}
};
</script>
<template>
<form v-if="!loggedIn" action="/api/user/login" method="post">
<span>Username</span> <input type="text" name="Username" /><br />
<span>Password</span> <input type="password" name="Password" />
<input type="submit" value="Login" />
</form>
<div class="w-full h-full flex flex-col justify-center items-center space-y-3">
<h1 class="text-8xl font-extrabold text-amber-500">Mizuki</h1>
<Form v-slot="$form" @submit="onFormSubmit" class="flex flex-col space-y-2">
<InputText name="Username" type="text" placeholder="Username" required /><br/>
<Password name="Password" type="text" placeholder="Password" :feedback="false" required /><br/>
<Button type="submit" severity="secondary" label="Login" />
<div class="text-gray-500 text-sm">Don't have an account? <a href="/register" class="text-amber-600 hover:underline">Register here!</a></div>
</Form>
</div>
</template>
<style scoped>

View file

@ -2,6 +2,15 @@
import { onBeforeMount, ref } from "vue";
import { checkIfLoggedIn } from "@/helpers/api.ts";
import { useRouter } from "vue-router";
import Password from "primevue/password";
import {Form, type FormResolverOptions, type FormSubmitEvent} from "@primevue/forms";
import InputText from "primevue/inputtext";
import Button from "primevue/button";
import Message from "primevue/message";
import Divider from "primevue/divider";
import { useToast } from 'primevue/usetoast';
const toast = useToast();
const router = useRouter();
const loggedIn = ref<boolean>(true);
@ -13,15 +22,87 @@ onBeforeMount(async () => {
return;
}
});
const onFormSubmit = (ev: FormSubmitEvent) => {
if (!ev.valid) {
toast.add({
severity: "error",
detail: "Invalid data supplied."
});
} else {
const form = ev.originalEvent.target as HTMLFormElement;
form.method = "POST";
form.action = "/api/user/register";
form.submit();
}
};
const formResolver = (ev: FormResolverOptions): Record<string, any> => {
const resp = {
errors: {
Username: [],
Password: [],
RepeatPassword: []
}
};
const usernameRegex = /^[a-zA-Z0-9_.]*$/;
if ("Username" in ev.values) {
if (ev.values["Username"] === undefined) {
resp.errors["Username"] = ["Username cannot be blank."];
} else if (!ev.values["Username"].match(usernameRegex)) {
resp.errors["Username"] = ["Username contains illegal characters."];
}
}
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@#$%^&*!])[A-Za-z\d@#$%^&*!]{8,}$/;
if ("Password" in ev.values && ev.values["Password"] === undefined || !ev.values["Password"].match(regex)) {
resp.errors["Password"] = ["Password doesn't meet requirements."];
}
if ("RepeatPassword" in ev.values && ev.values["RepeatPassword"] === undefined || ev.values["RepeatPassword"] != ev.values["Password"]) {
resp.errors["RepeatPassword"] = ["Invalid repeat password."];
}
return resp;
};
</script>
<template>
<form v-if="!loggedIn" method="post" action="/api/user/register">
<span>Username</span> <input type="text" name="Username" /><br />
<span>Password</span> <input type="password" name="Password" /><br />
<span>Repeat Password</span> <input type="password" name="ValidatePassword" /><br />
<input type="submit" value="Register" />
</form>
<div class="w-full h-full flex flex-col justify-center items-center space-y-3">
<h1 class="text-7xl font-extrabold text-amber-500">Register</h1>
<Form v-slot="$form" :resolver="formResolver" :validateOnValueUpdate="true" :validateOnBlur="true" @submit="onFormSubmit" class="flex flex-col space-y-2">
<div class="flex flex-col gap-1">
<InputText name="Username" type="text" placeholder="Username" fluid required />
<Message v-if="$form.Username?.invalid" severity="error" size="small" variant="simple">{{ $form.Username.error }}</Message>
</div>
<div class="flex flex-col gap-1">
<Password name="Password" type="text" placeholder="Password" toggleMask required>
<template #header>
<h6>Pick a password</h6>
</template>
<template #footer>
<Divider />
<p class="mt-2">Requirements</p>
<ul class="pl-2 ml-2 mt-0" style="line-height: 1.5">
<li>At least one lowercase</li>
<li>At least one uppercase</li>
<li>At least one numeric</li>
<li>At least one of @#$%^&*!</li>
<li>Minimum 8 characters</li>
</ul>
</template>
</Password>
<Message v-if="$form.Password?.invalid" severity="error" size="small" variant="simple">{{ $form.Password.error }}</Message>
</div>
<div class="flex flex-col gap-1">
<Password name="RepeatPassword" type="text" placeholder="Repeat Password" toggleMask required />
<Message v-if="$form.RepeatPassword?.invalid" severity="error" size="small" variant="simple">{{ $form.RepeatPassword.error }}</Message>
</div>
<Button type="submit" severity="secondary" label="Register" />
<div class="text-gray-500 text-sm">Already have an account? <a href="/login" class="text-amber-600 hover:underline">Log in here!</a></div>
</Form>
</div>
</template>
<style scoped>

View file

@ -0,0 +1,34 @@
<script setup lang="ts">
import Menu from "@/components/Menu.vue";
import {useRouter} from "vue-router";
import {onBeforeMount} from "vue";
import {checkIfLoggedIn} from "@/helpers/api.ts";
import Panel from "primevue/panel";
import Fieldset from 'primevue/fieldset';
import PasswordChanger from "@/components/PasswordChanger.vue";
const router = useRouter();
onBeforeMount(async () => {
const loggedIn = await checkIfLoggedIn();
if (!loggedIn) {
await router.push('/login');
return;
}
});
</script>
<template>
<div class="p-5 space-y-5 h-full">
<Menu />
<Panel header="Settings" class="h-[85vh]">
<Fieldset legend="Password" class="w-max">
<PasswordChanger />
</Fieldset>
</Panel>
</div>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'
],
theme: {
extend: {},
},
plugins: [require('tailwindcss-primeui')],
}