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,37 @@
|
|||||||
|
name: Deploy to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "18"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
uses: peaceiris/actions-gh-pages@v3
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
publish_dir: ./dist
|
||||||
|
cname: # Add your custom domain here if you have one
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/untrace/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Clean your images. Keep your identity. Remove EXIF metadata from images in your browser.">
|
||||||
|
<meta name="keywords" content="EXIF, metadata, privacy, image, cleaner, security">
|
||||||
|
<title>Untrace - Clean Your Images</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2800
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#2563eb"/>
|
||||||
|
<path d="M8 12h16v8H8v-8z" stroke="white" stroke-width="1.5" fill="none"/>
|
||||||
|
<path d="M11 15l3 3 6-6" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 9l3 3-3 3" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 454 B |
+333
@@ -0,0 +1,333 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import ImageUploader from "./lib/ImageUploader.svelte";
|
||||||
|
import MetadataViewer from "./lib/MetadataViewer.svelte";
|
||||||
|
import ImageProcessor from "./lib/ImageProcessor.svelte";
|
||||||
|
import ThemeToggle from "./lib/ThemeToggle.svelte";
|
||||||
|
import Header from "./lib/Header.svelte";
|
||||||
|
import Footer from "./lib/Footer.svelte";
|
||||||
|
import PrivacyInfo from "./lib/PrivacyInfo.svelte";
|
||||||
|
|
||||||
|
let selectedFile = null;
|
||||||
|
let metadata = null;
|
||||||
|
let processedImage = null;
|
||||||
|
let isProcessing = false;
|
||||||
|
let showMetadata = false;
|
||||||
|
let showFileInfo = false;
|
||||||
|
let showPrivacyInfo = false;
|
||||||
|
let darkMode = false;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const savedTheme = localStorage.getItem("theme");
|
||||||
|
if (savedTheme) {
|
||||||
|
darkMode = savedTheme === "dark";
|
||||||
|
} else {
|
||||||
|
darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
}
|
||||||
|
updateTheme();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateTheme() {
|
||||||
|
if (darkMode) {
|
||||||
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
}
|
||||||
|
localStorage.setItem("theme", darkMode ? "dark" : "light");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleThemeToggle() {
|
||||||
|
darkMode = !darkMode;
|
||||||
|
updateTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelected(event) {
|
||||||
|
selectedFile = event.detail.file;
|
||||||
|
metadata = event.detail.metadata;
|
||||||
|
processedImage = null;
|
||||||
|
showMetadata = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProcessingStart() {
|
||||||
|
isProcessing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProcessingComplete(event) {
|
||||||
|
isProcessing = false;
|
||||||
|
processedImage = event.detail.processedImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
selectedFile = null;
|
||||||
|
metadata = null;
|
||||||
|
processedImage = null;
|
||||||
|
isProcessing = false;
|
||||||
|
showMetadata = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMetadata() {
|
||||||
|
showMetadata = !showMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFileInfo() {
|
||||||
|
showFileInfo = !showFileInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePrivacyInfo() {
|
||||||
|
showPrivacyInfo = !showPrivacyInfo;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ThemeToggle {darkMode} on:toggle={handleThemeToggle} />
|
||||||
|
|
||||||
|
<main class="container py-8">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<div class="grid gap-6">
|
||||||
|
<section class="card">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold">Upload Your Image</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageUploader
|
||||||
|
on:fileSelected={handleFileSelected}
|
||||||
|
on:reset={handleReset}
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Processing Section -->
|
||||||
|
{#if selectedFile}
|
||||||
|
<section class="grid grid-2 gap-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<h3 class="text-lg font-semibold">Original</h3>
|
||||||
|
{#if metadata}
|
||||||
|
<button class="btn btn-ghost text-sm" on:click={toggleMetadata}>
|
||||||
|
{showMetadata ? "Hide" : "Show"} Metadata
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(selectedFile)}
|
||||||
|
alt="Original"
|
||||||
|
class="image-preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-secondary">
|
||||||
|
<p>
|
||||||
|
{selectedFile.name} • {(selectedFile.size / 1024).toFixed(1)} KB
|
||||||
|
</p>
|
||||||
|
{#if metadata && metadata.message && !metadata.hasMetadata}
|
||||||
|
<p class="text-success">✓ No metadata detected</p>
|
||||||
|
{:else if metadata}
|
||||||
|
<p class="text-warning">
|
||||||
|
⚠️ {Object.keys(metadata).filter(
|
||||||
|
(key) => !["fileType", "hasMetadata", "message"].includes(key)
|
||||||
|
).length} metadata entries found
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showMetadata && metadata}
|
||||||
|
<div class="mt-4">
|
||||||
|
<MetadataViewer {metadata} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-lg font-semibold mb-3">Clean Metadata</h3>
|
||||||
|
<ImageProcessor
|
||||||
|
file={selectedFile}
|
||||||
|
on:processingStart={handleProcessingStart}
|
||||||
|
on:processingComplete={handleProcessingComplete}
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if isProcessing}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: 100%;"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-secondary mt-1">Processing...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if processedImage}
|
||||||
|
<div class="mt-4 slide-up">
|
||||||
|
<h4 class="font-medium mb-2">✅ Clean Image</h4>
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<img
|
||||||
|
src={processedImage.url}
|
||||||
|
alt="Cleaned"
|
||||||
|
class="image-preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-secondary mb-3">
|
||||||
|
<p>
|
||||||
|
{(processedImage.size / 1024).toFixed(1)} KB •
|
||||||
|
{processedImage.compressionRatio
|
||||||
|
? `${processedImage.compressionRatio > 0 ? "-" : "+"}${Math.abs(processedImage.compressionRatio)}% size`
|
||||||
|
: "Processed"}
|
||||||
|
</p>
|
||||||
|
{#if processedImage.wasSecondPass}
|
||||||
|
<p class="status-warning">
|
||||||
|
⚠️ Used enhanced cleaning (second pass)
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<p class="status-success">✓ All EXIF metadata removed</p>
|
||||||
|
<p class="status-success">✓ AI/C2PA provenance data stripped</p>
|
||||||
|
<p class="status-success">✓ Location data cleared</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={processedImage.url}
|
||||||
|
download={processedImage.filename}
|
||||||
|
class="btn btn-success btn-lg w-full"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Download Cleaned Image
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold">🔐 Privacy & Security</h2>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost text-sm"
|
||||||
|
on:click={togglePrivacyInfo}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{showPrivacyInfo ? "Hide" : "Show"} Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
<span class="text-success font-semibold">✓</span> 100% client-side
|
||||||
|
processing
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<span class="text-success font-semibold">✓</span> No data collection
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<span class="text-success font-semibold">✓</span> Open source
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showPrivacyInfo}
|
||||||
|
<PrivacyInfo />
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
color: var(--warning-color, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up {
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
max-height: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+470
@@ -0,0 +1,470 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
:root {
|
||||||
|
--primary-color: #2563eb;
|
||||||
|
--primary-hover: #1d4ed8;
|
||||||
|
--secondary-color: #64748b;
|
||||||
|
--success-color: #10b981;
|
||||||
|
--danger-color: #ef4444;
|
||||||
|
--warning-color: #f59e0b;
|
||||||
|
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f8fafc;
|
||||||
|
--bg-tertiary: #f1f5f9;
|
||||||
|
--text-primary: #0f172a;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||||
|
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
|
/* Dark mode colors */
|
||||||
|
--dark-bg-primary: #0f172a;
|
||||||
|
--dark-bg-secondary: #1e293b;
|
||||||
|
--dark-bg-tertiary: #334155;
|
||||||
|
--dark-text-primary: #f8fafc;
|
||||||
|
--dark-text-secondary: #cbd5e1;
|
||||||
|
--dark-text-muted: #64748b;
|
||||||
|
--dark-border-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-primary: var(--dark-bg-primary);
|
||||||
|
--bg-secondary: var(--dark-bg-secondary);
|
||||||
|
--bg-tertiary: var(--dark-bg-tertiary);
|
||||||
|
--text-primary: var(--dark-text-primary);
|
||||||
|
--text-secondary: var(--dark-text-secondary);
|
||||||
|
--text-muted: var(--dark-text-muted);
|
||||||
|
--border-color: var(--dark-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
outline: none;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover:not(:disabled) {
|
||||||
|
background-color: #059669;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1.5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
border: 2px dashed var(--border-color);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area.dragover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background-color: rgba(37, 99, 235, 0.05);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-viewer {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: "Monaco", "Menlo", monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.5rem;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 300px;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-success {
|
||||||
|
background-color: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--success-color);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--danger-color);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
background-color: rgba(245, 158, 11, 0.1);
|
||||||
|
color: var(--warning-color);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-col {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-4 {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-6 {
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-lg {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-2xl {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-3xl {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-bold {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-semibold {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-6 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-4 {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-6 {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-2 {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-4 {
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-8 {
|
||||||
|
padding-top: 2rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.px-4 {
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up {
|
||||||
|
animation: slideUp 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: 0.875rem 1.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-3xl {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-2xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme Toggle */
|
||||||
|
.theme-toggle {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accessibility */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus styles for better accessibility */
|
||||||
|
input[type="file"]:focus-visible + label {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<footer class="mt-12 py-8 border-t border-color text-center">
|
||||||
|
<div class="container">
|
||||||
|
<div class="grid grid-2 gap-8 mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold mb-3">About Untrace</h3>
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
A privacy-first tool that removes EXIF metadata from images completely
|
||||||
|
in your browser. No servers, no tracking, no data collection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold mb-3">Open Source</h3>
|
||||||
|
<p class="text-sm text-secondary mb-2">
|
||||||
|
Built with Svelte and deployed on GitHub Pages.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://github.com/yourusername/untrace"
|
||||||
|
class="btn btn-secondary text-sm"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
View Source
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-muted">
|
||||||
|
<p>
|
||||||
|
© 2025 Untrace. Built for privacy advocates, journalists, and digital
|
||||||
|
rights.
|
||||||
|
</p>
|
||||||
|
<p class="mt-1">No analytics • No tracking • No data collection</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.border-t {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<header class="text-center mb-6">
|
||||||
|
<div class="flex items-center justify-center gap-3 mb-3">
|
||||||
|
<div class="logo">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 48 48" fill="none">
|
||||||
|
<rect width="48" height="48" rx="12" fill="var(--primary-color)" />
|
||||||
|
<path
|
||||||
|
d="M12 18h24v12H12V18z"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="2"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16 22l4 4 8-8"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M32 14l4 4-4 4"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Untrace</h1>
|
||||||
|
<p class="text-base text-secondary">Remove metadata. Protect privacy.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-secondary max-w-xl mx-auto">
|
||||||
|
<strong>100% browser-based</strong> metadata removal tool. No uploads, no tracking.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-w-xl {
|
||||||
|
max-width: 36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-auto {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let disabled = false;
|
||||||
|
|
||||||
|
let dragOver = false;
|
||||||
|
let fileInput;
|
||||||
|
|
||||||
|
const acceptedTypes = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/tiff",
|
||||||
|
"image/heic",
|
||||||
|
"image/avif",
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleDragOver(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!disabled) {
|
||||||
|
dragOver = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
dragOver = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
dragOver = false;
|
||||||
|
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
const files = event.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
processFile(files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileInput(event) {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
const files = event.target.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
processFile(files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processFile(file) {
|
||||||
|
if (!acceptedTypes.includes(file.type)) {
|
||||||
|
alert(
|
||||||
|
"Please select a supported image file (JPEG, PNG, TIFF, HEIC, or AVIF)."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadata = await extractMetadata(file);
|
||||||
|
dispatch("fileSelected", { file, metadata });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing file:", error);
|
||||||
|
alert("Error processing the file. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractMetadata(file) {
|
||||||
|
try {
|
||||||
|
const { parse } = await import("exifr");
|
||||||
|
|
||||||
|
if (
|
||||||
|
!file.type.includes("jpeg") &&
|
||||||
|
!file.type.includes("jpg") &&
|
||||||
|
!file.type.includes("tiff") &&
|
||||||
|
!file.type.includes("png") &&
|
||||||
|
!file.type.includes("heic") &&
|
||||||
|
!file.type.includes("avif")
|
||||||
|
) {
|
||||||
|
console.log("File type doesn't typically contain EXIF data");
|
||||||
|
return {
|
||||||
|
fileType: "other",
|
||||||
|
hasMetadata: false,
|
||||||
|
message: "This file type typically doesn't contain EXIF metadata",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const exifData = await parse(file, {
|
||||||
|
tiff: true,
|
||||||
|
exif: true,
|
||||||
|
gps: true,
|
||||||
|
interop: true,
|
||||||
|
iop: true,
|
||||||
|
makerNote: true,
|
||||||
|
userComment: true,
|
||||||
|
|
||||||
|
xmp: true,
|
||||||
|
iptc: true,
|
||||||
|
icc: true,
|
||||||
|
|
||||||
|
ihdr: true,
|
||||||
|
|
||||||
|
jfif: true,
|
||||||
|
|
||||||
|
chunked: true,
|
||||||
|
firstChunkSize: 131072,
|
||||||
|
chunkSize: 65536,
|
||||||
|
|
||||||
|
multiSegment: true,
|
||||||
|
silentErrors: false,
|
||||||
|
|
||||||
|
segments: [
|
||||||
|
"APP0",
|
||||||
|
"APP1",
|
||||||
|
"APP2",
|
||||||
|
"APP3",
|
||||||
|
"APP4",
|
||||||
|
"APP5",
|
||||||
|
"APP6",
|
||||||
|
"APP7",
|
||||||
|
"APP8",
|
||||||
|
"APP9",
|
||||||
|
"APP10",
|
||||||
|
"APP11",
|
||||||
|
"APP12",
|
||||||
|
"APP13",
|
||||||
|
"APP14",
|
||||||
|
"APP15",
|
||||||
|
],
|
||||||
|
|
||||||
|
mergeOutput: true,
|
||||||
|
|
||||||
|
translateKeys: true,
|
||||||
|
translateValues: true,
|
||||||
|
|
||||||
|
reviveValues: true,
|
||||||
|
|
||||||
|
sanitize: false,
|
||||||
|
|
||||||
|
pick: [],
|
||||||
|
skip: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
let allMetadata = exifData || {};
|
||||||
|
let rawMetadata = {};
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
|
|
||||||
|
rawMetadata = await extractRawMetadata(uint8Array, file.type);
|
||||||
|
|
||||||
|
allMetadata = { ...allMetadata, ...rawMetadata };
|
||||||
|
|
||||||
|
if (
|
||||||
|
file.type.includes("png") &&
|
||||||
|
(!allMetadata || Object.keys(allMetadata).length === 0)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const pngMetadata = await parse(file, {
|
||||||
|
ihdr: true,
|
||||||
|
itxt: true,
|
||||||
|
text: true,
|
||||||
|
ztxt: true,
|
||||||
|
time: true,
|
||||||
|
phys: true,
|
||||||
|
splt: true,
|
||||||
|
hist: true,
|
||||||
|
chrm: true,
|
||||||
|
gama: true,
|
||||||
|
iccp: true,
|
||||||
|
sbit: true,
|
||||||
|
srgb: true,
|
||||||
|
bkgd: true,
|
||||||
|
trns: true,
|
||||||
|
mergeOutput: false,
|
||||||
|
translateKeys: false,
|
||||||
|
sanitize: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pngMetadata && Object.keys(pngMetadata).length > 0) {
|
||||||
|
allMetadata = { ...allMetadata, ...pngMetadata };
|
||||||
|
}
|
||||||
|
} catch (pngError) {
|
||||||
|
console.log("PNG-specific parsing failed:", pngError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allMetadata || Object.keys(allMetadata).length === 0) {
|
||||||
|
return {
|
||||||
|
fileType: file.type.includes("png") ? "png" : "jpeg",
|
||||||
|
hasMetadata: false,
|
||||||
|
message: file.type.includes("png")
|
||||||
|
? "PNG files typically don't contain EXIF metadata"
|
||||||
|
: "No EXIF data found in this image - image appears to be clean",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organize the metadata into categories
|
||||||
|
const metadata = {};
|
||||||
|
|
||||||
|
// C2PA/AI Provenance data (priority category)
|
||||||
|
const c2paFields = Object.keys(allMetadata).filter(
|
||||||
|
(key) =>
|
||||||
|
key.toLowerCase().includes("c2pa") ||
|
||||||
|
key.toLowerCase().includes("cai") ||
|
||||||
|
key.toLowerCase().includes("provenance") ||
|
||||||
|
key.toLowerCase().includes("manifest") ||
|
||||||
|
key.toLowerCase().includes("signature") ||
|
||||||
|
key.toLowerCase().includes("claim") ||
|
||||||
|
key.toLowerCase().includes("assertion") ||
|
||||||
|
key.toLowerCase().includes("chatgpt") ||
|
||||||
|
key.toLowerCase().includes("openai") ||
|
||||||
|
key.toLowerCase().includes("ai_generated") ||
|
||||||
|
key.toLowerCase().includes("dall") ||
|
||||||
|
key.toLowerCase().includes("midjourney") ||
|
||||||
|
key.toLowerCase().includes("stable") ||
|
||||||
|
key.toLowerCase().includes("generated")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (c2paFields.length > 0) {
|
||||||
|
const c2paData = {};
|
||||||
|
for (const field of c2paFields) {
|
||||||
|
c2paData[field] = allMetadata[field];
|
||||||
|
}
|
||||||
|
metadata["C2PA/AI Provenance"] = c2paData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
rawMetadata.C2PA_FOUND ||
|
||||||
|
rawMetadata.AI_SIGNATURES ||
|
||||||
|
rawMetadata.JUMBF_BOXES
|
||||||
|
) {
|
||||||
|
if (!metadata["C2PA/AI Provenance"]) {
|
||||||
|
metadata["C2PA/AI Provenance"] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add if not already present to avoid duplicates
|
||||||
|
if (
|
||||||
|
rawMetadata.TOTAL_SIGNATURES &&
|
||||||
|
!metadata["C2PA/AI Provenance"]["TOTAL_SIGNATURES"]
|
||||||
|
) {
|
||||||
|
metadata["C2PA/AI Provenance"]["TOTAL_SIGNATURES"] =
|
||||||
|
rawMetadata.TOTAL_SIGNATURES;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rawMetadata.C2PA_FOUND &&
|
||||||
|
!metadata["C2PA/AI Provenance"]["C2PA_FOUND"]
|
||||||
|
) {
|
||||||
|
metadata["C2PA/AI Provenance"]["C2PA_FOUND"] = rawMetadata.C2PA_FOUND;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rawMetadata.AI_SIGNATURES &&
|
||||||
|
!metadata["C2PA/AI Provenance"]["AI_SIGNATURES"]
|
||||||
|
) {
|
||||||
|
metadata["C2PA/AI Provenance"]["AI_SIGNATURES"] =
|
||||||
|
rawMetadata.AI_SIGNATURES;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rawMetadata.JUMBF_BOXES &&
|
||||||
|
!metadata["C2PA/AI Provenance"]["JUMBF_BOXES"]
|
||||||
|
) {
|
||||||
|
metadata["C2PA/AI Provenance"]["JUMBF_BOXES"] =
|
||||||
|
rawMetadata.JUMBF_BOXES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpsFields = [
|
||||||
|
"latitude",
|
||||||
|
"longitude",
|
||||||
|
"GPSLatitude",
|
||||||
|
"GPSLongitude",
|
||||||
|
"GPSAltitude",
|
||||||
|
"GPSSpeed",
|
||||||
|
"GPSImgDirection",
|
||||||
|
"GPSDestBearing",
|
||||||
|
"GPSDateTime",
|
||||||
|
"GPSLatitudeRef",
|
||||||
|
"GPSLongitudeRef",
|
||||||
|
];
|
||||||
|
const gpsData = {};
|
||||||
|
for (const field of gpsFields) {
|
||||||
|
if (allMetadata[field] !== undefined) {
|
||||||
|
gpsData[field] = allMetadata[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(gpsData).length > 0) {
|
||||||
|
metadata.GPS = gpsData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera/EXIF data
|
||||||
|
const cameraFields = [
|
||||||
|
"Make",
|
||||||
|
"Model",
|
||||||
|
"Software",
|
||||||
|
"DateTime",
|
||||||
|
"DateTimeOriginal",
|
||||||
|
"DateTimeDigitized",
|
||||||
|
"ExposureTime",
|
||||||
|
"FNumber",
|
||||||
|
"ISO",
|
||||||
|
"Flash",
|
||||||
|
"FocalLength",
|
||||||
|
"WhiteBalance",
|
||||||
|
"LensModel",
|
||||||
|
"LensMake",
|
||||||
|
"Artist",
|
||||||
|
"Copyright",
|
||||||
|
];
|
||||||
|
const cameraData = {};
|
||||||
|
for (const field of cameraFields) {
|
||||||
|
if (allMetadata[field] !== undefined) {
|
||||||
|
cameraData[field] = allMetadata[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(cameraData).length > 0) {
|
||||||
|
metadata.Camera = cameraData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const technicalFields = [
|
||||||
|
"ColorSpace",
|
||||||
|
"Orientation",
|
||||||
|
"ResolutionUnit",
|
||||||
|
"XResolution",
|
||||||
|
"YResolution",
|
||||||
|
"ExifVersion",
|
||||||
|
"ComponentsConfiguration",
|
||||||
|
"ImageWidth",
|
||||||
|
"ImageHeight",
|
||||||
|
];
|
||||||
|
const technicalData = {};
|
||||||
|
for (const field of technicalFields) {
|
||||||
|
if (allMetadata[field] !== undefined) {
|
||||||
|
technicalData[field] = allMetadata[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(technicalData).length > 0) {
|
||||||
|
metadata.Technical = technicalData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// XMP data (often contains AI/software info)
|
||||||
|
const xmpFields = Object.keys(allMetadata).filter(
|
||||||
|
(key) =>
|
||||||
|
key.toLowerCase().includes("xmp") ||
|
||||||
|
key.includes("Creator") ||
|
||||||
|
key.includes("Subject")
|
||||||
|
);
|
||||||
|
if (xmpFields.length > 0) {
|
||||||
|
const xmpData = {};
|
||||||
|
for (const field of xmpFields) {
|
||||||
|
xmpData[field] = allMetadata[field];
|
||||||
|
}
|
||||||
|
metadata.XMP = xmpData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPTC data
|
||||||
|
const iptcFields = Object.keys(allMetadata).filter(
|
||||||
|
(key) =>
|
||||||
|
key.toLowerCase().includes("iptc") ||
|
||||||
|
key.includes("Keywords") ||
|
||||||
|
key.includes("Caption")
|
||||||
|
);
|
||||||
|
if (iptcFields.length > 0) {
|
||||||
|
const iptcData = {};
|
||||||
|
for (const field of iptcFields) {
|
||||||
|
iptcData[field] = allMetadata[field];
|
||||||
|
}
|
||||||
|
metadata.IPTC = iptcData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PNG-specific chunks
|
||||||
|
const pngFields = Object.keys(allMetadata).filter(
|
||||||
|
(key) =>
|
||||||
|
key.toLowerCase().includes("text") ||
|
||||||
|
key.toLowerCase().includes("itxt") ||
|
||||||
|
key.toLowerCase().includes("ztxt") ||
|
||||||
|
key.toLowerCase().includes("time") ||
|
||||||
|
key.toLowerCase().includes("phys") ||
|
||||||
|
key.toLowerCase().includes("hist") ||
|
||||||
|
key.toLowerCase().includes("chrm") ||
|
||||||
|
key.toLowerCase().includes("gama") ||
|
||||||
|
key.toLowerCase().includes("srgb") ||
|
||||||
|
key.toLowerCase().includes("iccp")
|
||||||
|
);
|
||||||
|
if (pngFields.length > 0) {
|
||||||
|
const pngData = {};
|
||||||
|
for (const field of pngFields) {
|
||||||
|
pngData[field] = allMetadata[field];
|
||||||
|
}
|
||||||
|
metadata["PNG Chunks"] = pngData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color Profile data (ICC profiles - not privacy concerning)
|
||||||
|
const colorProfileFields = Object.keys(allMetadata).filter(
|
||||||
|
(key) =>
|
||||||
|
key.includes("Profile") ||
|
||||||
|
key.includes("ColorSpace") ||
|
||||||
|
key.includes("WhitePoint") ||
|
||||||
|
key.includes("PrimaryChromaticities") ||
|
||||||
|
key.includes("ICC")
|
||||||
|
);
|
||||||
|
if (colorProfileFields.length > 0) {
|
||||||
|
const profileData = {};
|
||||||
|
for (const field of colorProfileFields) {
|
||||||
|
profileData[field] = allMetadata[field];
|
||||||
|
}
|
||||||
|
metadata["Color Profile"] = profileData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other interesting fields that might contain privacy-sensitive data
|
||||||
|
const otherPrivacyFields = Object.keys(allMetadata).filter(
|
||||||
|
(key) =>
|
||||||
|
!gpsFields.includes(key) &&
|
||||||
|
!cameraFields.includes(key) &&
|
||||||
|
!technicalFields.includes(key) &&
|
||||||
|
!xmpFields.includes(key) &&
|
||||||
|
!iptcFields.includes(key) &&
|
||||||
|
!pngFields.includes(key) &&
|
||||||
|
!c2paFields.includes(key) &&
|
||||||
|
!colorProfileFields.includes(key) &&
|
||||||
|
(key.includes("User") ||
|
||||||
|
key.includes("Comment") ||
|
||||||
|
key.includes("Description") ||
|
||||||
|
key.includes("AI") ||
|
||||||
|
key.includes("Generated") ||
|
||||||
|
key.includes("Thumbnail") ||
|
||||||
|
key.includes("Preview"))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (otherPrivacyFields.length > 0) {
|
||||||
|
const otherData = {};
|
||||||
|
for (const field of otherPrivacyFields) {
|
||||||
|
otherData[field] = allMetadata[field];
|
||||||
|
}
|
||||||
|
metadata.Other = otherData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add raw metadata summary
|
||||||
|
if (
|
||||||
|
rawMetadata.TOTAL_SIGNATURES > 0 ||
|
||||||
|
rawMetadata.METADATA_MARKERS > 0
|
||||||
|
) {
|
||||||
|
metadata["Raw Analysis"] = {
|
||||||
|
"Total Signatures Found": rawMetadata.TOTAL_SIGNATURES || 0,
|
||||||
|
"Metadata Markers": rawMetadata.METADATA_MARKERS || 0,
|
||||||
|
"File Contains Binary Metadata": true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Extracted metadata with exifr:", metadata);
|
||||||
|
console.log("Raw exifr data:", allMetadata);
|
||||||
|
console.log("Raw binary analysis:", rawMetadata);
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error extracting metadata:", error.message);
|
||||||
|
return {
|
||||||
|
fileType:
|
||||||
|
file.type.includes("jpeg") || file.type.includes("jpg")
|
||||||
|
? "jpeg"
|
||||||
|
: file.type.includes("png")
|
||||||
|
? "png"
|
||||||
|
: "other",
|
||||||
|
hasMetadata: false,
|
||||||
|
message: file.type.includes("png")
|
||||||
|
? "PNG files typically don't contain EXIF metadata"
|
||||||
|
: error.message.includes("not supported") ||
|
||||||
|
error.message.includes("invalid")
|
||||||
|
? "No EXIF data found in this image"
|
||||||
|
: "Image appears to be clean (no metadata found)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to extract raw metadata by scanning binary data for known signatures
|
||||||
|
async function extractRawMetadata(uint8Array, fileType) {
|
||||||
|
const rawMetadata = {
|
||||||
|
TOTAL_SIGNATURES: 0,
|
||||||
|
METADATA_MARKERS: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert to string for text-based searching
|
||||||
|
const textDecoder = new TextDecoder("utf-8", {
|
||||||
|
ignoreBOM: true,
|
||||||
|
fatal: false,
|
||||||
|
});
|
||||||
|
let textContent = "";
|
||||||
|
|
||||||
|
// Sample the file in chunks to avoid memory issues with large files
|
||||||
|
const chunkSize = 1024;
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < Math.min(uint8Array.length, 1024 * 1024);
|
||||||
|
i += chunkSize
|
||||||
|
) {
|
||||||
|
const chunk = uint8Array.slice(
|
||||||
|
i,
|
||||||
|
Math.min(i + chunkSize, uint8Array.length)
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
textContent += textDecoder.decode(chunk, { stream: true });
|
||||||
|
} catch (e) {
|
||||||
|
// Skip chunks that can't be decoded as text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C2PA/CAI signatures - more specific patterns
|
||||||
|
const c2paSignatures = [
|
||||||
|
"c2pa.manifest",
|
||||||
|
"c2pa.signature",
|
||||||
|
"c2pa.assertions",
|
||||||
|
"contentauthenticity.org",
|
||||||
|
"adobe.com/cai",
|
||||||
|
"CASS",
|
||||||
|
"c2pa.created",
|
||||||
|
"c2pa.converted",
|
||||||
|
];
|
||||||
|
|
||||||
|
// AI generation signatures - specific and definitive patterns only
|
||||||
|
const aiSignatures = [
|
||||||
|
"ChatGPT",
|
||||||
|
"OpenAI API",
|
||||||
|
"DALL-E",
|
||||||
|
"dall-e-",
|
||||||
|
"Midjourney",
|
||||||
|
"Stable Diffusion",
|
||||||
|
"AI-generated",
|
||||||
|
"trainedAlgorithmicMedia",
|
||||||
|
"digitalSourceType",
|
||||||
|
"GPT-4",
|
||||||
|
"generated",
|
||||||
|
"synthesized",
|
||||||
|
];
|
||||||
|
|
||||||
|
// JUMBF (JPEG Universal Metadata Box Format) signatures
|
||||||
|
const jumbfSignatures = ["jumb", "JUMB", "jumd", "JUMD"];
|
||||||
|
|
||||||
|
// Search for C2PA signatures with more precise matching
|
||||||
|
let c2paFound = [];
|
||||||
|
for (const sig of c2paSignatures) {
|
||||||
|
// Use case-sensitive matching for specific patterns
|
||||||
|
if (
|
||||||
|
textContent.includes(sig) ||
|
||||||
|
textContent.toLowerCase().includes(sig.toLowerCase())
|
||||||
|
) {
|
||||||
|
c2paFound.push(sig);
|
||||||
|
rawMetadata.TOTAL_SIGNATURES++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c2paFound.length > 0) {
|
||||||
|
rawMetadata.C2PA_FOUND = c2paFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for AI signatures with exact matching
|
||||||
|
let aiFound = [];
|
||||||
|
for (const sig of aiSignatures) {
|
||||||
|
// Use more precise matching to avoid false positives
|
||||||
|
if (
|
||||||
|
textContent.includes(sig) ||
|
||||||
|
textContent.toLowerCase().includes(sig.toLowerCase())
|
||||||
|
) {
|
||||||
|
// Additional validation: ensure it's not just a fragment
|
||||||
|
const context = textContent.toLowerCase();
|
||||||
|
const sigLower = sig.toLowerCase();
|
||||||
|
|
||||||
|
// Only count if we find the signature in a meaningful context
|
||||||
|
if (
|
||||||
|
context.includes(sigLower) &&
|
||||||
|
(sig.length > 4 || // Longer signatures are more reliable
|
||||||
|
context.includes(sigLower + " ") || // Word boundary
|
||||||
|
context.includes(" " + sigLower) || // Word boundary
|
||||||
|
context.includes(sigLower + ":") || // Metadata format
|
||||||
|
context.includes(sigLower + "=") || // Assignment format
|
||||||
|
context.includes('"' + sigLower + '"')) // Quoted value
|
||||||
|
) {
|
||||||
|
aiFound.push(sig);
|
||||||
|
rawMetadata.TOTAL_SIGNATURES++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (aiFound.length > 0) {
|
||||||
|
rawMetadata.AI_SIGNATURES = aiFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for JUMBF boxes
|
||||||
|
let jumbfFound = [];
|
||||||
|
for (const sig of jumbfSignatures) {
|
||||||
|
if (textContent.toLowerCase().includes(sig.toLowerCase())) {
|
||||||
|
jumbfFound.push(sig);
|
||||||
|
rawMetadata.TOTAL_SIGNATURES++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jumbfFound.length > 0) {
|
||||||
|
rawMetadata.JUMBF_BOXES = jumbfFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for common metadata markers - be more specific
|
||||||
|
const metadataMarkers = [
|
||||||
|
"EXIF\x00", // EXIF with null terminator
|
||||||
|
"IPTC",
|
||||||
|
"XMP\x00", // XMP with null terminator
|
||||||
|
"ICC_PROFILE",
|
||||||
|
"Photoshop 3.0",
|
||||||
|
"Adobe_CM",
|
||||||
|
"GPS\x00", // GPS with null terminator
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const marker of metadataMarkers) {
|
||||||
|
if (textContent.includes(marker)) {
|
||||||
|
rawMetadata.METADATA_MARKERS++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For JPEG files, look for specific APP segments that might contain C2PA
|
||||||
|
if (fileType.includes("jpeg") || fileType.includes("jpg")) {
|
||||||
|
// Look for APP11 segment marker (often used for C2PA)
|
||||||
|
const app11Marker = new Uint8Array([0xff, 0xeb]);
|
||||||
|
// Look for APP2 segment marker (sometimes used for C2PA/CAI, but also ICC profiles)
|
||||||
|
const app2Marker = new Uint8Array([0xff, 0xe2]);
|
||||||
|
|
||||||
|
let foundApp11 = false,
|
||||||
|
foundApp2WithC2PA = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < uint8Array.length - 10; i++) {
|
||||||
|
if (
|
||||||
|
uint8Array[i] === app11Marker[0] &&
|
||||||
|
uint8Array[i + 1] === app11Marker[1]
|
||||||
|
) {
|
||||||
|
foundApp11 = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
uint8Array[i] === app2Marker[0] &&
|
||||||
|
uint8Array[i + 1] === app2Marker[1]
|
||||||
|
) {
|
||||||
|
// Check if this APP2 segment contains C2PA data (not just ICC profile)
|
||||||
|
const segmentData = textContent.slice(i, i + 100).toLowerCase();
|
||||||
|
if (
|
||||||
|
segmentData.includes("c2pa") ||
|
||||||
|
segmentData.includes("cai") ||
|
||||||
|
segmentData.includes("jumb")
|
||||||
|
) {
|
||||||
|
foundApp2WithC2PA = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundApp11 || foundApp2WithC2PA) {
|
||||||
|
if (!rawMetadata.JUMBF_BOXES) rawMetadata.JUMBF_BOXES = [];
|
||||||
|
if (foundApp11) rawMetadata.JUMBF_BOXES.push("APP11_segment");
|
||||||
|
if (foundApp2WithC2PA) rawMetadata.JUMBF_BOXES.push("APP2_segment");
|
||||||
|
rawMetadata.TOTAL_SIGNATURES++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.value = "";
|
||||||
|
}
|
||||||
|
dispatch("reset");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="upload-section">
|
||||||
|
<div
|
||||||
|
class="upload-area"
|
||||||
|
class:dragover={dragOver}
|
||||||
|
class:disabled
|
||||||
|
on:dragover={handleDragOver}
|
||||||
|
on:dragleave={handleDragLeave}
|
||||||
|
on:drop={handleDrop}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
on:click={() => !disabled && fileInput.click()}
|
||||||
|
on:keydown={(e) =>
|
||||||
|
(e.key === "Enter" || e.key === " ") && !disabled && fileInput.click()}
|
||||||
|
>
|
||||||
|
<div class="upload-icon">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-semibold mb-2">
|
||||||
|
{dragOver ? "Drop your image here" : "Upload Image"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="text-secondary mb-4">
|
||||||
|
Drag and drop your image here, or click to browse
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
Supports JPEG, PNG, TIFF, HEIC, and AVIF files
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.tiff,.tif,.heic,.avif"
|
||||||
|
bind:this={fileInput}
|
||||||
|
on:change={handleFileInput}
|
||||||
|
class="hidden"
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.upload-section {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled:hover {
|
||||||
|
transform: none !important;
|
||||||
|
border-color: var(--border-color) !important;
|
||||||
|
background-color: var(--bg-secondary) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
<script>
|
||||||
|
export let metadata = {};
|
||||||
|
|
||||||
|
function formatMetadata(data) {
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
return "No metadata found";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.message && !data.hasMetadata) {
|
||||||
|
return data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatted = "";
|
||||||
|
|
||||||
|
Object.entries(data).forEach(([section, values]) => {
|
||||||
|
if (
|
||||||
|
section === "fileType" ||
|
||||||
|
section === "hasMetadata" ||
|
||||||
|
section === "message"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted += `[${section}]\n`;
|
||||||
|
|
||||||
|
if (typeof values === "object" && values !== null) {
|
||||||
|
Object.entries(values).forEach(([key, value]) => {
|
||||||
|
let displayValue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
displayValue = `[${value.join(", ")}]`;
|
||||||
|
} else if (typeof value === "string" && value.length > 100) {
|
||||||
|
displayValue = value.substring(0, 100) + "...";
|
||||||
|
} else {
|
||||||
|
displayValue = value;
|
||||||
|
}
|
||||||
|
formatted += ` ${key}: ${displayValue}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
formatted += "\n";
|
||||||
|
});
|
||||||
|
|
||||||
|
return formatted.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetadataStats(metadata) {
|
||||||
|
let totalEntries = 0;
|
||||||
|
let sections = [];
|
||||||
|
|
||||||
|
if (metadata.message && !metadata.hasMetadata) {
|
||||||
|
return { totalEntries: 0, sections: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(metadata).forEach(([section, values]) => {
|
||||||
|
if (
|
||||||
|
section === "fileType" ||
|
||||||
|
section === "hasMetadata" ||
|
||||||
|
section === "message"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof values === "object" && values !== null) {
|
||||||
|
const count = Object.keys(values).length;
|
||||||
|
totalEntries += count;
|
||||||
|
sections.push(`${section}: ${count} entries`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { totalEntries, sections };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAIMetadata(metadata) {
|
||||||
|
if (metadata["C2PA/AI Provenance"]) {
|
||||||
|
const c2paData = metadata["C2PA/AI Provenance"];
|
||||||
|
|
||||||
|
if (
|
||||||
|
c2paData.AI_SIGNATURES &&
|
||||||
|
Array.isArray(c2paData.AI_SIGNATURES) &&
|
||||||
|
c2paData.AI_SIGNATURES.length > 0
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
c2paData.C2PA_FOUND &&
|
||||||
|
Array.isArray(c2paData.C2PA_FOUND) &&
|
||||||
|
c2paData.C2PA_FOUND.length > 0
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c2paData.JUMBF_BOXES && Array.isArray(c2paData.JUMBF_BOXES)) {
|
||||||
|
const realJumbf = c2paData.JUMBF_BOXES.filter(
|
||||||
|
(box) =>
|
||||||
|
!box.includes("APP2_segment") ||
|
||||||
|
box.includes("jumb") ||
|
||||||
|
box.includes("JUMB") ||
|
||||||
|
box.includes("jumd") ||
|
||||||
|
box.includes("JUMD")
|
||||||
|
);
|
||||||
|
if (realJumbf.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c2paData.TOTAL_SIGNATURES && c2paData.TOTAL_SIGNATURES > 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiKeywords = [
|
||||||
|
"chatgpt",
|
||||||
|
"openai",
|
||||||
|
"dall-e",
|
||||||
|
"dalle",
|
||||||
|
"midjourney",
|
||||||
|
"stable diffusion",
|
||||||
|
"artificial intelligence",
|
||||||
|
"ai-generated",
|
||||||
|
"machine learning",
|
||||||
|
"neural network",
|
||||||
|
"synthesized",
|
||||||
|
"gpt-4",
|
||||||
|
"trainedAlgorithmicMedia",
|
||||||
|
];
|
||||||
|
|
||||||
|
let hasAI = false;
|
||||||
|
|
||||||
|
Object.entries(metadata).forEach(([section, values]) => {
|
||||||
|
if (
|
||||||
|
section === "fileType" ||
|
||||||
|
section === "hasMetadata" ||
|
||||||
|
section === "message" ||
|
||||||
|
section === "Raw Analysis" ||
|
||||||
|
section === "C2PA/AI Provenance"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof values === "object" && values !== null) {
|
||||||
|
Object.entries(values).forEach(([key, value]) => {
|
||||||
|
const valueString = String(value).toLowerCase();
|
||||||
|
if (
|
||||||
|
aiKeywords.some((keyword) =>
|
||||||
|
valueString.includes(keyword.toLowerCase())
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
hasAI = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasAI;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: formattedMetadata = formatMetadata(metadata);
|
||||||
|
$: containsAITags = hasAIMetadata(metadata);
|
||||||
|
$: metadataStats = getMetadataStats(metadata);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="metadata-section">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h4 class="font-semibold">EXIF Metadata</h4>
|
||||||
|
{#if containsAITags}
|
||||||
|
<span class="status-warning py-1 px-2 text-xs">
|
||||||
|
⚠️ Contains AI-related tags
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if Object.keys(metadata).length === 0 || (metadata.message && !metadata.hasMetadata)}
|
||||||
|
<div class="status-success">
|
||||||
|
{#if metadata.message}
|
||||||
|
✅ {metadata.message}
|
||||||
|
{:else}
|
||||||
|
✅ No metadata found - image is already clean!
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if metadata.fileType === "png"}
|
||||||
|
<div class="mt-3 p-3 bg-info rounded text-sm">
|
||||||
|
<p><strong>ℹ️ About PNG files:</strong></p>
|
||||||
|
<p>
|
||||||
|
PNG files typically don't contain EXIF metadata. However, you can
|
||||||
|
still process this image to ensure it's completely clean and remove
|
||||||
|
any potential hidden data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="metadata-summary">
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
<strong>Total entries:</strong>
|
||||||
|
{metadataStats.totalEntries}
|
||||||
|
</p>
|
||||||
|
<div class="text-xs text-muted mt-1">
|
||||||
|
{#each metadataStats.sections as section}
|
||||||
|
<span class="section-tag">{section}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata-viewer">
|
||||||
|
{formattedMetadata}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h5 class="font-medium mb-2">Privacy Concerns Found:</h5>
|
||||||
|
<ul class="text-sm text-secondary space-y-1">
|
||||||
|
{#if metadata.GPS}
|
||||||
|
<li class="status-warning">📍 GPS location data</li>
|
||||||
|
{/if}
|
||||||
|
{#if metadata.Camera}
|
||||||
|
<li class="status-warning">📷 Camera information</li>
|
||||||
|
{/if}
|
||||||
|
{#if metadata.Technical}
|
||||||
|
<li class="status-warning">🖼️ Image creation details</li>
|
||||||
|
{/if}
|
||||||
|
{#if containsAITags}
|
||||||
|
<li class="status-warning">🤖 AI generation indicators</li>
|
||||||
|
{#if metadata["C2PA/AI Provenance"]}
|
||||||
|
<ul class="ml-4 mt-1 text-xs">
|
||||||
|
{#if metadata["C2PA/AI Provenance"].AI_SIGNATURES && metadata["C2PA/AI Provenance"].AI_SIGNATURES.length > 0}
|
||||||
|
<li>
|
||||||
|
• AI Tools: {metadata[
|
||||||
|
"C2PA/AI Provenance"
|
||||||
|
].AI_SIGNATURES.join(", ")}
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#if metadata["C2PA/AI Provenance"].C2PA_FOUND && metadata["C2PA/AI Provenance"].C2PA_FOUND.length > 0}
|
||||||
|
<li>
|
||||||
|
• C2PA Provenance: {metadata["C2PA/AI Provenance"].C2PA_FOUND
|
||||||
|
.length} signature(s)
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#if metadata["C2PA/AI Provenance"].TOTAL_SIGNATURES && metadata["C2PA/AI Provenance"].TOTAL_SIGNATURES > 0}
|
||||||
|
<li>
|
||||||
|
• Total AI/C2PA markers: {metadata["C2PA/AI Provenance"]
|
||||||
|
.TOTAL_SIGNATURES}
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#if metadata["C2PA/AI Provenance"].JUMBF_BOXES && metadata["C2PA/AI Provenance"].JUMBF_BOXES.length > 0}
|
||||||
|
<li>
|
||||||
|
• JUMBF metadata containers: {metadata["C2PA/AI Provenance"]
|
||||||
|
.JUMBF_BOXES.length}
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if metadata.XMP}
|
||||||
|
<li class="status-warning">📝 XMP metadata</li>
|
||||||
|
{/if}
|
||||||
|
{#if metadata.IPTC}
|
||||||
|
<li class="status-warning">📋 IPTC metadata</li>
|
||||||
|
{/if}
|
||||||
|
{#if metadata.Other && Object.keys(metadata.Other).length > 0}
|
||||||
|
<li class="status-warning">📄 Other metadata</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if metadata["Color Profile"]}
|
||||||
|
<div class="mt-3 p-2 bg-info rounded text-xs">
|
||||||
|
<p>
|
||||||
|
<strong>ℹ️ Color Profile:</strong> Contains ICC color management data
|
||||||
|
(not privacy-concerning)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.space-y-1 > * + * {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-summary {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-tag {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<div class="grid grid-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
Client-Side Processing
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-secondary mb-4">
|
||||||
|
Images never leave your browser. Processing happens locally.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
No Data Collection
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-secondary mb-4">
|
||||||
|
No analytics, tracking, or data collection.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
Open Source
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
Fully auditable codebase. Verify what happens to your data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold mb-3">Why Remove Metadata?</h3>
|
||||||
|
<div class="space-y-3 text-sm text-secondary">
|
||||||
|
<div class="privacy-risk">
|
||||||
|
<strong>📍 Location:</strong> GPS coordinates reveal photo locations
|
||||||
|
</div>
|
||||||
|
<div class="privacy-risk">
|
||||||
|
<strong>📷 Device Info:</strong> Camera models can link photos to devices
|
||||||
|
</div>
|
||||||
|
<div class="privacy-risk">
|
||||||
|
<strong>🕒 Timestamps:</strong> Enable detailed activity timelines
|
||||||
|
</div>
|
||||||
|
<div class="privacy-risk">
|
||||||
|
<strong>🤖 AI Detection:</strong> Software tags reveal AI generation
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 p-3 bg-warning rounded">
|
||||||
|
<p class="text-sm">
|
||||||
|
<strong>⚠️</strong> Social media may strip some metadata, but it's safer
|
||||||
|
to clean images yourself.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 p-4 bg-info rounded">
|
||||||
|
<h4 class="font-semibold mb-2">Perfect for:</h4>
|
||||||
|
<div class="grid grid-2 gap-2 text-sm">
|
||||||
|
<div>• Journalists protecting sources</div>
|
||||||
|
<div>• Activists maintaining anonymity</div>
|
||||||
|
<div>• Content creators removing AI tags</div>
|
||||||
|
<div>• Anyone valuing digital privacy</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.text-success {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-3 > * + * {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-risk {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border-left: 3px solid var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-warning {
|
||||||
|
background-color: rgba(245, 158, 11, 0.1);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-info {
|
||||||
|
background-color: rgba(37, 99, 235, 0.05);
|
||||||
|
border: 1px solid rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let darkMode = false;
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
dispatch("toggle");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="theme-toggle"
|
||||||
|
on:click={toggle}
|
||||||
|
aria-label="Toggle dark mode"
|
||||||
|
title="Toggle dark mode"
|
||||||
|
>
|
||||||
|
{#if darkMode}
|
||||||
|
<svg width="24" height="24" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="24" height="24" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import './app.css'
|
||||||
|
import App from './App.svelte'
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
target: document.getElementById('app'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(),svelte()],
|
||||||
|
base: '/untrace/',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
assetsDir: 'assets'
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user