Refactor image conversion logic in convert-image-to-webp.ts to handle HEIC files and improve compatibility with Safari. Update ReceiptForm and +page components to streamline image upload process, removing camera functionality and enhancing user feedback during image processing. Adjust styles for better accessibility and clarity in image handling.
This commit is contained in:
@@ -1,42 +1,94 @@
|
|||||||
|
import { convertHeicBlobToJpeg } from "./convert-heic";
|
||||||
|
|
||||||
|
function isHeicFile(file: File): boolean {
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
const type = (file.type ?? "").toLowerCase();
|
||||||
|
return (
|
||||||
|
name.endsWith(".heic") ||
|
||||||
|
name.endsWith(".heif") ||
|
||||||
|
type === "image/heic" ||
|
||||||
|
type === "image/heif"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeOutputFile(blob: Blob, baseName: string, ext: string): File {
|
||||||
|
return new File([blob], `${baseName}.${ext}`, {
|
||||||
|
type: blob.type,
|
||||||
|
lastModified: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts an image File to WebP and returns a new File.
|
* Converts an image File to WebP (or JPEG on Safari where WebP isn't supported).
|
||||||
* Used so receipt uploads are stored only as WebP.
|
* 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> {
|
export function convertImageToWebP(file: File, quality = 0.85): Promise<File> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
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(file);
|
||||||
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
const canvas = document.createElement("canvas");
|
tryCanvas(file);
|
||||||
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);
|
|
||||||
canvas.toBlob(
|
|
||||||
(blob) => {
|
|
||||||
if (!blob) {
|
|
||||||
reject(new Error("Failed to encode WebP"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const baseName = file.name.replace(/\.[^.]+$/i, "") || "image";
|
|
||||||
const webpFile = new File([blob], `${baseName}.webp`, {
|
|
||||||
type: "image/webp",
|
|
||||||
lastModified: Date.now(),
|
|
||||||
});
|
|
||||||
resolve(webpFile);
|
|
||||||
},
|
|
||||||
"image/webp",
|
|
||||||
quality
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = async () => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
reject(new Error("Failed to load image"));
|
// 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"));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
img.src = url;
|
img.src = url;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
|
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
|
||||||
|
|
||||||
const BUTTON_OUTLINE_CLASS =
|
const SCAN_BUTTON_CLASS =
|
||||||
"border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-xs transition-colors";
|
"border-input bg-background hover:bg-accent hover:text-accent-foreground flex h-9 w-full cursor-pointer items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-xs transition-colors";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
action,
|
action,
|
||||||
@@ -24,69 +24,9 @@
|
|||||||
defaultType?: string;
|
defaultType?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let imageInputEl = $state<HTMLInputElement | null>(null);
|
|
||||||
let imageFileName = $state<string | null>(null);
|
let imageFileName = $state<string | null>(null);
|
||||||
let imageConverting = $state(false);
|
let imageConverting = $state(false);
|
||||||
|
|
||||||
let showCamera = $state(false);
|
|
||||||
let cameraError = $state<string | null>(null);
|
|
||||||
let videoEl = $state<HTMLVideoElement | undefined>(undefined);
|
|
||||||
let stream: MediaStream | null = null;
|
|
||||||
|
|
||||||
async function startCamera() {
|
|
||||||
cameraError = null;
|
|
||||||
showCamera = true;
|
|
||||||
try {
|
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
cameraError = e instanceof Error ? e.message : "Could not access camera.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!showCamera || !stream || !videoEl) return;
|
|
||||||
videoEl.srcObject = stream;
|
|
||||||
videoEl.play().catch(() => {});
|
|
||||||
return () => {
|
|
||||||
if (videoEl?.srcObject) videoEl.srcObject = null;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function stopCamera() {
|
|
||||||
showCamera = false;
|
|
||||||
cameraError = null;
|
|
||||||
if (stream) {
|
|
||||||
stream.getTracks().forEach((t) => t.stop());
|
|
||||||
stream = null;
|
|
||||||
}
|
|
||||||
if (videoEl?.srcObject) videoEl.srcObject = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function capturePhoto() {
|
|
||||||
if (!videoEl || videoEl.readyState < 2 || !imageInputEl) return;
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = videoEl.videoWidth;
|
|
||||||
canvas.height = videoEl.videoHeight;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
ctx.drawImage(videoEl, 0, 0);
|
|
||||||
canvas.toBlob(
|
|
||||||
(blob) => {
|
|
||||||
if (!blob) return;
|
|
||||||
const file = new File([blob], "receipt.webp", { type: "image/webp", lastModified: Date.now() });
|
|
||||||
const dt = new DataTransfer();
|
|
||||||
dt.items.add(file);
|
|
||||||
imageInputEl.files = dt.files;
|
|
||||||
imageFileName = file.name;
|
|
||||||
stopCamera();
|
|
||||||
},
|
|
||||||
"image/webp",
|
|
||||||
0.85
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onImageChange(e: Event) {
|
async function onImageChange(e: Event) {
|
||||||
const input = e.currentTarget as HTMLInputElement;
|
const input = e.currentTarget as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
@@ -94,12 +34,9 @@
|
|||||||
imageFileName = null;
|
imageFileName = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.type === "image/webp") {
|
|
||||||
imageFileName = file.name;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
imageConverting = true;
|
imageConverting = true;
|
||||||
imageFileName = null;
|
imageFileName = null;
|
||||||
|
// Let the UI paint "Processing image…" before we block the main thread
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
try {
|
try {
|
||||||
const webpFile = await convertImageToWebP(file);
|
const webpFile = await convertImageToWebP(file);
|
||||||
@@ -146,19 +83,19 @@
|
|||||||
<Label for="Image">Image</Label>
|
<Label for="Image">Image</Label>
|
||||||
<div class="relative flex flex-col gap-2">
|
<div class="relative flex flex-col gap-2">
|
||||||
<input
|
<input
|
||||||
bind:this={imageInputEl}
|
|
||||||
id="Image"
|
id="Image"
|
||||||
name="Image"
|
name="Image"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
onchange={onImageChange}
|
onchange={onImageChange}
|
||||||
class="absolute opacity-0 w-0 h-0 overflow-hidden pointer-events-none"
|
class="absolute opacity-0 w-0 h-0 overflow-hidden pointer-events-none"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-label="Choose image from gallery"
|
aria-label="Scan receipt"
|
||||||
/>
|
/>
|
||||||
{#if imageConverting}
|
{#if imageConverting}
|
||||||
<div
|
<div
|
||||||
class="{BUTTON_OUTLINE_CLASS} w-full pointer-events-none opacity-80"
|
class="{SCAN_BUTTON_CLASS} pointer-events-none opacity-80"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
@@ -166,56 +103,20 @@
|
|||||||
<span>Processing image…</span>
|
<span>Processing image…</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-wrap gap-2">
|
<label for="Image" class={SCAN_BUTTON_CLASS}>
|
||||||
<Button type="button" variant="outline" class="flex-1 sm:flex-none" onclick={startCamera}>
|
Scan receipt
|
||||||
Take photo
|
</label>
|
||||||
</Button>
|
|
||||||
<label for="Image" class="{BUTTON_OUTLINE_CLASS} flex-1 sm:flex-none">
|
|
||||||
Choose from gallery
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if imageConverting}
|
{#if imageConverting}
|
||||||
<p class="text-muted-foreground text-xs">Converting to WebP…</p>
|
<p class="text-muted-foreground text-xs">Converting to WebP…</p>
|
||||||
{:else if imageFileName}
|
{:else if imageFileName}
|
||||||
<p class="text-muted-foreground text-xs">Selected: {imageFileName}</p>
|
<p class="text-muted-foreground text-xs">Selected: {imageFileName}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-muted-foreground text-xs">Optional. Take a photo (saved as WebP) or choose from gallery.</p>
|
<p class="text-muted-foreground text-xs">Optional. Images are converted to WebP before upload.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showCamera}
|
|
||||||
<div
|
|
||||||
class="bg-background/95 fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 p-4 backdrop-blur-sm"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Take receipt photo"
|
|
||||||
>
|
|
||||||
<div class="flex w-full max-w-lg flex-col gap-3">
|
|
||||||
{#if cameraError}
|
|
||||||
<p class="text-destructive text-sm">{cameraError}</p>
|
|
||||||
{:else}
|
|
||||||
<video
|
|
||||||
bind:this={videoEl}
|
|
||||||
class="border-input aspect-video w-full rounded-lg border bg-black"
|
|
||||||
muted
|
|
||||||
playsinline
|
|
||||||
></video>
|
|
||||||
{/if}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button type="button" variant="outline" class="flex-1" onclick={stopCamera}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
{#if !cameraError}
|
|
||||||
<Button type="button" class="flex-1" onclick={capturePhoto}>
|
|
||||||
Capture
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-destructive text-sm">{error}</p>
|
<p class="text-destructive text-sm">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -125,11 +125,6 @@
|
|||||||
let imageError = $state(false);
|
let imageError = $state(false);
|
||||||
let imageConverting = $state(false);
|
let imageConverting = $state(false);
|
||||||
|
|
||||||
let showCamera = $state(false);
|
|
||||||
let cameraError = $state<string | null>(null);
|
|
||||||
let videoEl = $state<HTMLVideoElement | undefined>(undefined);
|
|
||||||
let stream: MediaStream | null = null;
|
|
||||||
|
|
||||||
/** On small screens image is collapsed by default; on lg+ expanded. */
|
/** On small screens image is collapsed by default; on lg+ expanded. */
|
||||||
let imageOpen = $state(false);
|
let imageOpen = $state(false);
|
||||||
let imageOpenInitialized = $state(false);
|
let imageOpenInitialized = $state(false);
|
||||||
@@ -140,57 +135,6 @@
|
|||||||
imageOpen = mq.matches;
|
imageOpen = mq.matches;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function startCamera() {
|
|
||||||
cameraError = null;
|
|
||||||
showCamera = true;
|
|
||||||
try {
|
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
cameraError = e instanceof Error ? e.message : "Could not access camera.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!showCamera || !stream || !videoEl) return;
|
|
||||||
videoEl.srcObject = stream;
|
|
||||||
videoEl.play().catch(() => {});
|
|
||||||
return () => {
|
|
||||||
if (videoEl?.srcObject) videoEl.srcObject = null;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function stopCamera() {
|
|
||||||
showCamera = false;
|
|
||||||
cameraError = null;
|
|
||||||
if (stream) {
|
|
||||||
stream.getTracks().forEach((t) => t.stop());
|
|
||||||
stream = null;
|
|
||||||
}
|
|
||||||
if (videoEl?.srcObject) videoEl.srcObject = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function capturePhoto() {
|
|
||||||
if (!videoEl || videoEl.readyState < 2) return;
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = videoEl.videoWidth;
|
|
||||||
canvas.height = videoEl.videoHeight;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
ctx.drawImage(videoEl, 0, 0);
|
|
||||||
canvas.toBlob(
|
|
||||||
(blob) => {
|
|
||||||
if (!blob) return;
|
|
||||||
selectedImageFile = new File([blob], "receipt.webp", { type: "image/webp", lastModified: Date.now() });
|
|
||||||
imageFileName = "receipt.webp";
|
|
||||||
stopCamera();
|
|
||||||
},
|
|
||||||
"image/webp",
|
|
||||||
0.85
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onImageChange() {
|
async function onImageChange() {
|
||||||
const input = imageInputEl;
|
const input = imageInputEl;
|
||||||
const file = input?.files?.[0] ?? null;
|
const file = input?.files?.[0] ?? null;
|
||||||
@@ -199,11 +143,6 @@
|
|||||||
imageFileName = null;
|
imageFileName = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.type === "image/webp") {
|
|
||||||
selectedImageFile = file;
|
|
||||||
imageFileName = file.name;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
imageConverting = true;
|
imageConverting = true;
|
||||||
selectedImageFile = null;
|
selectedImageFile = null;
|
||||||
imageFileName = null;
|
imageFileName = null;
|
||||||
@@ -233,37 +172,6 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="flex w-full max-w-full flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
|
<main class="flex w-full max-w-full flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
|
||||||
{#if showCamera}
|
|
||||||
<div
|
|
||||||
class="bg-background/95 fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 p-4 backdrop-blur-sm"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Take receipt photo"
|
|
||||||
>
|
|
||||||
<div class="flex w-full max-w-lg flex-col gap-3">
|
|
||||||
{#if cameraError}
|
|
||||||
<p class="text-destructive text-sm">{cameraError}</p>
|
|
||||||
{:else}
|
|
||||||
<video
|
|
||||||
bind:this={videoEl}
|
|
||||||
class="border-input aspect-video w-full rounded-lg border bg-black"
|
|
||||||
muted
|
|
||||||
playsinline
|
|
||||||
></video>
|
|
||||||
{/if}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button type="button" variant="outline" class="flex-1" onclick={stopCamera}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
{#if !cameraError}
|
|
||||||
<Button type="button" class="flex-1" onclick={capturePhoto}>
|
|
||||||
Capture
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<a href="/receipts" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to receipts">Back</a>
|
<a href="/receipts" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to receipts">Back</a>
|
||||||
<h1 class="text-xl font-semibold sm:text-2xl">Receipt</h1>
|
<h1 class="text-xl font-semibold sm:text-2xl">Receipt</h1>
|
||||||
@@ -334,17 +242,18 @@
|
|||||||
class="flex flex-col gap-4"
|
class="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="dataJson" value={buildDataJson()} />
|
<input type="hidden" name="dataJson" value={buildDataJson()} />
|
||||||
<div class="relative flex flex-wrap items-center gap-2">
|
<div class="relative flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
id="receipt-image-input"
|
id="receipt-image-input"
|
||||||
bind:this={imageInputEl}
|
bind:this={imageInputEl}
|
||||||
type="file"
|
type="file"
|
||||||
name="Image"
|
name="Image"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
onchange={onImageChange}
|
onchange={onImageChange}
|
||||||
class="absolute opacity-0 w-0 h-0 overflow-hidden pointer-events-none"
|
class="absolute opacity-0 w-0 h-0 overflow-hidden"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-label="Choose image from gallery"
|
aria-label={receipt.Image ? "Replace image" : "Add image"}
|
||||||
/>
|
/>
|
||||||
{#if imageConverting}
|
{#if imageConverting}
|
||||||
<div
|
<div
|
||||||
@@ -356,9 +265,6 @@
|
|||||||
<span>Processing image…</span>
|
<span>Processing image…</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Button type="button" variant="outline" size="sm" onclick={startCamera}>
|
|
||||||
Take photo
|
|
||||||
</Button>
|
|
||||||
<label
|
<label
|
||||||
for="receipt-image-input"
|
for="receipt-image-input"
|
||||||
class="border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex h-8 cursor-pointer items-center justify-center rounded-md border px-3 text-sm font-medium shadow-xs transition-colors"
|
class="border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex h-8 cursor-pointer items-center justify-center rounded-md border px-3 text-sm font-medium shadow-xs transition-colors"
|
||||||
|
|||||||
Reference in New Issue
Block a user