Enhance routing and layout in SvelteKit project by adding new routes for accounts and transactions, updating existing routes, and improving layout parameters. Refactor item handling and integrate barcode scanning functionality for better user experience.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m49s

This commit is contained in:
eewing
2026-02-18 10:39:29 -06:00
parent 27f297f9ec
commit a82c89674a
27 changed files with 1080 additions and 309 deletions
+10 -4
View File
@@ -6,16 +6,22 @@ export const nodes = [
() => import('./nodes/2'), () => import('./nodes/2'),
() => import('./nodes/3'), () => import('./nodes/3'),
() => import('./nodes/4'), () => import('./nodes/4'),
() => import('./nodes/5') () => import('./nodes/5'),
() => import('./nodes/6'),
() => import('./nodes/7'),
() => import('./nodes/8')
]; ];
export const server_loads = []; export const server_loads = [];
export const dictionary = { export const dictionary = {
"/": [~2], "/": [~2],
"/items": [~4], "/accounts": [5],
"/login": [~5], "/items": [~6],
"/logout": [~3] "/items/[id]": [~7],
"/login": [~8],
"/logout": [~3],
"/transactions": [4]
}; };
export const hooks = { export const hooks = {
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/items/+page.svelte"; export { default as component } from "../../../../src/routes/transactions/+page.svelte";
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/login/+page.svelte"; export { default as component } from "../../../../src/routes/accounts/+page.svelte";
+1
View File
@@ -0,0 +1 @@
export { default as component } from "../../../../src/routes/items/+page.svelte";
+1
View File
@@ -0,0 +1 @@
export { default as component } from "../../../../src/routes/items/[id]/+page.svelte";
+1
View File
@@ -0,0 +1 @@
export { default as component } from "../../../../src/routes/login/+page.svelte";
+2 -2
View File
@@ -21,10 +21,10 @@ export const options = {
service_worker: false, service_worker: false,
service_worker_options: undefined, service_worker_options: undefined,
templates: { templates: {
app: ({ head, body, assets, nonce, env }) => "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <link rel=\"icon\" href=\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='4' fill='%231e293b'/><text x='16' y='22' font-size='18' font-family='system-ui' fill='%2394a3b8' text-anchor='middle'>S</text></svg>\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n " + head + "\n </head>\n <body data-sveltekit-preload-data=\"hover\">\n <div style=\"display: contents\">" + body + "</div>\n </body>\n</html>\n", app: ({ head, body, assets, nonce, env }) => "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <link rel=\"icon\" href=\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='4' fill='%231e293b'/><text x='16' y='22' font-size='18' font-family='system-ui' fill='%2394a3b8' text-anchor='middle'>S</text></svg>\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\" />\n <meta name=\"theme-color\" content=\"#0f172a\" />\n <meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\" />\n " + head + "\n </head>\n <body data-sveltekit-preload-data=\"hover\">\n <div style=\"display: contents\">" + body + "</div>\n </body>\n</html>\n",
error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n" error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n"
}, },
version_hash: "6si2m4" version_hash: "ekcm0i"
}; };
export async function get_hooks() { export async function get_hooks() {
+9 -6
View File
@@ -27,18 +27,21 @@ export {};
declare module "$app/types" { declare module "$app/types" {
export interface AppTypes { export interface AppTypes {
RouteId(): "/" | "/access-denied" | "/items" | "/login" | "/logout"; RouteId(): "/" | "/access-denied" | "/accounts" | "/items" | "/items/[id]" | "/login" | "/logout" | "/transactions";
RouteParams(): { RouteParams(): {
"/items/[id]": { id: string }
}; };
LayoutParams(): { LayoutParams(): {
"/": Record<string, never>; "/": { id?: string };
"/access-denied": Record<string, never>; "/access-denied": Record<string, never>;
"/items": Record<string, never>; "/accounts": Record<string, never>;
"/items": { id?: string };
"/items/[id]": { id: string };
"/login": Record<string, never>; "/login": Record<string, never>;
"/logout": Record<string, never> "/logout": Record<string, never>;
"/transactions": Record<string, never>
}; };
Pathname(): "/" | "/items" | "/login" | "/logout"; Pathname(): "/" | "/accounts" | "/items" | `/items/${string}` & {} | "/login" | "/logout" | "/transactions";
ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`; ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`;
Asset(): string & {}; Asset(): string & {};
} }
+10
View File
@@ -4,10 +4,17 @@
"src/routes/+layout.ts", "src/routes/+layout.ts",
"src/routes/+layout.ts" "src/routes/+layout.ts"
], ],
"/accounts": [
"src/routes/+layout.ts"
],
"/items": [ "/items": [
"src/routes/items/+page.server.ts", "src/routes/items/+page.server.ts",
"src/routes/+layout.ts" "src/routes/+layout.ts"
], ],
"/items/[id]": [
"src/routes/items/[id]/+page.server.ts",
"src/routes/+layout.ts"
],
"/login": [ "/login": [
"src/routes/login/+page.server.ts", "src/routes/login/+page.server.ts",
"src/routes/+layout.ts" "src/routes/+layout.ts"
@@ -15,5 +22,8 @@
"/logout": [ "/logout": [
"src/routes/logout/+page.server.ts", "src/routes/logout/+page.server.ts",
"src/routes/+layout.ts" "src/routes/+layout.ts"
],
"/transactions": [
"src/routes/+layout.ts"
] ]
} }
+3 -3
View File
@@ -13,8 +13,8 @@ type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends
export type Snapshot<T = any> = Kit.Snapshot<T>; export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageServerParentData = EnsureDefined<LayoutServerData>; type PageServerParentData = EnsureDefined<LayoutServerData>;
type PageParentData = EnsureDefined<LayoutData>; type PageParentData = EnsureDefined<LayoutData>;
type LayoutRouteId = RouteId | "/" | "/logout" | "/items" | "/login" | null type LayoutRouteId = RouteId | "/" | "/logout" | "/transactions" | "/accounts" | "/items" | "/items/[id]" | "/login" | null
type LayoutParams = RouteParams & { } type LayoutParams = RouteParams & { id?: string }
type LayoutParentData = EnsureDefined<{}>; type LayoutParentData = EnsureDefined<{}>;
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>; export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
@@ -26,7 +26,7 @@ export type Action<OutputData extends Record<string, any> | void = Record<string
export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId> export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId>
export type PageProps = { params: RouteParams; data: PageData; form: ActionData } export type PageProps = { params: RouteParams; data: PageData; form: ActionData }
export type LayoutServerData = null; export type LayoutServerData = null;
export type LayoutLoad<OutputData extends Partial<App.PageData> & Record<string, any> | void = Partial<App.PageData> & Record<string, any> | void> = Kit.Load<LayoutParams, LayoutServerData, LayoutParentData, OutputData, LayoutRouteId>; export type LayoutLoad<OutputData extends OutputDataShape<LayoutParentData> = OutputDataShape<LayoutParentData>> = Kit.Load<LayoutParams, LayoutServerData, LayoutParentData, OutputData, LayoutRouteId>;
export type LayoutLoadEvent = Parameters<LayoutLoad>[0]; export type LayoutLoadEvent = Parameters<LayoutLoad>[0];
export type LayoutData = Expand<Omit<LayoutParentData, keyof LayoutParentData & EnsureDefined<LayoutServerData>> & OptionalUnion<EnsureDefined<LayoutParentData & EnsureDefined<LayoutServerData>>>>; export type LayoutData = Expand<Omit<LayoutParentData, keyof LayoutParentData & EnsureDefined<LayoutServerData>> & OptionalUnion<EnsureDefined<LayoutParentData & EnsureDefined<LayoutServerData>>>>;
export type LayoutProps = { params: LayoutParams; data: LayoutData; children: import("svelte").Snippet } export type LayoutProps = { params: LayoutParams; data: LayoutData; children: import("svelte").Snippet }
+18
View File
@@ -0,0 +1,18 @@
import type * as Kit from '@sveltejs/kit';
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
// @ts-ignore
type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
type RouteParams = { };
type RouteId = '/accounts';
type MaybeWithVoid<T> = {} extends T ? T | void : T;
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
type EnsureDefined<T> = T extends null | undefined ? {} : T;
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
export type PageServerData = null;
export type PageData = Expand<PageParentData>;
export type PageProps = { params: RouteParams; data: PageData }
+32
View File
@@ -0,0 +1,32 @@
import type * as Kit from '@sveltejs/kit';
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
// @ts-ignore
type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
type RouteParams = { id: string };
type RouteId = '/items/[id]';
type MaybeWithVoid<T> = {} extends T ? T | void : T;
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
type EnsureDefined<T> = T extends null | undefined ? {} : T;
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageServerParentData = EnsureDefined<import('../../$types.js').LayoutServerData>;
type PageParentData = EnsureDefined<import('../../$types.js').LayoutData>;
export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>;
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
type ExcludeActionFailure<T> = T extends Kit.ActionFailure<any> ? never : T extends void ? never : T;
type ActionsSuccess<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: ExcludeActionFailure<Awaited<ReturnType<T[Key]>>>; }[keyof T];
type ExtractActionFailure<T> = T extends Kit.ActionFailure<infer X> ? X extends void ? never : X : never;
type ActionsFailure<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: Exclude<ExtractActionFailure<Awaited<ReturnType<T[Key]>>>, void>; }[keyof T];
type ActionsExport = typeof import('./proxy+page.server.js').actions
export type SubmitFunction = Kit.SubmitFunction<Expand<ActionsSuccess<ActionsExport>>, Expand<ActionsFailure<ActionsExport>>>
export type ActionData = Expand<Kit.AwaitedActions<ActionsExport>> | null;
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('./proxy+page.server.js').load>>>>>>;
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId>
export type PageProps = { params: RouteParams; data: PageData; form: ActionData }
export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
@@ -0,0 +1,91 @@
// @ts-nocheck
import { error, fail, redirect } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import type { StackqItem } from "$lib/types/items";
const COLLECTION = "Stackq_Items";
function parseNum(val: unknown): number | undefined {
if (val === "" || val === null || val === undefined) return undefined;
const n = Number(val);
return Number.isFinite(n) ? n : undefined;
}
function parseBool(val: unknown): boolean {
return val === "on" || val === "true" || val === true;
}
export const load = async ({ params, locals }: Parameters<PageServerLoad>[0]) => {
const id = params.id;
if (!id) throw error(404, "Item not found");
try {
const item = await locals.pb
.collection(COLLECTION)
.getOne<StackqItem>(id, { expand: "Parent" });
const list = await locals.pb
.collection(COLLECTION)
.getFullList<StackqItem>({ sort: "-created", expand: "Parent" })
.catch(() => [] as StackqItem[]);
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
return { item, itemsForParent };
} catch {
throw error(404, "Item not found");
}
};
export const actions = {
update: async ({ request, locals, params }: import('./$types').RequestEvent) => {
const id = params.id;
if (!id) return fail(400, { error: "Missing id", form: "update" });
const form = await request.formData();
const Item = (form.get("Item") as string)?.trim() ?? "";
const SKU = (form.get("SKU") as string)?.trim() ?? "";
const Description = (form.get("Description") as string)?.trim() ?? "";
const UOM = (form.get("UOM") as string)?.trim() ?? "";
const Cost = parseNum(form.get("Cost"));
const Vendor = (form.get("Vendor") as string)?.trim() ?? "";
const Catagory = (form.get("Catagory") as string)?.trim() ?? "";
const Sub_Catagory = (form.get("Sub_Catagory") as string)?.trim() ?? "";
const Has_Parent = parseBool(form.get("Has_Parent"));
const Parent = (form.get("Parent") as string)?.trim() ?? "";
const Stock = parseBool(form.get("Stock"));
const Image = form.get("Image") as File | null;
const imageFile = Image && Image.size > 0 ? Image : undefined;
if (!Item) return fail(400, { error: "Item name is required", form: "update" });
try {
const body: Record<string, unknown> = {
Item,
SKU,
Description,
UOM: UOM || undefined,
Cost: Cost ?? 0,
Vendor,
Catagory: Catagory || undefined,
Sub_Catagory: Sub_Catagory || undefined,
Has_Parent: !!Has_Parent,
Parent: Parent || null,
Stock: !!Stock,
};
if (imageFile) body.Image = imageFile;
await locals.pb.collection(COLLECTION).update(id, body);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Update failed";
return fail(500, { error: message, form: "update" });
}
return { success: true };
},
delete: async ({ locals, params }: import('./$types').RequestEvent) => {
const id = params.id;
if (!id) return fail(400, { error: "Missing id", form: "delete" });
try {
await locals.pb.collection(COLLECTION).delete(id);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Delete failed";
return fail(500, { error: message, form: "delete" });
}
throw redirect(303, "/items");
},
};
;null as any as Actions;
@@ -11,5 +11,5 @@ export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
.catch(() => [] as StackqItem[]); .catch(() => [] as StackqItem[]);
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU })); const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
return { itemsForParent }; return { items: list, itemsForParent };
}; };
+18
View File
@@ -0,0 +1,18 @@
import type * as Kit from '@sveltejs/kit';
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
// @ts-ignore
type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
type RouteParams = { };
type RouteId = '/transactions';
type MaybeWithVoid<T> = {} extends T ? T | void : T;
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
type EnsureDefined<T> = T extends null | undefined ? {} : T;
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
export type PageServerData = null;
export type PageData = Expand<PageParentData>;
export type PageProps = { params: RouteParams; data: PageData }
+4 -1
View File
@@ -3,7 +3,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='4' fill='%231e293b'/><text x='16' y='22' font-size='18' font-family='system-ui' fill='%2394a3b8' text-anchor='middle'>S</text></svg>" /> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='4' fill='%231e293b'/><text x='16' y='22' font-size='18' font-family='system-ui' fill='%2394a3b8' text-anchor='middle'>S</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+185
View File
@@ -0,0 +1,185 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog";
import { Search, X } from "@lucide/svelte";
let {
open = $bindable(false),
onResult,
title = "Scan barcode",
}: {
open?: boolean;
onResult?: (value: string) => void;
title?: string;
} = $props();
let scanError = $state<string | null>(null);
let searchInput = $state("");
let videoEl = $state<HTMLVideoElement | undefined>(undefined);
let stream = $state<MediaStream | null>(null);
let scanRAF = $state(0);
function submitSearch() {
const value = searchInput.trim();
if (!value) return;
stopScan();
open = false;
searchInput = "";
onResult?.(value);
}
async function ensureBarcodeDetector(): Promise<boolean> {
if (typeof globalThis === "undefined" || "BarcodeDetector" in globalThis) return true;
try {
const mod = await import("barcode-detector-api-polyfill");
(globalThis as unknown as { BarcodeDetector: typeof BarcodeDetector }).BarcodeDetector =
mod.BarcodeDetector;
return true;
} catch {
return false;
}
}
async function startScan() {
scanError = null;
const hasDetector = await ensureBarcodeDetector();
if (!hasDetector) {
scanError = "Failed to load barcode scanner. Try again or use Chrome/Edge.";
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
});
} catch (e) {
scanError = e instanceof Error ? e.message : "Could not access camera.";
}
}
function stopScan() {
if (scanRAF) {
cancelAnimationFrame(scanRAF);
scanRAF = 0;
}
if (stream) {
stream.getTracks().forEach((t) => t.stop());
stream = null;
}
if (videoEl?.srcObject) {
videoEl.srcObject = null;
}
scanError = null;
}
$effect(() => {
if (!open) {
stopScan();
searchInput = "";
return;
}
startScan();
return () => stopScan();
});
$effect(() => {
if (!open || !stream || !videoEl) return;
videoEl.srcObject = stream;
videoEl.play().catch(() => {});
return () => {
if (videoEl?.srcObject) videoEl.srcObject = null;
};
});
const SCAN_INTERVAL_MS = 300;
function scanLoop() {
if (!videoEl || !("BarcodeDetector" in globalThis)) return;
const detector = new (globalThis as unknown as { BarcodeDetector: typeof BarcodeDetector }).BarcodeDetector();
let lastScan = 0;
async function detect() {
if (!open || !videoEl) return;
if (videoEl.readyState < 2) {
scanRAF = requestAnimationFrame(detect);
return;
}
const now = Date.now();
if (now - lastScan >= SCAN_INTERVAL_MS) {
lastScan = now;
try {
const barcodes = await detector.detect(videoEl);
if (barcodes.length > 0) {
const value = barcodes[0].rawValue?.trim() ?? "";
stopScan();
open = false;
onResult?.(value);
return;
}
} catch {
// ignore
}
}
scanRAF = requestAnimationFrame(detect);
}
detect();
}
$effect(() => {
if (!open || !stream || !videoEl) return;
scanLoop();
return () => {
if (scanRAF) cancelAnimationFrame(scanRAF);
};
});
</script>
<Dialog.Root bind:open>
<Dialog.Content
class="flex max-h-[100dvh] w-[100vw] max-w-none flex-col gap-4 rounded-none border-0 p-4 sm:max-h-[90dvh] sm:w-auto sm:max-w-md sm:rounded-lg sm:border"
onclose={() => stopScan()}
>
<Dialog.Header class="shrink-0">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>Scan a barcode or type a code to look up an item.</Dialog.Description>
</Dialog.Header>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<div class="flex shrink-0 gap-2">
<Input
type="text"
placeholder="Type barcode or SKU to search…"
bind:value={searchInput}
onkeydown={(e) => e.key === "Enter" && submitSearch()}
class="min-h-11 flex-1"
/>
<Button type="button" class="min-h-11 shrink-0" onclick={submitSearch} disabled={!searchInput.trim()}>
<Search class="mr-2 size-4" />
Search
</Button>
</div>
<p class="text-muted-foreground shrink-0 text-center text-xs">or use camera below</p>
{#if scanError}
<p class="text-destructive text-sm">{scanError}</p>
{:else if stream}
<video
bind:this={videoEl}
class="border-input max-h-[50vh] w-full flex-1 rounded-lg border object-cover sm:max-h-none"
muted
playsinline
></video>
{:else}
<p class="text-muted-foreground text-sm">Starting camera…</p>
{/if}
<div class="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
class="min-h-12 flex-1"
onclick={() => { stopScan(); open = false; }}
>
<X class="mr-2 size-4" />
Cancel
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
+1 -1
View File
@@ -10,5 +10,5 @@ export const load: PageServerLoad = async ({ locals }) => {
.catch(() => [] as StackqItem[]); .catch(() => [] as StackqItem[]);
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU })); const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
return { itemsForParent }; return { items: list, itemsForParent };
}; };
+22 -7
View File
@@ -2,29 +2,44 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
let { data }: { data: { itemsForParent: { id: string; Item: string; SKU: string }[] } } = $props(); let { data }: { data: { items: { id: string; SKU?: string }[]; itemsForParent: { id: string; Item: string; SKU: string }[] } } = $props();
</script> </script>
{#if browser} {#if browser}
{#await import("./HomeWithDialog.svelte")} {#await import("./HomeWithDialog.svelte")}
<main class="flex min-h-screen flex-col items-center justify-center gap-6 p-8"> <main class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 sm:p-8">
<p class="text-muted-foreground">Loading…</p> <p class="text-muted-foreground">Loading…</p>
</main> </main>
{:then { default: HomeWithDialog }} {:then { default: HomeWithDialog }}
<HomeWithDialog data={data} /> <HomeWithDialog data={data} />
{/await} {/await}
{:else} {:else}
<main class="flex min-h-screen flex-col items-center justify-center gap-6 p-8"> <main class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 sm:p-8">
<h1 class="text-2xl font-semibold">Stackq</h1> <h1 class="text-2xl font-semibold">Stackq</h1>
<div class="flex w-full max-w-xs flex-col gap-3">
<a href="/items" class="block">
<Button class="h-12 w-full min-h-12">Scan</Button>
</a>
<a href="/transactions" class="block">
<Button class="h-12 min-h-12 w-full" variant="outline">New Transaction</Button>
</a>
<a href="/items" class="block">
<Button class="h-12 min-h-12 w-full" variant="outline">New Item</Button>
</a>
</div>
<div class="text-muted-foreground w-full max-w-xs border-t pt-4 text-center text-sm">Go to</div>
<div class="flex w-full max-w-xs flex-col gap-2"> <div class="flex w-full max-w-xs flex-col gap-2">
<a href="/items" class="block"> <a href="/items" class="block">
<Button class="w-full">Items table</Button> <Button class="h-12 min-h-12 w-full" variant="outline">Items</Button>
</a> </a>
<a href="/items" class="block"> <a href="/transactions" class="block">
<Button class="w-full" variant="outline">New item</Button> <Button class="h-12 min-h-12 w-full" variant="outline">Transactions</Button>
</a>
<a href="/accounts" class="block">
<Button class="h-12 min-h-12 w-full" variant="outline">Accounts</Button>
</a> </a>
<a href="/logout" class="block"> <a href="/logout" class="block">
<Button class="w-full" variant="outline">Log out</Button> <Button class="h-12 min-h-12 w-full" variant="outline">Log out</Button>
</a> </a>
</div> </div>
</main> </main>
+72 -11
View File
@@ -2,40 +2,99 @@
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog"; import * as Dialog from "$lib/components/ui/dialog";
import ItemForm from "./items/ItemForm.svelte"; import ItemForm from "./items/ItemForm.svelte";
import BarcodeScanDialog from "$lib/components/BarcodeScanDialog.svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { Camera, Package, Receipt, Wallet } from "@lucide/svelte";
let { let {
data, data,
}: { }: {
data: { itemsForParent: { id: string; Item: string; SKU: string }[] }; data: {
items: { id: string; SKU?: string }[];
itemsForParent: { id: string; Item: string; SKU: string }[];
};
} = $props(); } = $props();
let dialogOpen = $state(false); let dialogOpen = $state(false);
let scanDialogOpen = $state(false);
const items = $derived(data?.items ?? []);
const itemsForParent = $derived(data?.itemsForParent ?? []); const itemsForParent = $derived(data?.itemsForParent ?? []);
function onSuccess() { function onItemSuccess() {
dialogOpen = false; dialogOpen = false;
goto("/items"); goto("/items");
} }
function onScanResult(value: string) {
const sku = value.trim();
const found = items.find((i) => (i.SKU ?? "").trim() === sku);
if (found) goto("/items/" + found.id);
else goto("/items?q=" + encodeURIComponent(sku));
}
</script> </script>
<main class="flex min-h-screen flex-col items-center justify-center gap-6 p-8"> <main class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 sm:p-8">
<h1 class="text-2xl font-semibold">Stackq</h1> <h1 class="w-full max-w-xs text-center text-2xl font-semibold">Stackq</h1>
<div class="flex w-full max-w-xs flex-col gap-3">
<p class="text-muted-foreground text-center text-sm font-medium">Quick actions</p>
<Button class="h-16 min-h-16 w-full" onclick={() => (scanDialogOpen = true)}>
<span class="flex w-full flex-col items-center justify-center gap-1">
<Camera class="size-5" />
<span class="text-sm font-medium leading-none">Scan</span>
</span>
</Button>
<a href="/transactions" class="block">
<Button class="h-16 min-h-16 w-full" variant="outline">
<span class="flex w-full flex-col items-center justify-center gap-1">
<Receipt class="size-5" />
<span class="text-sm font-medium leading-none">New Transaction</span>
</span>
</Button>
</a>
<Button class="h-16 min-h-16 w-full" variant="outline" onclick={() => (dialogOpen = true)}>
<span class="flex w-full flex-col items-center justify-center gap-1">
<Package class="size-5" />
<span class="text-sm font-medium leading-none">New Item</span>
</span>
</Button>
</div>
<div class="text-muted-foreground w-full max-w-xs border-t pt-4 text-center text-sm">Go to</div>
<div class="flex w-full max-w-xs flex-col gap-2"> <div class="flex w-full max-w-xs flex-col gap-2">
<a href="/items" class="block"> <a href="/items" class="block">
<Button class="w-full">Items table</Button> <Button class="h-16 min-h-16 w-full" variant="outline">
<span class="flex w-full flex-col items-center justify-center gap-1">
<Package class="size-4" />
<span class="text-sm font-medium leading-none">Items</span>
</span>
</Button>
</a>
<a href="/transactions" class="block">
<Button class="h-16 min-h-16 w-full" variant="outline">
<span class="flex w-full flex-col items-center justify-center gap-1">
<Receipt class="size-4" />
<span class="text-sm font-medium leading-none">Transactions</span>
</span>
</Button>
</a>
<a href="/accounts" class="block">
<Button class="h-16 min-h-16 w-full" variant="outline">
<span class="flex w-full flex-col items-center justify-center gap-1">
<Wallet class="size-4" />
<span class="text-sm font-medium leading-none">Accounts</span>
</span>
</Button>
</a> </a>
<Button class="w-full" variant="outline" onclick={() => (dialogOpen = true)}>
New item
</Button>
<a href="/logout" class="block"> <a href="/logout" class="block">
<Button class="w-full" variant="outline">Log out</Button> <Button class="h-16 min-h-16 w-full justify-center" variant="outline">Log out</Button>
</a> </a>
</div> </div>
</main> </main>
<Dialog.Root bind:open={dialogOpen}> <Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-[500px]"> <Dialog.Content class="max-h-[90dvh] w-[calc(100vw-2rem)] max-w-[500px] overflow-y-auto rounded-lg sm:w-full">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>New item</Dialog.Title> <Dialog.Title>New item</Dialog.Title>
<Dialog.Description>Add a new Stackq item.</Dialog.Description> <Dialog.Description>Add a new Stackq item.</Dialog.Description>
@@ -44,7 +103,9 @@
itemsForParent={itemsForParent} itemsForParent={itemsForParent}
action="/items?/create" action="/items?/create"
submitLabel="Create" submitLabel="Create"
onSuccess={onSuccess} onSuccess={onItemSuccess}
/> />
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
<BarcodeScanDialog bind:open={scanDialogOpen} onResult={onScanResult} title="Scan to look up item" />
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { Home } from "@lucide/svelte";
</script>
<svelte:head>
<title>Accounts Stackq</title>
</svelte:head>
<main class="container mx-auto flex flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
<div class="flex items-center gap-3">
<a href="/" class="touch-target text-muted-foreground hover:text-foreground shrink-0" title="Back to main">
<Home class="size-5" />
</a>
<h1 class="text-xl font-semibold sm:text-2xl">Accounts</h1>
</div>
<Card>
<CardContent class="flex flex-col items-center justify-center gap-4 py-12">
<p class="text-muted-foreground text-center text-sm">No accounts yet.</p>
<p class="text-muted-foreground text-center text-xs">Accounts will appear here once this feature is available.</p>
<a href="/">
<Button variant="outline">Back to home</Button>
</a>
</CardContent>
</Card>
</main>
+41 -107
View File
@@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import * as Table from "$lib/components/ui/table"; import { page } from "$app/stores";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { Card, CardContent } from "$lib/components/ui/card"; import { Card, CardContent } from "$lib/components/ui/card";
import type { StackqItem } from "$lib/types/items"; import type { StackqItem } from "$lib/types/items";
let { data }: { data: { items: StackqItem[]; itemsForParent: { id: string; Item: string; SKU: string }[] } } = $props(); let { data }: { data: { items: StackqItem[]; itemsForParent: { id: string; Item: string; SKU: string }[] } } = $props();
const searchFromUrl = $derived($page.url.searchParams.get("q") ?? "");
function asList(val: string | string[] | undefined): string[] { function asList(val: string | string[] | undefined): string[] {
if (val == null) return []; if (val == null) return [];
return Array.isArray(val) ? val : [val]; return Array.isArray(val) ? val : [val];
@@ -39,120 +41,52 @@
{#if browser} {#if browser}
{#await import("./ItemsCrud.svelte")} {#await import("./ItemsCrud.svelte")}
<main class="container mx-auto flex flex-col gap-6 py-8"> <main class="container mx-auto flex flex-col gap-6 px-4 py-8">
<p class="text-muted-foreground">Loading…</p> <p class="text-muted-foreground">Loading…</p>
</main> </main>
{:then { default: ItemsCrud }} {:then { default: ItemsCrud }}
<ItemsCrud data={data} /> <ItemsCrud data={data} searchFromUrl={searchFromUrl} />
{/await} {/await}
{:else} {:else}
<main class="container mx-auto flex flex-col gap-6 py-8"> <main class="container mx-auto flex flex-col gap-6 px-4 py-8">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Stackq Items</h1> <h1 class="text-2xl font-semibold">Stackq Items</h1>
<span class="text-muted-foreground text-sm">{items.length} items</span> <span class="text-muted-foreground text-sm">{items.length} items</span>
</div> </div>
<Card> {#if items.length === 0}
<CardContent class="p-0"> <Card>
<Table.Root> <CardContent class="flex h-32 items-center justify-center">
<Table.Header> <p class="text-muted-foreground text-sm">No items.</p>
<Table.Row> </CardContent>
<Table.Head>Image</Table.Head> </Card>
<Table.Head>Item</Table.Head> {:else}
<Table.Head>SKU</Table.Head> <div class="flex flex-col gap-2">
<Table.Head class="max-w-[200px]">Description</Table.Head> {#each items as item (item.id)}
<Table.Head>UOM</Table.Head> <Card class="overflow-hidden">
<Table.Head class="text-right">Cost</Table.Head> <CardContent class="flex flex-row items-center gap-3 p-3">
<Table.Head>Vendor</Table.Head> <div class="size-12 shrink-0 overflow-hidden rounded border">
<Table.Head>Catagory</Table.Head> {#if imageUrl(item)}
<Table.Head>Sub_Catagory</Table.Head> <img src={imageUrl(item)!} alt="" class="size-full object-cover" loading="lazy" />
<Table.Head>Has Parent</Table.Head> {:else}
<Table.Head>Parent</Table.Head> <span class="text-muted-foreground flex size-full items-center justify-center text-xs"></span>
<Table.Head>Stock</Table.Head> {/if}
<Table.Head class="text-muted-foreground">created</Table.Head> </div>
<Table.Head class="text-muted-foreground">updated</Table.Head> <div class="min-w-0 flex-1">
</Table.Row> <a href="/items/{item.id}" class="font-medium hover:underline">{item.Item ?? "—"}</a>
</Table.Header> <span class="text-muted-foreground font-mono text-xs"> · {item.SKU ?? "—"}</span>
<Table.Body> {#if item.Description}
{#if items.length === 0} <span class="text-muted-foreground ml-1 truncate text-xs">· {item.Description}</span>
<Table.Row> {/if}
<Table.Cell colspan="14" class="h-24 text-center text-muted-foreground"> </div>
No items. <div class="flex shrink-0 items-center gap-2 text-xs">
</Table.Cell> <Badge variant="outline">{item.UOM ?? "—"}</Badge>
</Table.Row> <span class="tabular-nums">{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"}</span>
{:else} {#if item.Stock}<Badge variant="default" class="text-xs">Stock</Badge>{/if}
{#each items as item (item.id)} </div>
<Table.Row> </CardContent>
<Table.Cell class="w-16"> </Card>
{#if imageUrl(item)} {/each}
<img </div>
src={imageUrl(item)!} {/if}
alt=""
class="size-14 rounded border object-cover"
loading="lazy"
/>
{:else}
<span class="text-muted-foreground text-xs"></span>
{/if}
</Table.Cell>
<Table.Cell class="font-medium">{item.Item ?? "—"}</Table.Cell>
<Table.Cell class="font-mono text-xs">{item.SKU ?? "—"}</Table.Cell>
<Table.Cell class="max-w-[200px] truncate" title={item.Description ?? ""}>
{item.Description ?? "—"}
</Table.Cell>
<Table.Cell>
<Badge variant="outline">{item.UOM ?? "—"}</Badge>
</Table.Cell>
<Table.Cell class="text-right tabular-nums">
{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"}
</Table.Cell>
<Table.Cell class="text-muted-foreground">{item.Vendor ?? "—"}</Table.Cell>
<Table.Cell>
{#each asList(item.Catagory) as cat}
<Badge variant="secondary" class="mr-1 text-xs">{cat}</Badge>
{/each}
{#if asList(item.Catagory).length === 0}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell>
{#each asList(item.Sub_Catagory) as sub}
<Badge variant="outline" class="mr-1 text-xs">{sub}</Badge>
{/each}
{#if asList(item.Sub_Catagory).length === 0}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell>
{#if item.Has_Parent}
<Badge>Yes</Badge>
{:else}
<span class="text-muted-foreground">No</span>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground">
{#if item.expand?.Parent}
{item.expand.Parent.Item ?? item.expand.Parent.SKU ?? item.Parent}
{:else if item.Parent}
<span class="font-mono text-xs">{item.Parent}</span>
{:else}
{/if}
</Table.Cell>
<Table.Cell>
{#if item.Stock}
<Badge variant="default">Stock</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">{formatDate(item.created)}</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">{formatDate(item.updated)}</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</CardContent>
</Card>
</main> </main>
{/if} {/if}
+159 -164
View File
@@ -1,31 +1,53 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card"; import { Card, CardContent } from "$lib/components/ui/card";
import * as Dialog from "$lib/components/ui/dialog"; import * as Dialog from "$lib/components/ui/dialog";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { Input } from "$lib/components/ui/input";
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
import { goto } from "$app/navigation";
import BarcodeScanDialog from "$lib/components/BarcodeScanDialog.svelte";
import ItemForm from "./ItemForm.svelte"; import ItemForm from "./ItemForm.svelte";
import { Ellipsis, Pencil, Plus, Trash2 } from "@lucide/svelte"; import { Camera, Ellipsis, ExternalLink, Home, Pencil, Plus, Search, Trash2 } from "@lucide/svelte";
import type { StackqItem } from "$lib/types/items"; import type { StackqItem } from "$lib/types/items";
let { let {
data, data,
searchFromUrl = "",
}: { }: {
data: { data: {
items: StackqItem[]; items: StackqItem[];
itemsForParent: { id: string; Item: string; SKU: string }[]; itemsForParent: { id: string; Item: string; SKU: string }[];
}; };
searchFromUrl?: string;
} = $props(); } = $props();
let dialogOpen = $state(false); let dialogOpen = $state(false);
let scanDialogOpen = $state(false);
let editingItem = $state<StackqItem | null>(null); let editingItem = $state<StackqItem | null>(null);
let formWhich = $state<"create" | "update">("create"); let formWhich = $state<"create" | "update">("create");
let searchQuery = $state(searchFromUrl);
const items = $derived(data?.items ?? []); const items = $derived(data?.items ?? []);
const itemsForParent = $derived(data?.itemsForParent ?? []); const itemsForParent = $derived(data?.itemsForParent ?? []);
const filteredItems = $derived(
(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return items;
return items.filter((i) => {
const item = (i.Item ?? "").toLowerCase();
const sku = (i.SKU ?? "").toLowerCase();
const desc = (i.Description ?? "").toLowerCase();
const vendor = (i.Vendor ?? "").toLowerCase();
const cat = Array.isArray(i.Catagory) ? i.Catagory.join(" ").toLowerCase() : (i.Catagory ?? "").toLowerCase();
const sub = Array.isArray(i.Sub_Catagory) ? i.Sub_Catagory.join(" ").toLowerCase() : (i.Sub_Catagory ?? "").toLowerCase();
return item.includes(q) || sku.includes(q) || desc.includes(q) || vendor.includes(q) || cat.includes(q) || sub.includes(q);
});
})()
);
const PB_BASE = "https://pocketbase.ccllc.pro"; const PB_BASE = "https://pocketbase.ccllc.pro";
const COLLECTION = "Stackq_Items"; const COLLECTION = "Stackq_Items";
function imageUrl(item: StackqItem): string | null { function imageUrl(item: StackqItem): string | null {
@@ -67,6 +89,13 @@
editingItem = null; editingItem = null;
} }
function onScanResult(value: string) {
const sku = value.trim();
const found = items.find((i) => (i.SKU ?? "").trim() === sku);
if (found) goto("/items/" + found.id);
else goto("/items?q=" + encodeURIComponent(sku));
}
const formError = $derived( const formError = $derived(
data?.form === "create" || data?.form === "update" ? (data?.error as string | undefined) ?? null : null data?.form === "create" || data?.form === "update" ? (data?.error as string | undefined) ?? null : null
); );
@@ -76,179 +105,143 @@
dialogOpen = true; dialogOpen = true;
} }
}); });
$effect(() => {
if (searchFromUrl) searchQuery = searchFromUrl;
});
</script> </script>
<svelte:head> <svelte:head>
<title>Items Stackq</title> <title>Items Stackq</title>
</svelte:head> </svelte:head>
<main class="container mx-auto flex flex-col gap-6 py-8"> <main class="container mx-auto flex flex-col gap-4 px-4 py-6 sm:gap-6 sm:px-6 sm:py-8">
<div class="flex items-center justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 class="text-2xl font-semibold">Stackq Items</h1> <div class="flex items-center gap-2 min-w-0">
<div class="flex items-center gap-2"> <a href="/" class="touch-target text-muted-foreground hover:text-foreground shrink-0" title="Back to main">
<span class="text-muted-foreground text-sm">{items.length} items</span> <Home class="size-5" />
<Button onclick={openCreate}> </a>
<Plus class="mr-2 size-4" /> <h1 class="text-xl font-semibold truncate sm:text-2xl">Stackq Items</h1>
New item </div>
</Button> <div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2">
<div class="relative w-full sm:w-64 sm:min-w-0">
<Search class="text-muted-foreground absolute left-3 top-1/2 size-4 -translate-y-1/2" />
<Input
type="search"
placeholder="Search items, SKU…"
bind:value={searchQuery}
class="h-11 w-full pl-9 sm:h-9"
/>
</div>
<span class="text-muted-foreground text-sm shrink-0">
{filteredItems.length}{filteredItems.length !== items.length ? ` of ${items.length}` : ""} items
</span>
<div class="flex gap-2">
<Button variant="outline" onclick={() => (scanDialogOpen = true)} title="Scan barcode to look up item" class="min-h-11 flex-1 sm:flex-none">
<Camera class="mr-2 size-4 shrink-0" />
Scan
</Button>
<Button onclick={openCreate} class="min-h-11 flex-1 sm:flex-none">
<Plus class="mr-2 size-4 shrink-0" />
New item
</Button>
</div>
</div> </div>
</div> </div>
<Card> {#if filteredItems.length === 0}
<CardContent class="p-0"> <Card>
<Table.Root> <CardContent class="flex h-32 items-center justify-center">
<Table.Header> <p class="text-muted-foreground text-center text-sm">
<Table.Row> {items.length === 0
<Table.Head>Image</Table.Head> ? "No items. Create one or check PocketBase collection name and auth."
<Table.Head>Item</Table.Head> : "No items match your search."}
<Table.Head>SKU</Table.Head> </p>
<Table.Head class="max-w-[200px]">Description</Table.Head> </CardContent>
<Table.Head>UOM</Table.Head> </Card>
<Table.Head class="text-right">Cost</Table.Head> {:else}
<Table.Head>Vendor</Table.Head> <div class="flex flex-col gap-2">
<Table.Head>Catagory</Table.Head> {#each filteredItems as item (item.id)}
<Table.Head>Sub_Catagory</Table.Head> <Card class="overflow-hidden">
<Table.Head>Has Parent</Table.Head> <CardContent class="flex flex-row items-center gap-3 p-3">
<Table.Head>Parent</Table.Head> <div class="size-12 shrink-0 overflow-hidden rounded border">
<Table.Head>Stock</Table.Head> {#if imageUrl(item)}
<Table.Head class="text-muted-foreground">created</Table.Head> <a
<Table.Head class="text-muted-foreground">updated</Table.Head> href={imageUrl(item)!}
<Table.Head class="w-[70px]">Actions</Table.Head> target="_blank"
</Table.Row> rel="noopener noreferrer"
</Table.Header> class="block size-full"
<Table.Body> >
{#if items.length === 0} <img
<Table.Row> src={imageUrl(item)!}
<Table.Cell colspan="15" class="h-24 text-center text-muted-foreground"> alt=""
No items. Create one or check PocketBase collection name and auth. class="size-full object-cover"
</Table.Cell> loading="lazy"
</Table.Row> />
{:else} </a>
{#each items as item (item.id)} {:else}
<Table.Row> <span class="text-muted-foreground flex size-full items-center justify-center text-xs"></span>
<Table.Cell class="w-16"> {/if}
{#if imageUrl(item)} </div>
<a <div class="min-w-0 flex-1">
href={imageUrl(item)!} <a href="/items/{item.id}" class="font-medium hover:underline">{item.Item ?? "—"}</a>
target="_blank" <span class="text-muted-foreground font-mono text-xs"> · {item.SKU ?? "—"}</span>
rel="noopener noreferrer" {#if item.Description}
class="block overflow-hidden rounded border" <span class="text-muted-foreground ml-1 truncate text-xs">· {item.Description}</span>
> {/if}
<img </div>
src={imageUrl(item)!} <div class="flex shrink-0 items-center gap-2 text-xs">
alt="" <Badge variant="outline">{item.UOM ?? "—"}</Badge>
class="size-14 object-cover" <span class="tabular-nums">{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"}</span>
loading="lazy" {#if item.Stock}<Badge variant="default" class="text-xs">Stock</Badge>{/if}
/> </div>
</a> <DropdownMenu.Root>
{:else} <DropdownMenu.Trigger
<span class="text-muted-foreground text-xs"></span> class="border-0 bg-transparent hover:bg-muted inline-flex min-h-9 min-w-9 shrink-0 items-center justify-center rounded-md"
{/if} >
</Table.Cell> <Ellipsis class="size-4" />
<Table.Cell class="font-medium">{item.Item ?? "—"}</Table.Cell> </DropdownMenu.Trigger>
<Table.Cell class="font-mono text-xs">{item.SKU ?? "—"}</Table.Cell> <DropdownMenu.Portal>
<Table.Cell class="max-w-[200px] truncate" title={item.Description ?? ""}> <DropdownMenu.Content align="end">
{item.Description ?? "—"} <DropdownMenu.Item onclick={() => goto("/items/" + item.id)}>
</Table.Cell> <ExternalLink class="mr-2 size-4" />
<Table.Cell> View
<Badge variant="outline">{item.UOM ?? "—"}</Badge> </DropdownMenu.Item>
</Table.Cell> <DropdownMenu.Item onclick={() => openEdit(item)}>
<Table.Cell class="text-right tabular-nums"> <Pencil class="mr-2 size-4" />
{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"} Edit
</Table.Cell> </DropdownMenu.Item>
<Table.Cell class="text-muted-foreground">{item.Vendor ?? "—"}</Table.Cell> <form
<Table.Cell> id="delete-form-{item.id}"
{#each asList(item.Catagory) as cat} method="POST"
<Badge variant="secondary" class="mr-1 text-xs">{cat}</Badge> action="?/delete"
{/each} use:enhance
{#if asList(item.Catagory).length === 0} class="hidden"
<span class="text-muted-foreground"></span> >
{/if} <input type="hidden" name="id" value={item.id} />
</Table.Cell> </form>
<Table.Cell> <DropdownMenu.Item
{#each asList(item.Sub_Catagory) as sub} class="text-destructive focus:text-destructive cursor-pointer"
<Badge variant="outline" class="mr-1 text-xs">{sub}</Badge> onclick={() => {
{/each} if (confirm("Delete this item? This cannot be undone.")) {
{#if asList(item.Sub_Catagory).length === 0} document.getElementById("delete-form-" + item.id)?.requestSubmit();
<span class="text-muted-foreground"></span> }
{/if} }}
</Table.Cell> >
<Table.Cell> <Trash2 class="mr-2 size-4" />
{#if item.Has_Parent} Delete
<Badge>Yes</Badge> </DropdownMenu.Item>
{:else} </DropdownMenu.Content>
<span class="text-muted-foreground">No</span> </DropdownMenu.Portal>
{/if} </DropdownMenu.Root>
</Table.Cell> </CardContent>
<Table.Cell class="text-muted-foreground"> </Card>
{#if item.expand?.Parent} {/each}
{item.expand.Parent.Item ?? item.expand.Parent.SKU ?? item.Parent} </div>
{:else if item.Parent} {/if}
<span class="font-mono text-xs">{item.Parent}</span>
{:else}
{/if}
</Table.Cell>
<Table.Cell>
{#if item.Stock}
<Badge variant="default">Stock</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">
{formatDate(item.created)}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">
{formatDate(item.updated)}
</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="border-0 bg-transparent hover:bg-muted inline-flex size-8 items-center justify-center rounded-md"
>
<Ellipsis class="size-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEdit(item)}>
<Pencil class="mr-2 size-4" />
Edit
</DropdownMenu.Item>
<form
id="delete-form-{item.id}"
method="POST"
action="?/delete"
use:enhance
class="hidden"
>
<input type="hidden" name="id" value={item.id} />
</form>
<DropdownMenu.Item
class="text-destructive focus:text-destructive cursor-pointer"
onclick={() => {
if (confirm("Delete this item? This cannot be undone.")) {
document.getElementById("delete-form-" + item.id)?.requestSubmit();
}
}}
>
<Trash2 class="mr-2 size-4" />
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</CardContent>
</Card>
<Dialog.Root bind:open={dialogOpen}> <Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-[500px]"> <Dialog.Content class="max-h-[90dvh] w-[calc(100vw-2rem)] max-w-[500px] overflow-y-auto rounded-lg sm:w-full">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{editingItem ? "Edit item" : "New item"}</Dialog.Title> <Dialog.Title>{editingItem ? "Edit item" : "New item"}</Dialog.Title>
<Dialog.Description> <Dialog.Description>
@@ -265,4 +258,6 @@
/> />
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
<BarcodeScanDialog bind:open={scanDialogOpen} onResult={onScanResult} title="Scan to look up item" />
</main> </main>
+89
View File
@@ -0,0 +1,89 @@
import { error, fail, redirect } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import type { StackqItem } from "$lib/types/items";
const COLLECTION = "Stackq_Items";
function parseNum(val: unknown): number | undefined {
if (val === "" || val === null || val === undefined) return undefined;
const n = Number(val);
return Number.isFinite(n) ? n : undefined;
}
function parseBool(val: unknown): boolean {
return val === "on" || val === "true" || val === true;
}
export const load: PageServerLoad = async ({ params, locals }) => {
const id = params.id;
if (!id) throw error(404, "Item not found");
try {
const item = await locals.pb
.collection(COLLECTION)
.getOne<StackqItem>(id, { expand: "Parent" });
const list = await locals.pb
.collection(COLLECTION)
.getFullList<StackqItem>({ sort: "-created", expand: "Parent" })
.catch(() => [] as StackqItem[]);
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
return { item, itemsForParent };
} catch {
throw error(404, "Item not found");
}
};
export const actions: Actions = {
update: async ({ request, locals, params }) => {
const id = params.id;
if (!id) return fail(400, { error: "Missing id", form: "update" });
const form = await request.formData();
const Item = (form.get("Item") as string)?.trim() ?? "";
const SKU = (form.get("SKU") as string)?.trim() ?? "";
const Description = (form.get("Description") as string)?.trim() ?? "";
const UOM = (form.get("UOM") as string)?.trim() ?? "";
const Cost = parseNum(form.get("Cost"));
const Vendor = (form.get("Vendor") as string)?.trim() ?? "";
const Catagory = (form.get("Catagory") as string)?.trim() ?? "";
const Sub_Catagory = (form.get("Sub_Catagory") as string)?.trim() ?? "";
const Has_Parent = parseBool(form.get("Has_Parent"));
const Parent = (form.get("Parent") as string)?.trim() ?? "";
const Stock = parseBool(form.get("Stock"));
const Image = form.get("Image") as File | null;
const imageFile = Image && Image.size > 0 ? Image : undefined;
if (!Item) return fail(400, { error: "Item name is required", form: "update" });
try {
const body: Record<string, unknown> = {
Item,
SKU,
Description,
UOM: UOM || undefined,
Cost: Cost ?? 0,
Vendor,
Catagory: Catagory || undefined,
Sub_Catagory: Sub_Catagory || undefined,
Has_Parent: !!Has_Parent,
Parent: Parent || null,
Stock: !!Stock,
};
if (imageFile) body.Image = imageFile;
await locals.pb.collection(COLLECTION).update(id, body);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Update failed";
return fail(500, { error: message, form: "update" });
}
return { success: true };
},
delete: async ({ locals, params }) => {
const id = params.id;
if (!id) return fail(400, { error: "Missing id", form: "delete" });
try {
await locals.pb.collection(COLLECTION).delete(id);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Delete failed";
return fail(500, { error: message, form: "delete" });
}
throw redirect(303, "/items");
},
};
+219
View File
@@ -0,0 +1,219 @@
<script lang="ts">
import { browser } from "$app/environment";
import { Button } from "$lib/components/ui/button";
import type { StackqItem } from "$lib/types/items";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import * as Dialog from "$lib/components/ui/dialog";
import { enhance } from "$app/forms";
import ItemForm from "../ItemForm.svelte";
import { ArrowLeft, Home, Pencil, Trash2 } from "@lucide/svelte";
let {
data,
}: {
data: {
item: StackqItem;
itemsForParent: { id: string; Item: string; SKU: string }[];
};
} = $props();
let dialogOpen = $state(false);
const item = $derived(data?.item);
const itemsForParent = $derived(data?.itemsForParent ?? []);
const PB_BASE = "https://pocketbase.ccllc.pro";
const COLLECTION = "Stackq_Items";
function imageUrl(i: StackqItem): string | null {
if (!i?.Image) return null;
return `${PB_BASE}/api/files/${COLLECTION}/${i.id}/${i.Image}`;
}
function asList(val: string | string[] | undefined): string[] {
if (val == null) return [];
return Array.isArray(val) ? val : [val];
}
function formatDate(iso: string) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString(undefined, {
dateStyle: "short",
timeStyle: "short",
});
} catch {
return iso;
}
}
const formError = $derived(
data?.form === "update" ? (data?.error as string | undefined) ?? null : null
);
$effect(() => {
if (formError && data?.form === "update") dialogOpen = true;
});
</script>
<svelte:head>
<title>{item?.Item ?? "Item"} Stackq</title>
</svelte:head>
{#if item}
<main class="container mx-auto flex flex-col gap-4 px-4 py-6 sm:gap-6 sm:px-6 sm:py-8">
<div class="flex flex-wrap items-center gap-2 sm:gap-4 min-w-0">
<a href="/" class="touch-target text-muted-foreground hover:text-foreground shrink-0" title="Back to main">
<Home class="size-5" />
</a>
<a href="/items" class="touch-target text-muted-foreground hover:text-foreground shrink-0" title="Back to items">
<ArrowLeft class="size-5" />
</a>
<h1 class="text-xl font-semibold truncate min-w-0 flex-1 sm:text-2xl">{item.Item ?? "—"}</h1>
{#if browser}
<div class="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onclick={() => (dialogOpen = true)} class="min-h-11 flex-1 sm:flex-none">
<Pencil class="mr-2 size-4" />
Edit
</Button>
<form
id="delete-form-detail"
method="POST"
action="?/delete"
use:enhance
class="inline"
>
<Button
type="button"
variant="destructive"
class="min-h-11 flex-1 sm:flex-none"
onclick={() => {
if (confirm("Delete this item? This cannot be undone.")) {
document.getElementById("delete-form-detail")?.requestSubmit();
}
}}
>
<Trash2 class="mr-2 size-4" />
Delete
</Button>
</form>
</div>
{/if}
</div>
<div class="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<h2 class="text-lg font-medium">Details</h2>
</CardHeader>
<CardContent class="space-y-4">
{#if imageUrl(item)}
<div>
<a
href={imageUrl(item)!}
target="_blank"
rel="noopener noreferrer"
class="block overflow-hidden rounded-lg border"
>
<img
src={imageUrl(item)!}
alt=""
class="max-h-64 w-full object-contain"
/>
</a>
</div>
{/if}
<dl class="grid gap-2 text-sm">
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">SKU</dt>
<dd class="font-mono">{item.SKU ?? "—"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Description</dt>
<dd>{item.Description ?? "—"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">UOM</dt>
<dd>{item.UOM ?? "—"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Cost</dt>
<dd>{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Vendor</dt>
<dd>{item.Vendor ?? "—"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Category</dt>
<dd>
{#each asList(item.Catagory) as cat}
<span class="mr-1 rounded bg-muted px-1.5 py-0.5 text-xs">{cat}</span>
{/each}
{#if asList(item.Catagory).length === 0}{/if}
</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Sub-category</dt>
<dd>
{#each asList(item.Sub_Catagory) as sub}
<span class="mr-1 rounded border px-1.5 py-0.5 text-xs">{sub}</span>
{/each}
{#if asList(item.Sub_Catagory).length === 0}{/if}
</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Has Parent</dt>
<dd>{item.Has_Parent ? "Yes" : "No"}</dd>
</div>
{#if item.Has_Parent && item.expand?.Parent}
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Parent</dt>
<dd>
<a href="/items/{item.Parent}" class="text-primary hover:underline">
{item.expand.Parent.Item ?? item.expand.Parent.SKU ?? item.Parent}
</a>
</dd>
</div>
{/if}
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Stock</dt>
<dd>{item.Stock ? "Yes" : "No"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Created</dt>
<dd>{formatDate(item.created)}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Updated</dt>
<dd>{formatDate(item.updated)}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
</main>
{#if browser}
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="max-h-[90dvh] w-[calc(100vw-2rem)] max-w-[500px] overflow-y-auto rounded-lg sm:w-full">
<Dialog.Header>
<Dialog.Title>Edit item</Dialog.Title>
<Dialog.Description>Update the item below.</Dialog.Description>
</Dialog.Header>
<ItemForm
item={item}
itemsForParent={itemsForParent}
action="?/update"
submitLabel="Update"
error={formError ?? null}
onSuccess={() => (dialogOpen = false)}
/>
</Dialog.Content>
</Dialog.Root>
{/if}
{:else}
<main class="container mx-auto py-8">
<p class="text-muted-foreground">Item not found.</p>
</main>
{/if}
+33
View File
@@ -104,5 +104,38 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
/* Safe area for notched devices */
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
}
/* Larger tap targets on touch devices (buttons only; links use .touch-target where needed) */
@media (pointer: coarse) {
button:not([class*="size-"]) {
min-height: 44px;
}
}
}
/* Use on icon-only links for 44px touch target */
.touch-target {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0.5rem;
}
/* Prevent iOS zoom on input focus (requires font-size >= 16px) */
@media (max-width: 640px) {
input[type="text"],
input[type="search"],
input[type="number"],
input[type="email"],
input[type="password"],
select,
textarea {
font-size: 16px;
} }
} }
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { Home } from "@lucide/svelte";
</script>
<svelte:head>
<title>Transactions Stackq</title>
</svelte:head>
<main class="container mx-auto flex flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
<div class="flex items-center gap-3">
<a href="/" class="touch-target text-muted-foreground hover:text-foreground shrink-0" title="Back to main">
<Home class="size-5" />
</a>
<h1 class="text-xl font-semibold sm:text-2xl">Transactions</h1>
</div>
<Card>
<CardContent class="flex flex-col items-center justify-center gap-4 py-12">
<p class="text-muted-foreground text-center text-sm">No transactions yet.</p>
<p class="text-muted-foreground text-center text-xs">Transactions will appear here once this feature is available.</p>
<a href="/">
<Button variant="outline">Back to home</Button>
</a>
</CardContent>
</Card>
</main>