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

@ -1,6 +1,10 @@
<template>
<NuxtLayout>
<Html lang="en" data-theme="garden" />
<Html lang="en" :data-theme="theme" />
<NuxtPage />
</NuxtLayout>
</template>
<script lang="ts" setup>
const { theme } = useTheme();
</script>

View file

@ -29,8 +29,9 @@
/>
<path d="M5443.74 520.879v4149.79" style="fill: none; stroke: #000; stroke-width: 153.5px" />
<path
class="bg-primary"
d="M8951.41 4102.72c0-41.65-22.221-80.136-58.291-100.961-36.069-20.825-80.51-20.825-116.58 0l-2439.92 1408.69c-36.07 20.825-58.29 59.311-58.29 100.961V7058c0 41.65 22.22 80.136 58.29 100.961 36.07 20.825 80.51 20.825 116.58 0l2439.92-1408.69c36.07-20.825 58.291-59.312 58.291-100.962v-1546.59Z"
style="fill: #567f67"
style="fill: hsl(var(--p) / var(--tw-bg-opacity))"
/>
<path
d="M8951.41 4102.72c0-41.65-22.221-80.136-58.291-100.961-36.069-20.825-80.51-20.825-116.58 0l-2439.92 1408.69c-36.07 20.825-58.29 59.311-58.29 100.961V7058c0 41.65 22.22 80.136 58.29 100.961 36.07 20.825 80.51 20.825 116.58 0l2439.92-1408.69c36.07-20.825 58.291-59.312 58.291-100.962v-1546.59ZM6463.98 5551.29v1387.06l2301.77-1328.92V4222.37L6463.98 5551.29Z"

View file

@ -26,6 +26,7 @@
'btn-sm': size === 'sm',
'btn-lg': size === 'lg',
}"
:style="upper ? '' : 'text-transform: none'"
>
<label v-if="$slots.icon" class="swap swap-rotate mr-2" :class="{ 'swap-active': isHover }">
<slot name="icon" />
@ -38,6 +39,10 @@
type Sizes = "sm" | "md" | "lg";
const props = defineProps({
upper: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,

View file

@ -1,44 +0,0 @@
<template>
<div class="overflow-hidden 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>
</div>
<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="(dValue, dKey) in details" :key="dKey" class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">
{{ dKey }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<slot :name="rmSpace(dKey)" v-bind="{ key: dKey, value: dValue }">
{{ dValue }}
</slot>
</dd>
</div>
</dl>
</div>
</div>
</template>
<script setup lang="ts">
type StringLike = string | number | boolean;
function rmSpace(str: string) {
return str.replace(" ", "");
}
defineProps({
details: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: Object as () => Record<string, StringLike | any>,
required: true,
},
});
</script>
<style scoped></style>

View file

@ -9,7 +9,7 @@
>
<slot />
</h3>
<p v-if="$slots.description" class="mt-2 max-w-4xl text-sm text-gray-500">
<p v-if="$slots.description" class="mt-2 max-w-4xl text-sm text-base-content">
<slot name="description" />
</p>
<div v-if="$slots.after">

View file

@ -1,5 +1,5 @@
<template>
<div ref="label" class="dropdown dropdown-end w-full">
<div ref="label" class="dropdown dropdown-end dropdown-top w-full">
<FormTextField v-model="dateText" tabindex="0" label="Date" :inline="inline" readonly />
<div tabindex="0" class="mt-1 card compact dropdown-content shadow bg-base-100 rounded-box w-64" @blur="resetTime">
<div class="card-body">

View file

@ -0,0 +1,37 @@
<template>
<button class="btn btn-outline btn-square btn-sm" @click="copyText">
<label
class="swap swap-rotate"
:class="{
'swap-active': copied,
}"
>
<Icon class="swap-off h-5 w-5" name="mdi-content-copy" />
<Icon class="swap-on h-5 w-5" name="mdi-clipboard" />
</label>
</button>
</template>
<script setup lang="ts">
const props = defineProps({
text: {
type: String as () => string,
default: "",
},
});
const copied = ref(false);
const { copy } = useClipboard();
function copyText() {
copy(props.text);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 1000);
}
</script>
<style scoped></style>

View file

@ -2,10 +2,10 @@
<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">
<dt class="text-sm font-medium text-base-content">
{{ detail.name }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<dd class="mt-1 text-sm text-base-content 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" />

View file

@ -1,5 +1,5 @@
import { PublicApi } from "~~/lib/api/public";
import { UserApi } from "~~/lib/api/user";
import { UserClient } from "~~/lib/api/user";
import { Requests } from "~~/lib/requests";
import { useAuthStore } from "~~/stores/auth";
@ -28,7 +28,7 @@ export function usePublicApi(): PublicApi {
return new PublicApi(requests);
}
export function useUserApi(): UserApi {
export function useUserApi(): UserClient {
const authStore = useAuthStore();
const requests = new Requests("", () => authStore.token, {});
@ -43,5 +43,5 @@ export function useUserApi(): UserApi {
requests.addResponseInterceptor(observer.handler);
}
return new UserApi(requests);
return new UserClient(requests);
}

View file

@ -1,9 +1,41 @@
import { Ref } from "vue";
export type DaisyTheme =
| "light"
| "dark"
| "cupcake"
| "bumblebee"
| "emerald"
| "corporate"
| "synthwave"
| "retro"
| "cyberpunk"
| "valentine"
| "halloween"
| "garden"
| "forest"
| "aqua"
| "lofi"
| "pastel"
| "fantasy"
| "wireframe"
| "black"
| "luxury"
| "dracula"
| "cmyk"
| "autumn"
| "business"
| "acid"
| "lemonade"
| "night"
| "coffee"
| "winter";
export type LocationViewPreferences = {
showDetails: boolean;
showEmpty: boolean;
editorSimpleView: boolean;
theme: DaisyTheme;
};
/**
@ -17,6 +49,7 @@ export function useViewPreferences(): Ref<LocationViewPreferences> {
showDetails: true,
showEmpty: true,
editorSimpleView: true,
theme: "garden",
},
{ mergeDefaults: true }
);

View file

@ -0,0 +1,40 @@
import { ComputedRef } from "vue";
import { DaisyTheme } from "./use-preferences";
export interface UseTheme {
theme: ComputedRef<DaisyTheme>;
setTheme: (theme: DaisyTheme) => void;
}
const themeRef = ref<DaisyTheme>("garden");
export function useTheme(): UseTheme {
const preferences = useViewPreferences();
themeRef.value = preferences.value.theme;
const setTheme = (newTheme: DaisyTheme) => {
preferences.value.theme = newTheme;
if (htmlEl) {
htmlEl.value.setAttribute("data-theme", newTheme);
}
themeRef.value = newTheme;
};
const htmlEl = ref<HTMLElement>(null);
onMounted(() => {
if (htmlEl.value) {
return;
}
htmlEl.value = document.querySelector("html");
});
const theme = computed(() => {
return themeRef.value;
});
return { theme, setTheme };
}

View file

@ -1,5 +1,17 @@
import { describe, test, expect } from "vitest";
import { client, userClient } from "./test-utils";
import { faker } from "@faker-js/faker";
import { UserRegistration } from "../types/data-contracts";
import { client, sharedUserClient, userClient } from "./test-utils";
function userFactory(): UserRegistration {
return {
email: faker.internet.email(),
password: faker.internet.password(),
name: faker.name.firstName(),
groupName: faker.animal.cat(),
token: "",
};
}
describe("[GET] /api/v1/status", () => {
test("server should respond", async () => {
@ -10,14 +22,9 @@ describe("[GET] /api/v1/status", () => {
});
});
describe("first time user workflow (register, login)", () => {
describe("first time user workflow (register, login, join group)", () => {
const api = client();
const userData = {
groupName: "test-group",
email: "test-user@email.com",
name: "test-user",
password: "test-password",
};
const userData = userFactory();
test("user should be able to register", async () => {
const { response } = await api.register(userData);
@ -32,8 +39,47 @@ describe("first time user workflow (register, login)", () => {
// Cleanup
const userApi = userClient(data.token);
{
const { response } = await userApi.deleteAccount();
const { response } = await userApi.user.delete();
expect(response.status).toBe(204);
}
});
test("user should be able to join create join token and have user signup", async () => {
// Setup User 1 Token
const client = await sharedUserClient();
const { data: user1 } = await client.user.self();
const { response, data } = await client.group.createInvitation({
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
uses: 1,
});
expect(response.status).toBe(201);
expect(data.token).toBeTruthy();
// Create User 2 with token
const duplicateUser = userFactory();
duplicateUser.token = data.token;
const { response: registerResp } = await api.register(duplicateUser);
expect(registerResp.status).toBe(204);
const { response: loginResp, data: loginData } = await api.login(duplicateUser.email, duplicateUser.password);
expect(loginResp.status).toBe(200);
// Get Self and Assert
const client2 = userClient(loginData.token);
const { data: user2 } = await client2.user.self();
user2.item.groupName = user1.item.groupName;
// Cleanup User 2
const { response: deleteResp } = await client2.user.delete();
expect(deleteResp.status).toBe(204);
});
});

View file

@ -3,7 +3,7 @@ import { Requests } from "../../requests";
import { overrideParts } from "../base/urls";
import { PublicApi } from "../public";
import * as config from "../../../test/config";
import { UserApi } from "../user";
import { UserClient } from "../user";
export function client() {
overrideParts(config.BASE_URL, "/api/v1");
@ -14,7 +14,7 @@ export function client() {
export function userClient(token: string) {
overrideParts(config.BASE_URL, "/api/v1");
const requests = new Requests("", token);
return new UserApi(requests);
return new UserClient(requests);
}
const cache = {
@ -25,7 +25,7 @@ const cache = {
* Shared UserApi token for tests where the creation of a user is _not_ import
* to the test. This is useful for tests that are testing the user API itself.
*/
export async function sharedUserClient(): Promise<UserApi> {
export async function sharedUserClient(): Promise<UserClient> {
if (cache.token) {
return userClient(cache.token);
}

View file

@ -1,7 +1,7 @@
import { describe, test, expect } from "vitest";
import { LocationOut } from "../../types/data-contracts";
import { AttachmentTypes } from "../../types/non-generated";
import { UserApi } from "../../user";
import { UserClient } from "../../user";
import { sharedUserClient } from "../test-utils";
describe("user should be able to create an item and add an attachment", () => {
@ -10,7 +10,7 @@ describe("user should be able to create an item and add an attachment", () => {
* useLocatio sets up a location resource for testing, and returns a function
* that can be used to delete the location from the backend server.
*/
async function useLocation(api: UserApi): Promise<[LocationOut, () => Promise<void>]> {
async function useLocation(api: UserClient): Promise<[LocationOut, () => Promise<void>]> {
const { response, data } = await api.locations.create({
name: `__test__.location.name_${increment}`,
description: `__test__.location.description_${increment}`,

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { LabelOut } from "../../types/data-contracts";
import { UserApi } from "../../user";
import { UserClient } from "../../user";
import { sharedUserClient } from "../test-utils";
describe("locations lifecycle (create, update, delete)", () => {
@ -10,7 +10,7 @@ describe("locations lifecycle (create, update, delete)", () => {
* useLabel sets up a label resource for testing, and returns a function
* that can be used to delete the label from the backend server.
*/
async function useLabel(api: UserApi): Promise<[LabelOut, () => Promise<void>]> {
async function useLabel(api: UserClient): Promise<[LabelOut, () => Promise<void>]> {
const { response, data } = await api.labels.create({
name: `__test__.label.name_${increment}`,
description: `__test__.label.description_${increment}`,

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { LocationOut } from "../../types/data-contracts";
import { UserApi } from "../../user";
import { UserClient } from "../../user";
import { sharedUserClient } from "../test-utils";
describe("locations lifecycle (create, update, delete)", () => {
@ -10,7 +10,7 @@ describe("locations lifecycle (create, update, delete)", () => {
* useLocatio sets up a location resource for testing, and returns a function
* that can be used to delete the location from the backend server.
*/
async function useLocation(api: UserApi): Promise<[LocationOut, () => Promise<void>]> {
async function useLocation(api: UserClient): Promise<[LocationOut, () => Promise<void>]> {
const { response, data } = await api.locations.create({
name: `__test__.location.name_${increment}`,
description: `__test__.location.description_${increment}`,

View file

@ -0,0 +1,11 @@
import { BaseAPI, route } from "../base";
import { GroupInvitation, GroupInvitationCreate } from "../types/data-contracts";
export class GroupApi extends BaseAPI {
createInvitation(data: GroupInvitationCreate) {
return this.http.post<GroupInvitationCreate, GroupInvitation>({
url: route("/groups/invitations"),
body: data,
});
}
}

View file

@ -0,0 +1,17 @@
import { BaseAPI, route } from "../base";
import { UserOut } from "../types/data-contracts";
import { Result } from "../types/non-generated";
export class UserApi extends BaseAPI {
public self() {
return this.http.get<Result<UserOut>>({ url: route("/users/self") });
}
public logout() {
return this.http.post<object, void>({ url: route("/users/logout") });
}
public delete() {
return this.http.delete<void>({ url: route("/users/self") });
}
}

View file

@ -222,6 +222,7 @@ export interface UserRegistration {
groupName: string;
name: string;
password: string;
token: string;
}
export interface ApiSummary {
@ -238,11 +239,22 @@ export interface Build {
version: string;
}
export interface GroupInvitation {
expiresAt: Date;
token: string;
uses: number;
}
export interface GroupInvitationCreate {
expiresAt: Date;
uses: number;
}
export interface ItemAttachmentToken {
token: string;
}
export interface TokenResponse {
expiresAt: string;
expiresAt: Date;
token: string;
}

View file

@ -4,3 +4,7 @@ export enum AttachmentTypes {
Warranty = "warranty",
Attachment = "attachment",
}
export type Result<T> = {
item: T;
};

View file

@ -1,37 +1,42 @@
import { BaseAPI, route } from "./base";
import { BaseAPI } from "./base";
import { ItemsApi } from "./classes/items";
import { LabelsApi } from "./classes/labels";
import { LocationsApi } from "./classes/locations";
import { UserOut } from "./types/data-contracts";
import { GroupApi } from "./classes/group";
import { UserApi } from "./classes/users";
import { Requests } from "~~/lib/requests";
export type Result<T> = {
item: T;
};
export class UserApi extends BaseAPI {
export class UserClient extends BaseAPI {
locations: LocationsApi;
labels: LabelsApi;
items: ItemsApi;
group: GroupApi;
user: UserApi;
constructor(requests: Requests) {
super(requests);
this.locations = new LocationsApi(requests);
this.labels = new LabelsApi(requests);
this.items = new ItemsApi(requests);
this.group = new GroupApi(requests);
this.user = new UserApi(requests);
Object.freeze(this);
}
/** @deprecated use this.user.self() */
public self() {
return this.http.get<Result<UserOut>>({ url: route("/users/self") });
return this.user.self();
}
/** @deprecated use this.user.logout() */
public logout() {
return this.http.post<object, void>({ url: route("/users/logout") });
return this.user.logout();
}
/** @deprecated use this.user.delete() */
public deleteAccount() {
return this.http.delete<void>({ url: route("/users/self") });
return this.user.delete();
}
}

View file

@ -12,6 +12,7 @@
"test:watch": " TEST_SHUTDOWN_API_SERVER=false vitest --config ./test/vitest.config.ts"
},
"devDependencies": {
"@faker-js/faker": "^7.5.0",
"@nuxtjs/eslint-config-typescript": "^11.0.0",
"@typescript-eslint/eslint-plugin": "^5.36.2",
"@typescript-eslint/parser": "^5.36.2",

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>

View file

@ -1,6 +1,7 @@
lockfileVersion: 5.4
specifiers:
'@faker-js/faker': ^7.5.0
'@iconify/vue': ^3.2.1
'@nuxtjs/eslint-config-typescript': ^11.0.0
'@nuxtjs/tailwindcss': ^5.3.2
@ -44,6 +45,7 @@ dependencies:
vue: 3.2.39
devDependencies:
'@faker-js/faker': 7.5.0
'@nuxtjs/eslint-config-typescript': 11.0.0_7ilbxdl5iguzcjriqqcg2m5cku
'@typescript-eslint/eslint-plugin': 5.38.0_4gkcvl6qsi23tqqawfqgcwtp54
'@typescript-eslint/parser': 5.38.0_7ilbxdl5iguzcjriqqcg2m5cku
@ -381,6 +383,11 @@ packages:
transitivePeerDependencies:
- supports-color
/@faker-js/faker/7.5.0:
resolution: {integrity: sha512-8wNUCCUHvfvI0gQpDUho/3gPzABffnCn5um65F8dzQ86zz6dlt4+nmAA7PQUc8L+eH+9RgR/qzy5N/8kN0Ozdw==}
engines: {node: '>=14.0.0', npm: '>=6.0.0'}
dev: true
/@humanwhocodes/config-array/0.10.5:
resolution: {integrity: sha512-XVVDtp+dVvRxMoxSiSfasYaG02VEe1qH5cKgMQJWhol6HwzbcqoCMJi8dAGoYAO57jhUyhI6cWuRiTcRaDaYug==}
engines: {node: '>=10.10.0'}

View file

@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { useLocalStorage } from "@vueuse/core";
import { UserApi } from "~~/lib/api/user";
import { UserClient } from "~~/lib/api/user";
import { UserOut } from "~~/lib/api/types/data-contracts";
export const useAuthStore = defineStore("auth", {
@ -23,12 +23,8 @@ export const useAuthStore = defineStore("auth", {
},
},
actions: {
async logout(api: UserApi) {
const result = await api.logout();
if (result.error) {
return result;
}
async logout(api: UserClient) {
const result = await api.user.logout();
this.token = "";
this.expires = "";