Feat: frontend working almost

This commit is contained in:
2026-02-14 22:57:24 -07:00
parent ad375d78fd
commit f0eafcd865
16 changed files with 1664 additions and 670 deletions

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.18",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"tailwindcss": "^4.1.18"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

View File

@@ -1,160 +1,191 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import Header from './components/Header' import Header from './components/Header'
import Sidebar from './components/Sidebar' import Sidebar from './components/Sidebar'
// import RightSidebar from './components/RightSidebar'
import Feed from './pages/Feed' import Feed from './pages/Feed'
import CreatePost from './pages/CreatePost' import CreatePost from './pages/CreatePost'
import History from './pages/History' import History from './pages/History'
import Settings from './pages/Settings' import Settings from './pages/Settings'
import { api } from './api'
export default function App() { export default function App() {
const [activeTab, setActiveTab] = useState('feed') const [activeTab, setActiveTab] = useState('feed')
const [user, setUser] = useState(null)
const [showLogin, setShowLogin] = useState(true)
const [loginEmail, setLoginEmail] = useState('')
const [loginPassword, setLoginPassword] = useState('')
const [isRegistering, setIsRegistering] = useState(false)
const [loginError, setLoginError] = useState(null)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
// Mock user data // Check for saved user on mount
const user = { useEffect(() => {
initials: 'JD', const savedUser = localStorage.getItem('voicevault_user')
name: 'John Doe', if (savedUser) {
role: 'Oral Historian', try {
location: 'San Francisco, CA', const userData = JSON.parse(savedUser)
stats: { setUser(userData)
posts: 127, api.setUserId(userData.user_id)
listeners: '2.4k', setShowLogin(false)
following: 89 } catch (err) {
console.error('Failed to parse saved user:', err)
localStorage.removeItem('voicevault_user')
}
}
}, [])
const handleLogin = async (e) => {
e.preventDefault()
setLoginError(null)
try {
const response = isRegistering
? await api.register(loginEmail, loginPassword, loginEmail.split('@')[0])
: await api.login(loginEmail, loginPassword)
setUser(response.user)
localStorage.setItem('voicevault_user', JSON.stringify(response.user))
setShowLogin(false)
setLoginEmail('')
setLoginPassword('')
} catch (err) {
setLoginError(err.message)
} }
} }
// Mock trending topics const handleLogout = () => {
const trendingTopics = [ setUser(null)
{ name: 'Historical Events', count: '1.2k', growth: 23 }, localStorage.removeItem('voicevault_user')
{ name: 'Family Stories', count: '892', growth: 18 }, setShowLogin(true)
{ name: 'Cultural Heritage', count: '654', growth: 12 }, setActiveTab('feed')
] }
// Mock posts data const handleSearch = async (query) => {
const posts = [ setSearchQuery(query)
{
id: 1, if (query.trim() && user?.user_id) {
user: { try {
name: 'Diana Martinez', const results = await api.searchRAG(query, user.user_id)
initials: 'DM', console.log('Search results:', results)
avatarColor: 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)' // You could display these results in a modal or separate view
}, } catch (err) {
title: "My Grandmother's Journey Through WWII", console.error('Search error:', err)
timeAgo: '2 hours ago', }
categories: [
{ name: 'Historical Events', color: 'yellow' },
{ name: 'Personal Stories', color: 'blue' }
],
audio: {
currentTime: '2:34',
duration: '7:52',
progress: 33
},
transcript: '"I remember the day clearly, despite all these years. We were living in a small village outside Warsaw when the news came. My mother gathered us all together and told us we had to leave everything behind..."',
likes: 248,
comments: 32
},
{
id: 2,
user: {
name: 'Robert Miller',
initials: 'RM',
avatarColor: 'linear-gradient(135deg, #a855f7 0%, #9333ea 100%)'
},
title: 'Traditional Music of the Appalachian Mountains',
timeAgo: '5 hours ago',
categories: [
{ name: 'Cultural Traditions', color: 'purple' },
{ name: 'Oral History', color: 'green' }
],
audio: {
currentTime: '4:15',
duration: '8:30',
progress: 50
},
transcript: '"This song has been passed down through five generations of our family. My great-great-grandfather used to play it on his banjo during summer evenings on the porch. The melody tells the story of..."',
likes: 412,
comments: 58
},{
id: 3,
user: {
name: 'Robert Miller',
initials: 'RM',
avatarColor: 'linear-gradient(135deg, #a855f7 0%, #9333ea 100%)'
},
title: 'Traditional Music of the Appalachian Mountains',
timeAgo: '5 hours ago',
categories: [
{ name: 'Cultural Traditions', color: 'purple' },
{ name: 'Oral History', color: 'green' }
],
audio: {
currentTime: '4:15',
duration: '8:30',
progress: 50
},
transcript: '"This song has been passed down through five generations of our family. My great-great-grandfather used to play it on his banjo during summer evenings on the porch. The melody tells the story of..."',
likes: 412,
comments: 58
} }
] }
// Mock listening history const handlePostCreated = () => {
const listeningHistory = [ // Switch to feed after creating a post
{ setActiveTab('feed')
id: 1, }
title: "My Grandmother's Journey Through WWII",
user: { name: 'Diana Martinez', initials: 'DM', avatarColor: 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)' },
listenedAt: '2 hours ago',
duration: '7:52',
progress: 100,
completed: true
},
{
id: 2,
title: 'Traditional Music of the Appalachian Mountains',
user: { name: 'Robert Miller', initials: 'RM', avatarColor: 'linear-gradient(135deg, #a855f7 0%, #9333ea 100%)' },
listenedAt: '1 day ago',
duration: '8:30',
progress: 75,
completed: false
}
]
// Mock search history const handleUserUpdate = (updatedUser) => {
const searchHistory = [ setUser(updatedUser)
{ id: 1, query: 'WWII stories', searchedAt: '2 hours ago' }, localStorage.setItem('voicevault_user', JSON.stringify(updatedUser))
{ id: 2, query: 'traditional music', searchedAt: '1 day ago' }, }
{ id: 3, query: 'family history', searchedAt: '3 days ago' }
]
// Render current page // Render current page
const renderPage = () => { const renderPage = () => {
if (!user) return null
switch (activeTab) { switch (activeTab) {
case 'create': case 'create':
return <CreatePost onSubmit={(data) => console.log('Post created:', data)} /> return <CreatePost user={user} onPostCreated={handlePostCreated} />
case 'history': case 'history':
return <History listeningHistory={listeningHistory} searchHistory={searchHistory} /> return <History user={user} />
case 'settings': case 'settings':
return <Settings onUpdate={(settings) => console.log('Settings updated:', settings)} /> return <Settings user={user} onUpdate={handleUserUpdate} />
default: default:
return <Feed posts={posts} /> return <Feed user={user} />
} }
} }
// Login/Register Screen
if (showLogin) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-[#f4b840] rounded-lg flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900">VoiceVault</h1>
<p className="text-gray-600 mt-2">Archive your audio memories</p>
</div>
{loginError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-800">
{loginError}
</div>
)}
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">
Email
</label>
<input
type="email"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="your@email.com"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">
Password
</label>
<input
type="password"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="••••••••"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
required
/>
</div>
<button
type="submit"
className="w-full bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] px-4 py-3 rounded-lg font-semibold transition-colors"
>
{isRegistering ? 'Register' : 'Log In'}
</button>
</form>
<div className="mt-4 text-center">
<button
onClick={() => {
setIsRegistering(!isRegistering)
setLoginError(null)
}}
className="text-sm text-gray-600 hover:text-gray-900"
>
{isRegistering
? 'Already have an account? Log in'
: "Don't have an account? Register"}
</button>
</div>
</div>
</div>
)
}
// Main App
return ( return (
<div className="h-screen bg-gray-50 text-gray-800 flex flex-col overflow-hidden"> <div className="h-screen bg-gray-50 text-gray-800 flex flex-col overflow-hidden">
<Header onSearch={setSearchQuery} /> <Header user={user} onSearch={handleSearch} onLogout={handleLogout} />
<div className="flex-1 flex overflow-hidden max-w-[1400px] mx-auto w-full"> <div className="flex-1 flex overflow-hidden max-w-[1400px] mx-auto w-full">
<Sidebar user={user} activeTab={activeTab} onTabChange={setActiveTab} /> <Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
<main className="flex-1 overflow-y-auto p-6"> <main className="flex-1 overflow-y-auto p-6">
{renderPage()} {renderPage()}
</main> </main>
{/* <RightSidebar trendingTopics={trendingTopics} /> */}
</div> </div>
</div> </div>
) )

188
frontend/src/api.js Normal file
View File

@@ -0,0 +1,188 @@
/**
* API Client for VoiceVault Backend
* Handles all communication with Flask API
*/
const API_BASE_URL = 'http://127.0.0.1:5000/api';
class ApiClient {
constructor() {
this.baseUrl = API_BASE_URL;
this.userId = null; // Store current user ID
}
setUserId(userId) {
this.userId = userId;
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const config = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
};
try {
const response = await fetch(url, config);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Request failed');
}
return data;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
// ==================== Auth ====================
async register(email, password, displayName = null) {
return this.request('/auth/register', {
method: 'POST',
body: JSON.stringify({
email,
password,
display_name: displayName,
}),
});
}
async login(email, password) {
const response = await this.request('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
// Store user ID for subsequent requests
if (response.user?.user_id) {
this.setUserId(response.user.user_id);
}
return response;
}
// ==================== Users ====================
async getUser(userId) {
return this.request(`/users/${userId}`);
}
async getUserHistory(userId, page = 1, limit = 20) {
return this.request(`/users/${userId}/history?page=${page}&limit=${limit}`);
}
// ==================== Posts ====================
async uploadPost(formData) {
const url = `${this.baseUrl}/posts/upload`;
try {
const response = await fetch(url, {
method: 'POST',
body: formData, // Don't set Content-Type, let browser set it with boundary
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Upload failed');
}
return data;
} catch (error) {
console.error('Upload Error:', error);
throw error;
}
}
async getPosts(params = {}) {
const queryParams = new URLSearchParams();
if (params.page) queryParams.append('page', params.page);
if (params.limit) queryParams.append('limit', params.limit);
if (params.visibility) queryParams.append('visibility', params.visibility);
if (params.user_id) queryParams.append('user_id', params.user_id);
return this.request(`/posts?${queryParams.toString()}`);
}
async getPost(postId) {
return this.request(`/posts/${postId}`);
}
async getPostBundle(postId) {
return this.request(`/posts/${postId}/bundle`);
}
async updatePost(postId, updates) {
return this.request(`/posts/${postId}`, {
method: 'PATCH',
body: JSON.stringify(updates),
});
}
async deletePost(postId) {
// Update status to mark as deleted
return this.updatePost(postId, { status: 'deleted' });
}
// ==================== Post Files ====================
async getPostFiles(postId) {
return this.request(`/posts/${postId}/files`);
}
// ==================== Post Metadata ====================
async getPostMetadata(postId) {
return this.request(`/posts/${postId}/metadata`);
}
// ==================== RAG Search ====================
async searchRAG(query, userId, page = 1, limit = 30) {
const queryParams = new URLSearchParams({
q: query,
user_id: userId,
page,
limit,
});
return this.request(`/rag/search?${queryParams.toString()}`);
}
// ==================== Audit Logs ====================
async getPostAudit(postId, page = 1, limit = 100) {
return this.request(`/posts/${postId}/audit?page=${page}&limit=${limit}`);
}
async getAuditLogs(params = {}) {
const queryParams = new URLSearchParams();
if (params.post_id) queryParams.append('post_id', params.post_id);
if (params.user_id) queryParams.append('user_id', params.user_id);
if (params.page) queryParams.append('page', params.page);
if (params.limit) queryParams.append('limit', params.limit);
return this.request(`/audit?${queryParams.toString()}`);
}
// ==================== Health Check ====================
async healthCheck() {
return this.request('/health');
}
}
// Export singleton instance
export const api = new ApiClient();
// Export class for testing
export default ApiClient;

View File

@@ -1,38 +1,42 @@
import { Play, Volume2, Heart, MessageCircle, Share2, Bookmark, MoreVertical } from 'lucide-react' import { Play, Volume2, MoreVertical, Clock } from 'lucide-react'
export default function AudioPostCard({ post, onPlay }) {
const formatDate = (dateString) => {
const date = new Date(dateString)
const now = new Date()
const diffMs = now - date
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 60) return `${diffMins}m ago`
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`
return `${Math.floor(diffMins / 1440)}d ago`
}
export default function AudioPostCard({ post, onLike, onComment, onShare, onBookmark }) {
return ( return (
<article className="bg-white rounded-lg border border-gray-200 overflow-hidden shadow-sm hover:shadow-md transition-shadow"> <article className="bg-white rounded-lg border border-gray-200 overflow-hidden shadow-sm hover:shadow-md transition-shadow">
{/* Post Header */} {/* Post Header */}
<div className="p-6 pb-4"> <div className="p-6 pb-4">
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex-1">
<div <div className="flex items-center gap-2 mb-1">
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-semibold text-lg" <h3 className="text-lg font-semibold text-gray-900">{post.title}</h3>
style={{ background: post.user.avatarColor }} {post.visibility === 'private' && (
> <span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-600 rounded">
{post.user.initials} Private
</span>
)}
</div> </div>
<div> <div className="flex items-center gap-2 text-sm text-gray-500">
<div className="flex items-center gap-2"> <Clock size={14} />
<span className="font-semibold text-gray-900">{post.user.name}</span> <span>{formatDate(post.created_at)}</span>
<span className="text-gray-500 text-sm"> {post.timeAgo}</span> <span></span>
</div> <span className={`px-2 py-0.5 rounded text-xs ${
<div className="flex items-center gap-2 mt-1"> post.status === 'ready' ? 'bg-green-100 text-green-700' :
{post.categories.map((category, index) => ( post.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
<span 'bg-red-100 text-red-700'
key={index} }`}>
className={`text-xs px-2 py-1 rounded border ${ {post.status}
category.color === 'yellow' ? 'bg-[#f4b840]/10 text-[#f4b840] border-[#f4b840]/20' : </span>
category.color === 'blue' ? 'bg-blue-500/10 text-blue-600 border-blue-500/20' :
category.color === 'purple' ? 'bg-purple-500/10 text-purple-600 border-purple-500/20' :
'bg-green-500/10 text-green-600 border-green-500/20'
}`}
>
{category.name}
</span>
))}
</div>
</div> </div>
</div> </div>
<button className="text-gray-500 hover:text-gray-700"> <button className="text-gray-500 hover:text-gray-700">
@@ -40,74 +44,62 @@ export default function AudioPostCard({ post, onLike, onComment, onShare, onBook
</button> </button>
</div> </div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">{post.title}</h3> {/* Description */}
{post.description && (
<p className="text-sm text-gray-700 mb-4 line-clamp-2">
{post.description}
</p>
)}
{/* Audio Player */} {/* Audio Player - Only show if ready */}
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200"> {post.status === 'ready' && (
<div className="flex items-center gap-4 mb-3"> <div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<button className="w-10 h-10 bg-[#f4b840] hover:bg-[#e5a930] rounded-full flex items-center justify-center text-[#1a1a1a]"> <div className="flex items-center gap-4">
<Play size={16} fill="currentColor" /> <button
</button> onClick={() => onPlay?.(post)}
<div className="flex-1"> className="w-10 h-10 bg-[#f4b840] hover:bg-[#e5a930] rounded-full flex items-center justify-center text-[#1a1a1a] transition-colors"
<div className="h-1.5 bg-gray-300 rounded-full overflow-hidden mb-2"> >
<div <Play size={16} fill="currentColor" />
className="h-full bg-[#f4b840] rounded-full" </button>
style={{ width: `${post.audio.progress}%` }} <div className="flex-1">
></div> <div className="h-1.5 bg-gray-300 rounded-full overflow-hidden mb-2">
</div> <div className="h-full w-0 bg-[#f4b840] rounded-full"></div>
<div className="flex justify-between text-xs text-gray-600"> </div>
<span>{post.audio.currentTime}</span> <div className="flex justify-between text-xs text-gray-600">
<span>{post.audio.duration}</span> <span>0:00</span>
<span>--:--</span>
</div>
</div> </div>
<button className="text-gray-600 hover:text-gray-900">
<Volume2 size={18} />
</button>
</div> </div>
<button className="text-gray-600 hover:text-gray-900">
<Volume2 size={18} />
</button>
</div>
</div>
{/* Transcription Preview */}
{post.transcript && (
<div className="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-sm text-gray-700 leading-relaxed line-clamp-3">
{post.transcript}
</p>
<button className="text-xs text-[#f4b840] hover:text-[#e5a930] mt-2 font-medium">
Read full transcript
</button>
</div> </div>
)} )}
</div>
{/* Post Actions */} {/* Processing Status */}
<div className="px-6 py-4 border-t border-gray-200 flex items-center gap-6"> {post.status === 'processing' && (
<button <div className="bg-yellow-50 rounded-lg p-4 border border-yellow-200 text-center">
onClick={() => onLike?.(post.id)} <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f4b840] mx-auto mb-2"></div>
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 text-sm" <p className="text-sm text-yellow-800">Processing audio and generating transcript...</p>
> </div>
<Heart size={18} /> )}
<span>{post.likes}</span>
</button> {/* Failed Status */}
<button {post.status === 'failed' && (
onClick={() => onComment?.(post.id)} <div className="bg-red-50 rounded-lg p-4 border border-red-200">
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 text-sm" <p className="text-sm text-red-800">Failed to process this recording. Please try uploading again.</p>
> </div>
<MessageCircle size={18} /> )}
<span>{post.comments}</span>
</button> {/* Language Tag */}
<button {post.language && (
onClick={() => onShare?.(post.id)} <div className="mt-3">
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 text-sm" <span className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded">
> {post.language.toUpperCase()}
<Share2 size={18} /> </span>
<span>Share</span> </div>
</button> )}
<button
onClick={() => onBookmark?.(post.id)}
className="ml-auto text-gray-600 hover:text-gray-900"
>
<Bookmark size={18} />
</button>
</div> </div>
</article> </article>
) )

View File

@@ -1,6 +1,15 @@
import { Search, User, Volume2 } from 'lucide-react' import { Search, User, Volume2 } from 'lucide-react'
import { useState } from 'react'
export default function Header({ user, onSearch, onLogout }) {
const [searchQuery, setSearchQuery] = useState('')
const handleSearch = (e) => {
const query = e.target.value
setSearchQuery(query)
onSearch?.(query)
}
export default function Header() {
return ( return (
<header className="bg-white border-b border-gray-200 px-4 py-3 flex-shrink-0"> <header className="bg-white border-b border-gray-200 px-4 py-3 flex-shrink-0">
<div className="max-w-[1400px] mx-auto flex items-center justify-between gap-6"> <div className="max-w-[1400px] mx-auto flex items-center justify-between gap-6">
@@ -18,18 +27,32 @@ export default function Header() {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input <input
type="text" type="text"
placeholder="Search archives, stories, history..." placeholder="Search your archives..."
className="w-full bg-white border border-gray-300 rounded-lg pl-10 pr-4 py-2 text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent" value={searchQuery}
onChange={handleSearch}
className="w-full bg-gray-50 border border-gray-300 rounded-lg pl-10 pr-4 py-2 text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
/> />
</div> </div>
</div> </div>
{/* Right: Login */} {/* Right: User Info / Login */}
<div className="flex items-center gap-3 flex-shrink-0"> <div className="flex items-center gap-3 flex-shrink-0">
<button className="bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] px-4 py-2 rounded text-sm font-medium flex items-center gap-2"> {user ? (
<User size={16} /> <div className="flex items-center gap-3">
Log In <span className="text-sm text-gray-700">{user.display_name || user.email}</span>
</button> <button
onClick={onLogout}
className="text-sm text-gray-600 hover:text-gray-900"
>
Logout
</button>
</div>
) : (
<button className="bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] px-4 py-2 rounded text-sm font-medium flex items-center gap-2">
<User size={16} />
Log In
</button>
)}
</div> </div>
</div> </div>
</header> </header>

View File

@@ -7,20 +7,10 @@ export default function Sidebar({ activeTab, onTabChange }) {
{ id: 'history', label: 'History', icon: History }, { id: 'history', label: 'History', icon: History },
{ id: 'settings', label: 'Settings', icon: Settings } { id: 'settings', label: 'Settings', icon: Settings }
] ]
return ( return (
<aside className="w-64 bg-white border-r border-gray-200 p-6 hidden md:block flex-shrink-0 overflow-y-auto"> <aside className="w-64 bg-white border-r border-gray-200 p-6 hidden md:block flex-shrink-0 overflow-y-auto">
<div className="sticky top-6"> <div className="sticky top-6">
{/* Profile Image */}
<div className="w-32 h-32 bg-gradient-to-br from-[#f4b840] to-[#e5a930] rounded-full flex items-center justify-center text-[#1a1a1a] font-bold text-4xl mx-auto mb-4">
JD
</div>
<div className="text-center mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-1">John Doe</h2>
<p className="text-sm text-gray-600">Oral Historian</p>
<p className="text-xs text-gray-500 mt-2">San Francisco, CA</p>
</div>
{/* Navigation */} {/* Navigation */}
<nav className="space-y-2"> <nav className="space-y-2">
{navItems.map((item) => { {navItems.map((item) => {

View File

@@ -1,44 +1,85 @@
import { useState } from 'react' import { useState } from 'react'
import { Mic, Upload, X } from 'lucide-react' import { Upload, X } from 'lucide-react'
import { api } from '../api'
export default function CreatePost({ onSubmit }) { export default function CreatePost({ user, onPostCreated }) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [selectedCategories, setSelectedCategories] = useState([]) const [description, setDescription] = useState('')
const [visibility, setVisibility] = useState('private')
const [language, setLanguage] = useState('en')
const [audioFile, setAudioFile] = useState(null) const [audioFile, setAudioFile] = useState(null)
const [isRecording, setIsRecording] = useState(false) const [uploading, setUploading] = useState(false)
const [isPrivate, setIsPrivate] = useState(false) const [error, setError] = useState(null)
const [success, setSuccess] = useState(false)
const categories = [ const handleFileSelect = (e) => {
{ id: 1, name: 'Historical Events', color: 'yellow' },
{ id: 2, name: 'Cultural Traditions', color: 'purple' },
{ id: 3, name: 'Personal Stories', color: 'blue' },
{ id: 4, name: 'Oral History', color: 'green' },
{ id: 5, name: 'Family History', color: 'blue' },
]
const handleCategoryToggle = (categoryId) => {
setSelectedCategories(prev =>
prev.includes(categoryId)
? prev.filter(id => id !== categoryId)
: [...prev, categoryId]
)
}
const handleFileUpload = (e) => {
const file = e.target.files[0] const file = e.target.files[0]
if (file && file.type.startsWith('audio/')) { if (file) {
// Check file type
const validTypes = ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/flac', 'audio/mp4', 'video/mp4']
if (!validTypes.includes(file.type) && !file.name.match(/\.(mp3|wav|ogg|flac|m4a|mp4|mov|mkv|webm)$/i)) {
setError('Invalid file type. Please upload an audio or video file.')
return
}
setAudioFile(file) setAudioFile(file)
setError(null)
} }
} }
const handleSubmit = (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
onSubmit?.({
title, if (!audioFile) {
categories: selectedCategories, setError('Please select an audio file')
audioFile, return
isPrivate }
})
if (!title.trim()) {
setError('Please enter a title')
return
}
if (!user?.user_id) {
setError('You must be logged in to create a post')
return
}
setUploading(true)
setError(null)
setSuccess(false)
try {
const formData = new FormData()
formData.append('file', audioFile)
formData.append('user_id', user.user_id)
formData.append('title', title.trim())
formData.append('description', description.trim())
formData.append('visibility', visibility)
formData.append('language', language)
const response = await api.uploadPost(formData)
console.log('Upload response:', response)
setSuccess(true)
// Reset form
setTitle('')
setDescription('')
setVisibility('private')
setLanguage('en')
setAudioFile(null)
// Notify parent component
onPostCreated?.(response)
// Show success message
setTimeout(() => setSuccess(false), 5000)
} catch (err) {
setError(err.message || 'Failed to upload post')
} finally {
setUploading(false)
}
} }
return ( return (
@@ -46,11 +87,25 @@ export default function CreatePost({ onSubmit }) {
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6"> <div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Create New Archive</h2> <h2 className="text-2xl font-bold text-gray-900 mb-6">Create New Archive</h2>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
{success && (
<div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-sm text-green-800">
Post uploaded successfully! Transcript is being generated...
</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Title Input */} {/* Title Input */}
<div> <div>
<label className="block text-sm font-medium text-gray-900 mb-2"> <label className="block text-sm font-medium text-gray-900 mb-2">
Title Title *
</label> </label>
<input <input
type="text" type="text"
@@ -59,126 +114,165 @@ export default function CreatePost({ onSubmit }) {
placeholder="Give your archive a descriptive title..." placeholder="Give your archive a descriptive title..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
required required
disabled={uploading}
/> />
</div> </div>
{/* Audio Recording/Upload */} {/* Description Input */}
<div> <div>
<label className="block text-sm font-medium text-gray-900 mb-2"> <label className="block text-sm font-medium text-gray-900 mb-2">
Audio Recording Description (optional)
</label> </label>
<div className="space-y-3"> <textarea
{/* Recording Controls */} value={description}
<div className="flex gap-3"> onChange={(e) => setDescription(e.target.value)}
<button placeholder="Add a description or context for this recording..."
type="button" rows={3}
onClick={() => setIsRecording(!isRecording)} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent resize-none"
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors ${ disabled={uploading}
isRecording />
? 'bg-red-50 border-red-300 text-red-700' </div>
: 'bg-gray-50 border-gray-300 text-gray-700 hover:bg-gray-100'
}`}
>
<Mic size={20} />
<span>{isRecording ? 'Recording...' : 'Start Recording'}</span>
</button>
<label className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-gray-300 bg-gray-50 text-gray-700 hover:bg-gray-100 cursor-pointer"> {/* Audio File Upload */}
<Upload size={20} /> <div>
<span>Upload Audio</span> <label className="block text-sm font-medium text-gray-900 mb-2">
<input Audio/Video File *
type="file" </label>
accept="audio/*"
onChange={handleFileUpload} {!audioFile ? (
className="hidden" <label className="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer hover:bg-gray-50 transition-colors">
/> <div className="flex flex-col items-center justify-center pt-5 pb-6">
</label> <Upload size={32} className="text-gray-400 mb-2" />
</div> <p className="text-sm text-gray-600 mb-1">
<span className="font-semibold">Click to upload</span> or drag and drop
{/* Audio File Preview */} </p>
{audioFile && ( <p className="text-xs text-gray-500">
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200"> MP3, WAV, OGG, FLAC, M4A, MP4, MOV, MKV, WEBM
<div className="flex items-center gap-3"> </p>
<div className="w-10 h-10 bg-[#f4b840] rounded-lg flex items-center justify-center">
<Mic size={18} className="text-white" />
</div>
<div>
<p className="text-sm font-medium text-gray-900">{audioFile.name}</p>
<p className="text-xs text-gray-600">{(audioFile.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
</div>
<button
type="button"
onClick={() => setAudioFile(null)}
className="text-gray-500 hover:text-gray-700"
>
<X size={20} />
</button>
</div> </div>
)} <input
</div> type="file"
accept="audio/*,video/mp4,video/quicktime,video/x-matroska,video/webm"
onChange={handleFileSelect}
className="hidden"
disabled={uploading}
/>
</label>
) : (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-10 h-10 bg-[#f4b840] rounded-lg flex items-center justify-center flex-shrink-0">
<Upload size={18} className="text-white" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{audioFile.name}
</p>
<p className="text-xs text-gray-600">
{(audioFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<button
type="button"
onClick={() => setAudioFile(null)}
className="text-gray-500 hover:text-gray-700 ml-2"
disabled={uploading}
>
<X size={20} />
</button>
</div>
)}
</div> </div>
{/* Categories */} {/* Language Selection */}
<div> <div>
<label className="block text-sm font-medium text-gray-900 mb-2"> <label className="block text-sm font-medium text-gray-900 mb-2">
Categories Language
</label> </label>
<div className="flex flex-wrap gap-2"> <select
{categories.map((category) => ( value={language}
<button onChange={(e) => setLanguage(e.target.value)}
key={category.id} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
type="button" disabled={uploading}
onClick={() => handleCategoryToggle(category.id)} >
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${ <option value="en">English</option>
selectedCategories.includes(category.id) <option value="es">Spanish</option>
? category.color === 'yellow' ? 'bg-[#f4b840] text-[#1a1a1a]' : <option value="fr">French</option>
category.color === 'blue' ? 'bg-blue-500 text-white' : <option value="de">German</option>
category.color === 'purple' ? 'bg-purple-500 text-white' : <option value="it">Italian</option>
'bg-green-500 text-white' <option value="pt">Portuguese</option>
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' <option value="zh">Chinese</option>
}`} <option value="ja">Japanese</option>
> <option value="ko">Korean</option>
{category.name} <option value="ar">Arabic</option>
</button> </select>
))}
</div>
</div> </div>
{/* Privacy Toggle */} {/* Visibility Toggle */}
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200"> <div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
<div> <div>
<p className="font-medium text-gray-900">Private Archive</p> <p className="font-medium text-gray-900">Visibility</p>
<p className="text-sm text-gray-600">Only you can see this post</p> <p className="text-sm text-gray-600">
{visibility === 'private' ? 'Only you can see this post' : 'Anyone can see this post'}
</p>
</div> </div>
<button <div className="flex gap-2">
type="button" <button
onClick={() => setIsPrivate(!isPrivate)} type="button"
className={`relative w-12 h-6 rounded-full transition-colors ${ onClick={() => setVisibility('private')}
isPrivate ? 'bg-[#f4b840]' : 'bg-gray-300' className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
}`} visibility === 'private'
> ? 'bg-[#f4b840] text-[#1a1a1a]'
<div : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
isPrivate ? 'translate-x-6' : 'translate-x-0'
}`} }`}
/> disabled={uploading}
</button> >
Private
</button>
<button
type="button"
onClick={() => setVisibility('public')}
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
visibility === 'public'
? 'bg-[#f4b840] text-[#1a1a1a]'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
disabled={uploading}
>
Public
</button>
</div>
</div> </div>
{/* Submit Button */} {/* Submit Buttons */}
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
type="button" type="button"
onClick={() => {
setTitle('')
setDescription('')
setAudioFile(null)
setError(null)
}}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
disabled={uploading}
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
className="flex-1 px-4 py-2 bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] rounded-lg font-medium transition-colors" disabled={uploading || !audioFile || !title.trim()}
className="flex-1 px-4 py-2 bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
Post Archive {uploading ? (
<span className="flex items-center justify-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[#1a1a1a]"></div>
Uploading...
</span>
) : (
'Create Archive'
)}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,22 +1,144 @@
import { useState, useEffect } from 'react'
import AudioPostCard from '../components/AudioPostCard' import AudioPostCard from '../components/AudioPostCard'
import { api } from '../api'
export default function Feed({ user }) {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [page, setPage] = useState(1)
const [visibilityFilter, setVisibilityFilter] = useState('all')
useEffect(() => {
if (user?.user_id) {
fetchPosts()
}
}, [user, page, visibilityFilter])
const fetchPosts = async () => {
setLoading(true)
setError(null)
try {
const params = {
page,
limit: 20,
user_id: user.user_id,
}
if (visibilityFilter !== 'all') {
params.visibility = visibilityFilter
}
const response = await api.getPosts(params)
setPosts(response.posts || [])
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handlePlay = async (post) => {
console.log('Playing post:', post)
// Implement audio playback logic
}
if (loading) {
return (
<div className="max-w-3xl mx-auto">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#f4b840]"></div>
</div>
</div>
)
}
if (error) {
return (
<div className="max-w-3xl mx-auto">
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
<p className="text-red-800">Error loading feed: {error}</p>
<button
onClick={fetchPosts}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Retry
</button>
</div>
</div>
)
}
export default function Feed({ posts, onLike, onComment, onShare, onBookmark }) {
return ( return (
<div className="max-w-3xl mx-auto space-y-6"> <div className="max-w-3xl mx-auto space-y-6">
{posts?.length > 0 ? ( {/* Filter Pills */}
<div className="flex gap-2">
<button
onClick={() => setVisibilityFilter('all')}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${
visibilityFilter === 'all'
? 'bg-[#f4b840] text-[#1a1a1a]'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
All Posts
</button>
<button
onClick={() => setVisibilityFilter('public')}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${
visibilityFilter === 'public'
? 'bg-[#f4b840] text-[#1a1a1a]'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Public
</button>
<button
onClick={() => setVisibilityFilter('private')}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${
visibilityFilter === 'private'
? 'bg-[#f4b840] text-[#1a1a1a]'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Private
</button>
</div>
{/* Posts List */}
{posts.length > 0 ? (
posts.map((post) => ( posts.map((post) => (
<AudioPostCard <AudioPostCard
key={post.id} key={post.post_id}
post={post} post={post}
onLike={onLike} onPlay={handlePlay}
onComment={onComment}
onShare={onShare}
onBookmark={onBookmark}
/> />
)) ))
) : ( ) : (
<div className="text-center py-12"> <div className="text-center py-12 bg-white rounded-lg border border-gray-200">
<p className="text-gray-600">No posts to display</p> <p className="text-gray-600 mb-2">No posts to display</p>
<p className="text-sm text-gray-500">Create your first archive to get started!</p>
</div>
)}
{/* Pagination */}
{posts.length >= 20 && (
<div className="flex justify-center gap-2 pb-6">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="px-4 py-2 text-gray-700">Page {page}</span>
<button
onClick={() => setPage(p => p + 1)}
className="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
>
Next
</button>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,14 +1,99 @@
import { FileText, Filter, Trash2 } from 'lucide-react' import { useState, useEffect } from 'react'
import { FileText, Trash2, Eye } from 'lucide-react'
import { api } from '../api'
export default function History({ user }) {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [page, setPage] = useState(1)
useEffect(() => {
if (user?.user_id) {
fetchHistory()
}
}, [user, page])
const fetchHistory = async () => {
setLoading(true)
setError(null)
try {
const response = await api.getUserHistory(user.user_id, page, 20)
setPosts(response.history || [])
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleDelete = async (postId) => {
if (!confirm('Are you sure you want to delete this post?')) {
return
}
try {
await api.deletePost(postId)
// Refresh the list
fetchHistory()
} catch (err) {
alert('Failed to delete post: ' + err.message)
}
}
const handleView = async (postId) => {
try {
const bundle = await api.getPostBundle(postId)
console.log('Post bundle:', bundle)
// You could open a modal or navigate to a detail view
} catch (err) {
alert('Failed to load post details: ' + err.message)
}
}
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
if (loading) {
return (
<div className="max-w-3xl mx-auto">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#f4b840]"></div>
</div>
</div>
)
}
if (error) {
return (
<div className="max-w-3xl mx-auto">
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
<p className="text-red-800">Error loading history: {error}</p>
<button
onClick={fetchHistory}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Retry
</button>
</div>
</div>
)
}
export default function History({ userPosts, onDelete }) {
return ( return (
<div className="max-w-3xl mx-auto space-y-6"> <div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">History</h2> <h2 className="text-2xl font-bold text-gray-900">History</h2>
<button className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"> <p className="text-sm text-gray-600">{posts.length} archive{posts.length !== 1 ? 's' : ''}</p>
<Filter size={18} />
<span>Filter</span>
</button>
</div> </div>
{/* My Posted Archives */} {/* My Posted Archives */}
@@ -17,71 +102,114 @@ export default function History({ userPosts, onDelete }) {
<FileText size={20} /> <FileText size={20} />
My Posted Archives My Posted Archives
</h3> </h3>
{userPosts?.length > 0 ? ( {posts.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{userPosts.map((post) => ( {posts.map((post) => (
<div key={post.id} className="flex items-start gap-4 p-4 hover:bg-gray-50 rounded-lg border border-gray-200"> <div key={post.post_id} className="flex items-start gap-4 p-4 hover:bg-gray-50 rounded-lg border border-gray-200 transition-colors">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 mb-1">{post.title}</h4> <h4 className="font-semibold text-gray-900 mb-1">{post.title}</h4>
{post.description && (
<p className="text-sm text-gray-600 mb-2 line-clamp-2">
{post.description}
</p>
)}
<div className="flex items-center gap-3 text-sm text-gray-600 mb-2"> <div className="flex items-center gap-3 text-sm text-gray-600 mb-2">
<span>{post.createdAt}</span> <span>{formatDate(post.created_at)}</span>
<span></span> <span></span>
<span>{post.duration}</span> <span className={`px-2 py-0.5 rounded text-xs ${
post.visibility === 'public'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
}`}>
{post.visibility}
</span>
<span></span> <span></span>
<span className={post.isPrivate ? 'text-gray-500' : 'text-green-600'}> <span className={`px-2 py-0.5 rounded text-xs ${
{post.isPrivate ? 'Private' : 'Public'} post.status === 'ready' ? 'bg-green-100 text-green-700' :
post.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{post.status}
</span> </span>
</div> </div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>👍 {post.likes} likes</span> {post.language && (
<span>💬 {post.comments} comments</span> <span className="inline-block text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded">
<span>🎧 {post.listens} listens</span> {post.language.toUpperCase()}
</div> </span>
{post.categories && (
<div className="flex gap-2 mt-2">
{post.categories.map((cat, idx) => (
<span key={idx} className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded">
{cat}
</span>
))}
</div>
)} )}
</div> </div>
<button
onClick={() => onDelete?.(post.id)} <div className="flex gap-2 flex-shrink-0">
className="text-red-500 hover:text-red-700 p-2 hover:bg-red-50 rounded transition-colors" <button
title="Delete post" onClick={() => handleView(post.post_id)}
> className="p-2 text-blue-600 hover:bg-blue-50 rounded transition-colors"
<Trash2 size={18} /> title="View details"
</button> >
<Eye size={18} />
</button>
<button
onClick={() => handleDelete(post.post_id)}
className="p-2 text-red-500 hover:bg-red-50 rounded transition-colors"
title="Delete post"
>
<Trash2 size={18} />
</button>
</div>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-gray-600 text-center py-8">No posts yet. Create your first archive!</p> <p className="text-gray-600 text-center py-8">
No posts yet. Create your first archive!
</p>
)} )}
</div> </div>
{/* Stats Summary */} {/* Summary Stats */}
<div className="grid grid-cols-3 gap-4"> {posts.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 p-4 text-center"> <div className="grid grid-cols-3 gap-4">
<div className="text-2xl font-bold text-gray-900">{userPosts?.length || 0}</div> <div className="bg-white rounded-lg border border-gray-200 p-4 text-center">
<div className="text-sm text-gray-600">Total Posts</div> <div className="text-2xl font-bold text-gray-900">{posts.length}</div>
</div> <div className="text-sm text-gray-600">Total Archives</div>
<div className="bg-white rounded-lg border border-gray-200 p-4 text-center">
<div className="text-2xl font-bold text-gray-900">
{userPosts?.reduce((sum, post) => sum + post.likes, 0) || 0}
</div> </div>
<div className="text-sm text-gray-600">Total Likes</div> <div className="bg-white rounded-lg border border-gray-200 p-4 text-center">
</div> <div className="text-2xl font-bold text-gray-900">
<div className="bg-white rounded-lg border border-gray-200 p-4 text-center"> {posts.filter(p => p.status === 'ready').length}
<div className="text-2xl font-bold text-gray-900"> </div>
{userPosts?.reduce((sum, post) => sum + post.listens, 0) || 0} <div className="text-sm text-gray-600">Ready</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4 text-center">
<div className="text-2xl font-bold text-gray-900">
{posts.filter(p => p.visibility === 'public').length}
</div>
<div className="text-sm text-gray-600">Public</div>
</div> </div>
<div className="text-sm text-gray-600">Total Listens</div>
</div> </div>
</div> )}
{/* Pagination */}
{posts.length >= 20 && (
<div className="flex justify-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="px-4 py-2 text-gray-700">Page {page}</span>
<button
onClick={() => setPage(p => p + 1)}
className="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
>
Next
</button>
</div>
)}
</div> </div>
) )
} }

View File

@@ -1,218 +1,129 @@
import { useState } from 'react' import { useState } from 'react'
import { User, Lock, Bell, Globe, Trash2 } from 'lucide-react' import { User, Trash2 } from 'lucide-react'
export default function Settings({ userSettings, onUpdate }) { export default function Settings({ user, onUpdate }) {
const [settings, setSettings] = useState(userSettings || { const [displayName, setDisplayName] = useState(user?.display_name || '')
notifications: { const [bio, setBio] = useState(user?.bio || '')
newFollowers: true, const [saving, setSaving] = useState(false)
comments: true, const [message, setMessage] = useState(null)
likes: false,
mentions: true, const handleSave = async () => {
}, setSaving(true)
privacy: { setMessage(null)
profileVisibility: 'public',
showListeningHistory: true, try {
allowComments: true, // In a real app, you'd call an update user API
}, // For now, just simulate success
account: { await new Promise(resolve => setTimeout(resolve, 1000))
email: 'john.doe@email.com',
username: 'johndoe', onUpdate?.({
...user,
display_name: displayName,
bio: bio
})
setMessage({ type: 'success', text: 'Settings saved successfully!' })
} catch (error) {
setMessage({ type: 'error', text: 'Failed to save settings: ' + error.message })
} finally {
setSaving(false)
} }
})
const handleToggle = (category, setting) => {
setSettings(prev => ({
...prev,
[category]: {
...prev[category],
[setting]: !prev[category][setting]
}
}))
} }
return ( return (
<div className="max-w-3xl mx-auto space-y-6"> <div className="max-w-3xl mx-auto space-y-6">
<h2 className="text-2xl font-bold text-gray-900">Settings</h2> <h2 className="text-2xl font-bold text-gray-900">Settings</h2>
{/* Account Settings */} {message && (
<div className={`p-4 rounded-lg border ${
message.type === 'success'
? 'bg-green-50 border-green-200 text-green-800'
: 'bg-red-50 border-red-200 text-red-800'
}`}>
<p className="text-sm">{message.text}</p>
</div>
)}
{/* Account Information */}
<div className="bg-white rounded-lg border border-gray-200 p-6"> <div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<User size={20} /> <User size={20} />
Account Information Account Information
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-900 mb-2">Username</label> <label className="block text-sm font-medium text-gray-900 mb-2">
<input Email
type="text" </label>
value={settings.account.username}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">Email</label>
<input <input
type="email" type="email"
value={settings.account.email} value={user?.email || ''}
disabled
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-600 cursor-not-allowed"
/>
<p className="text-xs text-gray-500 mt-1">Email cannot be changed</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your display name"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-900 mb-2">Bio</label> <label className="block text-sm font-medium text-gray-900 mb-2">
Bio
</label>
<textarea <textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={3} rows={3}
placeholder="Tell us about yourself..." placeholder="Tell us about yourself..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent resize-none" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent resize-none"
/> />
</div> </div>
</div>
</div>
{/* Privacy Settings */} <div>
<div className="bg-white rounded-lg border border-gray-200 p-6"> <label className="block text-sm font-medium text-gray-900 mb-2">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> User ID
<Lock size={20} /> </label>
Privacy <input
</h3> type="text"
value={user?.user_id || ''}
<div className="space-y-4"> disabled
<div className="flex items-center justify-between"> className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-600 cursor-not-allowed"
<div> />
<p className="font-medium text-gray-900">Profile Visibility</p> <p className="text-xs text-gray-500 mt-1">Your unique identifier</p>
<p className="text-sm text-gray-600">Who can see your profile</p>
</div>
<select className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840]">
<option value="public">Public</option>
<option value="followers">Followers Only</option>
<option value="private">Private</option>
</select>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Show Listening History</p>
<p className="text-sm text-gray-600">Allow others to see what you've listened to</p>
</div>
<button
onClick={() => handleToggle('privacy', 'showListeningHistory')}
className={`relative w-12 h-6 rounded-full transition-colors ${
settings.privacy.showListeningHistory ? 'bg-[#f4b840]' : 'bg-gray-300'
}`}
>
<div
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
settings.privacy.showListeningHistory ? 'translate-x-6' : 'translate-x-0'
}`}
/>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Allow Comments</p>
<p className="text-sm text-gray-600">Let others comment on your posts</p>
</div>
<button
onClick={() => handleToggle('privacy', 'allowComments')}
className={`relative w-12 h-6 rounded-full transition-colors ${
settings.privacy.allowComments ? 'bg-[#f4b840]' : 'bg-gray-300'
}`}
>
<div
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
settings.privacy.allowComments ? 'translate-x-6' : 'translate-x-0'
}`}
/>
</button>
</div> </div>
</div> </div>
</div> </div>
{/* Notification Settings */} {/* Account Created */}
<div className="bg-white rounded-lg border border-gray-200 p-6"> <div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> <h3 className="text-lg font-semibold text-gray-900 mb-4">
<Bell size={20} /> Account Details
Notifications
</h3> </h3>
<div className="space-y-4"> <div className="space-y-2 text-sm">
<div className="flex items-center justify-between"> <div className="flex justify-between">
<div> <span className="text-gray-600">Account Created</span>
<p className="font-medium text-gray-900">New Followers</p> <span className="font-medium text-gray-900">
<p className="text-sm text-gray-600">When someone follows you</p> {user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A'}
</div> </span>
<button
onClick={() => handleToggle('notifications', 'newFollowers')}
className={`relative w-12 h-6 rounded-full transition-colors ${
settings.notifications.newFollowers ? 'bg-[#f4b840]' : 'bg-gray-300'
}`}
>
<div
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
settings.notifications.newFollowers ? 'translate-x-6' : 'translate-x-0'
}`}
/>
</button>
</div> </div>
<div className="flex justify-between">
<div className="flex items-center justify-between"> <span className="text-gray-600">Last Updated</span>
<div> <span className="font-medium text-gray-900">
<p className="font-medium text-gray-900">Comments</p> {user?.updated_at ? new Date(user.updated_at).toLocaleDateString() : 'N/A'}
<p className="text-sm text-gray-600">When someone comments on your post</p> </span>
</div>
<button
onClick={() => handleToggle('notifications', 'comments')}
className={`relative w-12 h-6 rounded-full transition-colors ${
settings.notifications.comments ? 'bg-[#f4b840]' : 'bg-gray-300'
}`}
>
<div
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
settings.notifications.comments ? 'translate-x-6' : 'translate-x-0'
}`}
/>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Likes</p>
<p className="text-sm text-gray-600">When someone likes your post</p>
</div>
<button
onClick={() => handleToggle('notifications', 'likes')}
className={`relative w-12 h-6 rounded-full transition-colors ${
settings.notifications.likes ? 'bg-[#f4b840]' : 'bg-gray-300'
}`}
>
<div
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
settings.notifications.likes ? 'translate-x-6' : 'translate-x-0'
}`}
/>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Mentions</p>
<p className="text-sm text-gray-600">When someone mentions you</p>
</div>
<button
onClick={() => handleToggle('notifications', 'mentions')}
className={`relative w-12 h-6 rounded-full transition-colors ${
settings.notifications.mentions ? 'bg-[#f4b840]' : 'bg-gray-300'
}`}
>
<div
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
settings.notifications.mentions ? 'translate-x-6' : 'translate-x-0'
}`}
/>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -223,27 +134,35 @@ export default function Settings({ userSettings, onUpdate }) {
<Trash2 size={20} /> <Trash2 size={20} />
Danger Zone Danger Zone
</h3> </h3>
<div className="space-y-3"> <p className="text-sm text-gray-600 mb-4">
<button className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"> Once you delete your account, there is no going back. All your archives and data will be permanently deleted.
Export My Data </p>
</button>
<button className="w-full px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors"> <button className="w-full px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors">
Delete Account Delete Account
</button> </button>
</div>
</div> </div>
{/* Save Button */} {/* Save Button */}
<div className="flex gap-3"> <div className="flex gap-3">
<button className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"> <button
onClick={() => {
setDisplayName(user?.display_name || '')
setBio(user?.bio || '')
setMessage(null)
}}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
disabled={saving}
>
Cancel Cancel
</button> </button>
<button <button
onClick={() => onUpdate?.(settings)} onClick={handleSave}
className="flex-1 px-4 py-2 bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] rounded-lg font-medium transition-colors" disabled={saving}
className="flex-1 px-4 py-2 bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
Save Changes {saving ? 'Saving...' : 'Save Changes'}
</button> </button>
</div> </div>
</div> </div>