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

10
.env.example Archivo normal
Ver fichero

@@ -0,0 +1,10 @@
# GoToSocial React Frontend Configuration
# Optional: Default instance URL (can be overridden at login)
# REACT_APP_DEFAULT_INSTANCE_URL=https://your-gotosocial-instance.com
# Optional: Application name (used in OAuth registration)
# REACT_APP_CLIENT_NAME=GoToSocial React Frontend
# Optional: Enable development mode features
# REACT_APP_DEV_MODE=true

167
README.md
Ver fichero

@@ -1,43 +1,164 @@
# Getting Started with Create React App
# GoToSocial React Frontend
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
Un frontend moderno y responsivo en React para GoToSocial - un servidor de red social ActivityPub rápido, divertido y pequeño.
## Available Scripts
## ✨ Características
In the project directory, you can run:
### 🎨 Diseño Moderno
- Interfaz moderna basada en gradientes con estética limpia y profesional
- Diseño totalmente responsivo que funciona en desktop, tablet y móvil
- Navegación intuitiva con jerarquía visual clara
- Animaciones y transiciones suaves
### `npm start`
### 🏠 Vistas de Timeline
- **Home Timeline**: Timeline personalizado para usuarios autenticados
- **Public Timeline**: Posts públicos globales de instancias federadas
- **Local Timeline**: Posts públicos solo de la instancia local
- Scroll infinito con funcionalidad de cargar más
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
### 👤 Perfiles de Usuario
- Páginas de perfil completas con avatar, biografía y estadísticas
- Pestañas de posts, medios e interacciones
- Funcionalidad de seguir/no seguir
- Estadísticas de perfil (seguidores, siguiendo, posts)
The page will reload when you make changes.\
You may also see any lint errors in the console.
### 📝 Gestión de Posts
- Compositor de posts rico con controles de privacidad
- **Subida de medios e imágenes** con previsualización y edición de texto alternativo
- **Selector de emojis** con emojis comunes y inserción en posición del cursor
- Soporte para advertencias de contenido y archivos adjuntos
- Visualización completa de posts con interacciones (like, boost, reply, share)
- Conversaciones anidadas y cadenas de respuestas
### `npm test`
### 🔔 Notificaciones
- Sistema de notificaciones en tiempo real
- Notificaciones categorizadas (likes, boosts, follows, menciones)
- Indicadores visuales para notificaciones no leídas
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### 🔍 Búsqueda
- Búsqueda universal a través de cuentas, hashtags y posts
- Resultados de búsqueda con pestañas para filtrado fácil
- Sugerencias de búsqueda en tiempo real
### `npm run build`
### 🔐 Autenticación OAuth2
- Sistema de autenticación OAuth2 completamente funcional
- Registro automático de aplicación en instancias GoToSocial
- Intercambio seguro de códigos de autorización por tokens
- Gestión automática de refresh de tokens
- Sesiones persistentes con almacenamiento local
- Configuración de URL de instancia
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
## Tech Stack
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
- **React 19** - Modern React with latest features
- **React Router DOM** - Client-side routing
- **Styled Components** - CSS-in-JS for component styling
- **Axios** - HTTP client for API requests
- **React Icons** - Beautiful icon library
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
## Getting Started
### `npm run eject`
### Prerequisites
- Node.js 16 or higher
- npm or yarn
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
### Installation
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
1. Install dependencies:
```bash
npm install
```
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
2. Start the development server:
```bash
npm start
```
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
3. Open [http://localhost:3000](http://localhost:3000) in your browser
### Autenticación OAuth2
El frontend utiliza OAuth2 para autenticación segura:
1. Ingresa la URL de tu instancia GoToSocial o Mastodon
2. Haz clic en "Conectar con OAuth"
3. Serás redirigido a la página de autorización de tu instancia
4. Autoriza la aplicación
5. Serás redirigido de vuelta e iniciarás sesión automáticamente
### Funcionalidades de Medios y Emojis
- **Subida de Imágenes**: Selecciona hasta 4 imágenes con límite de 10MB por archivo
- **Previsualización de Medios**: Vista previa de imágenes antes de publicar
- **Texto Alternativo**: Edita el texto alternativo para accesibilidad
- **Selector de Emojis**: Más de 100 emojis comunes organizados para fácil selección
- **Inserción Inteligente**: Los emojis se insertan en la posición exacta del cursor
## Configuration
### Environment Variables
Create a `.env` file in the root directory:
```env
REACT_APP_API_BASE_URL=https://your-gotosocial-instance.com
```
### Production Deployment
For production deployment:
1. Build the application: `npm run build`
2. Serve the built files from a web server
3. Ensure your web server is configured for client-side routing
4. Configure HTTPS for OAuth security
5. Update OAuth redirect URIs in your GoToSocial instance if needed
## Project Structure
```
src/
├── components/ # Reusable UI components
│ ├── Layout/ # Layout components (Header, Layout)
│ ├── Compose/ # Post composition components
│ └── Post/ # Post display components
├── pages/ # Page components
│ ├── HomePage.js # Home timeline
│ ├── LoginPage.js # Authentication
│ ├── ProfilePage.js # User profiles
│ ├── SearchPage.js # Search functionality
│ └── ...
├── services/ # API and external services
│ └── api.js # GoToSocial API client
└── App.js # Main application component
```
## API Integration
The project includes a comprehensive API client (`src/services/api.js`) that implements:
- Authentication endpoints
- Timeline endpoints (home, public, local, tag)
- Status endpoints (create, delete, favorite, reblog)
- Account endpoints (profile, follow, search)
- Notification endpoints
- Search endpoints
## Responsive Design
The frontend is fully responsive with breakpoints for:
- **Desktop**: Full three-column layout with sidebars
- **Tablet**: Simplified layout with hidden sidebars
- **Mobile**: Single-column layout optimized for touch
## Screenshots
The frontend features:
- Modern gradient design with purple/blue color scheme
- Card-based layout for posts and profiles
- Intuitive navigation with clear iconography
- Responsive design that adapts to all screen sizes
- Professional typography and spacing
## Learn More

235
package-lock.json generado
Ver fichero

@@ -12,9 +12,13 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.12.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
"react-router-dom": "^7.9.3",
"react-scripts": "5.0.1",
"styled-components": "^6.1.19",
"web-vitals": "^2.1.4"
}
},
@@ -2366,6 +2370,27 @@
"postcss-selector-parser": "^6.0.10"
}
},
"node_modules/@emotion/is-prop-valid": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz",
"integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==",
"license": "MIT",
"dependencies": {
"@emotion/memoize": "^0.8.1"
}
},
"node_modules/@emotion/memoize": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
"integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==",
"license": "MIT"
},
"node_modules/@emotion/unitless": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
"integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==",
"license": "MIT"
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
@@ -3867,6 +3892,12 @@
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"license": "MIT"
},
"node_modules/@types/stylis": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz",
"integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==",
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -4891,6 +4922,33 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -5469,6 +5527,15 @@
"node": ">= 6"
}
},
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-api": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz",
@@ -6012,6 +6079,15 @@
"postcss": "^8.4"
}
},
"node_modules/css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
"license": "ISC",
"engines": {
"node": ">=4"
}
},
"node_modules/css-declaration-sorter": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz",
@@ -6161,6 +6237,17 @@
"integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==",
"license": "MIT"
},
"node_modules/css-to-react-native": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
"license": "MIT",
"dependencies": {
"camelize": "^1.0.0",
"css-color-keywords": "^1.0.0",
"postcss-value-parser": "^4.0.2"
}
},
"node_modules/css-tree": {
"version": "1.0.0-alpha.37",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz",
@@ -6369,6 +6456,12 @@
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -13633,6 +13726,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -13909,6 +14008,15 @@
"integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ==",
"license": "MIT"
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -13924,6 +14032,53 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz",
"integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/react-scripts": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
@@ -14818,6 +14973,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -14870,6 +15031,12 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -15552,6 +15719,68 @@
"webpack": "^5.0.0"
}
},
"node_modules/styled-components": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.19.tgz",
"integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==",
"license": "MIT",
"dependencies": {
"@emotion/is-prop-valid": "1.2.2",
"@emotion/unitless": "0.8.1",
"@types/stylis": "4.2.5",
"css-to-react-native": "3.2.0",
"csstype": "3.1.3",
"postcss": "8.4.49",
"shallowequal": "1.1.0",
"stylis": "4.3.2",
"tslib": "2.6.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/styled-components"
},
"peerDependencies": {
"react": ">= 16.8.0",
"react-dom": ">= 16.8.0"
}
},
"node_modules/styled-components/node_modules/postcss": {
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/styled-components/node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"license": "0BSD"
},
"node_modules/stylehacks": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz",
@@ -15568,6 +15797,12 @@
"postcss": "^8.2.15"
}
},
"node_modules/stylis": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz",
"integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",

Ver fichero

@@ -7,9 +7,13 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.12.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
"react-router-dom": "^7.9.3",
"react-scripts": "5.0.1",
"styled-components": "^6.1.19",
"web-vitals": "^2.1.4"
},
"scripts": {

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 3.8 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 17 KiB

Ver fichero

@@ -24,7 +24,14 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<meta name="application-name" content="GoToSocial React Frontend" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="GoToSocial React Frontend" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="description" content="GoToSocial React Frontend" />
<meta name="mobile-web-app-capable" content="yes" />
<title>GoToSocial React Frontend</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 5.2 KiB

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 9.4 KiB

Ver fichero

@@ -1,21 +1,11 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "GoToSocial React Frontend",
"name": "GoToSocial React Frontend",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",

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;