chore: cleanup (#27)

* implement password score UI and functions

* update strings tests to use `test`instead of `it`

* update typing

* refactor login/register UI+Logic

* fix width on switches to properly display

* fetch and store self in store

* (WIP) unify card styles

* update labels page

* bump nuxt

* use form area

* use text area for description

* unify confirm API

* unify UI around pages

* change header background height
This commit is contained in:
Hayden 2022-09-25 14:33:13 -08:00 committed by GitHub
parent b34cb2bbeb
commit 2e82398e5c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1313 additions and 1934 deletions

View file

@ -64,7 +64,7 @@
<LabelCreateModal v-model="modals.label" /> <LabelCreateModal v-model="modals.label" />
<LocationCreateModal v-model="modals.location" /> <LocationCreateModal v-model="modals.location" />
<div class="bg-neutral absolute shadow-xl top-0 h-[50vh] max-h-96 sm:h-[28vh] -z-10 w-full"></div> <div class="bg-neutral absolute shadow-xl top-0 h-[20rem] max-h-96 -z-10 w-full"></div>
<BaseContainer cmp="header" class="py-6 max-w-none"> <BaseContainer cmp="header" class="py-6 max-w-none">
<BaseContainer> <BaseContainer>

View file

@ -0,0 +1,16 @@
<template>
<div class="card bg-base-100 shadow-xl sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg font-medium leading-6">
<slot name="title"></slot>
</h3>
<p v-if="$slots.subtitle" class="mt-1 max-w-2xl text-sm text-gray-500">
<slot name="subtitle"></slot>
</p>
<template v-if="$slots['title-actions']">
<slot name="title-actions"></slot>
</template>
</div>
<slot />
</div>
</template>

View file

@ -9,7 +9,7 @@
:autofocus="true" :autofocus="true"
label="Label Name" label="Label Name"
/> />
<FormTextField v-model="form.description" label="Label Description" /> <FormTextArea v-model="form.description" label="Label Description" />
<div class="modal-action"> <div class="modal-action">
<BaseButton type="submit" :loading="loading"> Create </BaseButton> <BaseButton type="submit" :loading="loading"> Create </BaseButton>
</div> </div>

View file

@ -9,7 +9,7 @@
:autofocus="true" :autofocus="true"
label="Location Name" label="Location Name"
/> />
<FormTextField v-model="form.description" label="Location Description" /> <FormTextArea v-model="form.description" label="Location Description" />
<div class="modal-action"> <div class="modal-action">
<BaseButton type="submit" :loading="loading"> Create </BaseButton> <BaseButton type="submit" :loading="loading"> Create </BaseButton>
</div> </div>

View file

@ -0,0 +1,32 @@
<template>
<div class="border-t border-gray-300 px-4 py-5 sm:p-0">
<dl class="sm:divide-y sm:divide-gray-300">
<div v-for="(detail, i) in details" :key="i" class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">
{{ detail.name }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<slot :name="detail.slot || detail.name" v-bind="{ detail }">
<template v-if="detail.type == 'date'">
<DateTime :date="detail.text" />
</template>
<template v-else>
{{ detail.text }}
</template>
</slot>
</dd>
</div>
</dl>
</div>
</template>
<script setup lang="ts">
import type { DateDetail, Detail } from "./types";
defineProps({
details: {
type: Object as () => (Detail | DateDetail)[],
required: true,
},
});
</script>

View file

@ -0,0 +1,2 @@
import DetailsSection from "./DetailsSection.vue";
export default DetailsSection;

View file

@ -0,0 +1,15 @@
export type StringLike = string | number | boolean;
export type DateDetail = {
name: string;
text: string | Date;
slot?: string;
type: "date";
};
export type Detail = {
name: string;
text: StringLike;
slot?: string;
type?: "text";
};

View file

@ -0,0 +1,40 @@
<template>
<div class="py-4">
<p class="text-sm">Password Strength: {{ message }}</p>
<progress
class="progress w-full progress-bar"
:value="score"
max="100"
:class="{
'progress-success': score > 50,
'progress-warning': score > 25 && score < 50,
'progress-error': score < 25,
}"
/>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
password: {
type: String,
required: true,
},
valid: {
type: Boolean,
required: false,
},
});
const emits = defineEmits(["update:valid"]);
const { password } = toRefs(props);
const { score, message, isValid } = usePasswordScore(password);
watchEffect(() => {
emits("update:valid", isValid.value);
});
</script>
<style scoped></style>

View file

@ -1,10 +1,11 @@
import { UseConfirmDialogReturn } from "@vueuse/core"; import { UseConfirmDialogRevealResult, UseConfirmDialogReturn } from "@vueuse/core";
import { Ref } from "vue"; import { Ref } from "vue";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type Store = UseConfirmDialogReturn<any, boolean, boolean> & { type Store = UseConfirmDialogReturn<any, boolean, boolean> & {
text: Ref<string>; text: Ref<string>;
setup: boolean; setup: boolean;
open: (text: string) => Promise<UseConfirmDialogRevealResult<boolean, boolean>>;
}; };
const store: Partial<Store> = { const store: Partial<Store> = {
@ -30,13 +31,13 @@ export function useConfirm(): Store {
store.cancel = cancel; store.cancel = cancel;
} }
async function openDialog(msg: string) { async function openDialog(msg: string): Promise<UseConfirmDialogRevealResult<boolean, boolean>> {
store.text.value = msg; store.text.value = msg;
return await store.reveal(); return await store.reveal();
} }
return { return {
...(store as Store), ...(store as Store),
reveal: openDialog, open: openDialog,
}; };
} }

View file

@ -0,0 +1,37 @@
import type { ComputedRef, Ref } from "vue";
import { scorePassword } from "~~/lib/passwords";
export interface PasswordScore {
score: ComputedRef<number>;
message: ComputedRef<string>;
isValid: ComputedRef<boolean>;
}
export function usePasswordScore(pw: Ref<string>, min = 30): PasswordScore {
const score = computed(() => {
return scorePassword(pw.value) || 0;
});
const message = computed(() => {
if (score.value < 20) {
return "Very weak";
} else if (score.value < 40) {
return "Weak";
} else if (score.value < 60) {
return "Good";
} else if (score.value < 80) {
return "Strong";
}
return "Very strong";
});
const isValid = computed(() => {
return score.value >= min;
});
return {
score,
isValid,
message,
};
}

View file

@ -2,19 +2,13 @@ import { BaseAPI, route } from "./base";
import { ItemsApi } from "./classes/items"; import { ItemsApi } from "./classes/items";
import { LabelsApi } from "./classes/labels"; import { LabelsApi } from "./classes/labels";
import { LocationsApi } from "./classes/locations"; import { LocationsApi } from "./classes/locations";
import { UserOut } from "./types/data-contracts";
import { Requests } from "~~/lib/requests"; import { Requests } from "~~/lib/requests";
export type Result<T> = { export type Result<T> = {
item: T; item: T;
}; };
export type User = {
name: string;
email: string;
isSuperuser: boolean;
id: number;
};
export class UserApi extends BaseAPI { export class UserApi extends BaseAPI {
locations: LocationsApi; locations: LocationsApi;
labels: LabelsApi; labels: LabelsApi;
@ -30,7 +24,7 @@ export class UserApi extends BaseAPI {
} }
public self() { public self() {
return this.http.get<Result<User>>({ url: route("/users/self") }); return this.http.get<Result<UserOut>>({ url: route("/users/self") });
} }
public logout() { public logout() {

View file

@ -0,0 +1,30 @@
import { describe, test, expect } from "vitest";
import { scorePassword } from ".";
describe("scorePassword tests", () => {
test("flagged words should return negative number", () => {
const flaggedWords = ["password", "homebox", "admin", "qwerty", "login"];
for (const word of flaggedWords) {
expect(scorePassword(word)).toBe(0);
}
});
test("should return 0 for empty string", () => {
expect(scorePassword("")).toBe(0);
});
test("should return 0 for strings less than 6", () => {
expect(scorePassword("12345")).toBe(0);
});
test("should return positive number for long string", () => {
const result = expect(scorePassword("123456"));
result.toBeGreaterThan(0);
result.toBeLessThan(31);
});
test("should return max number for long string with all variations", () => {
expect(scorePassword("3bYWcfYOwqxljqeOmQXTLlBwkrH6HV")).toBe(100);
});
});

View file

@ -0,0 +1,45 @@
const flaggedWords = ["password", "homebox", "admin", "qwerty", "login"];
/**
* scorePassword returns a score for a given password between 0 and 100.
* if a password contains a flagged word, it returns 0.
* @param pass
* @returns
*/
export function scorePassword(pass: string): number {
let score = 0;
if (!pass) return score;
if (pass.length < 6) return score;
// Check for flagged words
for (const word of flaggedWords) {
if (pass.toLowerCase().includes(word)) {
return 0;
}
}
// award every unique letter until 5 repetitions
const letters: { [key: string]: number } = {};
for (let i = 0; i < pass.length; i++) {
letters[pass[i]] = (letters[pass[i]] || 0) + 1;
score += 5.0 / letters[pass[i]];
}
// bonus points for mixing it up
const variations: { [key: string]: boolean } = {
digits: /\d/.test(pass),
lower: /[a-z]/.test(pass),
upper: /[A-Z]/.test(pass),
nonWords: /\W/.test(pass),
};
let variationCount = 0;
for (const check in variations) {
variationCount += variations[check] === true ? 1 : 0;
}
score += (variationCount - 1) * 10;
return Math.max(Math.min(score, 100), 0);
}

View file

@ -1,56 +1,56 @@
import { describe, it, expect } from "vitest"; import { describe, test, expect } from "vitest";
import { titlecase, capitalize, truncate } from "."; import { titlecase, capitalize, truncate } from ".";
describe("title case tests", () => { describe("title case tests", () => {
it("should return the same string if it's already title case", () => { test("should return the same string if it's already title case", () => {
expect(titlecase("Hello World")).toBe("Hello World"); expect(titlecase("Hello World")).toBe("Hello World");
}); });
it("should title case a lower case word", () => { test("should title case a lower case word", () => {
expect(titlecase("hello")).toBe("Hello"); expect(titlecase("hello")).toBe("Hello");
}); });
it("should title case a sentence", () => { test("should title case a sentence", () => {
expect(titlecase("hello world")).toBe("Hello World"); expect(titlecase("hello world")).toBe("Hello World");
}); });
it("should title case a sentence with multiple words", () => { test("should title case a sentence with multiple words", () => {
expect(titlecase("hello world this is a test")).toBe("Hello World This Is A Test"); expect(titlecase("hello world this is a test")).toBe("Hello World This Is A Test");
}); });
}); });
describe("capitilize tests", () => { describe("capitilize tests", () => {
it("should return the same string if it's already capitalized", () => { test("should return the same string if it's already capitalized", () => {
expect(capitalize("Hello")).toBe("Hello"); expect(capitalize("Hello")).toBe("Hello");
}); });
it("should capitalize a lower case word", () => { test("should capitalize a lower case word", () => {
expect(capitalize("hello")).toBe("Hello"); expect(capitalize("hello")).toBe("Hello");
}); });
it("should capitalize a sentence", () => { test("should capitalize a sentence", () => {
expect(capitalize("hello world")).toBe("Hello world"); expect(capitalize("hello world")).toBe("Hello world");
}); });
it("should capitalize a sentence with multiple words", () => { test("should capitalize a sentence with multiple words", () => {
expect(capitalize("hello world this is a test")).toBe("Hello world this is a test"); expect(capitalize("hello world this is a test")).toBe("Hello world this is a test");
}); });
}); });
describe("truncase tests", () => { describe("truncase tests", () => {
it("should return the same string if it's already truncated", () => { test("should return the same string if it's already truncated", () => {
expect(truncate("Hello", 5)).toBe("Hello"); expect(truncate("Hello", 5)).toBe("Hello");
}); });
it("should truncate a lower case word", () => { test("should truncate a lower case word", () => {
expect(truncate("hello", 3)).toBe("hel..."); expect(truncate("hello", 3)).toBe("hel...");
}); });
it("should truncate a sentence", () => { test("should truncate a sentence", () => {
expect(truncate("hello world", 5)).toBe("hello..."); expect(truncate("hello world", 5)).toBe("hello...");
}); });
it("should truncate a sentence with multiple words", () => { test("should truncate a sentence with multiple words", () => {
expect(truncate("hello world this is a test", 10)).toBe("hello worl..."); expect(truncate("hello world this is a test", 10)).toBe("hello worl...");
}); });
}); });

View file

@ -20,7 +20,7 @@
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.4.0", "eslint-plugin-vue": "^9.4.0",
"isomorphic-fetch": "^3.0.0", "isomorphic-fetch": "^3.0.0",
"nuxt": "3.0.0-rc.8", "nuxt": "3.0.0-rc.11",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"typescript": "^4.8.3", "typescript": "^4.8.3",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",

View file

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAuthStore } from "~~/stores/auth";
import { useItemStore } from "~~/stores/items"; import { useItemStore } from "~~/stores/items";
import { useLabelStore } from "~~/stores/labels"; import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations"; import { useLocationStore } from "~~/stores/locations";
@ -12,6 +13,19 @@
const api = useUserApi(); const api = useUserApi();
const auth = useAuthStore();
if (auth.self === null) {
const { data, error } = await api.self();
if (error) {
navigateTo("/login");
}
auth.$patch({ self: data.item });
console.log(auth.self);
}
const itemsStore = useItemStore(); const itemsStore = useItemStore();
const items = computed(() => itemsStore.items); const items = computed(() => itemsStore.items);
@ -87,7 +101,7 @@
</script> </script>
<template> <template>
<BaseContainer class="space-y-16 pb-16"> <div>
<BaseModal v-model="importDialog"> <BaseModal v-model="importDialog">
<template #title> Import CSV File </template> <template #title> Import CSV File </template>
<p> <p>
@ -98,6 +112,7 @@
<form @submit.prevent="submitCsvFile"> <form @submit.prevent="submitCsvFile">
<div class="flex flex-col gap-2 py-6"> <div class="flex flex-col gap-2 py-6">
<input ref="importRef" type="file" class="hidden" accept=".csv" @change="setFile" /> <input ref="importRef" type="file" class="hidden" accept=".csv" @change="setFile" />
<BaseButton type="button" @click="uploadCsv"> <BaseButton type="button" @click="uploadCsv">
<Icon class="h-5 w-5 mr-2" name="mdi-upload" /> <Icon class="h-5 w-5 mr-2" name="mdi-upload" />
Upload Upload
@ -112,69 +127,58 @@
</div> </div>
</form> </form>
</BaseModal> </BaseModal>
<BaseContainer class="flex flex-col gap-16 pb-16">
<section aria-labelledby="profile-overview-title" class="mt-8"> <section>
<div class="overflow-hidden rounded-lg bg-white shadow"> <BaseCard>
<h2 id="profile-overview-title" class="sr-only">Profile Overview</h2> <template #title> Welcome Back, {{ auth.self ? auth.self.name : "Username" }} </template>
<div class="bg-white p-6"> <template #subtitle> {{ auth.self.isSuperuser ? "Admin" : "User" }} </template>
<div class="sm:flex sm:items-center sm:justify-between"> <template #title-actions>
<div class="sm:flex sm:space-x-5"> <div class="flex justify-end gap-2">
<div class="mt-4 text-center sm:mt-0 sm:pt-1 sm:text-left"> <div class="tooltip" data-tip="Import CSV File">
<p class="text-sm font-medium text-gray-600">Welcome back,</p> <button class="btn btn-primary btn-sm" @click="openDialog">
<p class="text-xl font-bold text-gray-900 sm:text-2xl">Username</p> <Icon name="mdi-database" class="mr-2"></Icon>
<p class="text-sm font-medium text-gray-600">User</p> Import
</button>
</div> </div>
<BaseButton type="button" size="sm">
<Icon class="h-5 w-5 mr-2" name="mdi-person" />
Profile
</BaseButton>
</div> </div>
<div class="mt-5 flex justify-center sm:mt-0"> </template>
<a
href="#" <div
class="flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50" class="grid grid-cols-1 divide-y divide-gray-300 border-t border-gray-300 sm:grid-cols-3 sm:divide-y-0 sm:divide-x"
>View profile</a >
> <div v-for="stat in stats" :key="stat.label" class="px-6 py-5 text-center text-sm font-medium">
<span class="text-gray-900">{{ stat.value.value }}</span>
{{ " " }}
<span class="text-gray-600">{{ stat.label }}</span>
</div> </div>
</div> </div>
</BaseCard>
</section>
<section>
<BaseSectionHeader class="mb-5"> Labels </BaseSectionHeader>
<div class="flex gap-2 flex-wrap">
<LabelChip v-for="label in labels" :key="label.id" size="lg" :label="label" />
</div> </div>
<div </section>
class="grid grid-cols-1 divide-y divide-gray-200 border-t border-gray-200 bg-gray-50 sm:grid-cols-3 sm:divide-y-0 sm:divide-x"
> <section>
<div v-for="stat in stats" :key="stat.label" class="px-6 py-5 text-center text-sm font-medium"> <BaseSectionHeader class="mb-5"> Storage Locations </BaseSectionHeader>
<span class="text-gray-900">{{ stat.value.value }}</span> <div class="grid grid-cols-1 sm:grid-cols-2 card md:grid-cols-3 gap-4">
{{ " " }} <LocationCard v-for="location in locations" :key="location.id" :location="location" />
<span class="text-gray-600">{{ stat.label }}</span>
</div>
</div> </div>
</div> </section>
</section>
<section> <section>
<BaseSectionHeader class="mb-5"> Storage Locations </BaseSectionHeader> <BaseSectionHeader class="mb-5"> Items </BaseSectionHeader>
<div class="grid grid-cols-1 sm:grid-cols-2 card md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<LocationCard v-for="location in locations" :key="location.id" :location="location" /> <ItemCard v-for="item in items" :key="item.id" :item="item" />
</div> </div>
</section> </section>
</BaseContainer>
<section> </div>
<BaseSectionHeader class="mb-5">
Items
<template #description>
<div class="tooltip" data-tip="Import CSV File">
<button class="btn btn-primary btn-sm" @click="openDialog">
<Icon name="mdi-database" class="mr-2"></Icon>
Import
</button>
</div>
</template>
</BaseSectionHeader>
<div class="grid sm:grid-cols-2 gap-4">
<ItemCard v-for="item in items" :key="item.id" :item="item" />
</div>
</section>
<section>
<BaseSectionHeader class="mb-5"> Labels </BaseSectionHeader>
<div class="flex gap-2 flex-wrap">
<LabelChip v-for="label in labels" :key="label.id" size="lg" :label="label" />
</div>
</section>
</BaseContainer>
</template> </template>

View file

@ -1,7 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import TextField from "@/components/Form/TextField.vue";
import { useNotifier } from "@/composables/use-notifier";
import { usePublicApi } from "@/composables/use-api";
import { useAuthStore } from "~~/stores/auth"; import { useAuthStore } from "~~/stores/auth";
useHead({ useHead({
title: "Homebox | Organize and Tag Your Stuff", title: "Homebox | Organize and Tag Your Stuff",
@ -11,49 +8,29 @@
layout: "empty", layout: "empty",
}); });
const api = usePublicApi();
const toast = useNotifier();
const authStore = useAuthStore(); const authStore = useAuthStore();
if (!authStore.isTokenExpired) { if (!authStore.isTokenExpired) {
navigateTo("/home"); navigateTo("/home");
} }
const registerFields = [ const username = ref("");
{ const email = ref("");
label: "What's your name?", const groupName = ref("");
value: "", const password = ref("");
}, const canRegister = ref(false);
{
label: "What's your email?",
value: "",
},
{
label: "Name your group",
value: "",
},
{
label: "Set your password",
value: "",
type: "password",
},
{
label: "Confirm your password",
value: "",
type: "password",
},
];
const api = usePublicApi();
async function registerUser() { async function registerUser() {
loading.value = true; loading.value = true;
// Print Values of registerFields
const { error } = await api.register({ const { error } = await api.register({
user: { user: {
name: registerFields[0].value, name: username.value,
email: registerFields[1].value, email: email.value,
password: registerFields[3].value, password: password.value,
}, },
groupName: registerFields[2].value, groupName: groupName.value,
}); });
if (error) { if (error) {
@ -64,48 +41,34 @@
toast.success("User registered"); toast.success("User registered");
loading.value = false; loading.value = false;
loginFields[0].value = registerFields[1].value;
registerForm.value = false; registerForm.value = false;
} }
const loginFields = [
{
label: "Email",
value: "",
},
{
label: "Password",
value: "",
type: "password",
},
];
const toast = useNotifier();
const loading = ref(false); const loading = ref(false);
const loginPassword = ref("");
async function login() { async function login() {
loading.value = true; loading.value = true;
const { data, error } = await api.login(loginFields[0].value, loginFields[1].value); const { data, error } = await api.login(email.value, loginPassword.value);
if (error) { if (error) {
toast.error("Invalid email or password"); toast.error("Invalid email or password");
} else { loading.value = false;
toast.success("Logged in successfully"); return;
authStore.$patch({
token: data.token,
expires: data.expiresAt,
});
navigateTo("/home");
} }
toast.success("Logged in successfully");
authStore.$patch({
token: data.token,
expires: data.expiresAt,
});
navigateTo("/home");
loading.value = false; loading.value = false;
} }
const registerForm = ref(false); const [registerForm, toggleLogin] = useToggle();
function toggleLogin() {
registerForm.value = !registerForm.value;
}
</script> </script>
<template> <template>
@ -159,19 +122,17 @@
<Icon name="heroicons-user" class="mr-1 w-7 h-7" /> <Icon name="heroicons-user" class="mr-1 w-7 h-7" />
Register Register
</h2> </h2>
<TextField <FormTextField v-model="email" label="Set your email?" />
v-for="field in registerFields" <FormTextField v-model="username" label="What's your name?" />
:key="field.label" <FormTextField v-model="groupName" label="Name your group" />
v-model="field.value" <FormTextField v-model="password" label="Set your password" type="password" />
:label="field.label" <PasswordScore v-model:valid="canRegister" :password="password" />
:type="field.type"
/>
<div class="card-actions justify-end"> <div class="card-actions justify-end">
<button <button
type="submit" type="submit"
class="btn btn-primary mt-2" class="btn btn-primary mt-2"
:class="loading ? 'loading' : ''" :class="loading ? 'loading' : ''"
:disabled="loading" :disabled="loading || !canRegister"
> >
Register Register
</button> </button>
@ -186,13 +147,8 @@
<Icon name="heroicons-user" class="mr-1 w-7 h-7" /> <Icon name="heroicons-user" class="mr-1 w-7 h-7" />
Login Login
</h2> </h2>
<TextField <FormTextField v-model="email" label="Email" />
v-for="field in loginFields" <FormTextField v-model="loginPassword" label="Password" type="password" />
:key="field.label"
v-model="field.value"
:label="field.label"
:type="field.type"
/>
<div class="card-actions justify-end mt-2"> <div class="card-actions justify-end mt-2">
<button type="submit" class="btn btn-primary" :class="loading ? 'loading' : ''" :disabled="loading"> <button type="submit" class="btn btn-primary" :class="loading ? 'loading' : ''" :disabled="loading">
Login Login
@ -205,7 +161,7 @@
<div class="text-center mt-6"> <div class="text-center mt-6">
<button <button
class="text-base-content text-lg hover:bg-primary hover:text-primary-content px-3 py-1 rounded-xl transition-colors duration-200" class="text-base-content text-lg hover:bg-primary hover:text-primary-content px-3 py-1 rounded-xl transition-colors duration-200"
@click="toggleLogin" @click="() => toggleLogin()"
> >
{{ registerForm ? "Already a User? Login" : "Not a User? Register" }} {{ registerForm ? "Already a User? Login" : "Not a User? Register" }}
</button> </button>
@ -227,4 +183,8 @@
transform: translateX(20px); transform: translateX(20px);
opacity: 0; opacity: 0;
} }
progress[value]::-webkit-progress-value {
transition: width 0.5s;
}
</style> </style>

View file

@ -204,7 +204,7 @@
const confirm = useConfirm(); const confirm = useConfirm();
async function deleteAttachment(attachmentId: string) { async function deleteAttachment(attachmentId: string) {
const confirmed = await confirm.reveal("Are you sure you want to delete this attachment?"); const confirmed = await confirm.open("Are you sure you want to delete this attachment?");
if (confirmed.isCanceled) { if (confirmed.isCanceled) {
return; return;

View file

@ -174,7 +174,7 @@
const confirm = useConfirm(); const confirm = useConfirm();
async function deleteItem() { async function deleteItem() {
const confirmed = await confirm.reveal("Are you sure you want to delete this item?"); const confirmed = await confirm.open("Are you sure you want to delete this item?");
if (!confirmed.data) { if (!confirmed.data) {
return; return;
@ -200,7 +200,7 @@
<BaseDetails :details="itemSummary"> <BaseDetails :details="itemSummary">
<template #title> <template #title>
<BaseSectionHeader v-if="item" class="pb-0"> <BaseSectionHeader v-if="item" class="pb-0">
<Icon name="mdi-package-variant" class="-mt-1 mr-2 text-gray-600" /> <Icon name="mdi-package-variant" class="mr-2 text-gray-600" />
<span class="text-gray-600"> <span class="text-gray-600">
{{ item.name }} {{ item.name }}
</span> </span>

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ActionsDivider from "../../components/Base/ActionsDivider.vue"; import type { DateDetail, Detail } from "~~/components/global/DetailsSection/types";
definePageMeta({ definePageMeta({
layout: "home", layout: "home",
@ -23,36 +23,47 @@
return data; return data;
}); });
function maybeTimeAgo(date?: string): string { const details = computed<(Detail | DateDetail)[]>(() => {
if (!date) { const details = [
return "??"; {
} name: "Name",
text: label.value?.name,
const time = new Date(date); },
{
return `${useTimeAgo(time).value} (${useDateFormat(time, "MM-DD-YYYY").value})`; name: "Description",
} text: label.value?.description,
},
const details = computed(() => { ];
const dt = {
Name: label.value?.name || "",
Description: label.value?.description || "",
};
if (preferences.value.showDetails) { if (preferences.value.showDetails) {
dt["Created At"] = maybeTimeAgo(label.value?.createdAt); return [
dt["Updated At"] = maybeTimeAgo(label.value?.updatedAt); ...details,
dt["Database ID"] = label.value?.id || ""; {
dt["Group Id"] = label.value?.groupId || ""; name: "Created",
text: label.value?.createdAt,
type: "date",
},
{
name: "Updated",
text: label.value?.updatedAt,
type: "date",
},
{
name: "Database ID",
text: label.value?.id,
},
];
} }
return dt; return details;
}); });
const { reveal } = useConfirm(); const confirm = useConfirm();
async function confirmDelete() { async function confirmDelete() {
const { isCanceled } = await reveal("Are you sure you want to delete this label? This action cannot be undone."); const { isCanceled } = await confirm.open(
"Are you sure you want to delete this label? This action cannot be undone."
);
if (isCanceled) { if (isCanceled) {
return; return;
@ -104,31 +115,48 @@
<template #title> Update Label </template> <template #title> Update Label </template>
<form v-if="label" @submit.prevent="update"> <form v-if="label" @submit.prevent="update">
<FormTextField v-model="updateData.name" :autofocus="true" label="Label Name" /> <FormTextField v-model="updateData.name" :autofocus="true" label="Label Name" />
<FormTextField v-model="updateData.description" label="Label Description" /> <FormTextArea v-model="updateData.description" label="Label Description" />
<div class="modal-action"> <div class="modal-action">
<BaseButton type="submit" :loading="updating"> Update </BaseButton> <BaseButton type="submit" :loading="updating"> Update </BaseButton>
</div> </div>
</form> </form>
</BaseModal> </BaseModal>
<section>
<BaseSectionHeader class="mb-5" dark> <BaseCard class="mb-16">
{{ label ? label.name : "" }} <template #title>
</BaseSectionHeader> <BaseSectionHeader>
<BaseDetails class="mb-2" :details="details"> <Icon name="mdi-tag" class="mr-2 text-gray-600" />
<template #title> Label Details </template> <span class="text-gray-600">
</BaseDetails> {{ label ? label.name : "" }}
<div class="form-control ml-auto mr-2 max-w-[130px]"> </span>
<label class="label cursor-pointer"> </BaseSectionHeader>
<input v-model="preferences.showDetails" type="checkbox" class="toggle" /> </template>
<span class="label-text"> Detailed View </span>
</label> <template #title-actions>
</div> <div class="flex mt-2 gap-2">
<ActionsDivider @delete="confirmDelete" @edit="openUpdate" /> <div class="form-control max-w-[160px]">
</section> <label class="label cursor-pointer">
<input v-model="preferences.showDetails" type="checkbox" class="toggle toggle-primary" />
<span class="label-text ml-2"> Detailed View </span>
</label>
</div>
<BaseButton class="ml-auto" size="sm" @click="openUpdate">
<Icon class="mr-1" name="mdi-pencil" />
Edit
</BaseButton>
<BaseButton size="sm" @click="confirmDelete">
<Icon class="mr-1" name="mdi-delete" />
Delete
</BaseButton>
</div>
</template>
<DetailsSection :details="details" />
</BaseCard>
<section v-if="label"> <section v-if="label">
<BaseSectionHeader class="mb-5"> Items </BaseSectionHeader> <BaseSectionHeader class="mb-5"> Items </BaseSectionHeader>
<div class="grid gap-2 grid-cols-2"> <div class="grid gap-2 grid-cols-1 sm:grid-cols-2">
<ItemCard v-for="item in label.items" :key="item.id" :item="item" /> <ItemCard v-for="item in label.items" :key="item.id" :item="item" />
</div> </div>
</section> </section>

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ActionsDivider from "../../components/Base/ActionsDivider.vue"; import { Detail, DateDetail } from "~~/components/global/DetailsSection/types";
definePageMeta({ definePageMeta({
layout: "home", layout: "home",
@ -23,47 +23,57 @@
return data; return data;
}); });
function maybeTimeAgo(date?: string): string { const details = computed<(Detail | DateDetail)[]>(() => {
if (!date) { const details = [
return "??"; {
} name: "Name",
text: location.value?.name,
const time = new Date(date); },
{
return `${useTimeAgo(time).value} (${useDateFormat(time, "MM-DD-YYYY").value})`; name: "Description",
} text: location.value?.description,
},
const details = computed(() => { ];
const dt = {
Name: location.value?.name || "",
Description: location.value?.description || "",
};
if (preferences.value.showDetails) { if (preferences.value.showDetails) {
dt["Created At"] = maybeTimeAgo(location.value?.createdAt); return [
dt["Updated At"] = maybeTimeAgo(location.value?.updatedAt); ...details,
dt["Database ID"] = location.value?.id || ""; {
dt["Group Id"] = location.value?.groupId || ""; name: "Created",
text: location.value?.createdAt,
type: "date",
},
{
name: "Updated",
text: location.value?.updatedAt,
type: "date",
},
{
name: "Database ID",
text: location.value?.id,
},
];
} }
return dt; return details;
}); });
const { reveal } = useConfirm(); const confirm = useConfirm();
async function confirmDelete() { async function confirmDelete() {
const { isCanceled } = await reveal("Are you sure you want to delete this location? This action cannot be undone."); const { isCanceled } = await confirm.open(
"Are you sure you want to delete this location? This action cannot be undone."
);
if (isCanceled) { if (isCanceled) {
return; return;
} }
const { error } = await api.locations.delete(locationId.value); const { error } = await api.locations.delete(locationId.value);
if (error) { if (error) {
toast.error("Failed to delete location"); toast.error("Failed to delete location");
return; return;
} }
toast.success("Location deleted"); toast.success("Location deleted");
navigateTo("/home"); navigateTo("/home");
} }
@ -103,31 +113,48 @@
<template #title> Update Location </template> <template #title> Update Location </template>
<form v-if="location" @submit.prevent="update"> <form v-if="location" @submit.prevent="update">
<FormTextField v-model="updateData.name" :autofocus="true" label="Location Name" /> <FormTextField v-model="updateData.name" :autofocus="true" label="Location Name" />
<FormTextField v-model="updateData.description" label="Location Description" /> <FormTextArea v-model="updateData.description" label="Location Description" />
<div class="modal-action"> <div class="modal-action">
<BaseButton type="submit" :loading="updating"> Update </BaseButton> <BaseButton type="submit" :loading="updating"> Update </BaseButton>
</div> </div>
</form> </form>
</BaseModal> </BaseModal>
<section>
<BaseSectionHeader class="mb-5" dark> <BaseCard class="mb-16">
{{ location ? location.name : "" }} <template #title>
</BaseSectionHeader> <BaseSectionHeader>
<BaseDetails class="mb-2" :details="details"> <Icon name="mdi-map-marker" class="mr-2 text-gray-600" />
<template #title> Location Details </template> <span class="text-gray-600">
</BaseDetails> {{ location ? location.name : "" }}
<div class="form-control ml-auto mr-2 max-w-[130px]"> </span>
<label class="label cursor-pointer"> </BaseSectionHeader>
<input v-model="preferences.showDetails" type="checkbox" class="toggle" /> </template>
<span class="label-text"> Detailed View </span>
</label> <template #title-actions>
</div> <div class="flex mt-2 gap-2">
<ActionsDivider @delete="confirmDelete" @edit="openUpdate" /> <div class="form-control max-w-[160px]">
</section> <label class="label cursor-pointer">
<input v-model="preferences.showDetails" type="checkbox" class="toggle toggle-primary" />
<span class="label-text ml-2"> Detailed View </span>
</label>
</div>
<BaseButton class="ml-auto" size="sm" @click="openUpdate">
<Icon class="mr-1" name="mdi-pencil" />
Edit
</BaseButton>
<BaseButton size="sm" @click="confirmDelete">
<Icon class="mr-1" name="mdi-delete" />
Delete
</BaseButton>
</div>
</template>
<DetailsSection :details="details" />
</BaseCard>
<section v-if="location"> <section v-if="location">
<BaseSectionHeader class="mb-5"> Items </BaseSectionHeader> <BaseSectionHeader class="mb-5"> Items </BaseSectionHeader>
<div class="grid gap-2 grid-cols-2"> <div class="grid gap-2 grid-cols-1 sm:grid-cols-2">
<ItemCard v-for="item in location.items" :key="item.id" :item="item" /> <ItemCard v-for="item in location.items" :key="item.id" :item="item" />
</div> </div>
</section> </section>

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,13 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { useLocalStorage } from "@vueuse/core"; import { useLocalStorage } from "@vueuse/core";
import { UserApi } from "~~/lib/api/user"; import { UserApi } from "~~/lib/api/user";
import { UserOut } from "~~/lib/api/types/data-contracts";
export const useAuthStore = defineStore("auth", { export const useAuthStore = defineStore("auth", {
state: () => ({ state: () => ({
token: useLocalStorage("pinia/auth/token", ""), token: useLocalStorage("pinia/auth/token", ""),
expires: useLocalStorage("pinia/auth/expires", ""), expires: useLocalStorage("pinia/auth/expires", ""),
self: null as UserOut | null,
}), }),
getters: { getters: {
isTokenExpired: state => { isTokenExpired: state => {
@ -30,6 +32,7 @@ export const useAuthStore = defineStore("auth", {
this.token = ""; this.token = "";
this.expires = ""; this.expires = "";
this.self = null;
return result; return result;
}, },

View file

@ -1,4 +1,3 @@
/// <reference types="vitest" />
import { defineConfig } from "vite"; import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({