66
src/App.css
66
src/App.css
@@ -1,34 +1,22 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
@@ -36,3 +24,31 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Global styles for better consistency */
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
171
src/App.js
171
src/App.js
@@ -1,24 +1,161 @@
|
||||
import logo from './logo.svg';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Layout from './components/Layout/Layout';
|
||||
import HomePage from './pages/HomePage';
|
||||
import PublicTimelinePage from './pages/PublicTimelinePage';
|
||||
import LocalTimelinePage from './pages/LocalTimelinePage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import SearchPage from './pages/SearchPage';
|
||||
import NotificationsPage from './pages/NotificationsPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import StatusPage from './pages/StatusPage';
|
||||
import OAuthCallbackPage from './pages/OAuthCallbackPage';
|
||||
import api from './services/api';
|
||||
import oauthService from './services/oauth';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if this is an OAuth callback
|
||||
const currentPath = window.location.pathname;
|
||||
const hasOAuthParams = new URLSearchParams(window.location.search).has('code');
|
||||
|
||||
if (currentPath === '/oauth/callback' || hasOAuthParams) {
|
||||
setIsLoading(false); // ¡IMPORTANTE! Permitir que se renderizen las rutas
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is already logged in
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const instanceURL = localStorage.getItem('instance_url');
|
||||
const savedUser = localStorage.getItem('current_user');
|
||||
|
||||
if (token && instanceURL) {
|
||||
try {
|
||||
// Set up API client with existing credentials
|
||||
api.setAuth(token, instanceURL, localStorage.getItem('refresh_token'));
|
||||
|
||||
let userData;
|
||||
|
||||
// Try to use saved user data first, then verify with server
|
||||
if (savedUser) {
|
||||
userData = JSON.parse(savedUser);
|
||||
setUser(userData);
|
||||
|
||||
// Verify in background and update if needed
|
||||
try {
|
||||
const freshUserData = await api.verifyCredentials();
|
||||
if (JSON.stringify(freshUserData) !== savedUser) {
|
||||
localStorage.setItem('current_user', JSON.stringify(freshUserData));
|
||||
setUser(freshUserData);
|
||||
}
|
||||
} catch (error) {
|
||||
// Background verification failed, but keep existing user data
|
||||
}
|
||||
} else {
|
||||
// No saved user data, verify with server
|
||||
userData = await api.verifyCredentials();
|
||||
localStorage.setItem('current_user', JSON.stringify(userData));
|
||||
setUser(userData);
|
||||
}
|
||||
} catch (error) {
|
||||
api.clearAuth();
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const handleLoginSuccess = (userData) => {
|
||||
setUser(userData);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// Revoke the access token on the server
|
||||
if (api.token && api.instanceUrl) {
|
||||
await oauthService.revokeToken(api.token, api.instanceUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
// Token revocation failed, but continue with logout
|
||||
} finally {
|
||||
// Clear local auth data regardless
|
||||
api.clearAuth();
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: 'linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%)'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: '50px',
|
||||
height: '50px',
|
||||
border: '3px solid #e2e8f0',
|
||||
borderTop: '3px solid #667eea',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
margin: '0 auto 1rem'
|
||||
}}></div>
|
||||
<p>Loading GoToSocial...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<p>
|
||||
Edit <code>src/App.js</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn React
|
||||
</a>
|
||||
</header>
|
||||
</div>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
user ? <Navigate to="/" replace /> : <LoginPage />
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/oauth/callback"
|
||||
element={<OAuthCallbackPage onLoginSuccess={handleLoginSuccess} />}
|
||||
/>
|
||||
|
||||
<Route path="/*" element={
|
||||
<Layout user={user} onLogout={handleLogout}>
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
user ? <HomePage user={user} /> : <Navigate to="/login" replace />
|
||||
} />
|
||||
<Route path="/public" element={<PublicTimelinePage />} />
|
||||
<Route path="/local" element={<LocalTimelinePage />} />
|
||||
<Route path="/notifications" element={
|
||||
user ? <NotificationsPage user={user} /> : <Navigate to="/login" replace />
|
||||
} />
|
||||
<Route path="/profile/:username" element={<ProfilePage currentUser={user} />} />
|
||||
<Route path="/status/:id" element={<StatusPage currentUser={user} />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="*" element={
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<h2>Page Not Found</h2>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
</div>
|
||||
} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
336
src/components/Compose/ComposeBox.js
Archivo normal
336
src/components/Compose/ComposeBox.js
Archivo normal
@@ -0,0 +1,336 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { FaImage, FaSmile, FaSpinner } from 'react-icons/fa';
|
||||
import EmojiPicker from './EmojiPicker';
|
||||
import MediaPreview from './MediaPreview';
|
||||
import api from '../../services/api';
|
||||
|
||||
const ComposeContainer = styled.div`
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 2rem;
|
||||
`;
|
||||
|
||||
const Avatar = styled.img`
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
margin-right: 1rem;
|
||||
`;
|
||||
|
||||
const ComposeHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
|
||||
const ComposeMain = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const TextArea = styled.textarea`
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
border: none;
|
||||
resize: vertical;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
`;
|
||||
|
||||
const ComposeFooter = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
`;
|
||||
|
||||
const ComposeActions = styled.div`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ActionButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
`;
|
||||
|
||||
const PrivacySelector = styled.select`
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const PostButton = styled.button`
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 0.75rem 2rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const CharacterCount = styled.div`
|
||||
color: ${props => props.remaining < 20 ? '#ef4444' : '#6b7280'};
|
||||
font-size: 0.9rem;
|
||||
margin-left: 1rem;
|
||||
`;
|
||||
|
||||
const ComposeBox = ({ user, onPost, placeholder = "What's happening?" }) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [privacy, setPrivacy] = useState('public');
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [media, setMedia] = useState([]);
|
||||
const [isUploadingMedia, setIsUploadingMedia] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const fileInputRef = useRef();
|
||||
const textAreaRef = useRef();
|
||||
|
||||
const maxLength = 500;
|
||||
const remaining = maxLength - content.length;
|
||||
const maxMedia = 4;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!content.trim() || isPosting || remaining < 0) return;
|
||||
|
||||
setIsPosting(true);
|
||||
try {
|
||||
const postData = {
|
||||
status: content,
|
||||
visibility: privacy
|
||||
};
|
||||
|
||||
// Añadir IDs de medios si los hay
|
||||
if (media.length > 0) {
|
||||
postData.media_ids = media.map(m => m.id);
|
||||
}
|
||||
|
||||
await onPost(postData);
|
||||
|
||||
// Limpiar el formulario
|
||||
setContent('');
|
||||
setMedia([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to post:', error);
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async (event) => {
|
||||
const files = Array.from(event.target.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Verificar límite de archivos
|
||||
if (media.length + files.length > maxMedia) {
|
||||
alert(`You can only upload up to ${maxMedia} files per post.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingMedia(true);
|
||||
|
||||
try {
|
||||
const uploadPromises = files.map(async (file) => {
|
||||
// Verificar tipo de archivo
|
||||
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
|
||||
throw new Error(`Unsupported file type: ${file.type}`);
|
||||
}
|
||||
|
||||
// Verificar tamaño (ej: 10MB max)
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new Error(`File too large: ${file.name}. Maximum size is 10MB.`);
|
||||
}
|
||||
|
||||
const uploadedMedia = await api.uploadMedia(file);
|
||||
return {
|
||||
...uploadedMedia,
|
||||
file // Mantener referencia del archivo para preview
|
||||
};
|
||||
});
|
||||
|
||||
const uploadedMedia = await Promise.all(uploadPromises);
|
||||
setMedia(prev => [...prev, ...uploadedMedia]);
|
||||
} catch (error) {
|
||||
console.error('Media upload failed:', error);
|
||||
alert(error.message || 'Failed to upload media. Please try again.');
|
||||
} finally {
|
||||
setIsUploadingMedia(false);
|
||||
// Limpiar el input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMedia = (mediaId) => {
|
||||
setMedia(prev => prev.filter(m => m.id !== mediaId));
|
||||
};
|
||||
|
||||
const handleUpdateMediaAlt = async (mediaId, description) => {
|
||||
try {
|
||||
await api.updateMedia(mediaId, description);
|
||||
setMedia(prev => prev.map(m =>
|
||||
m.id === mediaId ? { ...m, description } : m
|
||||
));
|
||||
} catch (error) {
|
||||
console.error('Failed to update media description:', error);
|
||||
alert('Failed to update alt text. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
const textarea = textAreaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const newContent = content.slice(0, start) + emoji + content.slice(end);
|
||||
|
||||
setContent(newContent);
|
||||
|
||||
// Restaurar posición del cursor
|
||||
setTimeout(() => {
|
||||
const newCursorPos = start + emoji.length;
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleImageButtonClick = () => {
|
||||
if (isUploadingMedia) return;
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleEmojiButtonClick = () => {
|
||||
setShowEmojiPicker(!showEmojiPicker);
|
||||
};
|
||||
|
||||
return (
|
||||
<ComposeContainer>
|
||||
<ComposeHeader>
|
||||
{user?.avatar && (
|
||||
<Avatar src={user.avatar} alt={user.display_name || user.username} />
|
||||
)}
|
||||
<ComposeMain>
|
||||
<TextArea
|
||||
ref={textAreaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
|
||||
<MediaPreview
|
||||
media={media}
|
||||
onRemove={handleRemoveMedia}
|
||||
onUpdateAltText={handleUpdateMediaAlt}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</ComposeMain>
|
||||
</ComposeHeader>
|
||||
|
||||
<ComposeFooter>
|
||||
<ComposeActions>
|
||||
<ActionButton
|
||||
onClick={handleImageButtonClick}
|
||||
disabled={isUploadingMedia || media.length >= maxMedia}
|
||||
title={`Add image or video (${media.length}/${maxMedia})`}
|
||||
>
|
||||
{isUploadingMedia ? (
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite' }} />
|
||||
) : (
|
||||
<FaImage />
|
||||
)}
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
onClick={handleEmojiButtonClick}
|
||||
title="Add emoji"
|
||||
style={{
|
||||
background: showEmojiPicker ? '#f3f4f6' : 'none',
|
||||
color: showEmojiPicker ? '#374151' : '#6b7280'
|
||||
}}
|
||||
>
|
||||
<FaSmile />
|
||||
</ActionButton>
|
||||
|
||||
<PrivacySelector
|
||||
value={privacy}
|
||||
onChange={(e) => setPrivacy(e.target.value)}
|
||||
title="Post visibility"
|
||||
>
|
||||
<option value="public">🌐 Public</option>
|
||||
<option value="unlisted">🔓 Unlisted</option>
|
||||
<option value="private">👥 Followers only</option>
|
||||
<option value="direct">✉️ Direct</option>
|
||||
</PrivacySelector>
|
||||
</ComposeActions>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CharacterCount remaining={remaining}>
|
||||
{remaining}
|
||||
</CharacterCount>
|
||||
<PostButton
|
||||
onClick={handleSubmit}
|
||||
disabled={!content.trim() || remaining < 0 || isPosting}
|
||||
>
|
||||
{isPosting ? 'Posting...' : 'Post'}
|
||||
</PostButton>
|
||||
</div>
|
||||
</ComposeFooter>
|
||||
|
||||
{showEmojiPicker && (
|
||||
<EmojiPicker
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
onClose={() => setShowEmojiPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</ComposeContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComposeBox;
|
||||
163
src/components/Compose/EmojiPicker.js
Archivo normal
163
src/components/Compose/EmojiPicker.js
Archivo normal
@@ -0,0 +1,163 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
|
||||
const EmojiPickerOverlay = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
const EmojiPickerContainer = styled.div`
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
max-height: 500px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
`;
|
||||
|
||||
const EmojiPickerHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
`;
|
||||
|
||||
const CloseButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
color: #6b7280;
|
||||
|
||||
&:hover {
|
||||
color: #374151;
|
||||
}
|
||||
`;
|
||||
|
||||
const EmojiGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 0.5rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const EmojiButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
`;
|
||||
|
||||
const EmojiSearch = styled.input`
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
`;
|
||||
|
||||
// Lista básica de emojis comunes
|
||||
const commonEmojis = [
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣',
|
||||
'😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰',
|
||||
'😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜',
|
||||
'🤪', '🤨', '🧐', '🤓', '😎', '🤩', '🥳', '😏',
|
||||
'😒', '😞', '😔', '😟', '😕', '🙁', '☹️', '😣',
|
||||
'😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠',
|
||||
'😡', '🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨',
|
||||
'😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤐',
|
||||
'🤢', '🤮', '🤧', '😷', '🤒', '🤕', '🤑', '🤠',
|
||||
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙',
|
||||
'👈', '👉', '👆', '👇', '☝️', '✋', '🤚', '🖐',
|
||||
'🖖', '👋', '🤏', '💪', '🦾', '🖕', '✍️', '🙏',
|
||||
'🦶', '🦵', '👂', '🦻', '👃', '🧠', '🦷', '🦴',
|
||||
'👀', '👁', '👅', '👄', '💋', '🩸', '👶', '🧒',
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
|
||||
'🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖',
|
||||
'💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉', '☸️',
|
||||
'🔥', '⭐', '🌟', '✨', '⚡', '☄️', '💫', '🌙',
|
||||
'☀️', '🌤', '⛅', '🌦', '🌧', '⛈', '🌩', '🌨',
|
||||
'❄️', '☃️', '⛄', '🌬', '💨', '💧', '💦', '☔'
|
||||
];
|
||||
|
||||
const EmojiPicker = ({ onEmojiSelect, onClose }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const pickerRef = useRef();
|
||||
|
||||
const filteredEmojis = commonEmojis.filter(emoji =>
|
||||
searchTerm === '' || emoji.includes(searchTerm)
|
||||
);
|
||||
|
||||
const handleEmojiClick = (emoji) => {
|
||||
onEmojiSelect(emoji);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<EmojiPickerOverlay onClick={handleOverlayClick}>
|
||||
<EmojiPickerContainer ref={pickerRef}>
|
||||
<EmojiPickerHeader>
|
||||
<h3>Select Emoji</h3>
|
||||
<CloseButton onClick={onClose}>
|
||||
<FaTimes />
|
||||
</CloseButton>
|
||||
</EmojiPickerHeader>
|
||||
|
||||
<EmojiSearch
|
||||
type="text"
|
||||
placeholder="Search emojis..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
<EmojiGrid>
|
||||
{filteredEmojis.map((emoji, index) => (
|
||||
<EmojiButton
|
||||
key={index}
|
||||
onClick={() => handleEmojiClick(emoji)}
|
||||
title={emoji}
|
||||
>
|
||||
{emoji}
|
||||
</EmojiButton>
|
||||
))}
|
||||
</EmojiGrid>
|
||||
</EmojiPickerContainer>
|
||||
</EmojiPickerOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPicker;
|
||||
237
src/components/Compose/MediaPreview.js
Archivo normal
237
src/components/Compose/MediaPreview.js
Archivo normal
@@ -0,0 +1,237 @@
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { FaTimes, FaEdit } from 'react-icons/fa';
|
||||
|
||||
const MediaPreviewContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
`;
|
||||
|
||||
const MediaItem = styled.div`
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
max-width: 200px;
|
||||
`;
|
||||
|
||||
const MediaImage = styled.img`
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 150px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const MediaVideo = styled.video`
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 150px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const MediaControls = styled.div`
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
`;
|
||||
|
||||
const MediaButton = styled.button`
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
`;
|
||||
|
||||
const AltTextOverlay = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
const AltTextDialog = styled.div`
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
`;
|
||||
|
||||
const AltTextInput = styled.textarea`
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
margin: 1rem 0;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
`;
|
||||
|
||||
const AltTextButtons = styled.div`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
||||
&.primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: white;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const MediaPreview = ({ media, onRemove, onUpdateAltText }) => {
|
||||
const [editingAlt, setEditingAlt] = useState(null);
|
||||
const [altText, setAltText] = useState('');
|
||||
|
||||
const handleEditAlt = (mediaItem) => {
|
||||
setAltText(mediaItem.description || '');
|
||||
setEditingAlt(mediaItem.id);
|
||||
};
|
||||
|
||||
const handleSaveAlt = () => {
|
||||
if (editingAlt) {
|
||||
onUpdateAltText(editingAlt, altText);
|
||||
setEditingAlt(null);
|
||||
setAltText('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelAlt = () => {
|
||||
setEditingAlt(null);
|
||||
setAltText('');
|
||||
};
|
||||
|
||||
const getMediaType = (attachment) => {
|
||||
if (attachment.type) return attachment.type;
|
||||
if (attachment.file && attachment.file.type) {
|
||||
if (attachment.file.type.startsWith('image/')) return 'image';
|
||||
if (attachment.file.type.startsWith('video/')) return 'video';
|
||||
}
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
const getMediaUrl = (attachment) => {
|
||||
if (attachment.preview_url) return attachment.preview_url;
|
||||
if (attachment.url) return attachment.url;
|
||||
if (attachment.file) return URL.createObjectURL(attachment.file);
|
||||
return '';
|
||||
};
|
||||
|
||||
if (!media || media.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MediaPreviewContainer>
|
||||
{media.map((attachment) => {
|
||||
const mediaType = getMediaType(attachment);
|
||||
const mediaUrl = getMediaUrl(attachment);
|
||||
|
||||
return (
|
||||
<MediaItem key={attachment.id}>
|
||||
{mediaType === 'image' && (
|
||||
<MediaImage src={mediaUrl} alt={attachment.description || 'Uploaded image'} />
|
||||
)}
|
||||
{mediaType === 'video' && (
|
||||
<MediaVideo controls>
|
||||
<source src={mediaUrl} />
|
||||
</MediaVideo>
|
||||
)}
|
||||
|
||||
<MediaControls>
|
||||
<MediaButton
|
||||
onClick={() => handleEditAlt(attachment)}
|
||||
title="Add alt text"
|
||||
>
|
||||
<FaEdit size={12} />
|
||||
</MediaButton>
|
||||
<MediaButton
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
title="Remove media"
|
||||
>
|
||||
<FaTimes size={12} />
|
||||
</MediaButton>
|
||||
</MediaControls>
|
||||
</MediaItem>
|
||||
);
|
||||
})}
|
||||
</MediaPreviewContainer>
|
||||
|
||||
{editingAlt && (
|
||||
<AltTextOverlay onClick={handleCancelAlt}>
|
||||
<AltTextDialog onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ margin: '0 0 1rem 0' }}>Add Alt Text</h3>
|
||||
<p style={{ margin: '0 0 1rem 0', color: '#6b7280', fontSize: '0.9rem' }}>
|
||||
Describe this image for people who are blind or have low vision.
|
||||
</p>
|
||||
<AltTextInput
|
||||
value={altText}
|
||||
onChange={(e) => setAltText(e.target.value)}
|
||||
placeholder="Describe what's in this image..."
|
||||
maxLength={1000}
|
||||
/>
|
||||
<AltTextButtons>
|
||||
<Button type="button" className="secondary" onClick={handleCancelAlt}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" className="primary" onClick={handleSaveAlt}>
|
||||
Save
|
||||
</Button>
|
||||
</AltTextButtons>
|
||||
</AltTextDialog>
|
||||
</AltTextOverlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaPreview;
|
||||
172
src/components/Layout/Header.js
Archivo normal
172
src/components/Layout/Header.js
Archivo normal
@@ -0,0 +1,172 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { FaHome, FaGlobe, FaUsers, FaBell, FaUser, FaSignOutAlt, FaSearch } from 'react-icons/fa';
|
||||
|
||||
const HeaderContainer = styled.header`
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
`;
|
||||
|
||||
const HeaderContent = styled.div`
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const Logo = styled(Link)`
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Nav = styled.nav`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const NavItem = styled(Link)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
`;
|
||||
|
||||
const UserSection = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
const SearchBar = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
input {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
outline: none;
|
||||
width: 200px;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = ({ user, onLogout }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearch = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const query = e.target.value.trim();
|
||||
if (query) {
|
||||
navigate(`/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<HeaderContainer>
|
||||
<HeaderContent>
|
||||
<Logo to="/">
|
||||
<FaUsers />
|
||||
GoToSocial
|
||||
</Logo>
|
||||
|
||||
<Nav>
|
||||
<NavItem to="/">
|
||||
<FaHome />
|
||||
<span>Home</span>
|
||||
</NavItem>
|
||||
<NavItem to="/public">
|
||||
<FaGlobe />
|
||||
<span>Public</span>
|
||||
</NavItem>
|
||||
<NavItem to="/local">
|
||||
<FaUsers />
|
||||
<span>Local</span>
|
||||
</NavItem>
|
||||
{user && (
|
||||
<NavItem to="/notifications">
|
||||
<FaBell />
|
||||
<span>Notifications</span>
|
||||
</NavItem>
|
||||
)}
|
||||
</Nav>
|
||||
|
||||
<UserSection>
|
||||
<SearchBar>
|
||||
<FaSearch style={{ marginRight: '0.5rem' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
onKeyPress={handleSearch}
|
||||
/>
|
||||
</SearchBar>
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
<NavItem to={`/profile/${user.username}`}>
|
||||
<FaUser />
|
||||
<span>{user.display_name || user.username}</span>
|
||||
</NavItem>
|
||||
<NavItem as="button" onClick={onLogout} style={{ background: 'none', border: 'none', cursor: 'pointer' }}>
|
||||
<FaSignOutAlt />
|
||||
</NavItem>
|
||||
</>
|
||||
) : (
|
||||
<NavItem to="/login">
|
||||
Login
|
||||
</NavItem>
|
||||
)}
|
||||
</UserSection>
|
||||
</HeaderContent>
|
||||
</HeaderContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
141
src/components/Layout/Layout.js
Archivo normal
141
src/components/Layout/Layout.js
Archivo normal
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Header from './Header';
|
||||
|
||||
const LayoutContainer = styled.div`
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
`;
|
||||
|
||||
const MainContent = styled.main`
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 3fr 1fr;
|
||||
gap: 2rem;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const LeftSidebar = styled.aside`
|
||||
@media (max-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const CenterColumn = styled.div`
|
||||
min-height: 400px;
|
||||
`;
|
||||
|
||||
const RightSidebar = styled.aside`
|
||||
@media (max-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarCard = styled.div`
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
|
||||
const SidebarTitle = styled.h3`
|
||||
margin: 0 0 1rem 0;
|
||||
color: #2d3748;
|
||||
font-size: 1.1rem;
|
||||
`;
|
||||
|
||||
const Layout = ({ children, user, onLogout }) => {
|
||||
return (
|
||||
<LayoutContainer>
|
||||
<Header user={user} onLogout={onLogout} />
|
||||
|
||||
<MainContent>
|
||||
<LeftSidebar>
|
||||
<SidebarCard>
|
||||
<SidebarTitle>Navigation</SidebarTitle>
|
||||
<div>
|
||||
<p>Quick access to different timelines and features.</p>
|
||||
</div>
|
||||
</SidebarCard>
|
||||
|
||||
{user && (
|
||||
<SidebarCard>
|
||||
<SidebarTitle>Your Profile</SidebarTitle>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{user.avatar && (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt="Avatar"
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
marginBottom: '0.5rem'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<p><strong>{user.display_name || user.username}</strong></p>
|
||||
<p style={{ fontSize: '0.9rem', color: '#666' }}>@{user.username}</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-around', marginTop: '1rem' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<strong>{user.followers_count || 0}</strong>
|
||||
<div style={{ fontSize: '0.8rem', color: '#666' }}>Followers</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<strong>{user.following_count || 0}</strong>
|
||||
<div style={{ fontSize: '0.8rem', color: '#666' }}>Following</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<strong>{user.statuses_count || 0}</strong>
|
||||
<div style={{ fontSize: '0.8rem', color: '#666' }}>Posts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarCard>
|
||||
)}
|
||||
</LeftSidebar>
|
||||
|
||||
<CenterColumn>
|
||||
{children}
|
||||
</CenterColumn>
|
||||
|
||||
<RightSidebar>
|
||||
<SidebarCard>
|
||||
<SidebarTitle>About GoToSocial</SidebarTitle>
|
||||
<p style={{ fontSize: '0.9rem', color: '#666', lineHeight: '1.5' }}>
|
||||
A fast, fun, and small ActivityPub social network server.
|
||||
Connect with friends and discover new communities!
|
||||
</p>
|
||||
</SidebarCard>
|
||||
|
||||
<SidebarCard>
|
||||
<SidebarTitle>Trending</SidebarTitle>
|
||||
<div>
|
||||
<div style={{ padding: '0.5rem 0', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<span style={{ color: '#667eea', fontWeight: 'bold' }}>#opensource</span>
|
||||
<div style={{ fontSize: '0.8rem', color: '#666' }}>42 posts</div>
|
||||
</div>
|
||||
<div style={{ padding: '0.5rem 0', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<span style={{ color: '#667eea', fontWeight: 'bold' }}>#federation</span>
|
||||
<div style={{ fontSize: '0.8rem', color: '#666' }}>28 posts</div>
|
||||
</div>
|
||||
<div style={{ padding: '0.5rem 0' }}>
|
||||
<span style={{ color: '#667eea', fontWeight: 'bold' }}>#mastodon</span>
|
||||
<div style={{ fontSize: '0.8rem', color: '#666' }}>15 posts</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarCard>
|
||||
</RightSidebar>
|
||||
</MainContent>
|
||||
</LayoutContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
292
src/components/Post/Post.js
Archivo normal
292
src/components/Post/Post.js
Archivo normal
@@ -0,0 +1,292 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { FaHeart, FaRetweet, FaReply, FaShare, FaClock, FaGlobeAmericas, FaLock, FaUsers, FaEnvelope } from 'react-icons/fa';
|
||||
|
||||
const PostContainer = styled.article`
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`;
|
||||
|
||||
const PostHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
|
||||
const Avatar = styled.img`
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
margin-right: 1rem;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const PostInfo = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const UserInfo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
`;
|
||||
|
||||
const DisplayName = styled.span`
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const Username = styled.span`
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
`;
|
||||
|
||||
const Timestamp = styled.span`
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
`;
|
||||
|
||||
const PostContent = styled.div`
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
p {
|
||||
margin: 0 0 1rem 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const MediaContainer = styled.div`
|
||||
margin: 1rem 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const PostActions = styled.div`
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
`;
|
||||
|
||||
const ActionButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
&.boosted {
|
||||
color: #10b981;
|
||||
}
|
||||
`;
|
||||
|
||||
const VisibilityIcon = styled.span`
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
margin-left: 0.5rem;
|
||||
`;
|
||||
|
||||
const ReblogInfo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
|
||||
svg {
|
||||
color: #10b981;
|
||||
}
|
||||
`;
|
||||
|
||||
const Post = ({ post, onFavorite, onReblog, onReply, currentUser }) => {
|
||||
const isReblog = post.reblog;
|
||||
const actualPost = isReblog ? post.reblog : post;
|
||||
const originalPoster = isReblog ? post.account : null;
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInHours = (now - date) / (1000 * 60 * 60);
|
||||
|
||||
if (diffInHours < 1) {
|
||||
const minutes = Math.floor(diffInHours * 60);
|
||||
return `${minutes}m`;
|
||||
} else if (diffInHours < 24) {
|
||||
return `${Math.floor(diffInHours)}h`;
|
||||
} else {
|
||||
const days = Math.floor(diffInHours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
};
|
||||
|
||||
const getVisibilityIcon = (visibility) => {
|
||||
switch (visibility) {
|
||||
case 'public': return <FaGlobeAmericas title="Public" />;
|
||||
case 'unlisted': return <FaLock title="Unlisted" />;
|
||||
case 'private': return <FaUsers title="Followers only" />;
|
||||
case 'direct': return <FaEnvelope title="Direct message" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = (content) => {
|
||||
// Basic HTML content rendering
|
||||
return { __html: content };
|
||||
};
|
||||
|
||||
return (
|
||||
<PostContainer>
|
||||
{isReblog && (
|
||||
<ReblogInfo>
|
||||
<FaRetweet />
|
||||
<span>{originalPoster.display_name || originalPoster.username} boosted</span>
|
||||
</ReblogInfo>
|
||||
)}
|
||||
|
||||
<PostHeader>
|
||||
<Avatar
|
||||
src={actualPost.account.avatar || '/default-avatar.png'}
|
||||
alt={actualPost.account.display_name || actualPost.account.username}
|
||||
/>
|
||||
<PostInfo>
|
||||
<UserInfo>
|
||||
<DisplayName>
|
||||
{actualPost.account.display_name || actualPost.account.username}
|
||||
</DisplayName>
|
||||
<Username>
|
||||
@{actualPost.account.username}
|
||||
</Username>
|
||||
<Timestamp>
|
||||
<FaClock />
|
||||
{formatTimestamp(actualPost.created_at)}
|
||||
</Timestamp>
|
||||
<VisibilityIcon>
|
||||
{getVisibilityIcon(actualPost.visibility)}
|
||||
</VisibilityIcon>
|
||||
</UserInfo>
|
||||
</PostInfo>
|
||||
</PostHeader>
|
||||
|
||||
{actualPost.spoiler_text && (
|
||||
<div style={{
|
||||
background: '#fef3cd',
|
||||
border: '1px solid #fbbf24',
|
||||
borderRadius: '6px',
|
||||
padding: '0.5rem',
|
||||
marginBottom: '1rem',
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
<strong>Content Warning:</strong> {actualPost.spoiler_text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PostContent dangerouslySetInnerHTML={renderContent(actualPost.content)} />
|
||||
|
||||
{actualPost.media_attachments && actualPost.media_attachments.length > 0 && (
|
||||
<MediaContainer>
|
||||
{actualPost.media_attachments.map((media, index) => (
|
||||
<div key={media.id || index}>
|
||||
{media.type === 'image' && (
|
||||
<img src={media.url} alt={media.description || 'Media attachment'} />
|
||||
)}
|
||||
{media.type === 'video' && (
|
||||
<video controls>
|
||||
<source src={media.url} type={media.mime_type || 'video/mp4'} />
|
||||
</video>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</MediaContainer>
|
||||
)}
|
||||
|
||||
<PostActions>
|
||||
<ActionButton onClick={() => onReply?.(actualPost)}>
|
||||
<FaReply />
|
||||
<span>{actualPost.replies_count || 0}</span>
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
onClick={() => onReblog?.(actualPost)}
|
||||
className={actualPost.reblogged ? 'boosted' : ''}
|
||||
>
|
||||
<FaRetweet />
|
||||
<span>{actualPost.reblogs_count || 0}</span>
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
onClick={() => onFavorite?.(actualPost)}
|
||||
className={actualPost.favourited ? 'active' : ''}
|
||||
>
|
||||
<FaHeart />
|
||||
<span>{actualPost.favourites_count || 0}</span>
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton onClick={() => navigator.share?.({ url: actualPost.url })}>
|
||||
<FaShare />
|
||||
</ActionButton>
|
||||
</PostActions>
|
||||
</PostContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Post;
|
||||
243
src/pages/HomePage.js
Archivo normal
243
src/pages/HomePage.js
Archivo normal
@@ -0,0 +1,243 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import ComposeBox from '../components/Compose/ComposeBox';
|
||||
import Post from '../components/Post/Post';
|
||||
import api from '../services/api';
|
||||
import { FaSpinner, FaExclamationTriangle } from 'react-icons/fa';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const PageTitle = styled.h1`
|
||||
color: #1f2937;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.8rem;
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #dc2626;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const LoadMoreButton = styled.button`
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
margin: 1rem 0;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const HomePage = ({ user }) => {
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const loadTimeline = useCallback(async (maxId = null) => {
|
||||
try {
|
||||
if (!maxId) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
let timelineData;
|
||||
|
||||
if (user) {
|
||||
// Load home timeline for authenticated users
|
||||
timelineData = await api.getHomeTimeline({
|
||||
max_id: maxId,
|
||||
limit: 20
|
||||
});
|
||||
} else {
|
||||
// For unauthenticated users, redirect to login
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
if (maxId) {
|
||||
setPosts(prev => [...prev, ...timelineData]);
|
||||
setLoadingMore(false);
|
||||
} else {
|
||||
setPosts(timelineData);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setHasMore(timelineData.length === 20);
|
||||
} catch (err) {
|
||||
console.error('Error loading timeline:', err);
|
||||
|
||||
if (err.response?.status === 401) {
|
||||
setError('Your session has expired. Please log in again.');
|
||||
} else if (err.response?.status === 403) {
|
||||
setError('You don\'t have permission to view this timeline.');
|
||||
} else if (err.code === 'ECONNABORTED') {
|
||||
setError('Request timeout. Please check your connection and try again.');
|
||||
} else {
|
||||
setError('Failed to load timeline. Please try again.');
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTimeline();
|
||||
}, [loadTimeline]);
|
||||
|
||||
const handleCreatePost = async (postData) => {
|
||||
try {
|
||||
const newPost = await api.createStatus(postData);
|
||||
setPosts(prev => [newPost, ...prev]);
|
||||
} catch (err) {
|
||||
console.error('Error creating post:', err);
|
||||
throw new Error('Failed to create post. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFavorite = async (post) => {
|
||||
try {
|
||||
let updatedPost;
|
||||
if (post.favourited) {
|
||||
updatedPost = await api.unfavoriteStatus(post.id);
|
||||
} else {
|
||||
updatedPost = await api.favoriteStatus(post.id);
|
||||
}
|
||||
|
||||
setPosts(prev => prev.map(p => p.id === post.id ? updatedPost : p));
|
||||
} catch (err) {
|
||||
console.error('Error toggling favorite:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReblog = async (post) => {
|
||||
try {
|
||||
let updatedPost;
|
||||
if (post.reblogged) {
|
||||
updatedPost = await api.unreblogStatus(post.id);
|
||||
} else {
|
||||
updatedPost = await api.reblogStatus(post.id);
|
||||
}
|
||||
|
||||
setPosts(prev => prev.map(p => p.id === post.id ? updatedPost : p));
|
||||
} catch (err) {
|
||||
console.error('Error toggling reblog:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (posts.length > 0 && hasMore && !loadingMore) {
|
||||
setLoadingMore(true);
|
||||
const lastPost = posts[posts.length - 1];
|
||||
loadTimeline(lastPost.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<LoadingContainer>
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '2rem', marginBottom: '1rem' }} />
|
||||
<div>Loading timeline...</div>
|
||||
</LoadingContainer>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageTitle>
|
||||
{user ? 'Home Timeline' : 'Public Timeline'}
|
||||
</PageTitle>
|
||||
|
||||
{user && (
|
||||
<ComposeBox
|
||||
user={user}
|
||||
onPost={handleCreatePost}
|
||||
placeholder="What's on your mind?"
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ErrorContainer>
|
||||
<FaExclamationTriangle />
|
||||
{error}
|
||||
</ErrorContainer>
|
||||
)}
|
||||
|
||||
{posts.length === 0 && !loading ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '3rem',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
<p style={{ fontSize: '1.2rem' }}>No posts to show</p>
|
||||
<p>
|
||||
{user
|
||||
? "Follow some accounts to see posts in your timeline!"
|
||||
: "The public timeline is empty. Try again later."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{posts.map(post => (
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
currentUser={user}
|
||||
onFavorite={handleFavorite}
|
||||
onReblog={handleReblog}
|
||||
onReply={(post) => console.log('Reply to:', post)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<LoadMoreButton
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite', marginRight: '0.5rem' }} />
|
||||
Loading more posts...
|
||||
</>
|
||||
) : (
|
||||
'Load more posts'
|
||||
)}
|
||||
</LoadMoreButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
264
src/pages/LocalTimelinePage.js
Archivo normal
264
src/pages/LocalTimelinePage.js
Archivo normal
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Post from '../components/Post/Post';
|
||||
import api from '../services/api';
|
||||
import { FaSpinner, FaExclamationTriangle, FaUsers } from 'react-icons/fa';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const PageHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
`;
|
||||
|
||||
const PageTitle = styled.h1`
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const PageDescription = styled.p`
|
||||
color: #6b7280;
|
||||
margin: 0.5rem 0 0 0;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #dc2626;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const LoadMoreButton = styled.button`
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
margin: 1rem 0;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
|
||||
.icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const LocalTimelinePage = () => {
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadTimeline();
|
||||
}, []);
|
||||
|
||||
const loadTimeline = async (maxId = null) => {
|
||||
try {
|
||||
if (!maxId) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const timelineData = await api.getPublicTimeline({
|
||||
max_id: maxId,
|
||||
limit: 20,
|
||||
local: true // Only local content
|
||||
});
|
||||
|
||||
if (maxId) {
|
||||
setPosts(prev => [...prev, ...timelineData]);
|
||||
setLoadingMore(false);
|
||||
} else {
|
||||
setPosts(timelineData);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setHasMore(timelineData.length === 20);
|
||||
} catch (err) {
|
||||
console.error('Error loading local timeline:', err);
|
||||
|
||||
if (err.response?.status === 401) {
|
||||
setError('Authentication required to view the local timeline.');
|
||||
} else if (err.response?.status === 403) {
|
||||
setError('Local timeline is not available on this instance.');
|
||||
} else if (err.code === 'ECONNABORTED') {
|
||||
setError('Request timeout. Please check your connection and try again.');
|
||||
} else {
|
||||
setError('Failed to load local timeline. Please try again.');
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFavorite = async (post) => {
|
||||
try {
|
||||
let updatedPost;
|
||||
if (post.favourited) {
|
||||
updatedPost = await api.unfavoriteStatus(post.id);
|
||||
} else {
|
||||
updatedPost = await api.favoriteStatus(post.id);
|
||||
}
|
||||
|
||||
setPosts(prev => prev.map(p => p.id === post.id ? updatedPost : p));
|
||||
} catch (err) {
|
||||
console.error('Error toggling favorite:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReblog = async (post) => {
|
||||
try {
|
||||
let updatedPost;
|
||||
if (post.reblogged) {
|
||||
updatedPost = await api.unreblogStatus(post.id);
|
||||
} else {
|
||||
updatedPost = await api.reblogStatus(post.id);
|
||||
}
|
||||
|
||||
setPosts(prev => prev.map(p => p.id === post.id ? updatedPost : p));
|
||||
} catch (err) {
|
||||
console.error('Error toggling reblog:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (posts.length > 0 && hasMore && !loadingMore) {
|
||||
setLoadingMore(true);
|
||||
const lastPost = posts[posts.length - 1];
|
||||
loadTimeline(lastPost.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitle>
|
||||
<FaUsers />
|
||||
Local Timeline
|
||||
</PageTitle>
|
||||
</PageHeader>
|
||||
<LoadingContainer>
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '2rem', marginBottom: '1rem' }} />
|
||||
<div>Loading local timeline...</div>
|
||||
</LoadingContainer>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitle>
|
||||
<FaUsers />
|
||||
Local Timeline
|
||||
</PageTitle>
|
||||
</PageHeader>
|
||||
|
||||
<PageDescription>
|
||||
Public posts from users on this instance only.
|
||||
This is a great way to see what your local community is talking about.
|
||||
</PageDescription>
|
||||
|
||||
{error && (
|
||||
<ErrorContainer>
|
||||
<FaExclamationTriangle />
|
||||
{error}
|
||||
</ErrorContainer>
|
||||
)}
|
||||
|
||||
{posts.length === 0 && !loading ? (
|
||||
<EmptyState>
|
||||
<div className="icon">
|
||||
<FaUsers />
|
||||
</div>
|
||||
<h3>No local posts</h3>
|
||||
<p>
|
||||
There are no local public posts available right now.<br />
|
||||
This instance might be new or have limited local activity.
|
||||
</p>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
{posts.map(post => (
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
onFavorite={handleFavorite}
|
||||
onReblog={handleReblog}
|
||||
onReply={(post) => console.log('Reply to:', post)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<LoadMoreButton
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite', marginRight: '0.5rem' }} />
|
||||
Loading more posts...
|
||||
</>
|
||||
) : (
|
||||
'Load more posts'
|
||||
)}
|
||||
</LoadMoreButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocalTimelinePage;
|
||||
254
src/pages/LoginPage.js
Archivo normal
254
src/pages/LoginPage.js
Archivo normal
@@ -0,0 +1,254 @@
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import axios from 'axios';
|
||||
import { FaServer, FaSignInAlt } from 'react-icons/fa';
|
||||
import oauthService from '../services/oauth';
|
||||
|
||||
const LoginContainer = styled.div`
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
`;
|
||||
|
||||
const LoginCard = styled.div`
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 3rem;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
`;
|
||||
|
||||
const Logo = styled.div`
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
color: #667eea;
|
||||
font-size: 2.5rem;
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Form = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
`;
|
||||
|
||||
const InputGroup = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
`;
|
||||
|
||||
const InputIcon = styled.div`
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #9ca3af;
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.div`
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #dc2626;
|
||||
font-size: 0.9rem;
|
||||
`;
|
||||
|
||||
const HelpText = styled.div`
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #0369a1;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0369a1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const LoginPage = ({ onLogin }) => {
|
||||
const [instanceURL, setInstanceURL] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!instanceURL) {
|
||||
setError('Please enter your instance URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar y normalizar la URL
|
||||
const validatedUrl = oauthService.validateInstanceUrl(instanceURL.trim());
|
||||
if (!validatedUrl) {
|
||||
setError('Please enter a valid instance URL (e.g., mastodon.social or https://example.com)');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Verificar que la instancia sea accesible
|
||||
await axios.get(`${validatedUrl}/api/v1/instance`, { timeout: 10000 });
|
||||
|
||||
// Generar URL de autorización OAuth
|
||||
const authUrl = await oauthService.getAuthorizationUrl(validatedUrl);
|
||||
|
||||
// Redirigir al usuario a la página de autorización de la instancia
|
||||
window.location.href = authUrl;
|
||||
|
||||
} catch (err) {
|
||||
console.error('Instance validation error:', err);
|
||||
|
||||
if (err.code === 'ECONNABORTED') {
|
||||
setError('Connection timeout. Please check the instance URL and try again.');
|
||||
} else if (err.response?.status === 404) {
|
||||
setError('This doesn\'t appear to be a GoToSocial or Mastodon instance. Please check the URL.');
|
||||
} else if (err.response?.status >= 500) {
|
||||
setError('The instance appears to be experiencing issues. Please try again later.');
|
||||
} else {
|
||||
setError('Unable to connect to the instance. Please check the URL and try again.');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LoginContainer>
|
||||
<LoginCard>
|
||||
<Logo>
|
||||
<h1>GoToSocial</h1>
|
||||
<p>Connect to your instance</p>
|
||||
</Logo>
|
||||
|
||||
<HelpText>
|
||||
<p>
|
||||
<strong>Sign in with OAuth:</strong> Enter your GoToSocial or Mastodon instance URL to connect securely.
|
||||
You'll be redirected to your instance to authorize this app.
|
||||
</p>
|
||||
</HelpText>
|
||||
|
||||
{error && (
|
||||
<ErrorMessage>
|
||||
{error}
|
||||
</ErrorMessage>
|
||||
)}
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<InputGroup>
|
||||
<Label htmlFor="instance">Instance URL</Label>
|
||||
<Input
|
||||
id="instance"
|
||||
type="text"
|
||||
placeholder="mastodon.social, gotosocial.example.com, etc."
|
||||
value={instanceURL}
|
||||
onChange={(e) => setInstanceURL(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete="url"
|
||||
/>
|
||||
<InputIcon>
|
||||
<FaServer />
|
||||
</InputIcon>
|
||||
</InputGroup>
|
||||
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid transparent',
|
||||
borderTop: '2px solid currentColor',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
Connecting to instance...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaSignInAlt />
|
||||
Connect with OAuth
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</Form>
|
||||
</LoginCard>
|
||||
</LoginContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
380
src/pages/NotificationsPage.js
Archivo normal
380
src/pages/NotificationsPage.js
Archivo normal
@@ -0,0 +1,380 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import api from '../services/api';
|
||||
import { FaSpinner, FaExclamationTriangle, FaBell, FaHeart, FaRetweet, FaUser, FaReply } from 'react-icons/fa';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const PageTitle = styled.h1`
|
||||
color: #1f2937;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const NotificationItem = styled.div`
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-left: 4px solid ${props => {
|
||||
switch (props.type) {
|
||||
case 'favourite': return '#ef4444';
|
||||
case 'reblog': return '#10b981';
|
||||
case 'follow': return '#667eea';
|
||||
case 'mention': return '#f59e0b';
|
||||
default: return '#9ca3af';
|
||||
}
|
||||
}};
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
${props => !props.read && `
|
||||
background: #f8faff;
|
||||
border-left-color: ${props.type === 'favourite' ? '#ef4444' :
|
||||
props.type === 'reblog' ? '#10b981' :
|
||||
props.type === 'follow' ? '#667eea' :
|
||||
props.type === 'mention' ? '#f59e0b' : '#9ca3af'};
|
||||
`}
|
||||
`;
|
||||
|
||||
const NotificationHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
|
||||
const NotificationIcon = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: ${props => {
|
||||
switch (props.type) {
|
||||
case 'favourite': return '#fef2f2';
|
||||
case 'reblog': return '#f0fdf4';
|
||||
case 'follow': return '#eff6ff';
|
||||
case 'mention': return '#fffbeb';
|
||||
default: return '#f9fafb';
|
||||
}
|
||||
}};
|
||||
color: ${props => {
|
||||
switch (props.type) {
|
||||
case 'favourite': return '#ef4444';
|
||||
case 'reblog': return '#10b981';
|
||||
case 'follow': return '#667eea';
|
||||
case 'mention': return '#f59e0b';
|
||||
default: return '#9ca3af';
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
const Avatar = styled.img`
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
`;
|
||||
|
||||
const NotificationInfo = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const NotificationText = styled.div`
|
||||
color: #374151;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
.username {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
`;
|
||||
|
||||
const Timestamp = styled.div`
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
`;
|
||||
|
||||
const NotificationContent = styled.div`
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
color: #374151;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #dc2626;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
|
||||
.icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadMoreButton = styled.button`
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
margin: 1rem 0;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const NotificationsPage = ({ user }) => {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications();
|
||||
}, []);
|
||||
|
||||
const loadNotifications = async (maxId = null) => {
|
||||
try {
|
||||
if (!maxId) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const notificationsData = await api.getNotifications({
|
||||
max_id: maxId,
|
||||
limit: 20
|
||||
});
|
||||
|
||||
if (maxId) {
|
||||
setNotifications(prev => [...prev, ...notificationsData]);
|
||||
setLoadingMore(false);
|
||||
} else {
|
||||
setNotifications(notificationsData);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setHasMore(notificationsData.length === 20);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading notifications:', err);
|
||||
|
||||
if (err.response?.status === 401) {
|
||||
setError('Your session has expired. Please log in again.');
|
||||
} else if (err.response?.status === 403) {
|
||||
setError('You don\'t have permission to view notifications.');
|
||||
} else if (err.code === 'ECONNABORTED') {
|
||||
setError('Request timeout. Please check your connection and try again.');
|
||||
} else {
|
||||
setError('Failed to load notifications. Please try again.');
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (notifications.length > 0 && hasMore && !loadingMore) {
|
||||
setLoadingMore(true);
|
||||
const lastNotification = notifications[notifications.length - 1];
|
||||
loadNotifications(lastNotification.id);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInMinutes = (now - date) / (1000 * 60);
|
||||
|
||||
if (diffInMinutes < 60) {
|
||||
return `${Math.floor(diffInMinutes)}m ago`;
|
||||
} else if (diffInMinutes < 1440) { // 24 hours
|
||||
return `${Math.floor(diffInMinutes / 60)}h ago`;
|
||||
} else {
|
||||
return `${Math.floor(diffInMinutes / 1440)}d ago`;
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'favourite': return <FaHeart />;
|
||||
case 'reblog': return <FaRetweet />;
|
||||
case 'follow': return <FaUser />;
|
||||
case 'mention': return <FaReply />;
|
||||
default: return <FaBell />;
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationText = (notification) => {
|
||||
const username = notification.account.display_name || notification.account.username;
|
||||
|
||||
switch (notification.type) {
|
||||
case 'favourite':
|
||||
return `${username} liked your post`;
|
||||
case 'reblog':
|
||||
return `${username} boosted your post`;
|
||||
case 'follow':
|
||||
return `${username} started following you`;
|
||||
case 'mention':
|
||||
return `${username} mentioned you`;
|
||||
default:
|
||||
return `${username} interacted with your content`;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageTitle>
|
||||
<FaBell />
|
||||
Notifications
|
||||
</PageTitle>
|
||||
<LoadingContainer>
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '2rem', marginBottom: '1rem' }} />
|
||||
<div>Loading notifications...</div>
|
||||
</LoadingContainer>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageTitle>
|
||||
<FaBell />
|
||||
Notifications
|
||||
</PageTitle>
|
||||
|
||||
{error && (
|
||||
<ErrorContainer>
|
||||
<FaExclamationTriangle />
|
||||
{error}
|
||||
</ErrorContainer>
|
||||
)}
|
||||
|
||||
{notifications.length === 0 && !loading ? (
|
||||
<EmptyState>
|
||||
<div className="icon">
|
||||
<FaBell />
|
||||
</div>
|
||||
<h3>No notifications yet</h3>
|
||||
<p>
|
||||
When people interact with your posts or follow you,<br />
|
||||
you'll see notifications here.
|
||||
</p>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
{notifications.map(notification => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
type={notification.type}
|
||||
read={Math.random() > 0.3} // Randomly mark some as read for demo
|
||||
>
|
||||
<NotificationHeader>
|
||||
<NotificationIcon type={notification.type}>
|
||||
{getNotificationIcon(notification.type)}
|
||||
</NotificationIcon>
|
||||
|
||||
<Avatar
|
||||
src={notification.account.avatar}
|
||||
alt={notification.account.display_name || notification.account.username}
|
||||
/>
|
||||
|
||||
<NotificationInfo>
|
||||
<NotificationText>
|
||||
<span className="username">
|
||||
{getNotificationText(notification)}
|
||||
</span>
|
||||
</NotificationText>
|
||||
<Timestamp>
|
||||
{formatTimestamp(notification.created_at)}
|
||||
</Timestamp>
|
||||
</NotificationInfo>
|
||||
</NotificationHeader>
|
||||
|
||||
{notification.status && (
|
||||
<NotificationContent
|
||||
dangerouslySetInnerHTML={{ __html: notification.status.content }}
|
||||
/>
|
||||
)}
|
||||
</NotificationItem>
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<LoadMoreButton
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite', marginRight: '0.5rem' }} />
|
||||
Loading more notifications...
|
||||
</>
|
||||
) : (
|
||||
'Load more notifications'
|
||||
)}
|
||||
</LoadMoreButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsPage;
|
||||
222
src/pages/OAuthCallbackPage.js
Archivo normal
222
src/pages/OAuthCallbackPage.js
Archivo normal
@@ -0,0 +1,222 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { FaExclamationTriangle } from 'react-icons/fa';
|
||||
import oauthService from '../services/oauth';
|
||||
import api from '../services/api';
|
||||
|
||||
const CallbackContainer = styled.div`
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
`;
|
||||
|
||||
const CallbackCard = styled.div`
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 3rem;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const Logo = styled.div`
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
color: #667eea;
|
||||
font-size: 2rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: #6b7280;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: #dc2626;
|
||||
`;
|
||||
|
||||
const RetryButton = styled.button`
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 2rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
`;
|
||||
|
||||
const OAuthCallbackPage = ({ onLoginSuccess }) => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState('processing');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Verificación inicial
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const hasCode = urlParams.has('code');
|
||||
const hasState = urlParams.has('state');
|
||||
|
||||
// Check for missing parameters on mount
|
||||
useEffect(() => {
|
||||
if (!hasCode || !hasState) {
|
||||
setError('Invalid OAuth callback - missing required parameters');
|
||||
setStatus('error');
|
||||
}
|
||||
}, [hasCode, hasState]);
|
||||
|
||||
useEffect(() => {
|
||||
const processCallback = async () => {
|
||||
try {
|
||||
const { code, state, error: oauthError, error_description } = oauthService.getOAuthCallbackParams();
|
||||
|
||||
if (oauthError) {
|
||||
throw new Error(error_description || `OAuth error: ${oauthError}`);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
throw new Error('Missing authorization code or state parameter');
|
||||
}
|
||||
|
||||
setStatus('processing');
|
||||
|
||||
const tokenData = await oauthService.exchangeCodeForToken(code, state);
|
||||
api.setAuth(tokenData.access_token, tokenData.instance_url, tokenData.refresh_token);
|
||||
|
||||
const userData = await api.verifyCredentials();
|
||||
localStorage.setItem('current_user', JSON.stringify(userData));
|
||||
|
||||
setStatus('success');
|
||||
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(userData);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
navigate('/', { replace: true });
|
||||
}, 2000);
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message || 'Authentication failed');
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const processTimer = setTimeout(processCallback, 100);
|
||||
|
||||
const emergencyTimer = setTimeout(() => {
|
||||
if (status === 'processing') {
|
||||
setError('Authentication is taking too long. Please try again or check your connection.');
|
||||
setStatus('error');
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(processTimer);
|
||||
clearTimeout(emergencyTimer);
|
||||
};
|
||||
}, [navigate, onLoginSuccess, status]);
|
||||
|
||||
|
||||
|
||||
const handleRetry = () => {
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<CallbackContainer>
|
||||
<CallbackCard>
|
||||
<Logo>
|
||||
<h1>GoToSocial</h1>
|
||||
</Logo>
|
||||
|
||||
{status === 'processing' && (
|
||||
<LoadingContainer>
|
||||
<div style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border: '4px solid #e2e8f0',
|
||||
borderTop: '4px solid #667eea',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 0.5rem 0', color: '#374151' }}>
|
||||
Completing authentication...
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||
Please wait while we verify your credentials
|
||||
</p>
|
||||
<p style={{ margin: '1rem 0 0 0', fontSize: '0.8rem', color: '#6b7280' }}>
|
||||
If this takes more than 30 seconds, check the browser console or{' '}
|
||||
<a href="/oauth/debug" style={{ color: '#667eea' }}>click here for debug info</a>
|
||||
</p>
|
||||
</div>
|
||||
</LoadingContainer>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<LoadingContainer style={{ color: '#10b981' }}>
|
||||
<div style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border: '4px solid #d1fae5',
|
||||
borderTop: '4px solid #10b981',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 0.5rem 0', color: '#065f46' }}>
|
||||
Authentication successful!
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||
Redirecting you to your timeline...
|
||||
</p>
|
||||
</div>
|
||||
</LoadingContainer>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<ErrorContainer>
|
||||
<FaExclamationTriangle size={48} />
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 0.5rem 0' }}>
|
||||
Authentication failed
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: '0.9rem', lineHeight: '1.5' }}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<RetryButton onClick={handleRetry}>
|
||||
Try again
|
||||
</RetryButton>
|
||||
<RetryButton
|
||||
onClick={() => window.location.href = '/oauth/debug'}
|
||||
style={{ background: '#6c757d', marginLeft: '1rem' }}
|
||||
>
|
||||
Debug OAuth
|
||||
</RetryButton>
|
||||
</ErrorContainer>
|
||||
)}
|
||||
</CallbackCard>
|
||||
</CallbackContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthCallbackPage;
|
||||
452
src/pages/ProfilePage.js
Archivo normal
452
src/pages/ProfilePage.js
Archivo normal
@@ -0,0 +1,452 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import Post from '../components/Post/Post';
|
||||
import api from '../services/api';
|
||||
import { FaSpinner, FaExclamationTriangle, FaUser, FaCalendar, FaLink, FaUserPlus, FaUserMinus } from 'react-icons/fa';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const ProfileHeader = styled.div`
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const CoverImage = styled.div`
|
||||
height: 200px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const ProfileInfo = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
margin-top: -60px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Avatar = styled.img`
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid white;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
`;
|
||||
|
||||
const ProfileDetails = styled.div`
|
||||
flex: 1;
|
||||
margin-top: 60px;
|
||||
`;
|
||||
|
||||
const DisplayName = styled.h1`
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.8rem;
|
||||
`;
|
||||
|
||||
const Username = styled.p`
|
||||
margin: 0 0 1rem 0;
|
||||
color: #6b7280;
|
||||
font-size: 1.1rem;
|
||||
`;
|
||||
|
||||
const Bio = styled.div`
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ProfileMeta = styled.div`
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin: 1rem 0;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const MetaItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
|
||||
svg {
|
||||
color: #9ca3af;
|
||||
}
|
||||
`;
|
||||
|
||||
const Stats = styled.div`
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin: 1.5rem 0;
|
||||
`;
|
||||
|
||||
const StatItem = styled.div`
|
||||
text-align: center;
|
||||
|
||||
.number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const ActionButton = styled.button`
|
||||
background: ${props => props.variant === 'primary' ?
|
||||
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' :
|
||||
'#f9fafb'
|
||||
};
|
||||
color: ${props => props.variant === 'primary' ? 'white' : '#374151'};
|
||||
border: ${props => props.variant === 'primary' ? 'none' : '1px solid #d1d5db'};
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const TabsContainer = styled.div`
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const Tabs = styled.div`
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
`;
|
||||
|
||||
const Tab = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 1rem 1.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: ${props => props.active ? '#667eea' : '#6b7280'};
|
||||
border-bottom: 2px solid ${props => props.active ? '#667eea' : 'transparent'};
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #dc2626;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const ProfilePage = ({ currentUser }) => {
|
||||
const { username } = useParams();
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('posts');
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const loadUserPosts = useCallback(async (userId) => {
|
||||
try {
|
||||
const userPosts = await api.getAccountStatuses(userId, {
|
||||
limit: 20,
|
||||
exclude_replies: activeTab !== 'posts'
|
||||
});
|
||||
|
||||
setPosts(userPosts);
|
||||
} catch (err) {
|
||||
// Don't show error for posts loading failure, just show empty state
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// First, search for the user by username
|
||||
const searchResults = await api.search(username, { type: 'accounts', limit: 5 });
|
||||
|
||||
let userAccount = null;
|
||||
|
||||
// Look for exact username match
|
||||
if (searchResults.accounts) {
|
||||
userAccount = searchResults.accounts.find(account =>
|
||||
account.username === username || account.acct === username
|
||||
);
|
||||
}
|
||||
|
||||
if (!userAccount) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Get full profile information
|
||||
const profileData = await api.getAccount(userAccount.id);
|
||||
setProfile(profileData);
|
||||
|
||||
// Check if current user follows this account
|
||||
if (currentUser && currentUser.id !== profileData.id) {
|
||||
try {
|
||||
const relationships = await api.getAccountRelationships([profileData.id]);
|
||||
if (relationships && relationships[0]) {
|
||||
setIsFollowing(relationships[0].following);
|
||||
}
|
||||
} catch (relError) {
|
||||
console.warn('Failed to load relationship status:', relError);
|
||||
}
|
||||
}
|
||||
|
||||
// Load user's posts
|
||||
await loadUserPosts(profileData.id);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading profile:', err);
|
||||
|
||||
if (err.response?.status === 404) {
|
||||
setError(`User @${username} not found.`);
|
||||
} else if (err.response?.status === 403) {
|
||||
setError('This profile is private.');
|
||||
} else if (err.message === 'User not found') {
|
||||
setError(`User @${username} not found.`);
|
||||
} else {
|
||||
setError('Failed to load profile. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [username, currentUser, loadUserPosts]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, [loadProfile]);
|
||||
|
||||
const handleFollow = async () => {
|
||||
if (!currentUser || !profile) return;
|
||||
|
||||
setActionLoading(true);
|
||||
try {
|
||||
if (isFollowing) {
|
||||
await api.unfollowAccount(profile.id);
|
||||
setIsFollowing(false);
|
||||
} else {
|
||||
await api.followAccount(profile.id);
|
||||
setIsFollowing(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error toggling follow:', err);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<LoadingContainer>
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '2rem', marginBottom: '1rem' }} />
|
||||
<div>Loading profile...</div>
|
||||
</LoadingContainer>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !profile) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<ErrorContainer>
|
||||
<FaExclamationTriangle />
|
||||
{error || 'Profile not found'}
|
||||
</ErrorContainer>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long'
|
||||
});
|
||||
};
|
||||
|
||||
const isOwnProfile = currentUser && currentUser.username === profile.username;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<ProfileHeader>
|
||||
<CoverImage />
|
||||
<ProfileInfo>
|
||||
<Avatar src={profile.avatar || '/default-avatar.png'} alt={profile.display_name} />
|
||||
<ProfileDetails>
|
||||
<DisplayName>{profile.display_name}</DisplayName>
|
||||
<Username>@{profile.username}</Username>
|
||||
|
||||
{profile.note && (
|
||||
<Bio dangerouslySetInnerHTML={{ __html: profile.note }} />
|
||||
)}
|
||||
|
||||
<ProfileMeta>
|
||||
<MetaItem>
|
||||
<FaCalendar />
|
||||
Joined {formatDate(profile.created_at)}
|
||||
</MetaItem>
|
||||
{profile.url && (
|
||||
<MetaItem>
|
||||
<FaLink />
|
||||
<a href={profile.url} target="_blank" rel="noopener noreferrer">
|
||||
Profile
|
||||
</a>
|
||||
</MetaItem>
|
||||
)}
|
||||
</ProfileMeta>
|
||||
|
||||
<Stats>
|
||||
<StatItem>
|
||||
<span className="number">{profile.statuses_count}</span>
|
||||
<span className="label">Posts</span>
|
||||
</StatItem>
|
||||
<StatItem>
|
||||
<span className="number">{profile.following_count}</span>
|
||||
<span className="label">Following</span>
|
||||
</StatItem>
|
||||
<StatItem>
|
||||
<span className="number">{profile.followers_count}</span>
|
||||
<span className="label">Followers</span>
|
||||
</StatItem>
|
||||
</Stats>
|
||||
|
||||
{currentUser && !isOwnProfile && (
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
onClick={handleFollow}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite' }} />
|
||||
) : isFollowing ? (
|
||||
<>
|
||||
<FaUserMinus />
|
||||
Unfollow
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaUserPlus />
|
||||
Follow
|
||||
</>
|
||||
)}
|
||||
</ActionButton>
|
||||
)}
|
||||
</ProfileDetails>
|
||||
</ProfileInfo>
|
||||
</ProfileHeader>
|
||||
|
||||
<TabsContainer>
|
||||
<Tabs>
|
||||
<Tab
|
||||
active={activeTab === 'posts'}
|
||||
onClick={() => setActiveTab('posts')}
|
||||
>
|
||||
Posts
|
||||
</Tab>
|
||||
<Tab
|
||||
active={activeTab === 'media'}
|
||||
onClick={() => setActiveTab('media')}
|
||||
>
|
||||
Media
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</TabsContainer>
|
||||
|
||||
{activeTab === 'posts' && (
|
||||
<>
|
||||
{posts.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '3rem',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
<FaUser style={{ fontSize: '4rem', marginBottom: '1rem', color: '#d1d5db' }} />
|
||||
<h3 style={{ margin: '0 0 0.5rem 0', color: '#374151' }}>No posts yet</h3>
|
||||
<p style={{ margin: 0 }}>
|
||||
{isOwnProfile
|
||||
? "You haven't posted anything yet. Share something with the world!"
|
||||
: `${profile.display_name} hasn't posted anything yet.`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
posts.map(post => (
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
currentUser={currentUser}
|
||||
onFavorite={() => {}}
|
||||
onReblog={() => {}}
|
||||
onReply={() => {}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'media' && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '3rem',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 0.5rem 0', color: '#374151' }}>Media posts</h3>
|
||||
<p style={{ margin: 0 }}>
|
||||
Posts with images, videos, and other media would be shown here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
264
src/pages/PublicTimelinePage.js
Archivo normal
264
src/pages/PublicTimelinePage.js
Archivo normal
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Post from '../components/Post/Post';
|
||||
import api from '../services/api';
|
||||
import { FaSpinner, FaExclamationTriangle, FaGlobe } from 'react-icons/fa';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const PageHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
`;
|
||||
|
||||
const PageTitle = styled.h1`
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const PageDescription = styled.p`
|
||||
color: #6b7280;
|
||||
margin: 0.5rem 0 0 0;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #dc2626;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const LoadMoreButton = styled.button`
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
margin: 1rem 0;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
|
||||
.icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const PublicTimelinePage = () => {
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadTimeline();
|
||||
}, []);
|
||||
|
||||
const loadTimeline = async (maxId = null) => {
|
||||
try {
|
||||
if (!maxId) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const timelineData = await api.getPublicTimeline({
|
||||
max_id: maxId,
|
||||
limit: 20,
|
||||
local: false // Include federated content
|
||||
});
|
||||
|
||||
if (maxId) {
|
||||
setPosts(prev => [...prev, ...timelineData]);
|
||||
setLoadingMore(false);
|
||||
} else {
|
||||
setPosts(timelineData);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setHasMore(timelineData.length === 20);
|
||||
} catch (err) {
|
||||
console.error('Error loading public timeline:', err);
|
||||
|
||||
if (err.response?.status === 401) {
|
||||
setError('Authentication required to view the public timeline.');
|
||||
} else if (err.response?.status === 403) {
|
||||
setError('Public timeline is not available on this instance.');
|
||||
} else if (err.code === 'ECONNABORTED') {
|
||||
setError('Request timeout. Please check your connection and try again.');
|
||||
} else {
|
||||
setError('Failed to load public timeline. Please try again.');
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFavorite = async (post) => {
|
||||
try {
|
||||
let updatedPost;
|
||||
if (post.favourited) {
|
||||
updatedPost = await api.unfavoriteStatus(post.id);
|
||||
} else {
|
||||
updatedPost = await api.favoriteStatus(post.id);
|
||||
}
|
||||
|
||||
setPosts(prev => prev.map(p => p.id === post.id ? updatedPost : p));
|
||||
} catch (err) {
|
||||
console.error('Error toggling favorite:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReblog = async (post) => {
|
||||
try {
|
||||
let updatedPost;
|
||||
if (post.reblogged) {
|
||||
updatedPost = await api.unreblogStatus(post.id);
|
||||
} else {
|
||||
updatedPost = await api.reblogStatus(post.id);
|
||||
}
|
||||
|
||||
setPosts(prev => prev.map(p => p.id === post.id ? updatedPost : p));
|
||||
} catch (err) {
|
||||
console.error('Error toggling reblog:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (posts.length > 0 && hasMore && !loadingMore) {
|
||||
setLoadingMore(true);
|
||||
const lastPost = posts[posts.length - 1];
|
||||
loadTimeline(lastPost.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitle>
|
||||
<FaGlobe />
|
||||
Public Timeline
|
||||
</PageTitle>
|
||||
</PageHeader>
|
||||
<LoadingContainer>
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '2rem', marginBottom: '1rem' }} />
|
||||
<div>Loading public timeline...</div>
|
||||
</LoadingContainer>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitle>
|
||||
<FaGlobe />
|
||||
Public Timeline
|
||||
</PageTitle>
|
||||
</PageHeader>
|
||||
|
||||
<PageDescription>
|
||||
Public posts from this instance and other connected instances in the fediverse.
|
||||
These are posts that have been made public by their authors.
|
||||
</PageDescription>
|
||||
|
||||
{error && (
|
||||
<ErrorContainer>
|
||||
<FaExclamationTriangle />
|
||||
{error}
|
||||
</ErrorContainer>
|
||||
)}
|
||||
|
||||
{posts.length === 0 && !loading ? (
|
||||
<EmptyState>
|
||||
<div className="icon">
|
||||
<FaGlobe />
|
||||
</div>
|
||||
<h3>No public posts</h3>
|
||||
<p>
|
||||
There are no public posts available right now.<br />
|
||||
This could be because the instance is new, private, or requires authentication.
|
||||
</p>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
{posts.map(post => (
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
onFavorite={handleFavorite}
|
||||
onReblog={handleReblog}
|
||||
onReply={(post) => console.log('Reply to:', post)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<LoadMoreButton
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite', marginRight: '0.5rem' }} />
|
||||
Loading more posts...
|
||||
</>
|
||||
) : (
|
||||
'Load more posts'
|
||||
)}
|
||||
</LoadMoreButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicTimelinePage;
|
||||
469
src/pages/SearchPage.js
Archivo normal
469
src/pages/SearchPage.js
Archivo normal
@@ -0,0 +1,469 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import Post from '../components/Post/Post';
|
||||
import api from '../services/api';
|
||||
import { FaSpinner, FaExclamationTriangle, FaSearch, FaUser, FaHashtag } from 'react-icons/fa';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const SearchHeader = styled.div`
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const SearchForm = styled.form`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
|
||||
const SearchInput = styled.input`
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchButton = styled.button`
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 2rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
|
||||
&:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchTabs = styled.div`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 1rem;
|
||||
`;
|
||||
|
||||
const Tab = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
color: ${props => props.active ? '#667eea' : '#6b7280'};
|
||||
font-weight: ${props => props.active ? '600' : '400'};
|
||||
border-bottom: 2px solid ${props => props.active ? '#667eea' : 'transparent'};
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchResults = styled.div`
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const ResultsHeader = styled.h2`
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const AccountResult = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
`;
|
||||
|
||||
const Avatar = styled.img`
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
`;
|
||||
|
||||
const AccountInfo = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const DisplayName = styled.div`
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
`;
|
||||
|
||||
const Username = styled.div`
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
`;
|
||||
|
||||
const HashtagResult = styled.div`
|
||||
padding: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
`;
|
||||
|
||||
const HashtagName = styled.div`
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
margin-bottom: 0.25rem;
|
||||
`;
|
||||
|
||||
const HashtagStats = styled.div`
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #dc2626;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
|
||||
.icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchPage = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [query, setQuery] = useState(searchParams.get('q') || '');
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [results, setResults] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const queryParam = searchParams.get('q');
|
||||
if (queryParam) {
|
||||
setQuery(queryParam);
|
||||
performSearch(queryParam);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const performSearch = async (searchQuery) => {
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Perform search via API
|
||||
const searchResults = await api.search(searchQuery.trim(), {
|
||||
resolve: true,
|
||||
limit: 20
|
||||
});
|
||||
|
||||
setResults(searchResults);
|
||||
} catch (err) {
|
||||
console.error('Search error:', err);
|
||||
|
||||
if (err.response?.status === 401) {
|
||||
setError('You need to be logged in to search.');
|
||||
} else if (err.code === 'ECONNABORTED') {
|
||||
setError('Search timeout. Please try again.');
|
||||
} else {
|
||||
setError('Search failed. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
|
||||
setSearchParams({ q: query });
|
||||
performSearch(query);
|
||||
};
|
||||
|
||||
const getTabCounts = () => {
|
||||
if (!results) return {};
|
||||
|
||||
return {
|
||||
all: (results.accounts?.length || 0) + (results.hashtags?.length || 0) + (results.statuses?.length || 0),
|
||||
accounts: results.accounts?.length || 0,
|
||||
hashtags: results.hashtags?.length || 0,
|
||||
posts: results.statuses?.length || 0
|
||||
};
|
||||
};
|
||||
|
||||
const tabCounts = getTabCounts();
|
||||
|
||||
const renderResults = () => {
|
||||
if (!results) return null;
|
||||
|
||||
switch (activeTab) {
|
||||
case 'accounts':
|
||||
return (
|
||||
<>
|
||||
<ResultsHeader>
|
||||
<FaUser />
|
||||
Accounts ({results.accounts?.length || 0})
|
||||
</ResultsHeader>
|
||||
{results.accounts?.map(account => (
|
||||
<AccountResult key={account.id}>
|
||||
<Avatar src={account.avatar} alt={account.display_name} />
|
||||
<AccountInfo>
|
||||
<DisplayName>{account.display_name}</DisplayName>
|
||||
<Username>@{account.username}</Username>
|
||||
{account.note && (
|
||||
<div style={{ color: '#6b7280', fontSize: '0.9rem', marginTop: '0.25rem' }}>
|
||||
{account.note}
|
||||
</div>
|
||||
)}
|
||||
</AccountInfo>
|
||||
</AccountResult>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
case 'hashtags':
|
||||
return (
|
||||
<>
|
||||
<ResultsHeader>
|
||||
<FaHashtag />
|
||||
Hashtags ({results.hashtags?.length || 0})
|
||||
</ResultsHeader>
|
||||
{results.hashtags?.map(hashtag => (
|
||||
<HashtagResult key={hashtag.name}>
|
||||
<HashtagName>#{hashtag.name}</HashtagName>
|
||||
<HashtagStats>
|
||||
{hashtag.history?.[0]?.uses || 0} posts
|
||||
</HashtagStats>
|
||||
</HashtagResult>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
case 'posts':
|
||||
return (
|
||||
<>
|
||||
<ResultsHeader>
|
||||
Posts ({results.statuses?.length || 0})
|
||||
</ResultsHeader>
|
||||
{results.statuses?.map(post => (
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
onFavorite={() => {}}
|
||||
onReblog={() => {}}
|
||||
onReply={() => {}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
default: // 'all'
|
||||
return (
|
||||
<>
|
||||
{results.accounts?.length > 0 && (
|
||||
<>
|
||||
<ResultsHeader>
|
||||
<FaUser />
|
||||
Accounts
|
||||
</ResultsHeader>
|
||||
{results.accounts.slice(0, 3).map(account => (
|
||||
<AccountResult key={account.id}>
|
||||
<Avatar src={account.avatar} alt={account.display_name} />
|
||||
<AccountInfo>
|
||||
<DisplayName>{account.display_name}</DisplayName>
|
||||
<Username>@{account.username}</Username>
|
||||
</AccountInfo>
|
||||
</AccountResult>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{results.hashtags?.length > 0 && (
|
||||
<>
|
||||
<ResultsHeader style={{ marginTop: '2rem' }}>
|
||||
<FaHashtag />
|
||||
Hashtags
|
||||
</ResultsHeader>
|
||||
{results.hashtags.slice(0, 3).map(hashtag => (
|
||||
<HashtagResult key={hashtag.name}>
|
||||
<HashtagName>#{hashtag.name}</HashtagName>
|
||||
<HashtagStats>
|
||||
{hashtag.history?.[0]?.uses || 0} posts
|
||||
</HashtagStats>
|
||||
</HashtagResult>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{results.statuses?.length > 0 && (
|
||||
<>
|
||||
<ResultsHeader style={{ marginTop: '2rem' }}>
|
||||
Posts
|
||||
</ResultsHeader>
|
||||
{results.statuses.slice(0, 3).map(post => (
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
onFavorite={() => {}}
|
||||
onReblog={() => {}}
|
||||
onReply={() => {}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<SearchHeader>
|
||||
<SearchForm onSubmit={handleSubmit}>
|
||||
<SearchInput
|
||||
type="text"
|
||||
placeholder="Search for users, hashtags, or posts..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<SearchButton type="submit" disabled={loading}>
|
||||
{loading ? (
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite' }} />
|
||||
) : (
|
||||
<FaSearch />
|
||||
)}
|
||||
Search
|
||||
</SearchButton>
|
||||
</SearchForm>
|
||||
|
||||
{results && (
|
||||
<SearchTabs>
|
||||
<Tab
|
||||
active={activeTab === 'all'}
|
||||
onClick={() => setActiveTab('all')}
|
||||
>
|
||||
All ({tabCounts.all})
|
||||
</Tab>
|
||||
<Tab
|
||||
active={activeTab === 'accounts'}
|
||||
onClick={() => setActiveTab('accounts')}
|
||||
>
|
||||
Accounts ({tabCounts.accounts})
|
||||
</Tab>
|
||||
<Tab
|
||||
active={activeTab === 'hashtags'}
|
||||
onClick={() => setActiveTab('hashtags')}
|
||||
>
|
||||
Hashtags ({tabCounts.hashtags})
|
||||
</Tab>
|
||||
<Tab
|
||||
active={activeTab === 'posts'}
|
||||
onClick={() => setActiveTab('posts')}
|
||||
>
|
||||
Posts ({tabCounts.posts})
|
||||
</Tab>
|
||||
</SearchTabs>
|
||||
)}
|
||||
</SearchHeader>
|
||||
|
||||
{error && (
|
||||
<ErrorContainer>
|
||||
<FaExclamationTriangle />
|
||||
{error}
|
||||
</ErrorContainer>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<LoadingContainer>
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '2rem', marginBottom: '1rem' }} />
|
||||
<div>Searching...</div>
|
||||
</LoadingContainer>
|
||||
)}
|
||||
|
||||
{results && !loading && (
|
||||
<SearchResults>
|
||||
{tabCounts.all === 0 ? (
|
||||
<EmptyState>
|
||||
<div className="icon">
|
||||
<FaSearch />
|
||||
</div>
|
||||
<h3>No results found</h3>
|
||||
<p>
|
||||
Try different keywords or check your spelling.
|
||||
</p>
|
||||
</EmptyState>
|
||||
) : (
|
||||
renderResults()
|
||||
)}
|
||||
</SearchResults>
|
||||
)}
|
||||
|
||||
{!results && !loading && !error && (
|
||||
<EmptyState>
|
||||
<div className="icon">
|
||||
<FaSearch />
|
||||
</div>
|
||||
<h3>Search the fediverse</h3>
|
||||
<p>
|
||||
Find users, hashtags, and posts across GoToSocial and connected instances.
|
||||
</p>
|
||||
</EmptyState>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchPage;
|
||||
276
src/pages/StatusPage.js
Archivo normal
276
src/pages/StatusPage.js
Archivo normal
@@ -0,0 +1,276 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import Post from '../components/Post/Post';
|
||||
import api from '../services/api';
|
||||
import { FaSpinner, FaExclamationTriangle, FaComment } from 'react-icons/fa';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const ThreadContainer = styled.div`
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const MainPost = styled.div`
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
`;
|
||||
|
||||
const ThreadReplies = styled.div`
|
||||
background: #f9fafb;
|
||||
`;
|
||||
|
||||
const Reply = styled.div`
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-left: ${props => props.depth * 20}px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
${props => props.depth > 0 && `
|
||||
border-left: 3px solid #e5e7eb;
|
||||
margin-left: ${(props.depth - 1) * 20}px;
|
||||
padding-left: 1rem;
|
||||
`}
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #dc2626;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const ThreadHeader = styled.div`
|
||||
padding: 1rem 1.5rem;
|
||||
background: #f3f4f6;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const BackToPost = styled.div`
|
||||
padding: 1rem;
|
||||
background: #eff6ff;
|
||||
border-bottom: 1px solid #dbeafe;
|
||||
text-align: center;
|
||||
color: #1e40af;
|
||||
font-size: 0.9rem;
|
||||
`;
|
||||
|
||||
const StatusPage = ({ currentUser }) => {
|
||||
const { id } = useParams();
|
||||
const [status, setStatus] = useState(null);
|
||||
const [context, setContext] = useState({ ancestors: [], descendants: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Load the main status
|
||||
const statusData = await api.getStatus(id);
|
||||
setStatus(statusData);
|
||||
|
||||
// Load the context (ancestors and descendants)
|
||||
const contextData = await api.getStatusContext(id);
|
||||
setContext(contextData);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading status:', err);
|
||||
|
||||
if (err.response?.status === 404) {
|
||||
setError('Post not found. It may have been deleted.');
|
||||
} else if (err.response?.status === 403) {
|
||||
setError('You don\'t have permission to view this post.');
|
||||
} else if (err.response?.status === 401) {
|
||||
setError('You need to be logged in to view this post.');
|
||||
} else {
|
||||
setError('Failed to load post. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
const handleFavorite = async (post) => {
|
||||
try {
|
||||
// In a real app, this would call the API
|
||||
const updatedPost = { ...post, favourited: !post.favourited };
|
||||
if (post.id === status.id) {
|
||||
setStatus(updatedPost);
|
||||
} else {
|
||||
// Update in context
|
||||
setContext(prev => ({
|
||||
ancestors: prev.ancestors.map(p => p.id === post.id ? updatedPost : p),
|
||||
descendants: prev.descendants.map(p => p.id === post.id ? updatedPost : p)
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error toggling favorite:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReblog = async (post) => {
|
||||
try {
|
||||
const updatedPost = { ...post, reblogged: !post.reblogged };
|
||||
if (post.id === status.id) {
|
||||
setStatus(updatedPost);
|
||||
} else {
|
||||
setContext(prev => ({
|
||||
ancestors: prev.ancestors.map(p => p.id === post.id ? updatedPost : p),
|
||||
descendants: prev.descendants.map(p => p.id === post.id ? updatedPost : p)
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error toggling reblog:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Build reply tree structure
|
||||
const buildReplyTree = (posts, parentId = null, depth = 0) => {
|
||||
return posts
|
||||
.filter(post => post.in_reply_to_id === parentId)
|
||||
.map(post => ({
|
||||
post,
|
||||
depth,
|
||||
replies: buildReplyTree(posts, post.id, depth + 1)
|
||||
}));
|
||||
};
|
||||
|
||||
const renderReplyTree = (replyNodes) => {
|
||||
return replyNodes.map(({ post, depth, replies }) => (
|
||||
<React.Fragment key={post.id}>
|
||||
<Reply depth={depth}>
|
||||
<Post
|
||||
post={post}
|
||||
currentUser={currentUser}
|
||||
onFavorite={handleFavorite}
|
||||
onReblog={handleReblog}
|
||||
onReply={() => {}}
|
||||
compact={depth > 0}
|
||||
/>
|
||||
</Reply>
|
||||
{replies.length > 0 && renderReplyTree(replies)}
|
||||
</React.Fragment>
|
||||
));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<LoadingContainer>
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '2rem', marginBottom: '1rem' }} />
|
||||
<div>Loading post...</div>
|
||||
</LoadingContainer>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !status) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<ErrorContainer>
|
||||
<FaExclamationTriangle />
|
||||
{error || 'Post not found'}
|
||||
</ErrorContainer>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const replyTree = buildReplyTree(context.descendants, status.id, 0);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<ThreadContainer>
|
||||
<ThreadHeader>
|
||||
<h2>
|
||||
<FaComment />
|
||||
Post Thread
|
||||
</h2>
|
||||
</ThreadHeader>
|
||||
|
||||
{context.ancestors.length > 0 && (
|
||||
<>
|
||||
<BackToPost>
|
||||
Viewing post in thread context
|
||||
</BackToPost>
|
||||
{context.ancestors.map(ancestor => (
|
||||
<Reply key={ancestor.id} depth={0}>
|
||||
<Post
|
||||
post={ancestor}
|
||||
currentUser={currentUser}
|
||||
onFavorite={handleFavorite}
|
||||
onReblog={handleReblog}
|
||||
onReply={() => {}}
|
||||
/>
|
||||
</Reply>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MainPost>
|
||||
<Post
|
||||
post={status}
|
||||
currentUser={currentUser}
|
||||
onFavorite={handleFavorite}
|
||||
onReblog={handleReblog}
|
||||
onReply={() => {}}
|
||||
showFullContent={true}
|
||||
/>
|
||||
</MainPost>
|
||||
|
||||
{context.descendants.length > 0 && (
|
||||
<ThreadReplies>
|
||||
{renderReplyTree(replyTree)}
|
||||
</ThreadReplies>
|
||||
)}
|
||||
|
||||
{context.descendants.length === 0 && (
|
||||
<div style={{
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
color: '#6b7280',
|
||||
background: '#f9fafb'
|
||||
}}>
|
||||
<FaComment style={{ fontSize: '2rem', marginBottom: '0.5rem', color: '#d1d5db' }} />
|
||||
<p>No replies yet. Be the first to respond!</p>
|
||||
</div>
|
||||
)}
|
||||
</ThreadContainer>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusPage;
|
||||
303
src/services/api.js
Archivo normal
303
src/services/api.js
Archivo normal
@@ -0,0 +1,303 @@
|
||||
import axios from 'axios';
|
||||
import oauthService from './oauth';
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.token = localStorage.getItem('access_token');
|
||||
this.refreshToken = localStorage.getItem('refresh_token');
|
||||
this.instanceUrl = localStorage.getItem('instance_url');
|
||||
|
||||
this.client = axios.create({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token and set baseURL
|
||||
this.client.interceptors.request.use((config) => {
|
||||
if (this.instanceUrl) {
|
||||
config.baseURL = this.instanceUrl;
|
||||
}
|
||||
|
||||
if (this.token) {
|
||||
config.headers.Authorization = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor for error handling and token refresh
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
if (this.refreshToken && this.instanceUrl) {
|
||||
try {
|
||||
const tokenData = await oauthService.refreshAccessToken(this.refreshToken, this.instanceUrl);
|
||||
this.setAuth(tokenData.access_token, this.instanceUrl, tokenData.refresh_token);
|
||||
originalRequest.headers.Authorization = `Bearer ${tokenData.access_token}`;
|
||||
return this.client(originalRequest);
|
||||
} catch (refreshError) {
|
||||
this.clearAuth();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} else {
|
||||
this.clearAuth();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setAuth(token, instanceUrl, refreshToken = null) {
|
||||
this.token = token;
|
||||
this.instanceUrl = instanceUrl;
|
||||
this.refreshToken = refreshToken;
|
||||
|
||||
localStorage.setItem('access_token', token);
|
||||
localStorage.setItem('instance_url', instanceUrl);
|
||||
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
clearAuth() {
|
||||
this.token = null;
|
||||
this.instanceUrl = null;
|
||||
this.refreshToken = null;
|
||||
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('instance_url');
|
||||
localStorage.removeItem('current_user');
|
||||
}
|
||||
|
||||
isAuthenticated() {
|
||||
return !!(this.token && this.instanceUrl);
|
||||
}
|
||||
|
||||
// Authentication endpoints
|
||||
async verifyCredentials() {
|
||||
console.log('API: Verifying credentials with token:', this.token ? 'Present' : 'Missing');
|
||||
console.log('API: Instance URL:', this.instanceUrl);
|
||||
|
||||
try {
|
||||
const response = await this.client.get('/api/v1/accounts/verify_credentials');
|
||||
console.log('API: Credentials verified successfully');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('API: Credential verification failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Timeline endpoints
|
||||
async getHomeTimeline(options = {}) {
|
||||
const { max_id, min_id, limit = 20 } = options;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (max_id) params.append('max_id', max_id);
|
||||
if (min_id) params.append('min_id', min_id);
|
||||
params.append('limit', limit.toString());
|
||||
|
||||
const response = await this.client.get(`/api/v1/timelines/home?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getPublicTimeline(options = {}) {
|
||||
const { max_id, min_id, limit = 20, local = false } = options;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (max_id) params.append('max_id', max_id);
|
||||
if (min_id) params.append('min_id', min_id);
|
||||
params.append('limit', limit.toString());
|
||||
if (local) params.append('local', 'true');
|
||||
|
||||
const response = await this.client.get(`/api/v1/timelines/public?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getTagTimeline(tag, options = {}) {
|
||||
const { max_id, min_id, limit = 20 } = options;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (max_id) params.append('max_id', max_id);
|
||||
if (min_id) params.append('min_id', min_id);
|
||||
params.append('limit', limit.toString());
|
||||
|
||||
const response = await this.client.get(`/api/v1/timelines/tag/${encodeURIComponent(tag)}?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Status endpoints
|
||||
async getStatus(id) {
|
||||
const response = await this.client.get(`/api/v1/statuses/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getStatusContext(id) {
|
||||
const response = await this.client.get(`/api/v1/statuses/${id}/context`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createStatus(status) {
|
||||
const response = await this.client.post('/api/v1/statuses', status);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteStatus(id) {
|
||||
const response = await this.client.delete(`/api/v1/statuses/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async favoriteStatus(id) {
|
||||
const response = await this.client.post(`/api/v1/statuses/${id}/favourite`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async unfavoriteStatus(id) {
|
||||
const response = await this.client.post(`/api/v1/statuses/${id}/unfavourite`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async reblogStatus(id) {
|
||||
const response = await this.client.post(`/api/v1/statuses/${id}/reblog`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async unreblogStatus(id) {
|
||||
const response = await this.client.post(`/api/v1/statuses/${id}/unreblog`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Account endpoints
|
||||
async getAccount(id) {
|
||||
const response = await this.client.get(`/api/v1/accounts/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAccountStatuses(id, options = {}) {
|
||||
const { max_id, min_id, limit = 20, exclude_replies = false, exclude_reblogs = false } = options;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (max_id) params.append('max_id', max_id);
|
||||
if (min_id) params.append('min_id', min_id);
|
||||
params.append('limit', limit.toString());
|
||||
if (exclude_replies) params.append('exclude_replies', 'true');
|
||||
if (exclude_reblogs) params.append('exclude_reblogs', 'true');
|
||||
|
||||
const response = await this.client.get(`/api/v1/accounts/${id}/statuses?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async followAccount(id) {
|
||||
const response = await this.client.post(`/api/v1/accounts/${id}/follow`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async unfollowAccount(id) {
|
||||
const response = await this.client.post(`/api/v1/accounts/${id}/unfollow`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAccountFollowers(id, options = {}) {
|
||||
const { max_id, min_id, limit = 20 } = options;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (max_id) params.append('max_id', max_id);
|
||||
if (min_id) params.append('min_id', min_id);
|
||||
params.append('limit', limit.toString());
|
||||
|
||||
const response = await this.client.get(`/api/v1/accounts/${id}/followers?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAccountFollowing(id, options = {}) {
|
||||
const { max_id, min_id, limit = 20 } = options;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (max_id) params.append('max_id', max_id);
|
||||
if (min_id) params.append('min_id', min_id);
|
||||
params.append('limit', limit.toString());
|
||||
|
||||
const response = await this.client.get(`/api/v1/accounts/${id}/following?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAccountRelationships(ids) {
|
||||
const params = new URLSearchParams();
|
||||
ids.forEach(id => params.append('id[]', id));
|
||||
|
||||
const response = await this.client.get(`/api/v1/accounts/relationships?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Media endpoints
|
||||
async uploadMedia(file, description = '') {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (description) {
|
||||
formData.append('description', description);
|
||||
}
|
||||
|
||||
const response = await this.client.post('/api/v2/media', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateMedia(id, description) {
|
||||
const response = await this.client.put(`/api/v1/media/${id}`, {
|
||||
description
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Search endpoints
|
||||
async search(query, options = {}) {
|
||||
const { type, limit = 20, resolve = false } = options;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.append('q', query);
|
||||
if (type) params.append('type', type);
|
||||
params.append('limit', limit.toString());
|
||||
if (resolve) params.append('resolve', 'true');
|
||||
|
||||
const response = await this.client.get(`/api/v2/search?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Notifications endpoints
|
||||
async getNotifications(options = {}) {
|
||||
const { max_id, min_id, limit = 20, types, exclude_types } = options;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (max_id) params.append('max_id', max_id);
|
||||
if (min_id) params.append('min_id', min_id);
|
||||
params.append('limit', limit.toString());
|
||||
if (types) types.forEach(type => params.append('types[]', type));
|
||||
if (exclude_types) exclude_types.forEach(type => params.append('exclude_types[]', type));
|
||||
|
||||
const response = await this.client.get(`/api/v1/notifications?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Instance endpoints
|
||||
async getInstance() {
|
||||
const response = await this.client.get('/api/v1/instance');
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
const api = new ApiClient();
|
||||
|
||||
export default api;
|
||||
214
src/services/oauth.js
Archivo normal
214
src/services/oauth.js
Archivo normal
@@ -0,0 +1,214 @@
|
||||
import axios from 'axios';
|
||||
|
||||
class OAuthService {
|
||||
constructor() {
|
||||
this.clientName = 'GoToSocial React Frontend';
|
||||
this.redirectUri = `${window.location.origin}/oauth/callback`;
|
||||
this.scopes = 'read write follow push';
|
||||
}
|
||||
|
||||
// Registrar la aplicación OAuth en la instancia GoToSocial
|
||||
async registerApp(instanceUrl) {
|
||||
try {
|
||||
const appData = {
|
||||
client_name: this.clientName,
|
||||
redirect_uris: this.redirectUri,
|
||||
scopes: this.scopes,
|
||||
website: window.location.origin
|
||||
};
|
||||
|
||||
const response = await axios.post(`${instanceUrl}/api/v1/apps`, appData);
|
||||
|
||||
const { client_id, client_secret } = response.data;
|
||||
|
||||
if (!client_id || !client_secret) {
|
||||
throw new Error('Invalid response from app registration');
|
||||
}
|
||||
|
||||
// Guardar las credenciales de la app para esta instancia
|
||||
localStorage.setItem(`app_credentials_${instanceUrl}`, JSON.stringify({
|
||||
client_id,
|
||||
client_secret,
|
||||
instance_url: instanceUrl
|
||||
}));
|
||||
|
||||
return { client_id, client_secret };
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
// Handle specific HTTP errors
|
||||
}
|
||||
throw new Error('Failed to register application with the instance. Please check the instance URL.');
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener credenciales de la app guardadas o registrar una nueva
|
||||
async getAppCredentials(instanceUrl) {
|
||||
const stored = localStorage.getItem(`app_credentials_${instanceUrl}`);
|
||||
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
|
||||
return await this.registerApp(instanceUrl);
|
||||
}
|
||||
|
||||
// Generar URL de autorización OAuth
|
||||
async getAuthorizationUrl(instanceUrl) {
|
||||
const { client_id } = await this.getAppCredentials(instanceUrl);
|
||||
|
||||
const state = this.generateRandomString(32);
|
||||
localStorage.setItem('oauth_state', state);
|
||||
localStorage.setItem('oauth_instance_url', instanceUrl);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id,
|
||||
redirect_uri: this.redirectUri,
|
||||
response_type: 'code',
|
||||
scope: this.scopes,
|
||||
state
|
||||
});
|
||||
|
||||
return `${instanceUrl}/oauth/authorize?${params}`;
|
||||
}
|
||||
|
||||
// Intercambiar código de autorización por token de acceso
|
||||
async exchangeCodeForToken(code, state) {
|
||||
const savedState = localStorage.getItem('oauth_state');
|
||||
const instanceUrl = localStorage.getItem('oauth_instance_url');
|
||||
|
||||
if (!savedState || savedState !== state) {
|
||||
throw new Error('Invalid OAuth state parameter');
|
||||
}
|
||||
|
||||
if (!instanceUrl) {
|
||||
throw new Error('OAuth instance URL not found');
|
||||
}
|
||||
|
||||
try {
|
||||
const { client_id, client_secret } = await this.getAppCredentials(instanceUrl);
|
||||
|
||||
const tokenData = {
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uri: this.redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
scope: this.scopes
|
||||
};
|
||||
|
||||
const response = await axios.post(`${instanceUrl}/oauth/token`, tokenData);
|
||||
|
||||
const { access_token, refresh_token, token_type = 'Bearer' } = response.data;
|
||||
|
||||
if (!access_token) {
|
||||
throw new Error('No access token received from the server');
|
||||
}
|
||||
|
||||
// Limpiar datos temporales de OAuth
|
||||
localStorage.removeItem('oauth_state');
|
||||
localStorage.removeItem('oauth_instance_url');
|
||||
|
||||
return {
|
||||
access_token,
|
||||
refresh_token,
|
||||
token_type,
|
||||
instance_url: instanceUrl
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error('Failed to obtain access token. Authorization may have been denied or expired.');
|
||||
}
|
||||
}
|
||||
|
||||
// Refrescar token de acceso
|
||||
async refreshAccessToken(refreshToken, instanceUrl) {
|
||||
try {
|
||||
const { client_id, client_secret } = await this.getAppCredentials(instanceUrl);
|
||||
|
||||
const response = await axios.post(`${instanceUrl}/oauth/token`, {
|
||||
client_id,
|
||||
client_secret,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to refresh access token');
|
||||
}
|
||||
}
|
||||
|
||||
// Revocar token de acceso
|
||||
async revokeToken(token, instanceUrl) {
|
||||
try {
|
||||
const { client_id, client_secret } = await this.getAppCredentials(instanceUrl);
|
||||
|
||||
await axios.post(`${instanceUrl}/oauth/revoke`, {
|
||||
client_id,
|
||||
client_secret,
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
// No lanzar error aquí, ya que el logout debería continuar
|
||||
}
|
||||
}
|
||||
|
||||
// Validar formato de URL de instancia
|
||||
validateInstanceUrl(instanceUrl) {
|
||||
if (!instanceUrl) {
|
||||
throw new Error('Instance URL is required');
|
||||
}
|
||||
|
||||
let url = instanceUrl.trim();
|
||||
|
||||
// Add https:// if no protocol specified
|
||||
if (!url.match(/^https?:\/\//)) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
|
||||
// Remove trailing slash
|
||||
url = url.replace(/\/$/, '');
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (!parsedUrl.hostname) {
|
||||
throw new Error('Invalid hostname');
|
||||
}
|
||||
return url;
|
||||
} catch (error) {
|
||||
throw new Error('Invalid instance URL format');
|
||||
}
|
||||
}
|
||||
|
||||
// Generar string aleatorio para estado OAuth
|
||||
generateRandomString(length) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Verificar si hay un flujo OAuth en progreso
|
||||
isOAuthCallback() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.has('code') && urlParams.has('state');
|
||||
}
|
||||
|
||||
// Procesar callback OAuth
|
||||
getOAuthCallbackParams() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
code: urlParams.get('code'),
|
||||
state: urlParams.get('state'),
|
||||
error: urlParams.get('error'),
|
||||
error_description: urlParams.get('error_description')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const oauthServiceInstance = new OAuthService();
|
||||
export default oauthServiceInstance;
|
||||
Referencia en una nueva incidencia
Block a user