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

SpectraCleanse AI

{mode === 'login' ? 'Login' : 'Create account'}

setEmail(e.target.value)} placeholder="Email" /> setPassword(e.target.value)} placeholder="Password" />{error &&

{error}

}
; +}; + +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]) =>