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"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"lucide-react": "^0.564.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@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 Sidebar from './components/Sidebar'
// import RightSidebar from './components/RightSidebar'
import Feed from './pages/Feed'
import CreatePost from './pages/CreatePost'
import History from './pages/History'
import Settings from './pages/Settings'
import { api } from './api'
export default function App() {
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('')
// Mock user data
const user = {
initials: 'JD',
name: 'John Doe',
role: 'Oral Historian',
location: 'San Francisco, CA',
stats: {
posts: 127,
listeners: '2.4k',
following: 89
// Check for saved user on mount
useEffect(() => {
const savedUser = localStorage.getItem('voicevault_user')
if (savedUser) {
try {
const userData = JSON.parse(savedUser)
setUser(userData)
api.setUserId(userData.user_id)
setShowLogin(false)
} 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 trendingTopics = [
{ name: 'Historical Events', count: '1.2k', growth: 23 },
{ name: 'Family Stories', count: '892', growth: 18 },
{ name: 'Cultural Heritage', count: '654', growth: 12 },
]
const handleLogout = () => {
setUser(null)
localStorage.removeItem('voicevault_user')
setShowLogin(true)
setActiveTab('feed')
}
// Mock posts data
const posts = [
{
id: 1,
user: {
name: 'Diana Martinez',
initials: 'DM',
avatarColor: 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)'
},
title: "My Grandmother's Journey Through WWII",
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
const handleSearch = async (query) => {
setSearchQuery(query)
if (query.trim() && user?.user_id) {
try {
const results = await api.searchRAG(query, user.user_id)
console.log('Search results:', results)
// You could display these results in a modal or separate view
} catch (err) {
console.error('Search error:', err)
}
}
]
}
// Mock listening history
const listeningHistory = [
{
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
}
]
const handlePostCreated = () => {
// Switch to feed after creating a post
setActiveTab('feed')
}
// Mock search history
const searchHistory = [
{ id: 1, query: 'WWII stories', searchedAt: '2 hours ago' },
{ id: 2, query: 'traditional music', searchedAt: '1 day ago' },
{ id: 3, query: 'family history', searchedAt: '3 days ago' }
]
const handleUserUpdate = (updatedUser) => {
setUser(updatedUser)
localStorage.setItem('voicevault_user', JSON.stringify(updatedUser))
}
// Render current page
const renderPage = () => {
if (!user) return null
switch (activeTab) {
case 'create':
return <CreatePost onSubmit={(data) => console.log('Post created:', data)} />
return <CreatePost user={user} onPostCreated={handlePostCreated} />
case 'history':
return <History listeningHistory={listeningHistory} searchHistory={searchHistory} />
return <History user={user} />
case 'settings':
return <Settings onUpdate={(settings) => console.log('Settings updated:', settings)} />
return <Settings user={user} onUpdate={handleUserUpdate} />
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 (
<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">
<Sidebar user={user} activeTab={activeTab} onTabChange={setActiveTab} />
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
<main className="flex-1 overflow-y-auto p-6">
{renderPage()}
</main>
{/* <RightSidebar trendingTopics={trendingTopics} /> */}
</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 (
<article className="bg-white rounded-lg border border-gray-200 overflow-hidden shadow-sm hover:shadow-md transition-shadow">
{/* Post Header */}
<div className="p-6 pb-4">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-semibold text-lg"
style={{ background: post.user.avatarColor }}
>
{post.user.initials}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900">{post.title}</h3>
{post.visibility === 'private' && (
<span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-600 rounded">
Private
</span>
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900">{post.user.name}</span>
<span className="text-gray-500 text-sm"> {post.timeAgo}</span>
</div>
<div className="flex items-center gap-2 mt-1">
{post.categories.map((category, index) => (
<span
key={index}
className={`text-xs px-2 py-1 rounded border ${
category.color === 'yellow' ? 'bg-[#f4b840]/10 text-[#f4b840] border-[#f4b840]/20' :
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 className="flex items-center gap-2 text-sm text-gray-500">
<Clock size={14} />
<span>{formatDate(post.created_at)}</span>
<span></span>
<span className={`px-2 py-0.5 rounded text-xs ${
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>
</div>
</div>
<button className="text-gray-500 hover:text-gray-700">
@@ -40,74 +44,62 @@ export default function AudioPostCard({ post, onLike, onComment, onShare, onBook
</button>
</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 */}
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex items-center gap-4 mb-3">
<button className="w-10 h-10 bg-[#f4b840] hover:bg-[#e5a930] rounded-full flex items-center justify-center text-[#1a1a1a]">
<Play size={16} fill="currentColor" />
</button>
<div className="flex-1">
<div className="h-1.5 bg-gray-300 rounded-full overflow-hidden mb-2">
<div
className="h-full bg-[#f4b840] rounded-full"
style={{ width: `${post.audio.progress}%` }}
></div>
</div>
<div className="flex justify-between text-xs text-gray-600">
<span>{post.audio.currentTime}</span>
<span>{post.audio.duration}</span>
{/* Audio Player - Only show if ready */}
{post.status === 'ready' && (
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex items-center gap-4">
<button
onClick={() => onPlay?.(post)}
className="w-10 h-10 bg-[#f4b840] hover:bg-[#e5a930] rounded-full flex items-center justify-center text-[#1a1a1a] transition-colors"
>
<Play size={16} fill="currentColor" />
</button>
<div className="flex-1">
<div className="h-1.5 bg-gray-300 rounded-full overflow-hidden mb-2">
<div className="h-full w-0 bg-[#f4b840] rounded-full"></div>
</div>
<div className="flex justify-between text-xs text-gray-600">
<span>0:00</span>
<span>--:--</span>
</div>
</div>
<button className="text-gray-600 hover:text-gray-900">
<Volume2 size={18} />
</button>
</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>
{/* Post Actions */}
<div className="px-6 py-4 border-t border-gray-200 flex items-center gap-6">
<button
onClick={() => onLike?.(post.id)}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 text-sm"
>
<Heart size={18} />
<span>{post.likes}</span>
</button>
<button
onClick={() => onComment?.(post.id)}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 text-sm"
>
<MessageCircle size={18} />
<span>{post.comments}</span>
</button>
<button
onClick={() => onShare?.(post.id)}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 text-sm"
>
<Share2 size={18} />
<span>Share</span>
</button>
<button
onClick={() => onBookmark?.(post.id)}
className="ml-auto text-gray-600 hover:text-gray-900"
>
<Bookmark size={18} />
</button>
{/* Processing Status */}
{post.status === 'processing' && (
<div className="bg-yellow-50 rounded-lg p-4 border border-yellow-200 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f4b840] mx-auto mb-2"></div>
<p className="text-sm text-yellow-800">Processing audio and generating transcript...</p>
</div>
)}
{/* Failed Status */}
{post.status === 'failed' && (
<div className="bg-red-50 rounded-lg p-4 border border-red-200">
<p className="text-sm text-red-800">Failed to process this recording. Please try uploading again.</p>
</div>
)}
{/* Language Tag */}
{post.language && (
<div className="mt-3">
<span className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded">
{post.language.toUpperCase()}
</span>
</div>
)}
</div>
</article>
)

View File

@@ -1,6 +1,15 @@
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 (
<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">
@@ -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} />
<input
type="text"
placeholder="Search archives, stories, history..."
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"
placeholder="Search your archives..."
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>
{/* Right: Login */}
{/* Right: User Info / Login */}
<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 size={16} />
Log In
</button>
{user ? (
<div className="flex items-center gap-3">
<span className="text-sm text-gray-700">{user.display_name || user.email}</span>
<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>
</header>

View File

@@ -7,20 +7,10 @@ export default function Sidebar({ activeTab, onTabChange }) {
{ id: 'history', label: 'History', icon: History },
{ id: 'settings', label: 'Settings', icon: Settings }
]
return (
<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">
{/* 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 */}
<nav className="space-y-2">
{navItems.map((item) => {

View File

@@ -1,44 +1,85 @@
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 [selectedCategories, setSelectedCategories] = useState([])
const [description, setDescription] = useState('')
const [visibility, setVisibility] = useState('private')
const [language, setLanguage] = useState('en')
const [audioFile, setAudioFile] = useState(null)
const [isRecording, setIsRecording] = useState(false)
const [isPrivate, setIsPrivate] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(false)
const categories = [
{ 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 handleFileSelect = (e) => {
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)
setError(null)
}
}
const handleSubmit = (e) => {
const handleSubmit = async (e) => {
e.preventDefault()
onSubmit?.({
title,
categories: selectedCategories,
audioFile,
isPrivate
})
if (!audioFile) {
setError('Please select an audio file')
return
}
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 (
@@ -46,11 +87,25 @@ export default function CreatePost({ onSubmit }) {
<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>
{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">
{/* Title Input */}
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">
Title
Title *
</label>
<input
type="text"
@@ -59,126 +114,165 @@ export default function CreatePost({ onSubmit }) {
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"
required
disabled={uploading}
/>
</div>
{/* Audio Recording/Upload */}
{/* Description Input */}
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">
Audio Recording
Description (optional)
</label>
<div className="space-y-3">
{/* Recording Controls */}
<div className="flex gap-3">
<button
type="button"
onClick={() => setIsRecording(!isRecording)}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors ${
isRecording
? 'bg-red-50 border-red-300 text-red-700'
: 'bg-gray-50 border-gray-300 text-gray-700 hover:bg-gray-100'
}`}
>
<Mic size={20} />
<span>{isRecording ? 'Recording...' : 'Start Recording'}</span>
</button>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description or context for this recording..."
rows={3}
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"
disabled={uploading}
/>
</div>
<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">
<Upload size={20} />
<span>Upload Audio</span>
<input
type="file"
accept="audio/*"
onChange={handleFileUpload}
className="hidden"
/>
</label>
</div>
{/* Audio File Preview */}
{audioFile && (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center gap-3">
<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>
{/* Audio File Upload */}
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">
Audio/Video File *
</label>
{!audioFile ? (
<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">
<Upload size={32} className="text-gray-400 mb-2" />
<p className="text-sm text-gray-600 mb-1">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-gray-500">
MP3, WAV, OGG, FLAC, M4A, MP4, MOV, MKV, WEBM
</p>
</div>
)}
</div>
<input
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>
{/* Categories */}
{/* Language Selection */}
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">
Categories
Language
</label>
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<button
key={category.id}
type="button"
onClick={() => handleCategoryToggle(category.id)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
selectedCategories.includes(category.id)
? category.color === 'yellow' ? 'bg-[#f4b840] text-[#1a1a1a]' :
category.color === 'blue' ? 'bg-blue-500 text-white' :
category.color === 'purple' ? 'bg-purple-500 text-white' :
'bg-green-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{category.name}
</button>
))}
</div>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
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"
disabled={uploading}
>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="it">Italian</option>
<option value="pt">Portuguese</option>
<option value="zh">Chinese</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
<option value="ar">Arabic</option>
</select>
</div>
{/* Privacy Toggle */}
{/* Visibility Toggle */}
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
<div>
<p className="font-medium text-gray-900">Private Archive</p>
<p className="text-sm text-gray-600">Only you can see this post</p>
<p className="font-medium text-gray-900">Visibility</p>
<p className="text-sm text-gray-600">
{visibility === 'private' ? 'Only you can see this post' : 'Anyone can see this post'}
</p>
</div>
<button
type="button"
onClick={() => setIsPrivate(!isPrivate)}
className={`relative w-12 h-6 rounded-full transition-colors ${
isPrivate ? 'bg-[#f4b840]' : 'bg-gray-300'
}`}
>
<div
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
isPrivate ? 'translate-x-6' : 'translate-x-0'
<div className="flex gap-2">
<button
type="button"
onClick={() => setVisibility('private')}
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
visibility === 'private'
? 'bg-[#f4b840] text-[#1a1a1a]'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
/>
</button>
disabled={uploading}
>
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>
{/* Submit Button */}
{/* Submit Buttons */}
<div className="flex gap-3">
<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"
disabled={uploading}
>
Cancel
</button>
<button
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>
</div>
</form>

View File

@@ -1,22 +1,144 @@
import { useState, useEffect } from 'react'
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 (
<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) => (
<AudioPostCard
key={post.id}
key={post.post_id}
post={post}
onLike={onLike}
onComment={onComment}
onShare={onShare}
onBookmark={onBookmark}
onPlay={handlePlay}
/>
))
) : (
<div className="text-center py-12">
<p className="text-gray-600">No posts to display</p>
<div className="text-center py-12 bg-white rounded-lg border border-gray-200">
<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>

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 (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<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">
<Filter size={18} />
<span>Filter</span>
</button>
<p className="text-sm text-gray-600">{posts.length} archive{posts.length !== 1 ? 's' : ''}</p>
</div>
{/* My Posted Archives */}
@@ -17,71 +102,114 @@ export default function History({ userPosts, onDelete }) {
<FileText size={20} />
My Posted Archives
</h3>
{userPosts?.length > 0 ? (
{posts.length > 0 ? (
<div className="space-y-3">
{userPosts.map((post) => (
<div key={post.id} className="flex items-start gap-4 p-4 hover:bg-gray-50 rounded-lg border border-gray-200">
{posts.map((post) => (
<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">
<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">
<span>{post.createdAt}</span>
<span>{formatDate(post.created_at)}</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 className={post.isPrivate ? 'text-gray-500' : 'text-green-600'}>
{post.isPrivate ? 'Private' : 'Public'}
<span className={`px-2 py-0.5 rounded text-xs ${
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>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>👍 {post.likes} likes</span>
<span>💬 {post.comments} comments</span>
<span>🎧 {post.listens} listens</span>
</div>
{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>
{post.language && (
<span className="inline-block text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded">
{post.language.toUpperCase()}
</span>
)}
</div>
<button
onClick={() => onDelete?.(post.id)}
className="text-red-500 hover:text-red-700 p-2 hover:bg-red-50 rounded transition-colors"
title="Delete post"
>
<Trash2 size={18} />
</button>
<div className="flex gap-2 flex-shrink-0">
<button
onClick={() => handleView(post.post_id)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="View details"
>
<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>
) : (
<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>
{/* Stats Summary */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-lg border border-gray-200 p-4 text-center">
<div className="text-2xl font-bold text-gray-900">{userPosts?.length || 0}</div>
<div className="text-sm text-gray-600">Total Posts</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">
{userPosts?.reduce((sum, post) => sum + post.likes, 0) || 0}
{/* Summary Stats */}
{posts.length > 0 && (
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-lg border border-gray-200 p-4 text-center">
<div className="text-2xl font-bold text-gray-900">{posts.length}</div>
<div className="text-sm text-gray-600">Total Archives</div>
</div>
<div className="text-sm text-gray-600">Total Likes</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">
{userPosts?.reduce((sum, post) => sum + post.listens, 0) || 0}
<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.status === 'ready').length}
</div>
<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 className="text-sm text-gray-600">Total Listens</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>
)
}

View File

@@ -1,218 +1,129 @@
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 }) {
const [settings, setSettings] = useState(userSettings || {
notifications: {
newFollowers: true,
comments: true,
likes: false,
mentions: true,
},
privacy: {
profileVisibility: 'public',
showListeningHistory: true,
allowComments: true,
},
account: {
email: 'john.doe@email.com',
username: 'johndoe',
export default function Settings({ user, onUpdate }) {
const [displayName, setDisplayName] = useState(user?.display_name || '')
const [bio, setBio] = useState(user?.bio || '')
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState(null)
const handleSave = async () => {
setSaving(true)
setMessage(null)
try {
// In a real app, you'd call an update user API
// For now, just simulate success
await new Promise(resolve => setTimeout(resolve, 1000))
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 (
<div className="max-w-3xl mx-auto space-y-6">
<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">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<User size={20} />
Account Information
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">Username</label>
<input
type="text"
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>
<label className="block text-sm font-medium text-gray-900 mb-2">
Email
</label>
<input
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"
/>
</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
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={3}
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"
/>
</div>
</div>
</div>
{/* Privacy Settings */}
<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">
<Lock size={20} />
Privacy
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Profile Visibility</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>
<label className="block text-sm font-medium text-gray-900 mb-2">
User ID
</label>
<input
type="text"
value={user?.user_id || ''}
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">Your unique identifier</p>
</div>
</div>
</div>
{/* Notification Settings */}
{/* Account Created */}
<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">
<Bell size={20} />
Notifications
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Account Details
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">New Followers</p>
<p className="text-sm text-gray-600">When someone follows you</p>
</div>
<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 className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Account Created</span>
<span className="font-medium text-gray-900">
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A'}
</span>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Comments</p>
<p className="text-sm text-gray-600">When someone comments on your post</p>
</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 className="flex justify-between">
<span className="text-gray-600">Last Updated</span>
<span className="font-medium text-gray-900">
{user?.updated_at ? new Date(user.updated_at).toLocaleDateString() : 'N/A'}
</span>
</div>
</div>
</div>
@@ -223,27 +134,35 @@ export default function Settings({ userSettings, onUpdate }) {
<Trash2 size={20} />
Danger Zone
</h3>
<div className="space-y-3">
<button className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
Export My Data
</button>
<button className="w-full px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors">
Delete Account
</button>
</div>
<p className="text-sm text-gray-600 mb-4">
Once you delete your account, there is no going back. All your archives and data will be permanently deleted.
</p>
<button className="w-full px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors">
Delete Account
</button>
</div>
{/* Save Button */}
<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
</button>
<button
onClick={() => onUpdate?.(settings)}
className="flex-1 px-4 py-2 bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] rounded-lg font-medium transition-colors"
onClick={handleSave}
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>
</div>
</div>