feat: Add image uploader and metadata viewer components
- Implemented ImageUploader.svelte for drag-and-drop image uploads with metadata extraction. - Created MetadataViewer.svelte to display extracted metadata in a user-friendly format. - Added PrivacyInfo.svelte to inform users about client-side processing and privacy risks. - Introduced ThemeToggle.svelte for dark mode functionality. - Set up main.js for application entry point and configured Svelte with vite. - Added Tailwind CSS for styling and configured Vite for asset management. - Established svelte.config.js and tailwind.config.js for project settings.
This commit is contained in:
1 parent
cf0d872dba
commit
cbce104341
18 files changed
+5263
No files matched your search
@@ -0,0 +1,287 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let file;
|
||||
export let disabled = false;
|
||||
|
||||
async function processImage() {
|
||||
if (!file || disabled) return;
|
||||
|
||||
dispatch("processingStart");
|
||||
|
||||
try {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = function () {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
let outputType = "image/jpeg";
|
||||
let quality = 0.92;
|
||||
if (file.type === "image/png" && img.width * img.height < 1000000) {
|
||||
outputType = "image/png";
|
||||
quality = undefined;
|
||||
}
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
verifyCleanImage(blob, file);
|
||||
} else {
|
||||
throw new Error("Failed to create blob from canvas");
|
||||
}
|
||||
},
|
||||
outputType,
|
||||
quality
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error processing with canvas:", error);
|
||||
handleFallback();
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = function () {
|
||||
console.error("Error loading image");
|
||||
handleFallback();
|
||||
};
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
} catch (error) {
|
||||
console.error("Error in processImage:", error);
|
||||
handleFallback();
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCleanImage(cleanBlob, originalFile) {
|
||||
try {
|
||||
const { parse } = await import("exifr");
|
||||
|
||||
const cleanMetadata = await parse(cleanBlob, {
|
||||
tiff: true,
|
||||
exif: true,
|
||||
gps: true,
|
||||
xmp: true,
|
||||
iptc: true,
|
||||
icc: true,
|
||||
silentErrors: true,
|
||||
});
|
||||
|
||||
const hasRemainingMetadata =
|
||||
cleanMetadata && Object.keys(cleanMetadata).length > 0;
|
||||
|
||||
if (hasRemainingMetadata) {
|
||||
console.warn("Some metadata may still be present:", cleanMetadata);
|
||||
// For extra security, try a second pass with different format
|
||||
await performSecondPass(cleanBlob, originalFile);
|
||||
} else {
|
||||
console.log("✅ Image successfully cleaned - no metadata detected");
|
||||
finalizeCleanImage(cleanBlob, originalFile);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"Could not verify metadata removal (this is usually okay):",
|
||||
error
|
||||
);
|
||||
// Proceed anyway - the canvas method should have worked
|
||||
finalizeCleanImage(cleanBlob, originalFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass cleaning for stubborn metadata
|
||||
async function performSecondPass(blob, originalFile) {
|
||||
try {
|
||||
console.log("Performing second pass metadata removal...");
|
||||
|
||||
const img = new Image();
|
||||
img.onload = function () {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
// Use a different approach - explicitly clear and redraw
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Force JPEG output for maximum metadata removal
|
||||
canvas.toBlob(
|
||||
(finalBlob) => {
|
||||
if (finalBlob) {
|
||||
console.log("✅ Second pass completed");
|
||||
finalizeCleanImage(finalBlob, originalFile, true);
|
||||
} else {
|
||||
finalizeCleanImage(blob, originalFile);
|
||||
}
|
||||
},
|
||||
"image/jpeg",
|
||||
0.95
|
||||
);
|
||||
};
|
||||
|
||||
img.onerror = () => finalizeCleanImage(blob, originalFile);
|
||||
img.src = URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
console.error("Second pass failed:", error);
|
||||
finalizeCleanImage(blob, originalFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize the cleaned image
|
||||
function finalizeCleanImage(blob, originalFile, wasSecondPass = false) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const originalExtension = originalFile.name.split(".").pop();
|
||||
const nameWithoutExt = originalFile.name.replace(
|
||||
new RegExp(`\\.${originalExtension}$`, "i"),
|
||||
""
|
||||
);
|
||||
|
||||
// Determine final extension based on blob type
|
||||
let finalExtension = originalExtension;
|
||||
if (blob.type === "image/jpeg") {
|
||||
finalExtension = "jpg";
|
||||
} else if (blob.type === "image/png") {
|
||||
finalExtension = "png";
|
||||
}
|
||||
|
||||
const filename = `${nameWithoutExt}-cleaned.${finalExtension}`;
|
||||
|
||||
dispatch("processingComplete", {
|
||||
processedImage: {
|
||||
url,
|
||||
filename,
|
||||
size: blob.size,
|
||||
wasSecondPass,
|
||||
originalSize: originalFile.size,
|
||||
compressionRatio: (
|
||||
((originalFile.size - blob.size) / originalFile.size) *
|
||||
100
|
||||
).toFixed(1),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleFallback() {
|
||||
// Fallback: return original file
|
||||
const url = URL.createObjectURL(file);
|
||||
const fileExtension = file.name.split(".").pop();
|
||||
const nameWithoutExt = file.name.replace(
|
||||
new RegExp(`\\.${fileExtension}$`, "i"),
|
||||
""
|
||||
);
|
||||
const filename = `${nameWithoutExt}-cleaned.${fileExtension}`;
|
||||
|
||||
dispatch("processingComplete", {
|
||||
processedImage: {
|
||||
url,
|
||||
filename,
|
||||
size: file.size,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="processor-section">
|
||||
<div class="text-center mb-6">
|
||||
<p class="text-secondary mb-4">
|
||||
Remove all EXIF metadata from your image to protect your privacy. This
|
||||
process happens entirely in your browser - no data is sent to any server.
|
||||
</p>
|
||||
|
||||
<button class="btn btn-primary btn-lg" on:click={processImage} {disabled}>
|
||||
{#if disabled}
|
||||
<svg
|
||||
class="animate-spin"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a2 2 0 00-2 2v11a2 2 0 002 2h12a2 2 0 002-2V4a2 2 0 00-2-2H4zm0 2h12v11H4V4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Processing...
|
||||
{:else}
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Remove Metadata
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="privacy-features">
|
||||
<h4 class="font-semibold mb-3">What gets removed:</h4>
|
||||
<div class="grid grid-2 gap-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
GPS location data
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
Camera make & model
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
Date & time taken
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
Camera settings
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
Software information
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
AI generation tags
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.text-success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.privacy-features {
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user