diff --git a/.vscode/settings.json b/.vscode/settings.json index f05ebc8..3c39b0e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,5 +20,5 @@ "[typescript]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, - + "eslint.format.enable": true, } diff --git a/backend/app/api/handlers/v1/v1_ctrl_items.go b/backend/app/api/handlers/v1/v1_ctrl_items.go index 1873d8b..ea961f3 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_items.go +++ b/backend/app/api/handlers/v1/v1_ctrl_items.go @@ -29,7 +29,7 @@ func (ctrl *V1Controller) HandleItemsGetAll() server.HandlerFunc { return repo.ItemQuery{ Page: queryIntOrNegativeOne(params.Get("page")), - PageSize: queryIntOrNegativeOne(params.Get("perPage")), + PageSize: queryIntOrNegativeOne(params.Get("pageSize")), Search: params.Get("q"), LocationIDs: queryUUIDList(params, "locations"), LabelIDs: queryUUIDList(params, "labels"), diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index 06cb95c..7863b0d 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -1665,6 +1665,10 @@ const docTemplate = `{ "name": { "type": "string" }, + "purchasePrice": { + "type": "string", + "example": "0" + }, "quantity": { "type": "integer" }, diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index 7e5ec85..cccc5f0 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -1657,6 +1657,10 @@ "name": { "type": "string" }, + "purchasePrice": { + "type": "string", + "example": "0" + }, "quantity": { "type": "integer" }, diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index 3d2fe2a..2efdf42 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -202,6 +202,9 @@ definitions: x-omitempty: true name: type: string + purchasePrice: + example: "0" + type: string quantity: type: integer updatedAt: diff --git a/backend/internal/core/services/service_items_csv.go b/backend/internal/core/services/service_items_csv.go index fb5e36a..147da67 100644 --- a/backend/internal/core/services/service_items_csv.go +++ b/backend/internal/core/services/service_items_csv.go @@ -97,18 +97,18 @@ func newCsvRow(row []string) csvRow { LabelStr: row[2], Item: repo.ItemOut{ ItemSummary: repo.ItemSummary{ - ImportRef: row[0], - Quantity: parseInt(row[3]), - Name: row[4], - Description: row[5], - Insured: parseBool(row[6]), + ImportRef: row[0], + Quantity: parseInt(row[3]), + Name: row[4], + Description: row[5], + Insured: parseBool(row[6]), + PurchasePrice: parseFloat(row[12]), }, SerialNumber: row[7], ModelNumber: row[8], Manufacturer: row[9], Notes: row[10], PurchaseFrom: row[11], - PurchasePrice: parseFloat(row[12]), PurchaseTime: parseDate(row[13]), LifetimeWarranty: parseBool(row[14]), WarrantyExpires: parseDate(row[15]), diff --git a/backend/internal/data/repo/repo_items.go b/backend/internal/data/repo/repo_items.go index 882f2d2..d8a3904 100644 --- a/backend/internal/data/repo/repo_items.go +++ b/backend/internal/data/repo/repo_items.go @@ -101,6 +101,8 @@ type ( CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` + PurchasePrice float64 `json:"purchasePrice,string"` + // Edges Location *LocationSummary `json:"location,omitempty" extensions:"x-nullable,x-omitempty"` Labels []LabelSummary `json:"labels"` @@ -121,9 +123,8 @@ type ( WarrantyDetails string `json:"warrantyDetails"` // Purchase - PurchaseTime time.Time `json:"purchaseTime"` - PurchaseFrom string `json:"purchaseFrom"` - PurchasePrice float64 `json:"purchasePrice,string"` + PurchaseTime time.Time `json:"purchaseTime"` + PurchaseFrom string `json:"purchaseFrom"` // Sold SoldTime time.Time `json:"soldTime"` @@ -157,13 +158,14 @@ func mapItemSummary(item *ent.Item) ItemSummary { } return ItemSummary{ - ID: item.ID, - Name: item.Name, - Description: item.Description, - Quantity: item.Quantity, - CreatedAt: item.CreatedAt, - UpdatedAt: item.UpdatedAt, - Archived: item.Archived, + ID: item.ID, + Name: item.Name, + Description: item.Description, + Quantity: item.Quantity, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + Archived: item.Archived, + PurchasePrice: item.PurchasePrice, // Edges Location: location, @@ -230,9 +232,8 @@ func mapItemOut(item *ent.Item) ItemOut { Manufacturer: item.Manufacturer, // Purchase - PurchaseTime: item.PurchaseTime, - PurchaseFrom: item.PurchaseFrom, - PurchasePrice: item.PurchasePrice, + PurchaseTime: item.PurchaseTime, + PurchaseFrom: item.PurchaseFrom, // Sold SoldTime: item.SoldTime, diff --git a/frontend/.nuxtignore b/frontend/.nuxtignore new file mode 100644 index 0000000..5e5ef76 --- /dev/null +++ b/frontend/.nuxtignore @@ -0,0 +1 @@ +pages/**/*.ts \ No newline at end of file diff --git a/frontend/app.vue b/frontend/app.vue index ca390af..43c7263 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -1,6 +1,6 @@ diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css new file mode 100644 index 0000000..a3c199c --- /dev/null +++ b/frontend/assets/css/main.css @@ -0,0 +1,3 @@ +.text-no-transform { + text-transform: none !important; +} \ No newline at end of file diff --git a/frontend/components/App/HeaderDecor.vue b/frontend/components/App/HeaderDecor.vue new file mode 100644 index 0000000..e1b0484 --- /dev/null +++ b/frontend/components/App/HeaderDecor.vue @@ -0,0 +1,494 @@ + + diff --git a/frontend/components/App/ImportDialog.vue b/frontend/components/App/ImportDialog.vue new file mode 100644 index 0000000..6fe8997 --- /dev/null +++ b/frontend/components/App/ImportDialog.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/components/Base/Container.vue b/frontend/components/Base/Container.vue index 98ffe07..a67bee8 100644 --- a/frontend/components/Base/Container.vue +++ b/frontend/components/Base/Container.vue @@ -8,7 +8,7 @@ diff --git a/frontend/components/Chart/Donut.vue b/frontend/components/Chart/Donut.vue new file mode 100644 index 0000000..97d4113 --- /dev/null +++ b/frontend/components/Chart/Donut.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend/components/Chart/Line.vue b/frontend/components/Chart/Line.vue new file mode 100644 index 0000000..c74e759 --- /dev/null +++ b/frontend/components/Chart/Line.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/frontend/components/Label/Chip.vue b/frontend/components/Label/Chip.vue index e44a775..02d764d 100644 --- a/frontend/components/Label/Chip.vue +++ b/frontend/components/Label/Chip.vue @@ -1,7 +1,7 @@ diff --git a/frontend/components/global/StatCard/types.ts b/frontend/components/global/StatCard/types.ts new file mode 100644 index 0000000..7b8be67 --- /dev/null +++ b/frontend/components/global/StatCard/types.ts @@ -0,0 +1 @@ +export type StatsFormat = "currency" | "number" | "percent"; diff --git a/frontend/components/global/Subtitle.vue b/frontend/components/global/Subtitle.vue new file mode 100644 index 0000000..11b946f --- /dev/null +++ b/frontend/components/global/Subtitle.vue @@ -0,0 +1,5 @@ + diff --git a/frontend/components/global/Table.types.ts b/frontend/components/global/Table.types.ts new file mode 100644 index 0000000..004f084 --- /dev/null +++ b/frontend/components/global/Table.types.ts @@ -0,0 +1,8 @@ +export type TableHeader = { + text: string; + value: string; + sortable?: boolean; + align?: "left" | "center" | "right"; +}; + +export type TableData = Record; diff --git a/frontend/components/global/Table.vue b/frontend/components/global/Table.vue new file mode 100644 index 0000000..f09ca94 --- /dev/null +++ b/frontend/components/global/Table.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/frontend/composables/use-confirm.ts b/frontend/composables/use-confirm.ts index a1cd20a..1ae1067 100644 --- a/frontend/composables/use-confirm.ts +++ b/frontend/composables/use-confirm.ts @@ -32,6 +32,13 @@ export function useConfirm(): Store { } async function openDialog(msg: string): Promise> { + if (!store.reveal) { + throw new Error("reveal is not defined"); + } + if (!store.text) { + throw new Error("text is not defined"); + } + store.text.value = msg; return await store.reveal(); } diff --git a/frontend/composables/use-css-var.ts b/frontend/composables/use-css-var.ts new file mode 100644 index 0000000..56ee2dc --- /dev/null +++ b/frontend/composables/use-css-var.ts @@ -0,0 +1,126 @@ +type ColorType = "hsla"; + +export type VarOptions = { + type: ColorType; + transparency?: number; + apply?: (value: string) => string; +}; + +export type Breakpoints = { + sm: boolean; + md: boolean; + lg: boolean; + xl: boolean; + xxl: boolean; +}; + +export function useBreakpoints(): Breakpoints { + const breakpoints: Breakpoints = reactive({ + sm: false, + md: false, + lg: false, + xl: false, + xxl: false, + }); + + const updateBreakpoints = () => { + breakpoints.sm = window.innerWidth < 640; + breakpoints.md = window.innerWidth >= 640; + breakpoints.lg = window.innerWidth >= 768; + breakpoints.xl = window.innerWidth >= 1024; + breakpoints.xxl = window.innerWidth >= 1280; + }; + + onMounted(() => { + updateBreakpoints(); + window.addEventListener("resize", updateBreakpoints); + }); + + onUnmounted(() => { + window.removeEventListener("resize", updateBreakpoints); + }); + + return breakpoints; +} + +class ThemeObserver { + // eslint-disable-next-line no-use-before-define + private static instance?: ThemeObserver; + private readonly observer: MutationObserver; + + private fns: (() => void)[] = []; + + private constructor() { + this.observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + if (mutation.attributeName === "data-theme") { + this.fire(); + } + }); + }); + + const html = document.querySelector("html"); + if (!html) { + throw new Error("No html element found"); + } + + this.observer.observe(html, { attributes: true }); + } + + public static getInstance() { + if (!ThemeObserver.instance) { + ThemeObserver.instance = new ThemeObserver(); + } + + return ThemeObserver.instance; + } + + private fire() { + this.fns.forEach(fn => fn()); + } + + public add(fn: () => void) { + this.fns.push(fn); + } + + public remove(fn: () => void) { + this.fns = this.fns.filter(f => f !== fn); + } +} + +export function useCssVar(name: string, options?: VarOptions) { + if (!options) { + options = { + type: "hsla", + transparency: 1, + apply: undefined, + }; + } + + const cssVal = ref(getComputedStyle(document.documentElement).getPropertyValue(name).trim()); + const update = () => { + cssVal.value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + }; + + ThemeObserver.getInstance().add(update); + onUnmounted(() => { + ThemeObserver.getInstance().remove(update); + }); + + switch (options.type) { + case "hsla": { + return computed(() => { + if (!document) { + return ""; + } + + let val = cssVal.value.trim().split(" ").join(", "); + if (options?.transparency) { + val += `, ${options.transparency}`; + } + + return `hsla(${val})`; + }); + } + } +} diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index ab21150..8dff148 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -1,20 +1,174 @@ diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index e313175..120a825 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -120,6 +120,8 @@ export interface ItemSummary { /** Edges */ location: LocationSummary | null; name: string; + /** @example "0" */ + purchasePrice: string; quantity: number; updatedAt: Date; } diff --git a/frontend/lib/data/themes.ts b/frontend/lib/data/themes.ts index 55f0f55..44f1c47 100644 --- a/frontend/lib/data/themes.ts +++ b/frontend/lib/data/themes.ts @@ -1,4 +1,5 @@ export type DaisyTheme = + | "homebox" | "light" | "dark" | "cupcake" @@ -35,6 +36,10 @@ export type ThemeOption = { }; export const themes: ThemeOption[] = [ + { + label: "Homebox", + value: "homebox", + }, { label: "Garden", value: "garden", diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 6f4638b..cba18a2 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -9,8 +9,9 @@ export default defineNuxtConfig({ "/api": { target: "http://localhost:7745/api", changeOrigin: true, - } + }, }, }, - plugins: [], + css: ["@/assets/css/main.css"], + plugins: [], }); diff --git a/frontend/package.json b/frontend/package.json index 973e383..93ca2a2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,12 +36,14 @@ "@tailwindcss/typography": "^0.5.4", "@vueuse/nuxt": "^9.1.1", "autoprefixer": "^10.4.8", + "chart.js": "^4.0.1", "daisyui": "^2.24.0", "dompurify": "^2.4.1", "markdown-it": "^13.0.1", "pinia": "^2.0.21", "postcss": "^8.4.16", "tailwindcss": "^3.1.8", - "vue": "^3.2.38" + "vue": "^3.2.38", + "vue-chartjs": "^4.1.2" } } \ No newline at end of file diff --git a/frontend/pages/home.vue b/frontend/pages/home.vue deleted file mode 100644 index e8a52e7..0000000 --- a/frontend/pages/home.vue +++ /dev/null @@ -1,166 +0,0 @@ - - - diff --git a/frontend/pages/home/charts.ts b/frontend/pages/home/charts.ts new file mode 100644 index 0000000..ff9c8c7 --- /dev/null +++ b/frontend/pages/home/charts.ts @@ -0,0 +1,71 @@ +import { TChartData } from "vue-chartjs/dist/types"; +import { UserClient } from "~~/lib/api/user"; + +export function purchasePriceOverTimeChart(api: UserClient) { + const { data: timeseries } = useAsyncData(async () => { + const { data } = await api.stats.totalPriceOverTime(); + return data; + }); + + const primary = useCssVar("--p"); + + return computed(() => { + if (!timeseries.value) { + return { + labels: ["Purchase Price"], + datasets: [ + { + label: "Purchase Price", + data: [], + backgroundColor: primary.value, + borderColor: primary.value, + }, + ], + } as TChartData<"line", number[], unknown>; + } + + let start = timeseries.value?.valueAtStart; + + return { + labels: timeseries?.value.entries.map(t => new Date(t.date).toDateString()) || [], + datasets: [ + { + label: "Purchase Price", + data: + timeseries.value?.entries.map(t => { + start += t.value; + return start; + }) || [], + backgroundColor: primary.value, + borderColor: primary.value, + }, + ], + } as TChartData<"line", number[], unknown>; + }); +} + +export function inventoryByLocationChart(api: UserClient) { + const { data: donutSeries } = useAsyncData(async () => { + const { data } = await api.stats.locations(); + return data; + }); + + const primary = useCssVar("--p"); + const secondary = useCssVar("--s"); + const neutral = useCssVar("--n"); + + return computed(() => { + return { + labels: donutSeries.value?.map(l => l.name) || [], + datasets: [ + { + label: "Value", + data: donutSeries.value?.map(l => l.total) || [], + backgroundColor: [primary.value, secondary.value, neutral.value], + borderColor: [primary.value, secondary.value, neutral.value], + hoverOffset: 4, + }, + ], + }; + }); +} diff --git a/frontend/pages/home/index.vue b/frontend/pages/home/index.vue new file mode 100644 index 0000000..372c0a9 --- /dev/null +++ b/frontend/pages/home/index.vue @@ -0,0 +1,120 @@ + + + diff --git a/frontend/pages/home/statistics.ts b/frontend/pages/home/statistics.ts new file mode 100644 index 0000000..e1c7bf1 --- /dev/null +++ b/frontend/pages/home/statistics.ts @@ -0,0 +1,39 @@ +import { UserClient } from "~~/lib/api/user"; + +type StatCard = { + label: string; + value: number; + type: "currency" | "number"; +}; + +export function statCardData(api: UserClient) { + const { data: statistics } = useAsyncData(async () => { + const { data } = await api.stats.group(); + return data; + }); + + return computed(() => { + return [ + { + label: "Total Value", + value: statistics.value?.totalItemPrice || 0, + type: "currency", + }, + { + label: "Total Items", + value: statistics.value?.totalItems || 0, + type: "number", + }, + { + label: "Total Locations", + value: statistics.value?.totalLocations || 0, + type: "number", + }, + { + label: "Total Labels", + value: statistics.value?.totalLabels || 0, + type: "number", + }, + ] as StatCard[]; + }); +} diff --git a/frontend/pages/home/table.ts b/frontend/pages/home/table.ts new file mode 100644 index 0000000..e198bb4 --- /dev/null +++ b/frontend/pages/home/table.ts @@ -0,0 +1,42 @@ +import { TableHeader } from "~~/components/global/Table.types"; + +import { UserClient } from "~~/lib/api/user"; + +export function itemsTable(api: UserClient) { + const { data: items } = useAsyncData(async () => { + const { data } = await api.items.getAll({ + page: 1, + pageSize: 5, + }); + return data.items; + }); + + const headers = [ + { + text: "Name", + sortable: true, + value: "name", + }, + { + text: "Location", + value: "location.name", + }, + { + text: "Warranty", + value: "warranty", + align: "center", + }, + { + text: "Price", + value: "purchasePrice", + align: "center", + }, + ] as TableHeader[]; + + return computed(() => { + return { + headers, + items: items.value || [], + }; + }); +} diff --git a/frontend/pages/item/[id]/index.vue b/frontend/pages/item/[id]/index.vue index 16d5a74..4bcef2c 100644 --- a/frontend/pages/item/[id]/index.vue +++ b/frontend/pages/item/[id]/index.vue @@ -343,8 +343,8 @@ -
-
+
+
@@ -410,7 +406,7 @@ -
+ -
+
diff --git a/frontend/pages/item/[id]/index/log.vue b/frontend/pages/item/[id]/index/log.vue index ab701db..b0f06b2 100644 --- a/frontend/pages/item/[id]/index/log.vue +++ b/frontend/pages/item/[id]/index/log.vue @@ -1,5 +1,6 @@ @@ -95,16 +112,32 @@ -
- - - Log Maintenance - -
-
-
+
+
+ + + Back + + + + Log Maintenance + +
+
+ +
+
@@ -137,37 +170,6 @@
-
-
-
-
{{ stat.title }}
-
{{ stat.value }}
-
{{ stat.subtitle }}
-
-
-
- - diff --git a/frontend/pages/items.vue b/frontend/pages/items.vue index 317a0f0..34d453d 100644 --- a/frontend/pages/items.vue +++ b/frontend/pages/items.vue @@ -135,7 +135,7 @@
diff --git a/frontend/pages/profile.vue b/frontend/pages/profile.vue index f2f32fe..fe3c3ef 100644 --- a/frontend/pages/profile.vue +++ b/frontend/pages/profile.vue @@ -22,8 +22,6 @@ if (group.value) { group.value.currency = currency.value.code; } - - console.log(group.value); }); const currencyExample = computed(() => { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 8013d1b..1de5c6f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -13,6 +13,7 @@ specifiers: '@typescript-eslint/parser': ^5.36.2 '@vueuse/nuxt': ^9.1.1 autoprefixer: ^10.4.8 + chart.js: ^4.0.1 daisyui: ^2.24.0 dompurify: ^2.4.1 eslint: ^8.23.0 @@ -30,6 +31,7 @@ specifiers: vite-plugin-eslint: ^1.8.1 vitest: ^0.22.1 vue: ^3.2.38 + vue-chartjs: ^4.1.2 dependencies: '@iconify/vue': 3.2.1_vue@3.2.45 @@ -40,6 +42,7 @@ dependencies: '@tailwindcss/typography': 0.5.8_tailwindcss@3.2.4 '@vueuse/nuxt': 9.6.0_nuxt@3.0.0+vue@3.2.45 autoprefixer: 10.4.13_postcss@8.4.19 + chart.js: 4.0.1 daisyui: 2.43.0_2lwn2upnx27dqeg6hqdu7sq75m dompurify: 2.4.1 markdown-it: 13.0.1 @@ -47,6 +50,7 @@ dependencies: postcss: 8.4.19 tailwindcss: 3.2.4_postcss@8.4.19 vue: 3.2.45 + vue-chartjs: 4.1.2_chart.js@4.0.1+vue@3.2.45 devDependencies: '@faker-js/faker': 7.6.0 @@ -1750,6 +1754,11 @@ packages: /chardet/0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + /chart.js/4.0.1: + resolution: {integrity: sha512-5/8/9eBivwBZK81mKvmIwTb2Pmw4D/5h1RK9fBWZLLZ8mCJ+kfYNmV9rMrGoa5Hgy2/wVDBMLSUDudul2/9ihA==} + engines: {pnpm: ^7.0.0} + dev: false + /check-error/1.0.2: resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==} dev: true @@ -6412,6 +6421,16 @@ packages: dependencies: ufo: 1.0.1 + /vue-chartjs/4.1.2_chart.js@4.0.1+vue@3.2.45: + resolution: {integrity: sha512-QSggYjeFv/L4jFSBQpX8NzrAvX0B+Ha6nDgxkTG8tEXxYOOTwKI4phRLe+B4f+REnkmg7hgPY24R0cixZJyXBg==} + peerDependencies: + chart.js: ^3.7.0 + vue: ^3.0.0-0 || ^2.6.0 + dependencies: + chart.js: 4.0.1 + vue: 3.2.45 + dev: false + /vue-demi/0.13.11_vue@3.2.45: resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==} engines: {node: '>=12'} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index fc78c6c..acb95bb 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -4,6 +4,52 @@ module.exports = { theme: { extend: {}, }, + daisyui: { + themes: [ + { + homebox: { + primary: "#5C7F67", + secondary: "#ECF4E7", + accent: "#FFDA56", + neutral: "#2C2E27", + "base-100": "#FFFFFF", + info: "#3ABFF8", + success: "#36D399", + warning: "#FBBD23", + error: "#F87272", + }, + }, + "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", + ], + }, variants: { extend: {}, },