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

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>