feat: user profiles (#32)

* add user profiles and theme selectors

* lowercase buttons by default

* basic layout

* (wip) init token APIs

* refactor server to support variable options

* fix types

* api refactor / registration tests

* implement UI for url and join

* remove console.logs

* rename repository factory

* fix upload size
This commit is contained in:
Hayden 2022-10-06 18:54:09 -08:00 committed by GitHub
parent 1ca430af21
commit 79f7ad40cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
76 changed files with 5154 additions and 388 deletions

View file

@ -16,14 +16,12 @@
const auth = useAuthStore();
if (auth.self === null) {
const { data, error } = await api.self();
const { data, error } = await api.user.self();
if (error) {
navigateTo("/");
}
auth.$patch({ self: data.item });
console.log(auth.self);
}
const itemsStore = useItemStore();
@ -140,7 +138,7 @@
Import
</button>
</div>
<BaseButton type="button" size="sm">
<BaseButton type="button" size="sm" to="/profile">
<Icon class="h-5 w-5 mr-2" name="mdi-person" />
Profile
</BaseButton>
@ -148,12 +146,12 @@
</template>
<div
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"
class="grid grid-cols-1 divide-y divide-base-300 border-t border-base-300 sm:grid-cols-3 sm:divide-y-0 sm:divide-x"
>
<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-base-900 font-bold">{{ stat.value.value }}</span>
{{ " " }}
<span class="text-gray-600">{{ stat.label }}</span>
<span class="text-base-600">{{ stat.label }}</span>
</div>
</div>
</BaseCard>

View file

@ -16,12 +16,34 @@
navigateTo("/home");
}
const route = useRoute();
const router = useRouter();
const username = ref("");
const email = ref("");
const groupName = ref("");
const password = ref("");
const canRegister = ref(false);
const groupToken = computed<string>({
get() {
const params = route.query.token;
if (typeof params === "string") {
return params;
}
return "";
},
set(v) {
router.push({
query: {
token: v,
},
});
},
});
async function registerUser() {
loading.value = true;
const { error } = await api.register({
@ -29,6 +51,7 @@
email: email.value,
password: password.value,
groupName: groupName.value,
token: groupToken.value,
});
if (error) {
@ -42,6 +65,12 @@
registerForm.value = false;
}
onMounted(() => {
if (groupToken.value !== "") {
registerForm.value = true;
}
});
const loading = ref(false);
const loginPassword = ref("");
@ -57,6 +86,7 @@
toast.success("Logged in successfully");
// @ts-expect-error - expires is either a date or a string, need to figure out store typing
authStore.$patch({
token: data.token,
expires: data.expiresAt,
@ -122,7 +152,13 @@
</h2>
<FormTextField v-model="email" label="Set your email?" />
<FormTextField v-model="username" label="What's your name?" />
<FormTextField v-model="groupName" label="Name your group" />
<FormTextField v-if="groupToken == ''" v-model="groupName" label="Name your group" />
<div v-else class="pt-4 pb-1 text-center">
<p>You're Joining an Existing Group!</p>
<button type="button" class="text-xs underline" @click="groupToken = ''">
Don't Want To Join a Group?
</button>
</div>
<FormTextField v-model="password" label="Set your password" type="password" />
<PasswordScore v-model:valid="canRegister" :password="password" />
<div class="card-actions justify-end">

View file

@ -283,13 +283,13 @@
<section class="px-3">
<div class="space-y-4">
<div class="overflow-hidden card bg-base-100 shadow-xl sm:rounded-lg">
<div class="card bg-base-100 shadow-xl sm:rounded-lg overflow-visible">
<BaseSectionHeader v-if="item" class="p-5">
<Icon name="mdi-package-variant" class="-mt-1 mr-2 text-gray-600" />
<span class="text-gray-600">
<Icon name="mdi-package-variant" class="-mt-1 mr-2 text-base-content" />
<span class="text-base-content">
{{ item.name }}
</span>
<p class="text-sm text-gray-600 font-bold pb-0 mb-0">Quantity {{ item.quantity }}</p>
<p class="text-sm text-base-content font-bold pb-0 mb-0">Quantity {{ item.quantity }}</p>
<template #after>
<div class="modal-action mt-3">
<div class="mr-auto tooltip" data-tip="Hide the cruft! ">

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import { DateDetail, Detail } from "~~/components/global/DetailsSection/types";
import { ItemAttachment } from "~~/lib/api/types/data-contracts";
definePageMeta({
@ -64,15 +65,33 @@
);
});
const itemSummary = computed(() => {
return {
Description: item.value?.description || "",
"Serial Number": item.value?.serialNumber || "",
"Model Number": item.value?.modelNumber || "",
Manufacturer: item.value?.manufacturer || "",
Notes: item.value?.notes || "",
Insured: item.value?.insured ? "Yes" : "No",
};
const itemDetails = computed(() => {
return [
{
name: "Description",
text: item.value?.description,
},
{
name: "Serial Number",
text: item.value?.serialNumber,
},
{
name: "Mode Number",
text: item.value?.modelNumber,
},
{
name: "Manufacturer",
text: item.value?.manufacturer,
},
{
name: "Insured",
text: item.value?.insured ? "Yes" : "No",
},
{
name: "Notes",
text: item.value?.notes,
},
];
});
const showAttachments = computed(() => {
@ -88,35 +107,34 @@
);
});
const itemAttachments = computed(() => {
const val: Record<string, string> = {};
const attachmentDetails = computed(() => {
const details: Detail[] = [];
if (preferences.value.showEmpty) {
return {
Photos: "",
Manuals: "",
Warranty: "",
Attachments: "",
};
}
const push = (name: string) => {
details.push({
name,
text: "",
slot: name.toLowerCase(),
});
};
if (attachments.value.photos.length > 0) {
val.Photos = "";
}
if (attachments.value.manuals.length > 0) {
val.Manuals = "";
}
if (attachments.value.warranty.length > 0) {
val.Warranty = "";
push("Photos");
}
if (attachments.value.attachments.length > 0) {
val.Attachments = "";
push("Attachments");
}
return val;
if (attachments.value.warranty.length > 0) {
push("Warranty");
}
if (attachments.value.manuals.length > 0) {
push("Manuals");
}
return details;
});
const showWarranty = computed(() => {
@ -127,17 +145,32 @@
});
const warrantyDetails = computed(() => {
const payload = {
"Lifetime Warranty": item.value?.lifetimeWarranty ? "Yes" : "No",
};
const details: (Detail | DateDetail)[] = [
{
name: "Lifetime Warranty",
text: item.value?.lifetimeWarranty ? "Yes" : "No",
},
];
if (showWarranty.value) {
payload["Warranty Expires"] = item.value?.warrantyExpires || "";
if (item.value?.lifetimeWarranty) {
details.push({
name: "Warranty Expires",
text: "N/A",
});
} else {
details.push({
name: "Warranty Expires",
text: item.value?.warrantyExpires,
type: "date",
});
}
payload["Warranty Details"] = item.value?.warrantyDetails || "";
details.push({
name: "Warranty Details",
text: item.value?.warrantyDetails || "",
});
return payload;
return details;
});
const showPurchase = computed(() => {
@ -147,28 +180,45 @@
return item.value?.purchaseFrom || item.value?.purchasePrice;
});
const purchaseDetails = computed(() => {
return {
"Purchased From": item.value?.purchaseFrom || "",
"Purchased Price": item.value?.purchasePrice ? fmtCurrency(item.value.purchasePrice) : "",
"Purchased At": item.value?.purchaseTime || "",
};
const purchaseDetails = computed<(Detail | DateDetail)[]>(() => {
return [
{
name: "Purchase From",
label: item.value?.purchaseFrom || "",
},
{
name: "Purchase Price",
text: item.value?.purchasePrice ? fmtCurrency(item.value.purchasePrice) : "",
},
{
name: "Purchase Date",
text: item.value.purchaseTime,
},
] as (Detail | DateDetail)[];
});
const showSold = computed(() => {
if (preferences.value.showEmpty) {
return true;
}
return item.value?.soldTo || item.value?.soldPrice;
});
const soldDetails = computed(() => {
return {
"Sold To": item.value?.soldTo || "",
"Sold Price": item.value?.soldPrice ? fmtCurrency(item.value.soldPrice) : "",
"Sold At": item.value?.soldTime || "",
};
const soldDetails = computed<Array<Detail>>(() => {
return [
{
name: "Sold To",
text: item.value?.soldTo || "",
},
{
name: "Sold Price",
text: item.value?.soldPrice ? fmtCurrency(item.value.soldPrice) : "",
},
{
name: "Sold At",
text: item.value?.soldTime || "",
},
] as Detail[];
});
const confirm = useConfirm();
@ -197,91 +247,95 @@
<div class="form-control"></div>
</div>
<div class="grid grid-cols-1 gap-3">
<BaseDetails :details="itemSummary">
<BaseCard>
<template #title>
<BaseSectionHeader v-if="item" class="pb-0">
<Icon name="mdi-package-variant" class="mr-2 text-gray-600" />
<span class="text-gray-600">
{{ item.name }}
<BaseSectionHeader>
<Icon name="mdi-package-variant" class="mr-2 -mt-1 text-base-content" />
<span class="text-base-content">
{{ item ? item.name : "" }}
</span>
<p class="text-sm text-gray-600 font-bold pb-0 mb-0">
{{ item.location.name }} - Quantity {{ item.quantity }}
</p>
<template #after>
<template #description>
<p class="text-sm text-base-content font-bold pb-0 mb-0">
{{ item.location.name }} - Quantity {{ item.quantity }}
</p>
<div v-if="item.labels && item.labels.length > 0" class="flex flex-wrap gap-3 mt-3">
<LabelChip v-for="label in item.labels" :key="label.id" class="badge-primary" :label="label" />
</div>
<div class="modal-action mt-3">
<label class="label cursor-pointer mr-auto">
<input v-model="preferences.showEmpty" type="checkbox" class="toggle toggle-primary" />
<span class="label-text ml-4"> Show Empty </span>
</label>
<BaseButton size="sm" :to="`/item/${itemId}/edit`">
<template #icon>
<Icon name="mdi-pencil" />
</template>
Edit
</BaseButton>
<BaseButton size="sm" @click="deleteItem">
<template #icon>
<Icon name="mdi-delete" />
</template>
Delete
</BaseButton>
</div>
</template>
</BaseSectionHeader>
</template>
</BaseDetails>
<BaseDetails v-if="showAttachments" :details="itemAttachments">
<template #title-actions>
<div class="modal-action mt-0">
<label class="label cursor-pointer mr-auto">
<input v-model="preferences.showEmpty" type="checkbox" class="toggle toggle-primary" />
<span class="label-text ml-4"> Show Empty </span>
</label>
<BaseButton size="sm" :to="`/item/${itemId}/edit`">
<template #icon>
<Icon name="mdi-pencil" />
</template>
Edit
</BaseButton>
<BaseButton size="sm" @click="deleteItem">
<template #icon>
<Icon name="mdi-delete" />
</template>
Delete
</BaseButton>
</div>
</template>
<DetailsSection :details="itemDetails" />
</BaseCard>
<BaseCard v-if="showAttachments">
<template #title> Attachments </template>
<template #Manuals>
<ItemAttachmentsList
v-if="attachments.manuals.length > 0"
:attachments="attachments.manuals"
:item-id="item.id"
/>
</template>
<template #Attachments>
<ItemAttachmentsList
v-if="attachments.attachments.length > 0"
:attachments="attachments.attachments"
:item-id="item.id"
/>
</template>
<template #Warranty>
<ItemAttachmentsList
v-if="attachments.warranty.length > 0"
:attachments="attachments.warranty"
:item-id="item.id"
/>
</template>
<template #Photos>
<ItemAttachmentsList
v-if="attachments.photos.length > 0"
:attachments="attachments.photos"
:item-id="item.id"
/>
</template>
</BaseDetails>
<BaseDetails v-if="showPurchase" :details="purchaseDetails">
<template #title> Purchase Details </template>
<template #PurchasedAt>
<DateTime :date="item.purchaseTime" />
</template>
</BaseDetails>
<BaseDetails v-if="showWarranty" :details="warrantyDetails">
<DetailsSection :details="attachmentDetails">
<template #manuals>
<ItemAttachmentsList
v-if="attachments.manuals.length > 0"
:attachments="attachments.manuals"
:item-id="item.id"
/>
</template>
<template #attachments>
<ItemAttachmentsList
v-if="attachments.attachments.length > 0"
:attachments="attachments.attachments"
:item-id="item.id"
/>
</template>
<template #warranty>
<ItemAttachmentsList
v-if="attachments.warranty.length > 0"
:attachments="attachments.warranty"
:item-id="item.id"
/>
</template>
<template #photos>
<ItemAttachmentsList
v-if="attachments.photos.length > 0"
:attachments="attachments.photos"
:item-id="item.id"
/>
</template>
</DetailsSection>
</BaseCard>
<BaseCard v-if="showPurchase">
<template #title> Purchase </template>
<DetailsSection :details="purchaseDetails" />
</BaseCard>
<BaseCard v-if="showWarranty">
<template #title> Warranty </template>
<template #WarrantyExpires>
<DateTime :date="item.warrantyExpires" />
</template>
</BaseDetails>
<BaseDetails v-if="showSold" :details="soldDetails">
<DetailsSection :details="warrantyDetails" />
</BaseCard>
<BaseCard v-if="showSold">
<template #title> Sold </template>
<template #SoldAt>
<DateTime :date="item.soldTime" />
</template>
</BaseDetails>
<DetailsSection :details="soldDetails" />
</BaseCard>
</div>
</section>
</BaseContainer>

View file

@ -125,8 +125,8 @@
<BaseCard class="mb-16">
<template #title>
<BaseSectionHeader>
<Icon name="mdi-tag" class="mr-2 text-gray-600" />
<span class="text-gray-600">
<Icon name="mdi-tag" class="mr-2 -mt-1 text-base-content" />
<span class="text-base-content">
{{ label ? label.name : "" }}
</span>
</BaseSectionHeader>

View file

@ -123,8 +123,8 @@
<BaseCard class="mb-16">
<template #title>
<BaseSectionHeader>
<Icon name="mdi-map-marker" class="mr-2 text-gray-600" />
<span class="text-gray-600">
<Icon name="mdi-map-marker" class="mr-2 -mt-1 text-base-content" />
<span class="text-base-content">
{{ location ? location.name : "" }}
</span>
</BaseSectionHeader>

293
frontend/pages/profile.vue Normal file
View file

@ -0,0 +1,293 @@
<script setup lang="ts">
import { Detail } from "~~/components/global/DetailsSection/types";
import { DaisyTheme } from "~~/composables/use-preferences";
import { useAuthStore } from "~~/stores/auth";
definePageMeta({
layout: "home",
});
useHead({
title: "Homebox | Profile",
});
const { setTheme } = useTheme();
type ThemeOption = {
label: string;
value: DaisyTheme;
};
const themes: ThemeOption[] = [
{
label: "Garden",
value: "garden",
},
{
label: "Light",
value: "light",
},
{
label: "Cupcake",
value: "cupcake",
},
{
label: "Bumblebee",
value: "bumblebee",
},
{
label: "Emerald",
value: "emerald",
},
{
label: "Corporate",
value: "corporate",
},
{
label: "Synthwave",
value: "synthwave",
},
{
label: "Retro",
value: "retro",
},
{
label: "Cyberpunk",
value: "cyberpunk",
},
{
label: "Valentine",
value: "valentine",
},
{
label: "Halloween",
value: "halloween",
},
{
label: "Forest",
value: "forest",
},
{
label: "Aqua",
value: "aqua",
},
{
label: "Lofi",
value: "lofi",
},
{
label: "Pastel",
value: "pastel",
},
{
label: "Fantasy",
value: "fantasy",
},
{
label: "Wireframe",
value: "wireframe",
},
{
label: "Black",
value: "black",
},
{
label: "Luxury",
value: "luxury",
},
{
label: "Dracula",
value: "dracula",
},
{
label: "Cmyk",
value: "cmyk",
},
{
label: "Autumn",
value: "autumn",
},
{
label: "Business",
value: "business",
},
{
label: "Acid",
value: "acid",
},
{
label: "Lemonade",
value: "lemonade",
},
{
label: "Night",
value: "night",
},
{
label: "Coffee",
value: "coffee",
},
{
label: "Winter",
value: "winter",
},
];
const auth = useAuthStore();
const details = computed(() => {
return [
{
name: "Name",
text: auth.self?.name || "Unknown",
},
{
name: "Email",
text: auth.self?.email || "Unknown",
},
] as Detail[];
});
const api = useUserApi();
const confirm = useConfirm();
const notify = useNotifier();
async function deleteProfile() {
const result = await confirm.open(
"Are you sure you want to delete your account? If you are the last member in your group all your data will be deleted. This action cannot be undone."
);
if (result.isCanceled) {
return;
}
const { response } = await api.user.delete();
if (response?.status === 204) {
notify.success("Your account has been deleted.");
auth.logout(api);
navigateTo("/");
}
notify.error("Failed to delete your account.");
}
const token = ref("");
const tokenUrl = computed(() => {
if (!window) {
return "";
}
return `${window.location.origin}?token=${token.value}`;
});
async function generateToken() {
const date = new Date();
const { response, data } = await api.group.createInvitation({
expiresAt: new Date(date.setDate(date.getDate() + 7)),
uses: 1,
});
if (response?.status === 201) {
token.value = data.token;
}
}
</script>
<template>
<BaseContainer class="flex flex-col gap-4 mb-6">
<BaseCard>
<template #title>
<BaseSectionHeader>
<Icon name="mdi-account" class="mr-2 -mt-1 text-base-600" />
<span class="text-base-600"> User Profile </span>
<template #description> Invite users, and manage your account. </template>
</BaseSectionHeader>
</template>
<DetailsSection :details="details" />
<div class="p-4">
<div class="flex gap-2">
<BaseButton size="sm"> Change Password </BaseButton>
<BaseButton size="sm" @click="generateToken"> Generate Invite Link </BaseButton>
</div>
<div v-if="token" class="pt-4 flex items-center pl-1">
<CopyText class="mr-2 btn-primary" :text="tokenUrl" />
{{ tokenUrl }}
</div>
<div v-if="token" class="pt-4 flex items-center pl-1">
<CopyText class="mr-2 btn-primary" :text="token" />
{{ token }}
</div>
</div>
</BaseCard>
<BaseCard>
<template #title>
<BaseSectionHeader>
<Icon name="mdi-fill" class="mr-2 text-base-600" />
<span class="text-base-600"> Theme Settings </span>
<template #description>
Theme settings are stored in your browser's local storage. You can change the theme at any time. If you're
having trouble setting your theme try refreshing your browser.
</template>
</BaseSectionHeader>
</template>
<div class="px-4 pb-4">
<div class="rounded-box grid grid-cols-1 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div
v-for="theme in themes"
:key="theme.value"
class="border-base-content/20 hover:border-base-content/40 outline-base-content overflow-hidden rounded-lg border outline-2 outline-offset-2"
:data-theme="theme.value"
:data-set-theme="theme.value"
data-act-class="outline"
@click="setTheme(theme.value)"
>
<div :data-theme="theme.value" class="bg-base-100 text-base-content w-full cursor-pointer font-sans">
<div class="grid grid-cols-5 grid-rows-3">
<div class="bg-base-200 col-start-1 row-span-2 row-start-1"></div>
<div class="bg-base-300 col-start-1 row-start-3"></div>
<div class="bg-base-100 col-span-4 col-start-2 row-span-3 row-start-1 flex flex-col gap-1 p-2">
<div class="font-bold">{{ theme.label }}</div>
<div class="flex flex-wrap gap-1">
<div class="bg-primary flex aspect-square w-5 items-center justify-center rounded lg:w-6">
<div class="text-primary-content text-sm font-bold">A</div>
</div>
<div class="bg-secondary flex aspect-square w-5 items-center justify-center rounded lg:w-6">
<div class="text-secondary-content text-sm font-bold">A</div>
</div>
<div class="bg-accent flex aspect-square w-5 items-center justify-center rounded lg:w-6">
<div class="text-accent-content text-sm font-bold">A</div>
</div>
<div class="bg-neutral flex aspect-square w-5 items-center justify-center rounded lg:w-6">
<div class="text-neutral-content text-sm font-bold">A</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</BaseCard>
<BaseCard>
<template #title>
<BaseSectionHeader>
<Icon name="mdi-delete" class="mr-2 -mt-1 text-base-600" />
<span class="text-base-600"> Delete Account</span>
<template #description> Delete your account and all it's associated data </template>
</BaseSectionHeader>
<div class="py-4 border-t-2 border-gray-300">
<BaseButton class="btn-error" @click="deleteProfile"> Delete Account </BaseButton>
</div>
</template>
</BaseCard>
</BaseContainer>
</template>
<style scoped></style>