Feat: frontend working almost
This commit is contained in:
BIN
backend/__pycache__/api_routes.cpython-314.pyc
Normal file
BIN
backend/__pycache__/api_routes.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/db_queries.cpython-314.pyc
Normal file
BIN
backend/__pycache__/db_queries.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/uploads/bdda41e3-a2db-4da4-992a-13c405531e05_data.m4a
Normal file
BIN
backend/uploads/bdda41e3-a2db-4da4-992a-13c405531e05_data.m4a
Normal file
Binary file not shown.
643
frontend/package-lock.json
generated
643
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,11 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"tailwindcss": "^4.1.18"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
@@ -1,160 +1,191 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Header from './components/Header'
|
import Header from './components/Header'
|
||||||
import Sidebar from './components/Sidebar'
|
import Sidebar from './components/Sidebar'
|
||||||
// import RightSidebar from './components/RightSidebar'
|
|
||||||
import Feed from './pages/Feed'
|
import Feed from './pages/Feed'
|
||||||
import CreatePost from './pages/CreatePost'
|
import CreatePost from './pages/CreatePost'
|
||||||
import History from './pages/History'
|
import History from './pages/History'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [activeTab, setActiveTab] = useState('feed')
|
const [activeTab, setActiveTab] = useState('feed')
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [showLogin, setShowLogin] = useState(true)
|
||||||
|
const [loginEmail, setLoginEmail] = useState('')
|
||||||
|
const [loginPassword, setLoginPassword] = useState('')
|
||||||
|
const [isRegistering, setIsRegistering] = useState(false)
|
||||||
|
const [loginError, setLoginError] = useState(null)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
// Mock user data
|
// Check for saved user on mount
|
||||||
const user = {
|
useEffect(() => {
|
||||||
initials: 'JD',
|
const savedUser = localStorage.getItem('voicevault_user')
|
||||||
name: 'John Doe',
|
if (savedUser) {
|
||||||
role: 'Oral Historian',
|
try {
|
||||||
location: 'San Francisco, CA',
|
const userData = JSON.parse(savedUser)
|
||||||
stats: {
|
setUser(userData)
|
||||||
posts: 127,
|
api.setUserId(userData.user_id)
|
||||||
listeners: '2.4k',
|
setShowLogin(false)
|
||||||
following: 89
|
} catch (err) {
|
||||||
|
console.error('Failed to parse saved user:', err)
|
||||||
|
localStorage.removeItem('voicevault_user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLogin = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoginError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = isRegistering
|
||||||
|
? await api.register(loginEmail, loginPassword, loginEmail.split('@')[0])
|
||||||
|
: await api.login(loginEmail, loginPassword)
|
||||||
|
|
||||||
|
setUser(response.user)
|
||||||
|
localStorage.setItem('voicevault_user', JSON.stringify(response.user))
|
||||||
|
setShowLogin(false)
|
||||||
|
setLoginEmail('')
|
||||||
|
setLoginPassword('')
|
||||||
|
} catch (err) {
|
||||||
|
setLoginError(err.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock trending topics
|
const handleLogout = () => {
|
||||||
const trendingTopics = [
|
setUser(null)
|
||||||
{ name: 'Historical Events', count: '1.2k', growth: 23 },
|
localStorage.removeItem('voicevault_user')
|
||||||
{ name: 'Family Stories', count: '892', growth: 18 },
|
setShowLogin(true)
|
||||||
{ name: 'Cultural Heritage', count: '654', growth: 12 },
|
setActiveTab('feed')
|
||||||
]
|
|
||||||
|
|
||||||
// Mock posts data
|
|
||||||
const 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
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
|
|
||||||
// Mock listening history
|
const handleSearch = async (query) => {
|
||||||
const listeningHistory = [
|
setSearchQuery(query)
|
||||||
{
|
|
||||||
id: 1,
|
if (query.trim() && user?.user_id) {
|
||||||
title: "My Grandmother's Journey Through WWII",
|
try {
|
||||||
user: { name: 'Diana Martinez', initials: 'DM', avatarColor: 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)' },
|
const results = await api.searchRAG(query, user.user_id)
|
||||||
listenedAt: '2 hours ago',
|
console.log('Search results:', results)
|
||||||
duration: '7:52',
|
// You could display these results in a modal or separate view
|
||||||
progress: 100,
|
} catch (err) {
|
||||||
completed: true
|
console.error('Search error:', err)
|
||||||
},
|
}
|
||||||
{
|
}
|
||||||
id: 2,
|
|
||||||
title: 'Traditional Music of the Appalachian Mountains',
|
|
||||||
user: { name: 'Robert Miller', initials: 'RM', avatarColor: 'linear-gradient(135deg, #a855f7 0%, #9333ea 100%)' },
|
|
||||||
listenedAt: '1 day ago',
|
|
||||||
duration: '8:30',
|
|
||||||
progress: 75,
|
|
||||||
completed: false
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
|
|
||||||
// Mock search history
|
const handlePostCreated = () => {
|
||||||
const searchHistory = [
|
// Switch to feed after creating a post
|
||||||
{ id: 1, query: 'WWII stories', searchedAt: '2 hours ago' },
|
setActiveTab('feed')
|
||||||
{ 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
|
// Render current page
|
||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'create':
|
case 'create':
|
||||||
return <CreatePost onSubmit={(data) => console.log('Post created:', data)} />
|
return <CreatePost user={user} onPostCreated={handlePostCreated} />
|
||||||
case 'history':
|
case 'history':
|
||||||
return <History listeningHistory={listeningHistory} searchHistory={searchHistory} />
|
return <History user={user} />
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return <Settings onUpdate={(settings) => console.log('Settings updated:', settings)} />
|
return <Settings user={user} onUpdate={handleUserUpdate} />
|
||||||
default:
|
default:
|
||||||
return <Feed posts={posts} />
|
return <Feed user={user} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Login/Register Screen
|
||||||
|
if (showLogin) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-gray-50 text-gray-800 flex flex-col overflow-hidden">
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
<Header onSearch={setSearchQuery} />
|
<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>
|
||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden max-w-[1400px] mx-auto w-full">
|
{loginError && (
|
||||||
<Sidebar user={user} activeTab={activeTab} onTabChange={setActiveTab} />
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-800">
|
||||||
|
{loginError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
{renderPage()}
|
<div>
|
||||||
</main>
|
<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>
|
||||||
|
|
||||||
{/* <RightSidebar trendingTopics={trendingTopics} /> */}
|
<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 user={user} onSearch={handleSearch} onLogout={handleLogout} />
|
||||||
|
|
||||||
|
<div className="flex-1 flex overflow-hidden max-w-[1400px] mx-auto w-full">
|
||||||
|
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
|
{renderPage()}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
188
frontend/src/api.js
Normal file
188
frontend/src/api.js
Normal 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;
|
||||||
@@ -1,38 +1,42 @@
|
|||||||
import { Play, Volume2, Heart, MessageCircle, Share2, Bookmark, MoreVertical } from 'lucide-react'
|
import { Play, Volume2, MoreVertical, Clock } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function AudioPostCard({ post, onPlay }) {
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now - date
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`
|
||||||
|
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`
|
||||||
|
return `${Math.floor(diffMins / 1440)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
export default function AudioPostCard({ post, onLike, onComment, onShare, onBookmark }) {
|
|
||||||
return (
|
return (
|
||||||
<article className="bg-white rounded-lg border border-gray-200 overflow-hidden shadow-sm hover:shadow-md transition-shadow">
|
<article className="bg-white rounded-lg border border-gray-200 overflow-hidden shadow-sm hover:shadow-md transition-shadow">
|
||||||
{/* Post Header */}
|
{/* Post Header */}
|
||||||
<div className="p-6 pb-4">
|
<div className="p-6 pb-4">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex-1">
|
||||||
<div
|
<div className="flex items-center gap-2 mb-1">
|
||||||
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-semibold text-lg"
|
<h3 className="text-lg font-semibold text-gray-900">{post.title}</h3>
|
||||||
style={{ background: post.user.avatarColor }}
|
{post.visibility === 'private' && (
|
||||||
>
|
<span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-600 rounded">
|
||||||
{post.user.initials}
|
Private
|
||||||
</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>
|
</span>
|
||||||
))}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<button className="text-gray-500 hover:text-gray-700">
|
<button className="text-gray-500 hover:text-gray-700">
|
||||||
@@ -40,24 +44,30 @@ export default function AudioPostCard({ post, onLike, onComment, onShare, onBook
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">{post.title}</h3>
|
{/* Description */}
|
||||||
|
{post.description && (
|
||||||
|
<p className="text-sm text-gray-700 mb-4 line-clamp-2">
|
||||||
|
{post.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Audio Player */}
|
{/* Audio Player - Only show if ready */}
|
||||||
|
{post.status === 'ready' && (
|
||||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||||
<div className="flex items-center gap-4 mb-3">
|
<div className="flex items-center gap-4">
|
||||||
<button className="w-10 h-10 bg-[#f4b840] hover:bg-[#e5a930] rounded-full flex items-center justify-center text-[#1a1a1a]">
|
<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" />
|
<Play size={16} fill="currentColor" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="h-1.5 bg-gray-300 rounded-full overflow-hidden mb-2">
|
<div className="h-1.5 bg-gray-300 rounded-full overflow-hidden mb-2">
|
||||||
<div
|
<div className="h-full w-0 bg-[#f4b840] rounded-full"></div>
|
||||||
className="h-full bg-[#f4b840] rounded-full"
|
|
||||||
style={{ width: `${post.audio.progress}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xs text-gray-600">
|
<div className="flex justify-between text-xs text-gray-600">
|
||||||
<span>{post.audio.currentTime}</span>
|
<span>0:00</span>
|
||||||
<span>{post.audio.duration}</span>
|
<span>--:--</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="text-gray-600 hover:text-gray-900">
|
<button className="text-gray-600 hover:text-gray-900">
|
||||||
@@ -65,49 +75,31 @@ export default function AudioPostCard({ post, onLike, onComment, onShare, onBook
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Transcription Preview */}
|
{/* Processing Status */}
|
||||||
{post.transcript && (
|
{post.status === 'processing' && (
|
||||||
<div className="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
<div className="bg-yellow-50 rounded-lg p-4 border border-yellow-200 text-center">
|
||||||
<p className="text-sm text-gray-700 leading-relaxed line-clamp-3">
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f4b840] mx-auto mb-2"></div>
|
||||||
{post.transcript}
|
<p className="text-sm text-yellow-800">Processing audio and generating transcript...</p>
|
||||||
</p>
|
|
||||||
<button className="text-xs text-[#f4b840] hover:text-[#e5a930] mt-2 font-medium">
|
|
||||||
Read full transcript →
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Post Actions */}
|
{/* Failed Status */}
|
||||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center gap-6">
|
{post.status === 'failed' && (
|
||||||
<button
|
<div className="bg-red-50 rounded-lg p-4 border border-red-200">
|
||||||
onClick={() => onLike?.(post.id)}
|
<p className="text-sm text-red-800">Failed to process this recording. Please try uploading again.</p>
|
||||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 text-sm"
|
</div>
|
||||||
>
|
)}
|
||||||
<Heart size={18} />
|
|
||||||
<span>{post.likes}</span>
|
{/* Language Tag */}
|
||||||
</button>
|
{post.language && (
|
||||||
<button
|
<div className="mt-3">
|
||||||
onClick={() => onComment?.(post.id)}
|
<span className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded">
|
||||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 text-sm"
|
{post.language.toUpperCase()}
|
||||||
>
|
</span>
|
||||||
<MessageCircle size={18} />
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { Search, User, Volume2 } from 'lucide-react'
|
import { Search, User, Volume2 } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function Header({ user, onSearch, onLogout }) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
const handleSearch = (e) => {
|
||||||
|
const query = e.target.value
|
||||||
|
setSearchQuery(query)
|
||||||
|
onSearch?.(query)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Header() {
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-white border-b border-gray-200 px-4 py-3 flex-shrink-0">
|
<header className="bg-white border-b border-gray-200 px-4 py-3 flex-shrink-0">
|
||||||
<div className="max-w-[1400px] mx-auto flex items-center justify-between gap-6">
|
<div className="max-w-[1400px] mx-auto flex items-center justify-between gap-6">
|
||||||
@@ -18,18 +27,32 @@ export default function Header() {
|
|||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search archives, stories, history..."
|
placeholder="Search your archives..."
|
||||||
className="w-full bg-white border border-gray-300 rounded-lg pl-10 pr-4 py-2 text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
|
value={searchQuery}
|
||||||
|
onChange={handleSearch}
|
||||||
|
className="w-full bg-gray-50 border border-gray-300 rounded-lg pl-10 pr-4 py-2 text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Login */}
|
{/* Right: User Info / Login */}
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
{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">
|
<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} />
|
<User size={16} />
|
||||||
Log In
|
Log In
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -11,16 +11,6 @@ export default function Sidebar({ activeTab, onTabChange }) {
|
|||||||
return (
|
return (
|
||||||
<aside className="w-64 bg-white border-r border-gray-200 p-6 hidden md:block flex-shrink-0 overflow-y-auto">
|
<aside className="w-64 bg-white border-r border-gray-200 p-6 hidden md:block flex-shrink-0 overflow-y-auto">
|
||||||
<div className="sticky top-6">
|
<div className="sticky top-6">
|
||||||
|
|
||||||
{/* Profile Image */}
|
|
||||||
<div className="w-32 h-32 bg-gradient-to-br from-[#f4b840] to-[#e5a930] rounded-full flex items-center justify-center text-[#1a1a1a] font-bold text-4xl mx-auto mb-4">
|
|
||||||
JD
|
|
||||||
</div>
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-1">John Doe</h2>
|
|
||||||
<p className="text-sm text-gray-600">Oral Historian</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">San Francisco, CA</p>
|
|
||||||
</div>
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="space-y-2">
|
<nav className="space-y-2">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
|
|||||||
@@ -1,44 +1,85 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Mic, Upload, X } from 'lucide-react'
|
import { Upload, X } from 'lucide-react'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
export default function CreatePost({ onSubmit }) {
|
export default function CreatePost({ user, onPostCreated }) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [selectedCategories, setSelectedCategories] = useState([])
|
const [description, setDescription] = useState('')
|
||||||
|
const [visibility, setVisibility] = useState('private')
|
||||||
|
const [language, setLanguage] = useState('en')
|
||||||
const [audioFile, setAudioFile] = useState(null)
|
const [audioFile, setAudioFile] = useState(null)
|
||||||
const [isRecording, setIsRecording] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [isPrivate, setIsPrivate] = useState(false)
|
const [error, setError] = useState(null)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
const categories = [
|
const handleFileSelect = (e) => {
|
||||||
{ id: 1, name: 'Historical Events', color: 'yellow' },
|
|
||||||
{ id: 2, name: 'Cultural Traditions', color: 'purple' },
|
|
||||||
{ id: 3, name: 'Personal Stories', color: 'blue' },
|
|
||||||
{ id: 4, name: 'Oral History', color: 'green' },
|
|
||||||
{ id: 5, name: 'Family History', color: 'blue' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const handleCategoryToggle = (categoryId) => {
|
|
||||||
setSelectedCategories(prev =>
|
|
||||||
prev.includes(categoryId)
|
|
||||||
? prev.filter(id => id !== categoryId)
|
|
||||||
: [...prev, categoryId]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileUpload = (e) => {
|
|
||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (file && file.type.startsWith('audio/')) {
|
if (file) {
|
||||||
|
// Check file type
|
||||||
|
const validTypes = ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/flac', 'audio/mp4', 'video/mp4']
|
||||||
|
if (!validTypes.includes(file.type) && !file.name.match(/\.(mp3|wav|ogg|flac|m4a|mp4|mov|mkv|webm)$/i)) {
|
||||||
|
setError('Invalid file type. Please upload an audio or video file.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setAudioFile(file)
|
setAudioFile(file)
|
||||||
|
setError(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSubmit?.({
|
|
||||||
title,
|
if (!audioFile) {
|
||||||
categories: selectedCategories,
|
setError('Please select an audio file')
|
||||||
audioFile,
|
return
|
||||||
isPrivate
|
}
|
||||||
})
|
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError('Please enter a title')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user?.user_id) {
|
||||||
|
setError('You must be logged in to create a post')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', audioFile)
|
||||||
|
formData.append('user_id', user.user_id)
|
||||||
|
formData.append('title', title.trim())
|
||||||
|
formData.append('description', description.trim())
|
||||||
|
formData.append('visibility', visibility)
|
||||||
|
formData.append('language', language)
|
||||||
|
|
||||||
|
const response = await api.uploadPost(formData)
|
||||||
|
|
||||||
|
console.log('Upload response:', response)
|
||||||
|
setSuccess(true)
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setTitle('')
|
||||||
|
setDescription('')
|
||||||
|
setVisibility('private')
|
||||||
|
setLanguage('en')
|
||||||
|
setAudioFile(null)
|
||||||
|
|
||||||
|
// Notify parent component
|
||||||
|
onPostCreated?.(response)
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
setTimeout(() => setSuccess(false), 5000)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to upload post')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,11 +87,25 @@ export default function CreatePost({ onSubmit }) {
|
|||||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Create New Archive</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Create New Archive</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-sm text-red-800">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<p className="text-sm text-green-800">
|
||||||
|
Post uploaded successfully! Transcript is being generated...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Title Input */}
|
{/* Title Input */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-2">
|
<label className="block text-sm font-medium text-gray-900 mb-2">
|
||||||
Title
|
Title *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -59,126 +114,165 @@ export default function CreatePost({ onSubmit }) {
|
|||||||
placeholder="Give your archive a descriptive title..."
|
placeholder="Give your archive a descriptive title..."
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
|
||||||
required
|
required
|
||||||
|
disabled={uploading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audio Recording/Upload */}
|
{/* Description Input */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-2">
|
<label className="block text-sm font-medium text-gray-900 mb-2">
|
||||||
Audio Recording
|
Description (optional)
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-3">
|
<textarea
|
||||||
{/* Recording Controls */}
|
value={description}
|
||||||
<div className="flex gap-3">
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
<button
|
placeholder="Add a description or context for this recording..."
|
||||||
type="button"
|
rows={3}
|
||||||
onClick={() => setIsRecording(!isRecording)}
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent resize-none"
|
||||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors ${
|
disabled={uploading}
|
||||||
isRecording
|
/>
|
||||||
? 'bg-red-50 border-red-300 text-red-700'
|
</div>
|
||||||
: 'bg-gray-50 border-gray-300 text-gray-700 hover:bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Mic size={20} />
|
|
||||||
<span>{isRecording ? 'Recording...' : 'Start Recording'}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<label className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-gray-300 bg-gray-50 text-gray-700 hover:bg-gray-100 cursor-pointer">
|
{/* Audio File Upload */}
|
||||||
<Upload size={20} />
|
<div>
|
||||||
<span>Upload Audio</span>
|
<label className="block text-sm font-medium text-gray-900 mb-2">
|
||||||
|
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>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="audio/*"
|
accept="audio/*,video/mp4,video/quicktime,video/x-matroska,video/webm"
|
||||||
onChange={handleFileUpload}
|
onChange={handleFileSelect}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
disabled={uploading}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
||||||
|
<div className="min-w-0">
|
||||||
{/* Audio File Preview */}
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
{audioFile && (
|
{audioFile.name}
|
||||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200">
|
</p>
|
||||||
<div className="flex items-center gap-3">
|
<p className="text-xs text-gray-600">
|
||||||
<div className="w-10 h-10 bg-[#f4b840] rounded-lg flex items-center justify-center">
|
{(audioFile.size / 1024 / 1024).toFixed(2)} MB
|
||||||
<Mic size={18} className="text-white" />
|
</p>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAudioFile(null)}
|
onClick={() => setAudioFile(null)}
|
||||||
className="text-gray-500 hover:text-gray-700"
|
className="text-gray-500 hover:text-gray-700 ml-2"
|
||||||
|
disabled={uploading}
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Language Selection */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-2">
|
<label className="block text-sm font-medium text-gray-900 mb-2">
|
||||||
Categories
|
Language
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<select
|
||||||
{categories.map((category) => (
|
value={language}
|
||||||
<button
|
onChange={(e) => setLanguage(e.target.value)}
|
||||||
key={category.id}
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
|
||||||
type="button"
|
disabled={uploading}
|
||||||
onClick={() => handleCategoryToggle(category.id)}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
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}
|
<option value="en">English</option>
|
||||||
</button>
|
<option value="es">Spanish</option>
|
||||||
))}
|
<option value="fr">French</option>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Privacy Toggle */}
|
{/* Visibility Toggle */}
|
||||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
|
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">Private Archive</p>
|
<p className="font-medium text-gray-900">Visibility</p>
|
||||||
<p className="text-sm text-gray-600">Only you can see this post</p>
|
<p className="text-sm text-gray-600">
|
||||||
|
{visibility === 'private' ? 'Only you can see this post' : 'Anyone can see this post'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsPrivate(!isPrivate)}
|
onClick={() => setVisibility('private')}
|
||||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||||
isPrivate ? 'bg-[#f4b840]' : 'bg-gray-300'
|
visibility === 'private'
|
||||||
|
? 'bg-[#f4b840] text-[#1a1a1a]'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
}`}
|
}`}
|
||||||
|
disabled={uploading}
|
||||||
>
|
>
|
||||||
<div
|
Private
|
||||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
</button>
|
||||||
isPrivate ? 'translate-x-6' : 'translate-x-0'
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Buttons */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTitle('')
|
||||||
|
setDescription('')
|
||||||
|
setAudioFile(null)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
disabled={uploading}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex-1 px-4 py-2 bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] rounded-lg font-medium transition-colors"
|
disabled={uploading || !audioFile || !title.trim()}
|
||||||
|
className="flex-1 px-4 py-2 bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Post Archive
|
{uploading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[#1a1a1a]"></div>
|
||||||
|
Uploading...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Create Archive'
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,22 +1,144 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
import AudioPostCard from '../components/AudioPostCard'
|
import AudioPostCard from '../components/AudioPostCard'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
export default function Feed({ user }) {
|
||||||
|
const [posts, setPosts] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [visibilityFilter, setVisibilityFilter] = useState('all')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.user_id) {
|
||||||
|
fetchPosts()
|
||||||
|
}
|
||||||
|
}, [user, page, visibilityFilter])
|
||||||
|
|
||||||
|
const fetchPosts = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page,
|
||||||
|
limit: 20,
|
||||||
|
user_id: user.user_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibilityFilter !== 'all') {
|
||||||
|
params.visibility = visibilityFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.getPosts(params)
|
||||||
|
setPosts(response.posts || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlay = async (post) => {
|
||||||
|
console.log('Playing post:', post)
|
||||||
|
// Implement audio playback logic
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#f4b840]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
|
||||||
|
<p className="text-red-800">Error loading feed: {error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchPosts}
|
||||||
|
className="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Feed({ posts, onLike, onComment, onShare, onBookmark }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto space-y-6">
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
{posts?.length > 0 ? (
|
{/* Filter Pills */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibilityFilter('all')}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm font-medium ${
|
||||||
|
visibilityFilter === 'all'
|
||||||
|
? 'bg-[#f4b840] text-[#1a1a1a]'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All Posts
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibilityFilter('public')}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm font-medium ${
|
||||||
|
visibilityFilter === 'public'
|
||||||
|
? 'bg-[#f4b840] text-[#1a1a1a]'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Public
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibilityFilter('private')}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm font-medium ${
|
||||||
|
visibilityFilter === 'private'
|
||||||
|
? 'bg-[#f4b840] text-[#1a1a1a]'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Private
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Posts List */}
|
||||||
|
{posts.length > 0 ? (
|
||||||
posts.map((post) => (
|
posts.map((post) => (
|
||||||
<AudioPostCard
|
<AudioPostCard
|
||||||
key={post.id}
|
key={post.post_id}
|
||||||
post={post}
|
post={post}
|
||||||
onLike={onLike}
|
onPlay={handlePlay}
|
||||||
onComment={onComment}
|
|
||||||
onShare={onShare}
|
|
||||||
onBookmark={onBookmark}
|
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12 bg-white rounded-lg border border-gray-200">
|
||||||
<p className="text-gray-600">No posts to display</p>
|
<p className="text-gray-600 mb-2">No posts to display</p>
|
||||||
|
<p className="text-sm text-gray-500">Create your first archive to get started!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{posts.length >= 20 && (
|
||||||
|
<div className="flex justify-center gap-2 pb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="px-4 py-2 text-gray-700">Page {page}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,99 @@
|
|||||||
import { FileText, Filter, Trash2 } from 'lucide-react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { FileText, Trash2, Eye } from 'lucide-react'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
export default function History({ user }) {
|
||||||
|
const [posts, setPosts] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.user_id) {
|
||||||
|
fetchHistory()
|
||||||
|
}
|
||||||
|
}, [user, page])
|
||||||
|
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.getUserHistory(user.user_id, page, 20)
|
||||||
|
setPosts(response.history || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (postId) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this post?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deletePost(postId)
|
||||||
|
// Refresh the list
|
||||||
|
fetchHistory()
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to delete post: ' + err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleView = async (postId) => {
|
||||||
|
try {
|
||||||
|
const bundle = await api.getPostBundle(postId)
|
||||||
|
console.log('Post bundle:', bundle)
|
||||||
|
// You could open a modal or navigate to a detail view
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to load post details: ' + err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#f4b840]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
|
||||||
|
<p className="text-red-800">Error loading history: {error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchHistory}
|
||||||
|
className="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function History({ userPosts, onDelete }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto space-y-6">
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">History</h2>
|
<h2 className="text-2xl font-bold text-gray-900">History</h2>
|
||||||
<button className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
|
<p className="text-sm text-gray-600">{posts.length} archive{posts.length !== 1 ? 's' : ''}</p>
|
||||||
<Filter size={18} />
|
|
||||||
<span>Filter</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* My Posted Archives */}
|
{/* My Posted Archives */}
|
||||||
@@ -18,70 +103,113 @@ export default function History({ userPosts, onDelete }) {
|
|||||||
My Posted Archives
|
My Posted Archives
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{userPosts?.length > 0 ? (
|
{posts.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{userPosts.map((post) => (
|
{posts.map((post) => (
|
||||||
<div key={post.id} className="flex items-start gap-4 p-4 hover:bg-gray-50 rounded-lg border border-gray-200">
|
<div key={post.post_id} className="flex items-start gap-4 p-4 hover:bg-gray-50 rounded-lg border border-gray-200 transition-colors">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="font-semibold text-gray-900 mb-1">{post.title}</h4>
|
<h4 className="font-semibold text-gray-900 mb-1">{post.title}</h4>
|
||||||
|
|
||||||
|
{post.description && (
|
||||||
|
<p className="text-sm text-gray-600 mb-2 line-clamp-2">
|
||||||
|
{post.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-sm text-gray-600 mb-2">
|
<div className="flex items-center gap-3 text-sm text-gray-600 mb-2">
|
||||||
<span>{post.createdAt}</span>
|
<span>{formatDate(post.created_at)}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{post.duration}</span>
|
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||||
|
post.visibility === 'public'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-gray-100 text-gray-700'
|
||||||
|
}`}>
|
||||||
|
{post.visibility}
|
||||||
|
</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span className={post.isPrivate ? 'text-gray-500' : 'text-green-600'}>
|
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||||
{post.isPrivate ? 'Private' : 'Public'}
|
post.status === 'ready' ? 'bg-green-100 text-green-700' :
|
||||||
|
post.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{post.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
|
||||||
<span>👍 {post.likes} likes</span>
|
{post.language && (
|
||||||
<span>💬 {post.comments} comments</span>
|
<span className="inline-block text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded">
|
||||||
<span>🎧 {post.listens} listens</span>
|
{post.language.toUpperCase()}
|
||||||
</div>
|
|
||||||
{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>
|
</span>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete?.(post.id)}
|
onClick={() => handleView(post.post_id)}
|
||||||
className="text-red-500 hover:text-red-700 p-2 hover:bg-red-50 rounded transition-colors"
|
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"
|
title="Delete post"
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-600 text-center py-8">No posts yet. Create your first archive!</p>
|
<p className="text-gray-600 text-center py-8">
|
||||||
|
No posts yet. Create your first archive!
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Summary */}
|
{/* Summary Stats */}
|
||||||
|
{posts.length > 0 && (
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-4 text-center">
|
<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-2xl font-bold text-gray-900">{posts.length}</div>
|
||||||
<div className="text-sm text-gray-600">Total Posts</div>
|
<div className="text-sm text-gray-600">Total Archives</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-4 text-center">
|
<div className="bg-white rounded-lg border border-gray-200 p-4 text-center">
|
||||||
<div className="text-2xl font-bold text-gray-900">
|
<div className="text-2xl font-bold text-gray-900">
|
||||||
{userPosts?.reduce((sum, post) => sum + post.likes, 0) || 0}
|
{posts.filter(p => p.status === 'ready').length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600">Total Likes</div>
|
<div className="text-sm text-gray-600">Ready</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-4 text-center">
|
<div className="bg-white rounded-lg border border-gray-200 p-4 text-center">
|
||||||
<div className="text-2xl font-bold text-gray-900">
|
<div className="text-2xl font-bold text-gray-900">
|
||||||
{userPosts?.reduce((sum, post) => sum + post.listens, 0) || 0}
|
{posts.filter(p => p.visibility === 'public').length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600">Total Listens</div>
|
<div className="text-sm text-gray-600">Public</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{posts.length >= 20 && (
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="px-4 py-2 text-gray-700">Page {page}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,50 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { User, Lock, Bell, Globe, Trash2 } from 'lucide-react'
|
import { User, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
export default function Settings({ userSettings, onUpdate }) {
|
export default function Settings({ user, onUpdate }) {
|
||||||
const [settings, setSettings] = useState(userSettings || {
|
const [displayName, setDisplayName] = useState(user?.display_name || '')
|
||||||
notifications: {
|
const [bio, setBio] = useState(user?.bio || '')
|
||||||
newFollowers: true,
|
const [saving, setSaving] = useState(false)
|
||||||
comments: true,
|
const [message, setMessage] = useState(null)
|
||||||
likes: false,
|
|
||||||
mentions: true,
|
const handleSave = async () => {
|
||||||
},
|
setSaving(true)
|
||||||
privacy: {
|
setMessage(null)
|
||||||
profileVisibility: 'public',
|
|
||||||
showListeningHistory: true,
|
try {
|
||||||
allowComments: true,
|
// In a real app, you'd call an update user API
|
||||||
},
|
// For now, just simulate success
|
||||||
account: {
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
email: 'john.doe@email.com',
|
|
||||||
username: 'johndoe',
|
onUpdate?.({
|
||||||
}
|
...user,
|
||||||
|
display_name: displayName,
|
||||||
|
bio: bio
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleToggle = (category, setting) => {
|
setMessage({ type: 'success', text: 'Settings saved successfully!' })
|
||||||
setSettings(prev => ({
|
} catch (error) {
|
||||||
...prev,
|
setMessage({ type: 'error', text: 'Failed to save settings: ' + error.message })
|
||||||
[category]: {
|
} finally {
|
||||||
...prev[category],
|
setSaving(false)
|
||||||
[setting]: !prev[category][setting]
|
|
||||||
}
|
}
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto space-y-6">
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">Settings</h2>
|
<h2 className="text-2xl font-bold text-gray-900">Settings</h2>
|
||||||
|
|
||||||
{/* Account Settings */}
|
{message && (
|
||||||
|
<div className={`p-4 rounded-lg border ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'bg-green-50 border-green-200 text-green-800'
|
||||||
|
: 'bg-red-50 border-red-200 text-red-800'
|
||||||
|
}`}>
|
||||||
|
<p className="text-sm">{message.text}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Account Information */}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
<User size={20} />
|
<User size={20} />
|
||||||
@@ -43,176 +53,77 @@ export default function Settings({ userSettings, onUpdate }) {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-2">Username</label>
|
<label className="block text-sm font-medium text-gray-900 mb-2">
|
||||||
<input
|
Email
|
||||||
type="text"
|
</label>
|
||||||
value={settings.account.username}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-2">Email</label>
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={settings.account.email}
|
value={user?.email || ''}
|
||||||
|
disabled
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-600 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Email cannot be changed</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-900 mb-2">
|
||||||
|
Display Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="Your display name"
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-2">Bio</label>
|
<label className="block text-sm font-medium text-gray-900 mb-2">
|
||||||
|
Bio
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
value={bio}
|
||||||
|
onChange={(e) => setBio(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Tell us about yourself..."
|
placeholder="Tell us about yourself..."
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent resize-none"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Privacy Settings */}
|
{/* Account Created */}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
<Lock size={20} />
|
Account Details
|
||||||
Privacy
|
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex justify-between">
|
||||||
<div>
|
<span className="text-gray-600">Account Created</span>
|
||||||
<p className="font-medium text-gray-900">Profile Visibility</p>
|
<span className="font-medium text-gray-900">
|
||||||
<p className="text-sm text-gray-600">Who can see your profile</p>
|
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<select className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840]">
|
<div className="flex justify-between">
|
||||||
<option value="public">Public</option>
|
<span className="text-gray-600">Last Updated</span>
|
||||||
<option value="followers">Followers Only</option>
|
<span className="font-medium text-gray-900">
|
||||||
<option value="private">Private</option>
|
{user?.updated_at ? new Date(user.updated_at).toLocaleDateString() : 'N/A'}
|
||||||
</select>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Show Listening History</p>
|
|
||||||
<p className="text-sm text-gray-600">Allow others to see what you've listened to</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle('privacy', 'showListeningHistory')}
|
|
||||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
|
||||||
settings.privacy.showListeningHistory ? 'bg-[#f4b840]' : 'bg-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
|
||||||
settings.privacy.showListeningHistory ? 'translate-x-6' : 'translate-x-0'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Allow Comments</p>
|
|
||||||
<p className="text-sm text-gray-600">Let others comment on your posts</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle('privacy', 'allowComments')}
|
|
||||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
|
||||||
settings.privacy.allowComments ? 'bg-[#f4b840]' : 'bg-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
|
||||||
settings.privacy.allowComments ? 'translate-x-6' : 'translate-x-0'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notification 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">
|
|
||||||
<Bell size={20} />
|
|
||||||
Notifications
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,26 +135,34 @@ export default function Settings({ userSettings, onUpdate }) {
|
|||||||
Danger Zone
|
Danger Zone
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
<button className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
|
Once you delete your account, there is no going back. All your archives and data will be permanently deleted.
|
||||||
Export My Data
|
</p>
|
||||||
</button>
|
|
||||||
<button className="w-full px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors">
|
<button className="w-full px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors">
|
||||||
Delete Account
|
Delete Account
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setDisplayName(user?.display_name || '')
|
||||||
|
setBio(user?.bio || '')
|
||||||
|
setMessage(null)
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onUpdate?.(settings)}
|
onClick={handleSave}
|
||||||
className="flex-1 px-4 py-2 bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] rounded-lg font-medium transition-colors"
|
disabled={saving}
|
||||||
|
className="flex-1 px-4 py-2 bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Save Changes
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user