initial commit

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2025-09-29 02:07:21 +02:00
padre f26be9dae3
commit 0888baa32f
Se han modificado 28 ficheros con 5280 adiciones y 78 borrados

Ver fichero

@@ -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;
}

Ver fichero

@@ -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>
);
}

Ver fichero

@@ -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;

Ver fichero

@@ -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;

Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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;