Update UI components in +page.svelte and HomeWithDialog.svelte to disable certain buttons with a "Coming soon" label, enhancing user experience by indicating future functionality. Adjust server internal options to reflect a new version hash.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m28s

This commit is contained in:
eewing
2026-02-26 09:20:43 -06:00
parent 8b977b5e86
commit 47625259ba
6 changed files with 145 additions and 101 deletions
+88 -66
View File
@@ -11,6 +11,13 @@ function isHeicFile(file: File): boolean {
);
}
/** True if the file is an image we can process (image/* or HEIC by type/extension). */
export function isAcceptableImageFile(file: File | null): boolean {
if (!file) return false;
const type = (file.type ?? "").toLowerCase();
return type.startsWith("image/") || isHeicFile(file);
}
function makeOutputFile(blob: Blob, baseName: string, ext: string): File {
return new File([blob], `${baseName}.${ext}`, {
type: blob.type,
@@ -18,78 +25,93 @@ function makeOutputFile(blob: Blob, baseName: string, ext: string): File {
});
}
/**
* Converts an image File to WebP (or JPEG on Safari where WebP isn't supported).
* HEIC from iPhone is handled: if the image fails to load, we decode with heic2any first.
*/
export function convertImageToWebP(file: File, quality = 0.85): Promise<File> {
function isSafari(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent;
return ua.includes("Safari") && !ua.includes("Chrome");
}
/** Load blob in Image, draw to canvas, encode as WebP or JPEG. Rejects if image fails to load or encode. */
function tryCanvas(sourceBlob: Blob, baseName: string, quality: number): Promise<File> {
return new Promise((resolve, reject) => {
const baseName = file.name.replace(/\.[^.]+$/i, "") || "image";
const tryCanvas = (sourceBlob: Blob) => {
const url = URL.createObjectURL(sourceBlob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Could not get canvas context"));
return;
}
ctx.drawImage(img, 0, 0);
// Safari doesn't support WebP in toBlob; try WebP first, fall back to JPEG
canvas.toBlob(
(blob) => {
if (blob && blob.type === "image/webp") {
resolve(makeOutputFile(blob, baseName, "webp"));
return;
}
canvas.toBlob(
(jpegBlob) => {
if (!jpegBlob) {
reject(new Error("Failed to encode image"));
return;
}
resolve(makeOutputFile(jpegBlob, baseName, "jpg"));
},
"image/jpeg",
quality
);
},
"image/webp",
quality
);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to load image"));
};
img.src = url;
};
const url = URL.createObjectURL(file);
const url = URL.createObjectURL(sourceBlob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
tryCanvas(file);
};
img.onerror = async () => {
URL.revokeObjectURL(url);
// HEIC often fails to load in Image() on iOS; decode with heic2any then run canvas
if (isHeicFile(file)) {
try {
const jpegBlob = await convertHeicBlobToJpeg(file);
tryCanvas(jpegBlob);
} catch (e) {
reject(e instanceof Error ? e : new Error("Failed to convert HEIC"));
}
} else {
reject(new Error("Failed to load image"));
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Could not get canvas context"));
return;
}
ctx.drawImage(img, 0, 0);
// Safari doesn't support WebP in toBlob; try WebP first, fall back to JPEG
canvas.toBlob(
(blob) => {
if (blob && blob.type === "image/webp") {
resolve(makeOutputFile(blob, baseName, "webp"));
return;
}
canvas.toBlob(
(jpegBlob) => {
if (!jpegBlob) {
reject(new Error("Failed to encode image"));
return;
}
resolve(makeOutputFile(jpegBlob, baseName, "jpg"));
},
"image/jpeg",
quality
);
},
"image/webp",
quality
);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to load image"));
};
img.src = url;
});
}
/**
* Converts an image File to WebP (or JPEG on Safari where WebP isn't supported).
* HEIC: Safari uses native Image() first, then heic2any fallback; other browsers use heic2any first, then Image() fallback.
*/
export async function convertImageToWebP(file: File, quality = 0.85): Promise<File> {
const baseName = file.name.replace(/\.[^.]+$/i, "") || "image";
if (isHeicFile(file)) {
const safari = isSafari();
if (safari) {
// Safari can decode HEIC in Image(); heic2any is unreliable on Safari (e.g. transparent output)
try {
return await tryCanvas(file, baseName, quality);
} catch {
try {
const jpegBlob = await convertHeicBlobToJpeg(file);
return await tryCanvas(jpegBlob, baseName, quality);
} catch (e) {
throw e instanceof Error ? e : new Error("Failed to convert HEIC");
}
}
}
// Non-Safari: Image() usually can't decode HEIC; use heic2any first
try {
const jpegBlob = await convertHeicBlobToJpeg(file);
return await tryCanvas(jpegBlob, baseName, quality);
} catch {
try {
return await tryCanvas(file, baseName, quality);
} catch (e) {
throw e instanceof Error ? e : new Error("Failed to convert HEIC");
}
}
}
return await tryCanvas(file, baseName, quality);
}
+28 -16
View File
@@ -17,27 +17,39 @@
<main class="flex min-h-0 flex-1 flex-col items-center justify-center gap-6 p-6">
<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>
<div class="text-muted-foreground flex h-12 min-h-12 w-full cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 text-sm opacity-60">
<span>Scan</span>
<span class="text-xs font-medium">Coming soon</span>
</div>
<div class="text-muted-foreground flex h-12 min-h-12 w-full cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 text-sm opacity-60">
<span>New Transaction</span>
<span class="text-xs font-medium">Coming soon</span>
</div>
<a href="/receipts" class="block">
<Button class="h-12 min-h-12 w-full" variant="outline">New receipt</Button>
</a>
<div class="text-muted-foreground flex h-12 min-h-12 w-full cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 text-sm opacity-60">
<span>New Item</span>
<span class="text-xs font-medium">Coming soon</span>
</div>
</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">
<a href="/items" class="block">
<Button class="h-12 min-h-12 w-full" variant="outline">Items</Button>
</a>
<a href="/transactions" class="block">
<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>
<div class="text-muted-foreground flex h-12 min-h-12 w-full cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 text-sm opacity-60">
<span>Items</span>
<span class="text-xs font-medium">Coming soon</span>
</div>
<div class="text-muted-foreground flex h-12 min-h-12 w-full cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 text-sm opacity-60">
<span>Transactions</span>
<span class="text-xs font-medium">Coming soon</span>
</div>
<a href="/receipts" class="block">
<Button class="h-12 min-h-12 w-full" variant="outline">Receipts</Button>
</a>
<div class="text-muted-foreground flex h-12 min-h-12 w-full cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 text-sm opacity-60">
<span>Accounts</span>
<span class="text-xs font-medium">Coming soon</span>
</div>
</div>
</main>
{/if}
+24 -14
View File
@@ -42,31 +42,41 @@
<h1 class="w-full max-w-xs shrink-0 text-center text-2xl font-semibold">Stackq</h1>
<div class="flex w-full max-w-xs shrink-0 flex-col gap-3">
<Button class="h-16 min-h-16 w-full" onclick={() => (scanDialogOpen = true)}>Scan</Button>
<a href="/transactions/new" class="block">
<Button class="h-16 min-h-16 w-full" variant="outline">New transaction</Button>
</a>
<div class="text-muted-foreground flex h-16 w-full min-h-16 cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 opacity-60">
<span>Scan</span>
<span class="text-muted-foreground shrink-0 text-xs font-medium">Coming soon</span>
</div>
<div class="text-muted-foreground flex h-16 w-full min-h-16 cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 opacity-60">
<span>New transaction</span>
<span class="text-muted-foreground shrink-0 text-xs font-medium">Coming soon</span>
</div>
<Button class="h-16 min-h-16 w-full" variant="outline" onclick={() => (receiptDialogOpen = true)}>
New receipt
</Button>
<Button class="h-16 min-h-16 w-full" variant="outline" onclick={() => (dialogOpen = true)}>New Item</Button>
<div class="text-muted-foreground flex h-16 w-full min-h-16 cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 opacity-60">
<span>New Item</span>
<span class="text-muted-foreground shrink-0 text-xs font-medium">Coming soon</span>
</div>
</div>
<div class="w-full max-w-xs shrink-0 border-t pt-4"></div>
<div class="flex w-full max-w-xs shrink-0 flex-col gap-2">
<a href="/items" class="block">
<Button class="h-16 min-h-16 w-full" variant="outline">Items</Button>
</a>
<a href="/transactions" class="block">
<Button class="h-16 min-h-16 w-full" variant="outline">Transactions</Button>
</a>
<div class="text-muted-foreground flex h-16 w-full min-h-16 cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 opacity-60">
<span>Items</span>
<span class="text-muted-foreground shrink-0 text-xs font-medium">Coming soon</span>
</div>
<div class="text-muted-foreground flex h-16 w-full min-h-16 cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 opacity-60">
<span>Transactions</span>
<span class="text-muted-foreground shrink-0 text-xs font-medium">Coming soon</span>
</div>
<a href="/receipts" class="block">
<Button class="h-16 min-h-16 w-full" variant="outline">Receipts</Button>
</a>
<a href="/accounts" class="block">
<Button class="h-16 min-h-16 w-full" variant="outline">Accounts</Button>
</a>
<div class="text-muted-foreground flex h-16 w-full min-h-16 cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 opacity-60">
<span>Accounts</span>
<span class="text-muted-foreground shrink-0 text-xs font-medium">Coming soon</span>
</div>
</div>
</main>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Label } from "$lib/components/ui/label";
import { convertImageToWebP } from "$lib/convert-image-to-webp";
import { convertImageToWebP, isAcceptableImageFile } from "$lib/convert-image-to-webp";
import { enhance } from "$app/forms";
import Loader2Icon from "@lucide/svelte/icons/loader-2";
@@ -30,7 +30,7 @@
async function onImageChange(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file || !file.type.startsWith("image/")) {
if (!file || !isAcceptableImageFile(file)) {
imageFileName = null;
return;
}
+2 -2
View File
@@ -4,7 +4,7 @@
import * as Collapsible from "$lib/components/ui/collapsible";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { convertImageToWebP } from "$lib/convert-image-to-webp";
import { convertImageToWebP, isAcceptableImageFile } from "$lib/convert-image-to-webp";
import { POCKETBASE_BASE_URL } from "$lib/pocketbase";
import { enhance } from "$app/forms";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
@@ -138,7 +138,7 @@
async function onImageChange() {
const input = imageInputEl;
const file = input?.files?.[0] ?? null;
if (!file || !file.type.startsWith("image/")) {
if (!file || !isAcceptableImageFile(file)) {
selectedImageFile = null;
imageFileName = null;
return;