Rebuild on the RealBrain design system and retarget SEO to content credentials
Search Console (Jun 4 – Sep 3) shows the page ranking on the first results page for "content credentials" queries while converting badly: "ai credentials remover" at 113 impressions and 0.88% CTR from position 6.7, "remove content credentials from image online" at 2.15% from position 5.0, "remove ai content credentials from image" at 0% from position 7.8. The title led with "EXIF metadata", a phrase that appears in none of the top queries, so the snippet never matched the intent behind the impressions. SEO - Title, description and H1 rebuilt around "remove content credentials from an image"; EXIF kept as a secondary term. - Added a how-to section and a nine-question FAQ answering the question queries that had no content behind them, including the LinkedIn AI label and the Instagram AI tag. - Structured data as an @graph: WebSite (alternateName "Untrace AI" for the unserved brand query), Organization, WebApplication, HowTo, FAQPage. - Fixed sitemap.xml, which pointed at /untrace/ — a URL that does not exist. - Regenerated the Open Graph image to match the new design. Design - Vendored the design system at src/tokens.css and composed the app from it. The old styling broke most of its rules: gradients, font weights 800 and 900, raw #2563eb, Inter, and emoji as the only status signal. - Reworked the tool into a Scan / Review / Download flow that lists what it found before changing anything, with the primary action pinned within thumb reach on small screens. - Dark by default with a light theme, applied before first paint so the page never flashes the wrong surface. - Replaced the floating donation widget, which painted an off-token circle over the page and covered the mobile action bar, with a footer link. Structure - Moved detection into src/lib/metadata.js and removal into src/lib/clean.js; the canvas callbacks are now a promise chain. - Dropped Tailwind and PostCSS. Both were configured twice, and no Tailwind utility was used. Stylesheet is 6.2 kB gzipped with the whole system in it. Fixes - The stepper never advanced: stepState(index) read `current` from the closure, which Svelte 4 cannot track as a template dependency. - The cleaner's fallback returned the original, uncleaned file while the UI reported "All EXIF metadata removed". It now reports the failure instead. Verified against a JPEG carrying EXIF, GPS, XMP and an APP11 JUMBF C2PA manifest: the output retains only APP0 and the sRGB ICC profile, with every provenance marker gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pRSdR57Ze9M2meDfYZgYE
This commit is contained in:
1 parent
83a4d3f2c4
commit
00991c1608
40 files changed
+3577
-3697
No files matched your search
+92
-301
@@ -1,335 +1,126 @@
|
||||
<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 { theme } from "./lib/theme.js";
|
||||
import TopBar from "./lib/TopBar.svelte";
|
||||
import Tool from "./lib/Tool.svelte";
|
||||
import Removals from "./lib/Removals.svelte";
|
||||
import HowItWorks from "./lib/HowItWorks.svelte";
|
||||
import Faq from "./lib/Faq.svelte";
|
||||
import Footer from "./lib/Footer.svelte";
|
||||
import PrivacyInfo from "./lib/PrivacyInfo.svelte";
|
||||
import Toast from "./lib/Toast.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;
|
||||
let toasts = [];
|
||||
let nextId = 0;
|
||||
|
||||
onMount(() => {
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
if (savedTheme) {
|
||||
darkMode = savedTheme === "dark";
|
||||
} else {
|
||||
darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
updateTheme();
|
||||
});
|
||||
const assurances = [
|
||||
{
|
||||
title: "Runs in your browser",
|
||||
detail: "No upload, no queue, no server that could keep a copy.",
|
||||
},
|
||||
{
|
||||
title: "Reads the whole file",
|
||||
detail: "EXIF, XMP, IPTC and the JUMBF boxes that hold C2PA data.",
|
||||
},
|
||||
{
|
||||
title: "Shows its work",
|
||||
detail: "Every finding is listed before anything is changed.",
|
||||
},
|
||||
];
|
||||
|
||||
function updateTheme() {
|
||||
if (darkMode) {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
}
|
||||
localStorage.setItem("theme", darkMode ? "dark" : "light");
|
||||
function notify(event) {
|
||||
const id = nextId++;
|
||||
toasts = [...toasts, { id, ...event.detail }];
|
||||
setTimeout(() => dismiss(id), 5000);
|
||||
}
|
||||
|
||||
function handleThemeToggle() {
|
||||
darkMode = !darkMode;
|
||||
updateTheme();
|
||||
function dismiss(id) {
|
||||
toasts = toasts.filter((toast) => toast.id !== id);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
onMount(() => theme.init());
|
||||
</script>
|
||||
|
||||
<ThemeToggle {darkMode} on:toggle={handleThemeToggle} />
|
||||
<TopBar />
|
||||
|
||||
<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>
|
||||
<main>
|
||||
<div class="wrap">
|
||||
<section class="lede">
|
||||
<p class="eyebrow">C2PA · AI credentials · EXIF</p>
|
||||
<h1>Remove content credentials from an image</h1>
|
||||
<p>
|
||||
Untrace strips C2PA content credentials, AI generation tags, GPS
|
||||
coordinates and EXIF metadata from a photo — free, and without the file
|
||||
ever leaving your device.
|
||||
</p>
|
||||
<div class="chip-row">
|
||||
<span class="chip">Free</span>
|
||||
<span class="chip">No sign-in</span>
|
||||
<span class="chip">Open source</span>
|
||||
</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>
|
||||
<Tool on:notify={notify} />
|
||||
|
||||
<div class="text-center mb-3">
|
||||
<img
|
||||
src={URL.createObjectURL(selectedFile)}
|
||||
alt="Original"
|
||||
class="image-preview"
|
||||
/>
|
||||
</div>
|
||||
<ul class="assurances">
|
||||
{#each assurances as item}
|
||||
<li>
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.detail}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<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">✓ GPS location data cleared</p>
|
||||
<p class="status-success">✓ AI content credentials stripped</p>
|
||||
<p class="status-success">✓ C2PA provenance data removed</p>
|
||||
<p class="status-success">✓ Camera & device info 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 class="stack sections">
|
||||
<Removals />
|
||||
<HowItWorks />
|
||||
<Faq />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
<Toast {toasts} {dismiss} />
|
||||
|
||||
<style>
|
||||
.w-full {
|
||||
width: 100%;
|
||||
main {
|
||||
padding-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.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 {
|
||||
.assurances {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-5);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-height: 150px;
|
||||
@media (min-width: 768px) {
|
||||
.assurances {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
.assurances li {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
padding-left: var(--space-4);
|
||||
border-left: var(--border-w) solid var(--border-strong);
|
||||
}
|
||||
|
||||
.assurances h2 {
|
||||
font-size: var(--fs-2);
|
||||
line-height: var(--lh-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assurances p {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-1);
|
||||
line-height: var(--lh-2);
|
||||
}
|
||||
|
||||
.sections {
|
||||
margin-top: var(--space-9);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user