diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..0c6e15c
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "spectracleanse-client",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "browser-id3-writer": "4.4.0",
+ "lucide-react": "^0.390.0",
+ "music-metadata-browser": "2.5.11",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.2.1",
+ "autoprefixer": "^10.4.19",
+ "postcss": "^8.4.38",
+ "tailwindcss": "^3.4.4",
+ "typescript": "^5.5.2",
+ "vite": "^4.5.3"
+ }
+}
diff --git a/client/src/App.tsx b/client/src/App.tsx
new file mode 100644
index 0000000..5c54204
--- /dev/null
+++ b/client/src/App.tsx
@@ -0,0 +1,141 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { Upload, LogOut, Crown } from 'lucide-react';
+import { readFileMetadata, writeMP3Metadata } from './utils/metadata';
+
+const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
+const TOKEN_KEY = 'spectra_token';
+const USER_KEY = 'spectra_user';
+
+type Tab = 'context' | 'seo' | 'analysis';
+type User = { id: number; email: string; plan: 'free' | 'creator' | 'studio' | 'enterprise' };
+
+type LogItem = { id: string; level: 'info' | 'success' | 'error'; message: string; ts: string };
+
+const AuthScreen = ({ onAuth }: { onAuth: (token: string, user: User) => void }) => {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [mode, setMode] = useState<'login' | 'register'>('login');
+ const [error, setError] = useState('');
+
+ const submit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ const res = await fetch(`${BACKEND_URL}/api/${mode}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) });
+ const data = await res.json();
+ if (!res.ok) return setError(data.error || 'Auth failed');
+ localStorage.setItem(TOKEN_KEY, data.token); localStorage.setItem(USER_KEY, JSON.stringify(data.user));
+ onAuth(data.token, data.user);
+ };
+
+ return
;
+};
+
+export default function App() {
+ const [token, setToken] = useState(null);
+ const [user, setUser] = useState(null);
+ const [tab, setTab] = useState('context');
+ const [file, setFile] = useState(null);
+ const [analysis, setAnalysis] = useState(null);
+ const [processedAsset, setProcessedAsset] = useState<{ url: string; name: string } | null>(null);
+ const [forensicReport, setForensicReport] = useState(null);
+ const [showUpgrade, setShowUpgrade] = useState(false);
+ const [usage, setUsage] = useState({ thisMonth: 0, limit: 3 as number | null });
+ const [logs, setLogs] = useState([]);
+ const fileRef = useRef(null);
+ const supported = '.mp3,.wav,.flac,.m4a,.mp4';
+
+ const [ctx, setCtx] = useState({ artist: '', title: '', genre: '', vibe: '', lyrics: '' });
+ const [seo, setSeo] = useState({ title: '', description: '', tags: '', lyrics: '' });
+
+ const isMp3 = useMemo(() => file?.name.toLowerCase().endsWith('.mp3') ?? false, [file]);
+
+ const addLog = (level: LogItem['level'], message: string) => setLogs((s) => [{ id: crypto.randomUUID(), level, message, ts: new Date().toLocaleTimeString() }, ...s].slice(0, 100));
+
+ useEffect(() => {
+ const t = localStorage.getItem(TOKEN_KEY); const u = localStorage.getItem(USER_KEY);
+ if (t && u) { setToken(t); setUser(JSON.parse(u)); }
+ }, []);
+
+ useEffect(() => () => { if (processedAsset) URL.revokeObjectURL(processedAsset.url); }, [processedAsset]);
+
+ const logout = () => { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); setToken(null); setUser(null); };
+
+ const onFile = async (f: File) => {
+ if (processedAsset) URL.revokeObjectURL(processedAsset.url);
+ setProcessedAsset(null); setForensicReport(null); setFile(f);
+ const meta = await readFileMetadata(f);
+ setAnalysis(meta);
+ setCtx((s) => ({ ...s, artist: meta.artist || s.artist, title: meta.title || s.title, genre: meta.genre || s.genre }));
+ setTab('analysis');
+ addLog('info', `Loaded ${f.name}`);
+ };
+
+ const generateSeo = async () => {
+ if (!token) return;
+ const promptText = `Artist: ${ctx.artist}\nTitle: ${ctx.title}\nGenre: ${ctx.genre}\nVibe: ${ctx.vibe}\nLyrics: ${ctx.lyrics}`;
+ const res = await fetch(`${BACKEND_URL}/api/generate-seo`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ promptText }) });
+ if (res.status === 401) return logout();
+ const data = await res.json();
+ setSeo({ title: data.title || '', description: data.description || '', tags: Array.isArray(data.tags) ? data.tags.join(', ') : (data.tags || ''), lyrics: ctx.lyrics });
+ addLog('success', 'SEO payload generated');
+ setTab('seo');
+ };
+
+ const quickCleanse = async () => {
+ if (!file || !isMp3) return;
+ const blob = await writeMP3Metadata(file, { title: seo.title || ctx.title, artist: ctx.artist, album: '', genre: ctx.genre, comment: seo.description, lyrics: seo.lyrics, year: new Date().getFullYear() });
+ const url = URL.createObjectURL(blob);
+ if (processedAsset) URL.revokeObjectURL(processedAsset.url);
+ setProcessedAsset({ url, name: file.name.replace(/\.mp3$/i, '-cleanse.mp3') });
+ addLog('success', 'In-browser cleanse complete');
+ };
+
+ const serverCleanse = async () => {
+ if (!file || !token) return;
+ const fd = new FormData();
+ fd.append('file', file); fd.append('title', seo.title || ctx.title); fd.append('artist', ctx.artist); fd.append('genre', ctx.genre); fd.append('description', seo.description); fd.append('tags', seo.tags); fd.append('lyrics', seo.lyrics);
+ const res = await fetch(`${BACKEND_URL}/api/process`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: fd });
+ if (res.status === 401) return logout();
+ if (res.status === 402) { setShowUpgrade(true); addLog('error', 'Free limit reached'); return; }
+ if (!res.ok) { addLog('error', 'Server cleanse failed'); return; }
+ const blob = await res.blob();
+ const usageThisMonth = res.headers.get('X-Usage-This-Month'); const usageLimit = res.headers.get('X-Usage-Limit');
+ if (usageThisMonth) setUsage({ thisMonth: Number(usageThisMonth), limit: usageLimit === 'unlimited' ? null : Number(usageLimit || 3) });
+ const url = URL.createObjectURL(blob);
+ if (processedAsset) URL.revokeObjectURL(processedAsset.url);
+ setProcessedAsset({ url, name: file.name.replace(/(\.[^.]+)$/, '-server-cleanse$1') });
+ setForensicReport({ removedCount: 0, removedTags: ['ID3 private frames', 'vendor signatures'], timestamp: new Date().toISOString() });
+ addLog('success', 'Server cleanse complete');
+ };
+
+ if (!token || !user) return { setToken(t); setUser(u); }} />;
+
+ return SpectraCleanse AI
Plan: {user.plan} ยท Usage: {usage.limit === null ? 'Unlimited' : `${usage.thisMonth}/${usage.limit}`}
+
+
+ {[['context','Track Context'],['seo','SEO Payload'],['analysis','File Analysis']].map(([k,l]) => )}
+
+
+
e.target.files?.[0] && onFile(e.target.files[0])} />
+
+ {file &&
{file.name}
}
+
+
+ {tab==='context' && {Object.entries(ctx).map(([k,v]) =>
}
+ {tab==='seo' && {Object.entries(seo).map(([k,v]) =>
}
+ {tab==='analysis' && analysis && Format: {analysis.format}
Title: {analysis.title || 'N/A'}
Artist: {analysis.artist || 'N/A'}
Risk: {analysis.risk}
{analysis.detectedMarkers?.length>0 &&
Detected markers: {analysis.detectedMarkers.join(', ')}
}
}
+
+
+
+
+
+
+ {processedAsset && Download Processed File}
+
+ {forensicReport && Forensic Report
Tags removed: {forensicReport.removedCount}
{forensicReport.removedTags.join(', ')}
}
+
+ System Log
{logs.map((l)=>
[{l.ts}] {l.message}
)}
+
+ {showUpgrade &&
Usage limit reached. Upgrade required.
}
+
;
+}
diff --git a/client/src/utils/metadata.js b/client/src/utils/metadata.js
new file mode 100644
index 0000000..53d2d61
--- /dev/null
+++ b/client/src/utils/metadata.js
@@ -0,0 +1,52 @@
+import { parseBlob } from 'music-metadata-browser';
+import ID3Writer from 'browser-id3-writer';
+
+const MARKERS = [
+ 'suno', 'udio', 'midjourney', 'dall-e', 'dalle', 'c2pa', 'jumbf', 'openai',
+ 'stability', 'runway', 'synthid', 'elevenlabs', 'ai-generated', 'aigc'
+];
+
+export async function readFileMetadata(file) {
+ const metadata = await parseBlob(file);
+ const common = metadata.common || {};
+ const format = metadata.format?.container || file.type || 'Unknown';
+ const raw = JSON.stringify(metadata).toLowerCase();
+ const filename = file.name.toLowerCase();
+
+ const detectedMarkers = MARKERS.filter((m) => raw.includes(m) || filename.includes(m));
+
+ return {
+ title: common.title || '',
+ artist: common.artist || '',
+ genre: Array.isArray(common.genre) ? (common.genre[0] || '') : (common.genre || ''),
+ format,
+ risk: detectedMarkers.length > 0 ? 'HIGH' : 'Low',
+ detectedMarkers,
+ };
+}
+
+export async function writeMP3Metadata(file, metadata) {
+ const buffer = await file.arrayBuffer();
+ const writer = new ID3Writer(buffer);
+
+ writer
+ .setFrame('TIT2', metadata.title || '')
+ .setFrame('TPE1', [metadata.artist || ''])
+ .setFrame('TALB', metadata.album || '')
+ .setFrame('TCON', [metadata.genre || ''])
+ .setFrame('COMM', {
+ description: 'comment',
+ text: metadata.comment || '',
+ });
+
+ if (metadata.year) writer.setFrame('TYER', String(metadata.year));
+ if (metadata.lyrics) {
+ writer.setFrame('USLT', {
+ description: 'lyrics',
+ lyrics: metadata.lyrics,
+ });
+ }
+
+ writer.addTag();
+ return new Blob([writer.arrayBuffer], { type: 'audio/mpeg' });
+}