Compare commits
20 Commits
924a121eef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 89dad2ee13 | |||
| a31c99453b | |||
| eb036dece5 | |||
| 67e3eef364 | |||
| 7eb3a08b20 | |||
| 0dcc1323bd | |||
| 45e0989c5d | |||
| a55b248e1b | |||
| d31b0275e5 | |||
| e1176fd52e | |||
| 9628eea874 | |||
| 459873f0e5 | |||
| 9b078254ac | |||
| d74105529e | |||
| c4caaddb10 | |||
| f3b71ecb32 | |||
| 1cb1c93739 | |||
| 82a56b8221 | |||
| 7455c30510 | |||
| b463a8f5b8 |
100
.gitignore
vendored
@@ -1 +1,99 @@
|
|||||||
node_modules
|
# Strapi
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Typescript v1 declaration files
|
||||||
|
typings/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
|
|
||||||
|
### Astro ###
|
||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Local fonts
|
||||||
|
fonts/
|
||||||
|
|
||||||
|
# cache files
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Local Netlify folder
|
||||||
|
.netlify
|
||||||
|
|
||||||
|
# Local Vercel folder
|
||||||
|
.vercel
|
||||||
262
DOCKER_README.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
# Strapi with PostgreSQL and Astro Client - Docker Compose Setup
|
||||||
|
|
||||||
|
This setup provides a complete Docker Compose environment for running Strapi with PostgreSQL and an Astro frontend client.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker
|
||||||
|
- Docker Compose
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. **Clone and navigate to the project directory**
|
||||||
|
```bash
|
||||||
|
cd /path/to/your/project
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install PostgreSQL dependencies**
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm install pg
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Generate secure environment variables**
|
||||||
|
```bash
|
||||||
|
# Generate JWT secrets (you can use any secure random string generator)
|
||||||
|
echo "JWT_SECRET=$(openssl rand -base64 32)"
|
||||||
|
echo "ADMIN_JWT_SECRET=$(openssl rand -base64 32)"
|
||||||
|
echo "APP_KEYS=$(openssl rand -base64 32),$(openssl rand -base64 32),$(openssl rand -base64 32),$(openssl rand -base64 32)"
|
||||||
|
echo "API_TOKEN_SALT=$(openssl rand -base64 32)"
|
||||||
|
echo "TRANSFER_TOKEN_SALT=$(openssl rand -base64 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Create a .env file** (copy from docker-compose.env and update with your generated secrets)
|
||||||
|
```bash
|
||||||
|
cp docker-compose.env .env
|
||||||
|
# Edit .env file with your generated secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Start the services**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Access the applications**
|
||||||
|
- Strapi Admin Panel: http://localhost:1337/admin
|
||||||
|
- Strapi API: http://localhost:1337/api
|
||||||
|
- Astro Client: http://localhost:4321
|
||||||
|
|
||||||
|
## Environment Variables Setup
|
||||||
|
|
||||||
|
### Automatic Setup (Recommended)
|
||||||
|
Use the provided setup script to automatically configure environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./setup-env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This script will:
|
||||||
|
- Copy `docker-compose.env` to `.env`
|
||||||
|
- Generate secure random secrets for JWT tokens and salts
|
||||||
|
- Set up all necessary environment variables
|
||||||
|
|
||||||
|
### Manual Setup
|
||||||
|
If you prefer to set up environment variables manually:
|
||||||
|
|
||||||
|
1. **Copy the environment template**
|
||||||
|
```bash
|
||||||
|
cp docker-compose.env .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Edit the .env file**
|
||||||
|
```bash
|
||||||
|
nano .env # or use your preferred editor
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Update sensitive values**
|
||||||
|
- Replace all `your-*-here-change-this-in-production` values with secure random strings
|
||||||
|
- Modify database credentials if needed
|
||||||
|
- Adjust URLs and ports as required
|
||||||
|
|
||||||
|
### Environment Variables Reference
|
||||||
|
|
||||||
|
The following variables are available in `docker-compose.env`:
|
||||||
|
|
||||||
|
#### Database Configuration
|
||||||
|
- `DATABASE_CLIENT`: Database type (postgres)
|
||||||
|
- `DATABASE_HOST`: Database host (postgres)
|
||||||
|
- `DATABASE_PORT`: Database port (5432)
|
||||||
|
- `DATABASE_NAME`: Database name (strapi)
|
||||||
|
- `DATABASE_USERNAME`: Database username (strapi)
|
||||||
|
- `DATABASE_PASSWORD`: Database password (strapi_password)
|
||||||
|
- `DATABASE_SSL`: SSL connection (false)
|
||||||
|
|
||||||
|
#### Strapi Configuration
|
||||||
|
- `NODE_ENV`: Environment (development/production)
|
||||||
|
- `JWT_SECRET`: JWT signing secret
|
||||||
|
- `ADMIN_JWT_SECRET`: Admin JWT signing secret
|
||||||
|
- `APP_KEYS`: Application keys (comma-separated)
|
||||||
|
- `API_TOKEN_SALT`: API token salt
|
||||||
|
- `TRANSFER_TOKEN_SALT`: Transfer token salt
|
||||||
|
- `HOST`: Server host (0.0.0.0)
|
||||||
|
- `PORT`: Server port (1337)
|
||||||
|
|
||||||
|
#### Client Configuration
|
||||||
|
- `CLIENT_NODE_ENV`: Client environment (development/production)
|
||||||
|
- `STRAPI_URL`: Strapi API URL (http://strapi:1337)
|
||||||
|
|
||||||
|
### Security Notes
|
||||||
|
- The `.env` file is gitignored for security reasons
|
||||||
|
- Never commit sensitive secrets to version control
|
||||||
|
- Use different secrets for development and production
|
||||||
|
- Regularly rotate secrets in production environments
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
### Strapi Application (Backend)
|
||||||
|
- **Port**: 1337
|
||||||
|
- **Container**: strapi-app
|
||||||
|
- **Database**: PostgreSQL
|
||||||
|
- **Purpose**: Headless CMS and API
|
||||||
|
|
||||||
|
### Astro Client (Frontend)
|
||||||
|
- **Port**: 4321
|
||||||
|
- **Container**: astro-client
|
||||||
|
- **Purpose**: Frontend application
|
||||||
|
- **Backend**: Connects to Strapi API
|
||||||
|
|
||||||
|
### PostgreSQL Database
|
||||||
|
- **Port**: 5432
|
||||||
|
- **Container**: strapi-postgres
|
||||||
|
- **Database**: strapi
|
||||||
|
- **Username**: strapi
|
||||||
|
- **Password**: strapi_password
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
The following environment variables are configured:
|
||||||
|
|
||||||
|
### Strapi Configuration
|
||||||
|
- `DATABASE_CLIENT`: postgres
|
||||||
|
- `DATABASE_HOST`: postgres (Docker service name)
|
||||||
|
- `DATABASE_PORT`: 5432
|
||||||
|
- `DATABASE_NAME`: strapi
|
||||||
|
- `DATABASE_USERNAME`: strapi
|
||||||
|
- `DATABASE_PASSWORD`: strapi_password
|
||||||
|
- `NODE_ENV`: development
|
||||||
|
|
||||||
|
### Client Configuration
|
||||||
|
- `NODE_ENV`: development
|
||||||
|
- `STRAPI_URL`: http://strapi:1337 (internal Docker network)
|
||||||
|
|
||||||
|
## Volumes
|
||||||
|
|
||||||
|
- `postgres_data`: Persistent PostgreSQL data
|
||||||
|
- `./server/public/uploads`: Strapi uploads directory
|
||||||
|
- `./server/.tmp`: Strapi temporary files
|
||||||
|
- `./client/src`: Astro source code (for development)
|
||||||
|
- `./client/public`: Astro public assets
|
||||||
|
- `./client/astro.config.mjs`: Astro configuration
|
||||||
|
- `./client/tsconfig.json`: TypeScript configuration
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs for specific services
|
||||||
|
docker-compose logs -f strapi
|
||||||
|
docker-compose logs -f client
|
||||||
|
docker-compose logs -f postgres
|
||||||
|
|
||||||
|
# View all logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Stop all services
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Stop and remove volumes (WARNING: This will delete all data)
|
||||||
|
docker-compose down -v
|
||||||
|
|
||||||
|
# Rebuild and start
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Rebuild specific service
|
||||||
|
docker-compose up -d --build client
|
||||||
|
|
||||||
|
# Access PostgreSQL directly
|
||||||
|
docker-compose exec postgres psql -U strapi -d strapi
|
||||||
|
|
||||||
|
# Access Strapi container
|
||||||
|
docker-compose exec strapi sh
|
||||||
|
|
||||||
|
# Access Astro client container
|
||||||
|
docker-compose exec client sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Hot Reload
|
||||||
|
Both Strapi and Astro support hot reloading in development mode:
|
||||||
|
- Changes to Strapi files will automatically restart the server
|
||||||
|
- Changes to Astro files will automatically reload the browser
|
||||||
|
|
||||||
|
### API Communication
|
||||||
|
The Astro client can communicate with Strapi using:
|
||||||
|
- Internal Docker network: `http://strapi:1337`
|
||||||
|
- External access: `http://localhost:1337`
|
||||||
|
|
||||||
|
### Example API call from Astro
|
||||||
|
```javascript
|
||||||
|
// In your Astro component or page
|
||||||
|
const response = await fetch('http://strapi:1337/api/your-content-type');
|
||||||
|
const data = await response.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
## First Time Setup
|
||||||
|
|
||||||
|
1. After starting the services, visit http://localhost:1337/admin
|
||||||
|
2. Create your first admin user
|
||||||
|
3. Configure your content types and permissions
|
||||||
|
4. Visit http://localhost:4321 to see your Astro frontend
|
||||||
|
5. Configure your Astro app to fetch data from Strapi
|
||||||
|
|
||||||
|
## Production Considerations
|
||||||
|
|
||||||
|
For production deployment:
|
||||||
|
|
||||||
|
1. Change all default passwords and secrets
|
||||||
|
2. Use proper SSL certificates
|
||||||
|
3. Set `NODE_ENV=production` for both services
|
||||||
|
4. Configure proper backup strategies for PostgreSQL
|
||||||
|
5. Use environment-specific configuration files
|
||||||
|
6. Consider using Docker secrets for sensitive data
|
||||||
|
7. Build the Astro app for production: `npm run build`
|
||||||
|
8. Use a production-ready web server for the Astro build
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Strapi won't start
|
||||||
|
- Check if PostgreSQL is running: `docker-compose logs postgres`
|
||||||
|
- Verify database connection settings
|
||||||
|
- Check if all environment variables are set correctly
|
||||||
|
|
||||||
|
### Astro client won't start
|
||||||
|
- Check if Strapi is running: `docker-compose logs strapi`
|
||||||
|
- Verify the STRAPI_URL environment variable
|
||||||
|
- Check for port conflicts on 4321
|
||||||
|
|
||||||
|
### Database connection issues
|
||||||
|
- Ensure PostgreSQL container is healthy: `docker-compose ps`
|
||||||
|
- Check database logs: `docker-compose logs postgres`
|
||||||
|
- Verify network connectivity between containers
|
||||||
|
|
||||||
|
### Permission issues
|
||||||
|
- Ensure proper file permissions on mounted volumes
|
||||||
|
- Check if the Docker user has access to the project files
|
||||||
|
|
||||||
|
### Network connectivity between services
|
||||||
|
- Verify all services are on the same network: `docker network ls`
|
||||||
|
- Check if services can reach each other using container names
|
||||||
16
client/.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.nyc_output
|
||||||
|
coverage
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
.next
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.astro
|
||||||
|
.vscode
|
||||||
26
client/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM node:lts AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# By copying only the package.json and package-lock.json here, we ensure that the following `-deps` steps are independent of the source code.
|
||||||
|
# Therefore, the `-deps` steps will be skipped if only the source code changes.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
FROM base AS prod-deps
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
FROM base AS build-deps
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
FROM build-deps AS build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM base AS runtime
|
||||||
|
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=4114
|
||||||
|
|
||||||
|
EXPOSE 4114
|
||||||
|
CMD node ./dist/server/entry.mjs
|
||||||
240
client/I18N_README.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# Internationalization (i18n) Setup
|
||||||
|
|
||||||
|
This project includes a comprehensive internationalization system built with Astro 5.x. The system supports multiple languages with URL-based routing and automatic language detection.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ URL-based language routing (Spanish: `/`, English: `/en/`)
|
||||||
|
- ✅ Spanish as default language (no URL prefix)
|
||||||
|
- ✅ Automatic language detection from URL
|
||||||
|
- ✅ Language switcher component
|
||||||
|
- ✅ Type-safe translation keys
|
||||||
|
- ✅ Fallback to default language
|
||||||
|
- ✅ Middleware for automatic redirects
|
||||||
|
- ✅ SEO-friendly URLs
|
||||||
|
|
||||||
|
## Supported Languages
|
||||||
|
|
||||||
|
Currently supported languages:
|
||||||
|
- **Spanish (es)** - Default language (no URL prefix)
|
||||||
|
- **English (en)** - Secondary language (with `/en/` prefix)
|
||||||
|
|
||||||
|
## URL Structure
|
||||||
|
|
||||||
|
- **Spanish (default)**: `/`, `/elements`, `/about`
|
||||||
|
- **English**: `/en/`, `/en/elements`, `/en/about`
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
### 1. Adding New Translations
|
||||||
|
|
||||||
|
Edit the translation dictionaries in `src/lib/i18n.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const translations = {
|
||||||
|
es: {
|
||||||
|
'your.new.key': 'Texto en español',
|
||||||
|
// ... more translations
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
'your.new.key': 'English text',
|
||||||
|
// ... more translations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Using Translations in Components
|
||||||
|
|
||||||
|
```astro
|
||||||
|
---
|
||||||
|
import { t, getLanguageFromPath } from '../lib/i18n';
|
||||||
|
|
||||||
|
const currentLang = getLanguageFromPath(Astro.url.pathname);
|
||||||
|
---
|
||||||
|
|
||||||
|
<h1>{t('your.new.key', currentLang)}</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Creating Language-Specific Pages
|
||||||
|
|
||||||
|
For English pages, create them in the `src/pages/[lang]/` directory:
|
||||||
|
|
||||||
|
```astro
|
||||||
|
---
|
||||||
|
// src/pages/[lang]/about.astro (English only)
|
||||||
|
import { t, isSupportedLanguage, type SupportedLanguage, DEFAULT_LANGUAGE } from '../../lib/i18n';
|
||||||
|
|
||||||
|
const { lang } = Astro.params;
|
||||||
|
|
||||||
|
if (!lang || !isSupportedLanguage(lang)) {
|
||||||
|
return Astro.redirect('/about');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLang = lang as SupportedLanguage;
|
||||||
|
|
||||||
|
// Redirect Spanish to root
|
||||||
|
if (currentLang === DEFAULT_LANGUAGE) {
|
||||||
|
return Astro.redirect('/about');
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<h1>{t('about.title', currentLang)}</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
For Spanish pages (default), create them directly in `src/pages/`:
|
||||||
|
|
||||||
|
```astro
|
||||||
|
---
|
||||||
|
// src/pages/about.astro (Spanish default)
|
||||||
|
import { t, DEFAULT_LANGUAGE } from '../lib/i18n';
|
||||||
|
|
||||||
|
const currentLang = DEFAULT_LANGUAGE;
|
||||||
|
---
|
||||||
|
|
||||||
|
<h1>{t('about.title', currentLang)}</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Adding the Language Switcher
|
||||||
|
|
||||||
|
The language switcher is automatically included in the main layout. To add it to other components:
|
||||||
|
|
||||||
|
```astro
|
||||||
|
---
|
||||||
|
import LanguageSwitcher from '../components/LanguageSwitcher.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<LanguageSwitcher />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Creating Localized Links
|
||||||
|
|
||||||
|
Use the `getLocalizedPath` function for navigation:
|
||||||
|
|
||||||
|
```astro
|
||||||
|
---
|
||||||
|
import { getLocalizedPath, getLanguageFromPath } from '../lib/i18n';
|
||||||
|
|
||||||
|
const currentLang = getLanguageFromPath(Astro.url.pathname);
|
||||||
|
---
|
||||||
|
|
||||||
|
<a href={getLocalizedPath('/about', currentLang)}>About</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib/
|
||||||
|
│ └── i18n.ts # Main i18n utilities and translations
|
||||||
|
├── components/
|
||||||
|
│ ├── LanguageSwitcher.astro # Language selection component
|
||||||
|
│ └── ... # Other components
|
||||||
|
├── pages/
|
||||||
|
│ ├── [lang]/ # English-specific pages
|
||||||
|
│ │ ├── index.astro # Redirects Spanish to root
|
||||||
|
│ │ ├── elements.astro # Redirects Spanish to root
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── index.astro # Spanish default homepage
|
||||||
|
│ ├── elements.astro # Spanish default elements page
|
||||||
|
│ └── ... # Other Spanish default pages
|
||||||
|
└── middleware.ts # Language routing middleware
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Translation Functions
|
||||||
|
|
||||||
|
#### `t(key, lang?)`
|
||||||
|
Get a translation for a given key and language.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
t('nav.home', 'es') // Returns: "Inicio"
|
||||||
|
t('nav.home', 'en') // Returns: "Home"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `getLanguageFromPath(pathname)`
|
||||||
|
Extract language from URL path.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
getLanguageFromPath('/en/elements') // Returns: "en"
|
||||||
|
getLanguageFromPath('/elements') // Returns: "es" (default)
|
||||||
|
getLanguageFromPath('/') // Returns: "es" (default)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `getLocalizedPath(path, lang)`
|
||||||
|
Generate a localized URL path.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
getLocalizedPath('/elements', 'es') // Returns: "/elements"
|
||||||
|
getLocalizedPath('/elements', 'en') // Returns: "/en/elements"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `isSupportedLanguage(lang)`
|
||||||
|
Check if a language is supported.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
isSupportedLanguage('es') // Returns: true
|
||||||
|
isSupportedLanguage('fr') // Returns: false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Constants
|
||||||
|
|
||||||
|
- `SUPPORTED_LANGUAGES` - Array of supported language codes (['es', 'en'])
|
||||||
|
- `DEFAULT_LANGUAGE` - Default language code ('es')
|
||||||
|
- `TranslationKey` - TypeScript type for translation keys
|
||||||
|
|
||||||
|
## Adding New Languages
|
||||||
|
|
||||||
|
1. Add the language code to `SUPPORTED_LANGUAGES` in `src/lib/i18n.ts`
|
||||||
|
2. Add translations for the new language in the `translations` object
|
||||||
|
3. Update the language switcher component if needed
|
||||||
|
4. Create language-specific pages in `src/pages/[lang]/`
|
||||||
|
|
||||||
|
Example for French:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const SUPPORTED_LANGUAGES = ['es', 'en', 'fr'] as const;
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
es: { /* Spanish translations */ },
|
||||||
|
en: { /* English translations */ },
|
||||||
|
fr: { /* French translations */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use descriptive keys**: Use namespaced keys like `nav.home`, `elements.title`
|
||||||
|
2. **Keep translations organized**: Group related translations together
|
||||||
|
3. **Test all languages**: Ensure all translations are complete
|
||||||
|
4. **Use TypeScript**: Leverage the `TranslationKey` type for type safety
|
||||||
|
5. **Handle missing translations**: The system falls back to the default language
|
||||||
|
6. **Spanish first**: Always put Spanish translations first in the dictionary
|
||||||
|
|
||||||
|
## SEO Considerations
|
||||||
|
|
||||||
|
- Spanish content is served at root URLs (better for SEO)
|
||||||
|
- English content has `/en/` prefix
|
||||||
|
- Language is properly set in the HTML `lang` attribute
|
||||||
|
- Search engines can index content in multiple languages
|
||||||
|
- Use proper hreflang tags if needed for advanced SEO
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Translation not found**: Check if the key exists in all language dictionaries
|
||||||
|
2. **Language not detected**: Ensure the URL follows the correct pattern
|
||||||
|
3. **Redirect loops**: Check middleware configuration
|
||||||
|
4. **Type errors**: Use the `TranslationKey` type for translation keys
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
Enable debug logging by adding console logs in the i18n functions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function t(key: TranslationKey, lang: SupportedLanguage = DEFAULT_LANGUAGE): string {
|
||||||
|
console.log(`Translating: ${key} for language: ${lang}`);
|
||||||
|
// ... rest of function
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,11 +1,62 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
import AstroPWA from '@vite-pwa/astro'
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
integrations: [
|
||||||
|
AstroPWA({
|
||||||
|
mode: 'production',
|
||||||
|
base: '/',
|
||||||
|
scope: '/',
|
||||||
|
includeAssets: ['favicon.ico'],
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
manifest: {
|
||||||
|
name: 'Pole Book',
|
||||||
|
short_name: 'Pole Book',
|
||||||
|
theme_color: '#ffffff',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
navigateFallback: '/',
|
||||||
|
globPatterns: ['**/*.{css,js,html,svg,png,ico,txt}'],
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: false,
|
||||||
|
navigateFallbackAllowlist: [/^\/$/],
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
directoryAndTrailingSlashHandler: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
output: 'server',
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()]
|
plugins: [tailwindcss()]
|
||||||
}
|
},
|
||||||
|
|
||||||
|
adapter: node({
|
||||||
|
mode: 'standalone'
|
||||||
|
})
|
||||||
});
|
});
|
||||||
91
client/dev-dist/sw.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// If the loader is already loaded, just stop.
|
||||||
|
if (!self.define) {
|
||||||
|
let registry = {};
|
||||||
|
|
||||||
|
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||||
|
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||||
|
let nextDefineUri;
|
||||||
|
|
||||||
|
const singleRequire = (uri, parentUri) => {
|
||||||
|
uri = new URL(uri + ".js", parentUri).href;
|
||||||
|
return registry[uri] || (
|
||||||
|
|
||||||
|
new Promise(resolve => {
|
||||||
|
if ("document" in self) {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = uri;
|
||||||
|
script.onload = resolve;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
} else {
|
||||||
|
nextDefineUri = uri;
|
||||||
|
importScripts(uri);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.then(() => {
|
||||||
|
let promise = registry[uri];
|
||||||
|
if (!promise) {
|
||||||
|
throw new Error(`Module ${uri} didn’t register its module`);
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
self.define = (depsNames, factory) => {
|
||||||
|
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||||
|
if (registry[uri]) {
|
||||||
|
// Module is already loading or loaded.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let exports = {};
|
||||||
|
const require = depUri => singleRequire(depUri, uri);
|
||||||
|
const specialDeps = {
|
||||||
|
module: { uri },
|
||||||
|
exports,
|
||||||
|
require
|
||||||
|
};
|
||||||
|
registry[uri] = Promise.all(depsNames.map(
|
||||||
|
depName => specialDeps[depName] || require(depName)
|
||||||
|
)).then(deps => {
|
||||||
|
factory(...deps);
|
||||||
|
return exports;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
||||||
|
|
||||||
|
self.skipWaiting();
|
||||||
|
workbox.clientsClaim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The precacheAndRoute() method efficiently caches and responds to
|
||||||
|
* requests for URLs in the manifest.
|
||||||
|
* See https://goo.gl/S9QRab
|
||||||
|
*/
|
||||||
|
workbox.precacheAndRoute([{
|
||||||
|
"url": "/",
|
||||||
|
"revision": "0.2pgnfiqdf3g"
|
||||||
|
}], {
|
||||||
|
"directoryIndex": "index.html"
|
||||||
|
});
|
||||||
|
workbox.cleanupOutdatedCaches();
|
||||||
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/"), {
|
||||||
|
allowlist: [/^\/$/]
|
||||||
|
}));
|
||||||
|
|
||||||
|
}));
|
||||||
3391
client/dev-dist/workbox-54d0af47.js
Normal file
27
client/docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: pole-book-astro-client
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4114:4114"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
NODE_ENV: ${NODE_ENV}
|
||||||
|
STRAPI_URL: ${STRAPI_URL}
|
||||||
|
volumes:
|
||||||
|
- ./src:/opt/app/src
|
||||||
|
- ./public:/opt/app/public
|
||||||
|
- ./astro.config.mjs:/opt/app/astro.config.mjs
|
||||||
|
- ./tsconfig.json:/opt/app/tsconfig.json
|
||||||
|
networks:
|
||||||
|
- pole-book_strapi-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
pole-book_strapi-network:
|
||||||
|
external: true
|
||||||
4546
client/package-lock.json
generated
@@ -6,14 +6,20 @@
|
|||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro"
|
"astro": "astro",
|
||||||
|
"generate-pwa-assets": "pwa-assets-generator --preset minimal-2023 public/logo.png"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@astrojs/node": "^9.2.2",
|
||||||
"@tailwindcss/vite": "^4.1.10",
|
"@tailwindcss/vite": "^4.1.10",
|
||||||
"@types/qs": "^6.14.0",
|
"@types/qs": "^6.14.0",
|
||||||
|
"@vite-pwa/astro": "^1.1.0",
|
||||||
"astro": "^5.7.12",
|
"astro": "^5.7.12",
|
||||||
"marked": "^15.0.12",
|
"marked": "^15.0.12",
|
||||||
"qs": "^6.14.0",
|
"qs": "^6.14.0",
|
||||||
"tailwindcss": "^4.1.10"
|
"tailwindcss": "^4.1.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vite-pwa/assets-generator": "^1.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
client/public/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
client/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
client/public/logo.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
client/public/maskable-icon-512x512.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
client/public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
client/public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
client/public/pwa-64x64.png
Normal file
|
After Width: | Height: | Size: 974 B |
@@ -1,61 +0,0 @@
|
|||||||
import { defineCollection, z } from "astro:content";
|
|
||||||
import qs from "qs";
|
|
||||||
|
|
||||||
// Define a custom content collection that loads data from Strapi
|
|
||||||
const strapiPoleElementsLoader = defineCollection({
|
|
||||||
// Async loader function that fetches data from Strapi API
|
|
||||||
loader: async () => {
|
|
||||||
// Get Strapi URL from environment variables or fallback to localhost
|
|
||||||
const BASE_URL = import.meta.env.STRAPI_URL || "http://localhost:1337";
|
|
||||||
const path = "/api/elements";
|
|
||||||
const url = new URL(path, BASE_URL);
|
|
||||||
|
|
||||||
// Build query parameters using qs to populate cover image data
|
|
||||||
url.search = qs.stringify({
|
|
||||||
populate: {
|
|
||||||
mainImage: {
|
|
||||||
fields: ["url", "alternativeText"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch articles from Strapi
|
|
||||||
const poleElementsData = await fetch(url.href);
|
|
||||||
const { data }= await poleElementsData.json();
|
|
||||||
|
|
||||||
// Transform the API response into the desired data structure
|
|
||||||
return data.map((item) => ({
|
|
||||||
id: item.id.toString(),
|
|
||||||
name: item.name,
|
|
||||||
title: item.name,
|
|
||||||
description: item.description,
|
|
||||||
createdAt: item.createdAt,
|
|
||||||
updatedAt: item.updatedAt,
|
|
||||||
publishedAt: item.publishedAt,
|
|
||||||
mainImage: {
|
|
||||||
id: Number(item.mainImage.id),
|
|
||||||
documentId: item.mainImage.documentId,
|
|
||||||
url: item.mainImage.url,
|
|
||||||
alternativeText: item.mainImage.alternativeText,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
// Define the schema for type validation using Zod
|
|
||||||
schema: z.object({
|
|
||||||
id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
title: z.string(),
|
|
||||||
description: z.string(),
|
|
||||||
createdAt: z.string(),
|
|
||||||
updatedAt: z.string(),
|
|
||||||
publishedAt: z.string(),
|
|
||||||
mainImage: z.object({
|
|
||||||
id: z.number(),
|
|
||||||
documentId: z.string(),
|
|
||||||
url: z.string(),
|
|
||||||
alternativeText: z.string(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default strapiPoleElementsLoader;
|
|
||||||
49
client/src/components/LanguageSwitcher.astro
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
import { SUPPORTED_LANGUAGES, getLanguageFromPath, getLocalizedPath, type SupportedLanguage } from '../lib/i18n';
|
||||||
|
|
||||||
|
// Get current language from URL
|
||||||
|
const currentPath = Astro.url.pathname;
|
||||||
|
const currentLang = getLanguageFromPath(currentPath);
|
||||||
|
|
||||||
|
// Generate language options
|
||||||
|
const languageOptions = SUPPORTED_LANGUAGES.map(lang => ({
|
||||||
|
code: lang,
|
||||||
|
name: lang === 'es' ? 'Español' : 'English',
|
||||||
|
path: getLocalizedPath(currentPath, lang),
|
||||||
|
isCurrent: lang === currentLang
|
||||||
|
}));
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="language-switcher">
|
||||||
|
<select
|
||||||
|
id="language-select"
|
||||||
|
class="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
onchange="window.location.href = this.value"
|
||||||
|
>
|
||||||
|
{languageOptions.map((option: any) => (
|
||||||
|
<option
|
||||||
|
value={option.path}
|
||||||
|
selected={option.isCurrent}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.language-switcher {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher select {
|
||||||
|
background-color: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher select:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
---
|
---
|
||||||
// Navigation component for Pole Sport website
|
// Navigation component for Pole Sport website
|
||||||
|
import { t, getLanguageFromPath, getLocalizedPath } from "../lib/i18n";
|
||||||
|
|
||||||
|
// Get current language from URL
|
||||||
|
const currentLang = getLanguageFromPath(Astro.url.pathname);
|
||||||
---
|
---
|
||||||
|
|
||||||
<nav class="bg-white shadow-lg">
|
<nav class="bg-white shadow-lg">
|
||||||
@@ -7,18 +11,18 @@
|
|||||||
<div class="flex justify-between items-center py-4">
|
<div class="flex justify-between items-center py-4">
|
||||||
<!-- Logo/Brand -->
|
<!-- Logo/Brand -->
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<a href="/" class="text-xl font-bold text-gray-800 hover:text-gray-600 transition-colors">
|
<a href={getLocalizedPath("/", currentLang)} class="text-xl font-bold text-gray-800 hover:text-gray-600 transition-colors">
|
||||||
Pole Sport
|
Pole Book
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation Links -->
|
<!-- Navigation Links -->
|
||||||
<div class="hidden md:flex space-x-8">
|
<div class="hidden md:flex space-x-8">
|
||||||
<a href="/" class="text-gray-600 hover:text-gray-900 transition-colors font-medium">
|
<a href={getLocalizedPath("/", currentLang)} class="text-gray-600 hover:text-gray-900 transition-colors font-medium">
|
||||||
Home
|
{t('nav.home', currentLang)}
|
||||||
</a>
|
</a>
|
||||||
<a href="/elements" class="text-gray-600 hover:text-gray-900 transition-colors font-medium">
|
<a href={getLocalizedPath("/elements", currentLang)} class="text-gray-600 hover:text-gray-900 transition-colors font-medium">
|
||||||
Elements
|
{t('nav.elements', currentLang)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -35,11 +39,11 @@
|
|||||||
<!-- Mobile menu -->
|
<!-- Mobile menu -->
|
||||||
<div id="mobile-menu" class="md:hidden hidden">
|
<div id="mobile-menu" class="md:hidden hidden">
|
||||||
<div class="px-2 pt-2 pb-3 space-y-1 sm:px-3">
|
<div class="px-2 pt-2 pb-3 space-y-1 sm:px-3">
|
||||||
<a href="/" class="block px-3 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-md font-medium">
|
<a href={getLocalizedPath("/", currentLang)} class="block px-3 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-md font-medium">
|
||||||
Home
|
{t('nav.home', currentLang)}
|
||||||
</a>
|
</a>
|
||||||
<a href="/elements" class="block px-3 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-md font-medium">
|
<a href={getLocalizedPath("/elements", currentLang)} class="block px-3 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-md font-medium">
|
||||||
Elements
|
{t('nav.elements', currentLang)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
---
|
---
|
||||||
// Import necessary components and utilities
|
// Import necessary components and utilities
|
||||||
import MarkdownComponent from "./MardownContent.astro";
|
import { getStrapiMedia, getStrapiBaseUrl } from "../lib/strapi";
|
||||||
import { getStrapiMedia } from "../utils/strapi";
|
import { t, getLanguageFromPath, type SupportedLanguage } from "../lib/i18n";
|
||||||
import { getStrapiBaseUrl } from "../config/strapi";
|
|
||||||
import type { HTMLAttributes } from "astro/types";
|
import type { HTMLAttributes } from "astro/types";
|
||||||
|
|
||||||
// Define the props interface
|
// Define the props interface
|
||||||
@@ -13,28 +12,42 @@ export interface Props extends HTMLAttributes<'div'> {
|
|||||||
// Destructure props and get BASE_URL from global config
|
// Destructure props and get BASE_URL from global config
|
||||||
const { elements, ...otherProps } = Astro.props;
|
const { elements, ...otherProps } = Astro.props;
|
||||||
const BASE_URL = getStrapiBaseUrl();
|
const BASE_URL = getStrapiBaseUrl();
|
||||||
|
|
||||||
|
// Get current language
|
||||||
|
const currentLang = getLanguageFromPath(Astro.url.pathname);
|
||||||
---
|
---
|
||||||
|
|
||||||
<div {...otherProps}>
|
<div {...otherProps} data-elements-container>
|
||||||
{
|
{
|
||||||
elements.map((poleElement) => (
|
elements.length === 0 ? (
|
||||||
<a href={`/elements/${poleElement.id}`} class="block">
|
<div class="text-center py-8">
|
||||||
<article class="flex items-center bg-white rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow duration-200">
|
<p class="text-gray-500 text-lg">{t('elements.noElements', currentLang)}</p>
|
||||||
<img
|
</div>
|
||||||
src={getStrapiMedia(
|
) : (
|
||||||
poleElement.data.mainImage.url,
|
<>
|
||||||
BASE_URL,
|
{elements.map((poleElement) => (
|
||||||
)}
|
<a href={`/elements/${poleElement.id}`} class="block mb-4">
|
||||||
alt={poleElement.data.mainImage.alternativeText}
|
<article class="flex items-center bg-white rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow duration-200">
|
||||||
class="w-24 h-24 object-cover flex-shrink-0"
|
<img
|
||||||
/>
|
src={getStrapiMedia(
|
||||||
<div class="p-4">
|
poleElement.mainImage.url,
|
||||||
<h2 class="text-xl font-bold">
|
BASE_URL,
|
||||||
{poleElement.data.name}
|
)}
|
||||||
</h2>
|
alt={poleElement.mainImage.alternativeText}
|
||||||
</div>
|
class="w-24 h-24 object-cover flex-shrink-0"
|
||||||
</article>
|
/>
|
||||||
</a>
|
<div class="p-4">
|
||||||
))
|
<h2 class="text-xl font-bold">
|
||||||
|
{poleElement.name}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<div id="no-results-message" class="text-center py-8 hidden">
|
||||||
|
<p class="text-gray-500 text-lg">{t('elements.noResults', currentLang)}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
52
client/src/components/ReloadPrompt.astro
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script src="../pwa.ts"></script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="pwa-toast"
|
||||||
|
role="alert"
|
||||||
|
aria-labelledby="toast-message"
|
||||||
|
>
|
||||||
|
<div class="message">
|
||||||
|
<span id="toast-message"></span>
|
||||||
|
</div>
|
||||||
|
<button id="pwa-refresh">
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
<button id="pwa-close">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#pwa-toast {
|
||||||
|
visibility: hidden;
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #8885;
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: 3px 4px 5px 0 #8885;
|
||||||
|
}
|
||||||
|
#pwa-toast .message {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
#pwa-toast button {
|
||||||
|
border: 1px solid #8885;
|
||||||
|
outline: none;
|
||||||
|
margin-right: 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
}
|
||||||
|
#pwa-toast.show {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
button#pwa-refresh {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#pwa-toast.show.refresh button#pwa-refresh {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
129
client/src/components/SearchBar.astro
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
// Import necessary utilities
|
||||||
|
import { t, getLanguageFromPath, type SupportedLanguage } from "../lib/i18n";
|
||||||
|
|
||||||
|
// Define the props interface
|
||||||
|
export interface Props {
|
||||||
|
placeholder?: string;
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destructure props
|
||||||
|
const { placeholder, onSearch } = Astro.props;
|
||||||
|
|
||||||
|
// Get current language
|
||||||
|
const currentLang = getLanguageFromPath(Astro.url.pathname);
|
||||||
|
const defaultPlaceholder = t('elements.searchPlaceholder', currentLang);
|
||||||
|
|
||||||
|
// Get current search query from URL
|
||||||
|
const currentSearchQuery = Astro.url.searchParams.get('search') || '';
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="search-input"
|
||||||
|
placeholder={placeholder || defaultPlaceholder}
|
||||||
|
value={currentSearchQuery}
|
||||||
|
class="w-full px-4 py-3 pl-12 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="clear-search"
|
||||||
|
class="absolute inset-y-0 right-0 pr-3 flex items-center opacity-0 pointer-events-none transition-opacity duration-200 hover:text-gray-600"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Client-side search functionality with API calls
|
||||||
|
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||||
|
const clearButton = document.getElementById('clear-search') as HTMLButtonElement;
|
||||||
|
const elementsContainer = document.querySelector('[data-elements-container]');
|
||||||
|
|
||||||
|
if (searchInput && elementsContainer) {
|
||||||
|
let searchTimeout: NodeJS.Timeout;
|
||||||
|
|
||||||
|
// Function to update clear button visibility
|
||||||
|
const updateClearButton = () => {
|
||||||
|
const hasValue = searchInput.value.length > 0;
|
||||||
|
clearButton.style.opacity = hasValue ? '1' : '0';
|
||||||
|
clearButton.style.pointerEvents = hasValue ? 'auto' : 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to fetch filtered data from API
|
||||||
|
const fetchFilteredElements = async (query: string) => {
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
if (elementsContainer) {
|
||||||
|
elementsContainer.innerHTML = '<div class="text-center py-8"><p class="text-gray-500 text-lg">Searching...</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the API URL with search parameters
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
if (query.trim()) {
|
||||||
|
currentUrl.searchParams.set('search', query.trim());
|
||||||
|
} else {
|
||||||
|
currentUrl.searchParams.delete('search');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the new page content
|
||||||
|
const response = await fetch(currentUrl.toString());
|
||||||
|
const html = await response.text();
|
||||||
|
|
||||||
|
// Parse the HTML and extract the elements container
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
const newElementsContainer = doc.querySelector('[data-elements-container]');
|
||||||
|
|
||||||
|
if (newElementsContainer && elementsContainer) {
|
||||||
|
elementsContainer.innerHTML = newElementsContainer.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update URL without page reload
|
||||||
|
window.history.pushState({}, '', currentUrl.toString());
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching filtered elements:', error);
|
||||||
|
if (elementsContainer) {
|
||||||
|
elementsContainer.innerHTML = '<div class="text-center py-8"><p class="text-red-500 text-lg">Error loading results</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search input event listener with debouncing
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
const query = (e.target as HTMLInputElement).value;
|
||||||
|
updateClearButton();
|
||||||
|
|
||||||
|
// Clear previous timeout
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
|
||||||
|
// Set new timeout for debounced search
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
fetchFilteredElements(query);
|
||||||
|
}, 300); // 300ms delay
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear button event listener
|
||||||
|
clearButton.addEventListener('click', () => {
|
||||||
|
searchInput.value = '';
|
||||||
|
updateClearButton();
|
||||||
|
fetchFilteredElements('');
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize clear button state
|
||||||
|
updateClearButton();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
98
client/src/components/SwipeNavigation.astro
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
// SwipeNavigation component for mobile swipe-to-go-back functionality
|
||||||
|
---
|
||||||
|
|
||||||
|
<div id="swipe-container" class="fixed inset-0 pointer-events-none z-50">
|
||||||
|
<div id="swipe-area" class="absolute left-0 top-0 w-8 h-full pointer-events-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
class SwipeNavigation {
|
||||||
|
private startX: number = 0;
|
||||||
|
private startY: number = 0;
|
||||||
|
private isTracking: boolean = false;
|
||||||
|
private readonly minSwipeDistance: number = 100;
|
||||||
|
private readonly maxVerticalDistance: number = 50;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
const swipeArea = document.getElementById('swipe-area');
|
||||||
|
if (!swipeArea) return;
|
||||||
|
|
||||||
|
// Only enable on mobile devices
|
||||||
|
if (!this.isMobile()) return;
|
||||||
|
|
||||||
|
swipeArea.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: true });
|
||||||
|
swipeArea.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: true });
|
||||||
|
swipeArea.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMobile(): boolean {
|
||||||
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTouchStart(e: TouchEvent) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
this.startX = touch.clientX;
|
||||||
|
this.startY = touch.clientY;
|
||||||
|
this.isTracking = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTouchMove(e: TouchEvent) {
|
||||||
|
if (!this.isTracking) return;
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const deltaX = touch.clientX - this.startX;
|
||||||
|
const deltaY = Math.abs(touch.clientY - this.startY);
|
||||||
|
|
||||||
|
// If vertical movement is too large, cancel the swipe
|
||||||
|
if (deltaY > this.maxVerticalDistance) {
|
||||||
|
this.isTracking = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default only if we're swiping left (negative deltaX)
|
||||||
|
if (deltaX < 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTouchEnd(e: TouchEvent) {
|
||||||
|
if (!this.isTracking) return;
|
||||||
|
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const deltaX = touch.clientX - this.startX;
|
||||||
|
const deltaY = Math.abs(touch.clientY - this.startY);
|
||||||
|
|
||||||
|
// Check if it's a valid left swipe
|
||||||
|
if (deltaX < -this.minSwipeDistance && deltaY < this.maxVerticalDistance) {
|
||||||
|
this.goBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isTracking = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private goBack() {
|
||||||
|
// Check if there's history to go back to
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize swipe navigation when the DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
new SwipeNavigation();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#swipe-area {
|
||||||
|
background: transparent;
|
||||||
|
/* Optional: Add a subtle visual indicator for debugging */
|
||||||
|
/* background: rgba(255, 0, 0, 0.1); */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// Global Strapi configuration
|
|
||||||
export const STRAPI_CONFIG = {
|
|
||||||
BASE_URL: (import.meta.env.STRAPI_URL as string) || "http://localhost:1337",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Helper function to get the base URL
|
|
||||||
export function getStrapiBaseUrl(): string {
|
|
||||||
return STRAPI_CONFIG.BASE_URL;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import strapiPoleElementsLoader from "./collections/strapiPoleElementsLoader.mjs";
|
|
||||||
|
|
||||||
// Export the collection for use in Astro pages
|
|
||||||
export const collections = {
|
|
||||||
poleElements: strapiPoleElementsLoader,
|
|
||||||
};
|
|
||||||
3
client/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly STRAPI_URL: string;
|
||||||
|
}
|
||||||
15
client/src/interfaces/poleElement.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export default interface PoleElement {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
publishedAt: string;
|
||||||
|
mainImage: {
|
||||||
|
id: number;
|
||||||
|
documentId: string;
|
||||||
|
url: string;
|
||||||
|
alternativeText: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,26 @@
|
|||||||
---
|
---
|
||||||
const { title, description } = Astro.props;
|
const { title, description } = Astro.props;
|
||||||
|
import { pwaInfo } from 'virtual:pwa-info';
|
||||||
|
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<meta name="generator" content={ Astro.generator} />
|
<meta name="generator" content={ Astro.generator} />
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
|
<link rel="apple-touch-icon" href="/pwa-192x192.png">
|
||||||
|
<link rel="mask-icon" href="/favicon.ico" color="#FFFFFF">
|
||||||
|
<meta name="msapplication-TileColor" content="#FFFFFF">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
|
<script src="/src/pwa.ts"></script>
|
||||||
|
{ pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} /> }
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||||
|
{ pwaAssetsHead.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} /> }
|
||||||
|
{ pwaAssetsHead.links.map(link => (
|
||||||
|
<link {...link} />
|
||||||
|
)) }
|
||||||
</head>
|
</head>
|
||||||
@@ -2,16 +2,28 @@
|
|||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
import Head from "./Head.astro";
|
import Head from "./Head.astro";
|
||||||
import Navigation from "../components/Navigation.astro";
|
import Navigation from "../components/Navigation.astro";
|
||||||
|
import SwipeNavigation from "../components/SwipeNavigation.astro";
|
||||||
|
import { getLanguageFromPath } from "../lib/i18n";
|
||||||
|
import ReloadPrompt from '../components/ReloadPrompt.astro';
|
||||||
|
|
||||||
const { title = "Just a title", description = "Adescription" } = Astro.props;
|
const { title = "Just a title", description = "Adescription" } = Astro.props;
|
||||||
|
|
||||||
|
// Get current language from URL
|
||||||
|
const currentLang = getLanguageFromPath(Astro.url.pathname);
|
||||||
---
|
---
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang={currentLang}>
|
||||||
<Head {title} {description} />
|
<Head {title} {description} />
|
||||||
<body>
|
<body>
|
||||||
<Navigation />
|
<header class="bg-white shadow-sm">
|
||||||
<div class="container max-w-4xl mx-auto p-4">
|
<Navigation />
|
||||||
|
</header>
|
||||||
|
<main class="container max-w-4xl mx-auto p-4">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
<ReloadPrompt />
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<SwipeNavigation />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
155
client/src/lib/i18n.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
// Supported languages
|
||||||
|
export const SUPPORTED_LANGUAGES = ['es', 'en'] as const;
|
||||||
|
export type SupportedLanguage = typeof SUPPORTED_LANGUAGES[number];
|
||||||
|
|
||||||
|
// Default language
|
||||||
|
export const DEFAULT_LANGUAGE: SupportedLanguage = 'es';
|
||||||
|
|
||||||
|
// Translation dictionaries
|
||||||
|
const translations = {
|
||||||
|
es: {
|
||||||
|
// Common
|
||||||
|
'common.loading': 'Cargando...',
|
||||||
|
'common.error': 'Error',
|
||||||
|
'common.success': 'Éxito',
|
||||||
|
'common.cancel': 'Cancelar',
|
||||||
|
'common.save': 'Guardar',
|
||||||
|
'common.edit': 'Editar',
|
||||||
|
'common.delete': 'Eliminar',
|
||||||
|
'common.back': 'Atrás',
|
||||||
|
'common.next': 'Siguiente',
|
||||||
|
'common.previous': 'Anterior',
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
'nav.home': 'Inicio',
|
||||||
|
'nav.about': 'Acerca de',
|
||||||
|
'nav.contact': 'Contacto',
|
||||||
|
'nav.elements': 'Elementos',
|
||||||
|
|
||||||
|
// Elements
|
||||||
|
'elements.title': 'Elementos',
|
||||||
|
'elements.description': 'Explora la colección de elementos de pole sport.',
|
||||||
|
'elements.view': 'Ver Elementos',
|
||||||
|
'elements.noElements': 'No se encontraron elementos',
|
||||||
|
'elements.loading': 'Cargando elementos...',
|
||||||
|
'elements.search': 'Buscar elementos...',
|
||||||
|
'elements.searchPlaceholder': 'Buscar por nombre...',
|
||||||
|
'elements.noResults': 'No se encontraron elementos que coincidan con tu búsqueda',
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
'form.required': 'Este campo es obligatorio',
|
||||||
|
'form.invalid': 'Entrada inválida',
|
||||||
|
'form.submit': 'Enviar',
|
||||||
|
'form.reset': 'Restablecer',
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
'message.elementNotFound': 'Elemento no encontrado',
|
||||||
|
'message.loadingError': 'Error al cargar contenido',
|
||||||
|
'message.saveSuccess': 'Guardado exitosamente',
|
||||||
|
'message.deleteSuccess': 'Eliminado exitosamente',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// Common
|
||||||
|
'common.loading': 'Loading...',
|
||||||
|
'common.error': 'Error',
|
||||||
|
'common.success': 'Success',
|
||||||
|
'common.cancel': 'Cancel',
|
||||||
|
'common.save': 'Save',
|
||||||
|
'common.edit': 'Edit',
|
||||||
|
'common.delete': 'Delete',
|
||||||
|
'common.back': 'Back',
|
||||||
|
'common.next': 'Next',
|
||||||
|
'common.previous': 'Previous',
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
'nav.home': 'Home',
|
||||||
|
'nav.about': 'About',
|
||||||
|
'nav.contact': 'Contact',
|
||||||
|
'nav.elements': 'Elements',
|
||||||
|
|
||||||
|
// Elements
|
||||||
|
'elements.title': 'Elements',
|
||||||
|
'elements.description': 'Explore a complete collection of pole dance elements, techniques, and combinations. From basic spins to advanced tricks, discover everything you need to master the pole sport.',
|
||||||
|
'elements.view': 'View Elements',
|
||||||
|
'elements.noElements': 'No elements found',
|
||||||
|
'elements.loading': 'Loading elements...',
|
||||||
|
'elements.search': 'Search elements...',
|
||||||
|
'elements.searchPlaceholder': 'Search by name...',
|
||||||
|
'elements.noResults': 'No elements found matching your search',
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
'form.required': 'This field is required',
|
||||||
|
'form.invalid': 'Invalid input',
|
||||||
|
'form.submit': 'Submit',
|
||||||
|
'form.reset': 'Reset',
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
'message.elementNotFound': 'Element not found',
|
||||||
|
'message.loadingError': 'Error loading content',
|
||||||
|
'message.saveSuccess': 'Successfully saved',
|
||||||
|
'message.deleteSuccess': 'Successfully deleted',
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Type for translation keys
|
||||||
|
export type TranslationKey = keyof typeof translations.es;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get translation for a given key and language
|
||||||
|
*/
|
||||||
|
export function t(key: TranslationKey, lang: SupportedLanguage = DEFAULT_LANGUAGE): string {
|
||||||
|
const langTranslations = translations[lang];
|
||||||
|
if (!langTranslations) {
|
||||||
|
console.warn(`Language ${lang} not supported, falling back to ${DEFAULT_LANGUAGE}`);
|
||||||
|
return translations[DEFAULT_LANGUAGE][key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return langTranslations[key] || translations[DEFAULT_LANGUAGE][key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current language from URL path
|
||||||
|
*/
|
||||||
|
export function getLanguageFromPath(pathname: string): SupportedLanguage {
|
||||||
|
const pathSegments = pathname.split('/').filter(Boolean);
|
||||||
|
const firstSegment = pathSegments[0];
|
||||||
|
|
||||||
|
if (SUPPORTED_LANGUAGES.includes(firstSegment as SupportedLanguage)) {
|
||||||
|
return firstSegment as SupportedLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_LANGUAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get path with language prefix
|
||||||
|
*/
|
||||||
|
export function getLocalizedPath(path: string, lang: SupportedLanguage): string {
|
||||||
|
if (lang === DEFAULT_LANGUAGE) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove leading slash if present
|
||||||
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||||
|
return `/${lang}/${cleanPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all translations for a given key across all languages
|
||||||
|
*/
|
||||||
|
export function getAllTranslations(key: TranslationKey): Record<SupportedLanguage, string> {
|
||||||
|
const result: Record<SupportedLanguage, string> = {} as Record<SupportedLanguage, string>;
|
||||||
|
|
||||||
|
SUPPORTED_LANGUAGES.forEach(lang => {
|
||||||
|
result[lang] = t(key, lang);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a language is supported
|
||||||
|
*/
|
||||||
|
export function isSupportedLanguage(lang: string): lang is SupportedLanguage {
|
||||||
|
return SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage);
|
||||||
|
}
|
||||||
77
client/src/lib/strapi.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Global Strapi configuration
|
||||||
|
export const STRAPI_CONFIG = {
|
||||||
|
BASE_URL: (import.meta.env.STRAPI_URL as string) || (process.env.STRAPI_URL as string) || "http://localhost:1337",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Helper function to get the base URL
|
||||||
|
export function getStrapiBaseUrl(): string {
|
||||||
|
return STRAPI_CONFIG.BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
endpoint: string;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
wrappedByKey?: string;
|
||||||
|
wrappedByList?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches data from the Strapi API
|
||||||
|
* @param endpoint - The endpoint to fetch from
|
||||||
|
* @param query - The query parameters to add to the url
|
||||||
|
* @param wrappedByKey - The key to unwrap the response from
|
||||||
|
* @param wrappedByList - If the response is a list, unwrap it
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export default async function fetchApi<T>({
|
||||||
|
endpoint,
|
||||||
|
query,
|
||||||
|
wrappedByKey,
|
||||||
|
wrappedByList,
|
||||||
|
}: Props): Promise<T> {
|
||||||
|
if (endpoint.startsWith('/')) {
|
||||||
|
endpoint = endpoint.slice(1);
|
||||||
|
}
|
||||||
|
const strapiUrl = getStrapiBaseUrl();
|
||||||
|
const url = new URL(`${strapiUrl}/api/${endpoint}`);
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
Object.entries(query).forEach(([key, value]) => {
|
||||||
|
url.searchParams.append(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = await res.json();
|
||||||
|
|
||||||
|
if (wrappedByKey) {
|
||||||
|
data = data[wrappedByKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wrappedByList) {
|
||||||
|
data = data[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching from Strapi API:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to handle media URLs from Strapi
|
||||||
|
export function getStrapiMedia(url: string | null, baseUrl: string) {
|
||||||
|
if (url == null) return null;
|
||||||
|
// Return as-is if it's a data URL (base64)
|
||||||
|
if (url.startsWith("data:")) return url;
|
||||||
|
// Return as-is if it's an absolute URL
|
||||||
|
if (url.startsWith("http") || url.startsWith("//")) return url;
|
||||||
|
// Prepend baseUrl for relative URLs
|
||||||
|
return `${baseUrl}${url}`;
|
||||||
|
}
|
||||||
26
client/src/middleware.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineMiddleware } from 'astro:middleware';
|
||||||
|
import { SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE, isSupportedLanguage } from './lib/i18n';
|
||||||
|
|
||||||
|
export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
|
const { pathname } = context.url;
|
||||||
|
|
||||||
|
// Skip middleware for static assets
|
||||||
|
if (pathname.startsWith('/_astro/') || pathname.startsWith('/assets/') || pathname.includes('.')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathSegments = pathname.split('/').filter(Boolean);
|
||||||
|
const firstSegment = pathSegments[0];
|
||||||
|
|
||||||
|
// If the first segment is a supported language, continue
|
||||||
|
if (isSupportedLanguage(firstSegment)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no language prefix, it's Spanish (default) - continue
|
||||||
|
if (pathname === '/' || !isSupportedLanguage(firstSegment)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
});
|
||||||
53
client/src/pages/[lang]/elements.astro
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
// Import necessary components and utilities
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import PoleElementsList from "../../components/PoleElementsList.astro";
|
||||||
|
import SearchBar from "../../components/SearchBar.astro";
|
||||||
|
import { t, isSupportedLanguage, type SupportedLanguage, DEFAULT_LANGUAGE } from "../../lib/i18n";
|
||||||
|
import fetchApi from "../../lib/strapi";
|
||||||
|
import type PoleElement from "../../interfaces/poleElement";
|
||||||
|
|
||||||
|
// Get language from URL parameter
|
||||||
|
const { lang } = Astro.params;
|
||||||
|
|
||||||
|
// Validate language parameter
|
||||||
|
if (!lang || !isSupportedLanguage(lang)) {
|
||||||
|
return Astro.redirect('/elements');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLang = lang as SupportedLanguage;
|
||||||
|
|
||||||
|
// If it's the default language (Spanish), redirect to root
|
||||||
|
if (currentLang === DEFAULT_LANGUAGE) {
|
||||||
|
return Astro.redirect('/elements');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get search query from URL parameters
|
||||||
|
const searchQuery = Astro.url.searchParams.get('search') || '';
|
||||||
|
|
||||||
|
// Build query parameters for API
|
||||||
|
const queryParams: Record<string, string> = {
|
||||||
|
'populate': '*'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add search filter if query exists
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
queryParams['filters[name][$containsi]'] = searchQuery.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = await fetchApi<PoleElement[]>({
|
||||||
|
endpoint: 'elements',
|
||||||
|
query: queryParams,
|
||||||
|
wrappedByKey: 'data',
|
||||||
|
});
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={t('elements.title', currentLang)} description="Pole Elements">
|
||||||
|
<div class="container mx-auto p-4">
|
||||||
|
<div class="py-8">
|
||||||
|
<h1 class="text-3xl font-bold mb-8">{t('elements.title', currentLang)}</h1>
|
||||||
|
<SearchBar />
|
||||||
|
<PoleElementsList elements={elements} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
34
client/src/pages/[lang]/index.astro
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
// Import necessary components and utilities
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import BigCard from "../../components/BigCard.astro";
|
||||||
|
import { t, isSupportedLanguage, type SupportedLanguage, DEFAULT_LANGUAGE } from "../../lib/i18n";
|
||||||
|
|
||||||
|
// Get language from URL parameter
|
||||||
|
const { lang } = Astro.params;
|
||||||
|
|
||||||
|
// Validate language parameter
|
||||||
|
if (!lang || !isSupportedLanguage(lang)) {
|
||||||
|
return Astro.redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLang = lang as SupportedLanguage;
|
||||||
|
|
||||||
|
// If it's the default language (Spanish), redirect to root
|
||||||
|
if (currentLang === DEFAULT_LANGUAGE) {
|
||||||
|
return Astro.redirect('/');
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={t('nav.home', currentLang)} description="Pole Elements">
|
||||||
|
<div class="container mx-auto p-4">
|
||||||
|
<div class="py-12">
|
||||||
|
<BigCard
|
||||||
|
url={`/${currentLang}/elements`}
|
||||||
|
title={t('elements.title', currentLang)}
|
||||||
|
description="Explore a comprehensive collection of pole dance elements, techniques, and combinations. From basic spins to advanced tricks, discover everything you need to master pole sport."
|
||||||
|
ctaText={t('elements.view', currentLang)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
@@ -1,26 +1,49 @@
|
|||||||
---
|
---
|
||||||
// Import necessary components and utilities
|
// Import necessary components and utilities
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import { getCollection } from "astro:content";
|
|
||||||
import PoleElementsList from "../components/PoleElementsList.astro";
|
import PoleElementsList from "../components/PoleElementsList.astro";
|
||||||
import { getStrapiBaseUrl } from "../config/strapi";
|
import SearchBar from "../components/SearchBar.astro";
|
||||||
|
import { t, DEFAULT_LANGUAGE } from "../lib/i18n";
|
||||||
|
|
||||||
// Fetch all posts from Strapi using Astro's content collection
|
import fetchApi from "../lib/strapi";
|
||||||
const strapiPoleElements = await getCollection("poleElements");
|
import type PoleElement from "../interfaces/poleElement";
|
||||||
// Get Strapi URL from global config
|
|
||||||
const BASE_URL = getStrapiBaseUrl();
|
// Use Spanish as default language
|
||||||
|
const currentLang = DEFAULT_LANGUAGE;
|
||||||
|
|
||||||
|
// Get search query from URL parameters
|
||||||
|
const searchQuery = Astro.url.searchParams.get('search') || '';
|
||||||
|
|
||||||
|
// Build query parameters for API
|
||||||
|
const queryParams: Record<string, string> = {
|
||||||
|
'populate': '*'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add search filter if query exists
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
queryParams['filters[name][$containsi]'] = searchQuery.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const strapiPoleElements = await fetchApi<PoleElement[]>({
|
||||||
|
endpoint: "elements",
|
||||||
|
query: queryParams,
|
||||||
|
wrappedByKey: "data",
|
||||||
|
});
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Pole Elements" description="Pole Elements">
|
<Layout title={t("elements.title", currentLang)} description="Pole Elements">
|
||||||
<!-- Main heading -->
|
<div class="container mx-auto p-4">
|
||||||
<h1 class="text-3xl font-bold mb-8">
|
<div class="py-8">
|
||||||
Pole Elements
|
<h1 class="text-3xl font-bold mb-8">
|
||||||
</h1>
|
{t("elements.title", currentLang)}
|
||||||
<!-- Pole Elements List -->
|
</h1>
|
||||||
<PoleElementsList
|
<SearchBar />
|
||||||
elements={strapiPoleElements}
|
<PoleElementsList
|
||||||
class="max-w-4xl mx-auto space-y-4"
|
elements={strapiPoleElements}
|
||||||
id="pole-elements-list"
|
class="max-w-4xl mx-auto space-y-4"
|
||||||
data-testid="pole-elements"
|
id="pole-elements-list"
|
||||||
/>
|
data-testid="pole-elements"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
---
|
---
|
||||||
import { getCollection } from 'astro:content';
|
import ElementView from '../../views/ElementView.astro';
|
||||||
import MardownContent from '../../components/MardownContent.astro';
|
|
||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
|
|
||||||
// 1. Genera una nueva ruta para cada entrada de colección
|
import fetchApi from '../../lib/strapi';
|
||||||
export async function getStaticPaths() {
|
import type PoleElement from '../../interfaces/poleElement';
|
||||||
const poleElements = await getCollection('poleElements');
|
|
||||||
return poleElements.map(entry => ({
|
|
||||||
params: { id: entry.id }, props: { entry },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
// 2. Para tu plantilla, puedes obtener la entrada directamente de la prop
|
|
||||||
const { entry } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout title={entry.data.title} description={entry.data.description}>
|
const { id } = Astro.params;
|
||||||
<h1>{entry.data.title}</h1>
|
|
||||||
<MardownContent content={entry.data.description} />
|
let poleElement: PoleElement;
|
||||||
|
|
||||||
|
try {
|
||||||
|
poleElement = await fetchApi<PoleElement>({
|
||||||
|
endpoint: 'elements',
|
||||||
|
wrappedByKey: 'data',
|
||||||
|
wrappedByList: true,
|
||||||
|
query: {
|
||||||
|
'populate': '*',
|
||||||
|
'filters[id][$eq]': id || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return Astro.redirect('/404');
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
<Layout title={poleElement.name} description={poleElement.description}>
|
||||||
|
<ElementView entry={{ data: poleElement }} />
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -2,16 +2,20 @@
|
|||||||
// Import necessary components and utilities
|
// Import necessary components and utilities
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import BigCard from "../components/BigCard.astro";
|
import BigCard from "../components/BigCard.astro";
|
||||||
|
import { t, DEFAULT_LANGUAGE } from "../lib/i18n";
|
||||||
|
|
||||||
|
// Use Spanish as default language
|
||||||
|
const currentLang = DEFAULT_LANGUAGE;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Pole Elements" description="Pole Elements">
|
<Layout title={t('nav.home', currentLang)} description="Pole Elements">
|
||||||
<div class="container mx-auto p-4">
|
<div class="container mx-auto p-4">
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
<BigCard
|
<BigCard
|
||||||
url="/elements"
|
url="/elements"
|
||||||
title="Pole Elements"
|
title={t('elements.title', currentLang)}
|
||||||
description="Explore a comprehensive collection of pole dance elements, techniques, and combinations. From basic spins to advanced tricks, discover everything you need to master pole sport."
|
description={t('elements.description', currentLang)}
|
||||||
ctaText="Explore Elements"
|
ctaText={t('elements.view', currentLang)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
50
client/src/pwa.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { registerSW } from 'virtual:pwa-register'
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const pwaToast = document.querySelector<HTMLDivElement>('#pwa-toast')!
|
||||||
|
const pwaToastMessage = pwaToast.querySelector<HTMLDivElement>('.message #toast-message')!
|
||||||
|
const pwaCloseBtn = pwaToast.querySelector<HTMLButtonElement>('#pwa-close')!
|
||||||
|
const pwaRefreshBtn = pwaToast.querySelector<HTMLButtonElement>('#pwa-refresh')!
|
||||||
|
|
||||||
|
let refreshSW: ((reloadPage?: boolean) => Promise<void>) | undefined
|
||||||
|
|
||||||
|
const refreshCallback = () => refreshSW?.(true)
|
||||||
|
|
||||||
|
const hidePwaToast = (raf = false) => {
|
||||||
|
if (raf) {
|
||||||
|
requestAnimationFrame(() => hidePwaToast(false))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pwaToast.classList.contains('refresh'))
|
||||||
|
pwaRefreshBtn.removeEventListener('click', refreshCallback)
|
||||||
|
|
||||||
|
pwaToast.classList.remove('show', 'refresh')
|
||||||
|
}
|
||||||
|
const showPwaToast = (offline: boolean) => {
|
||||||
|
if (!offline)
|
||||||
|
pwaRefreshBtn.addEventListener('click', refreshCallback)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
hidePwaToast(false)
|
||||||
|
if (!offline)
|
||||||
|
pwaToast.classList.add('refresh')
|
||||||
|
pwaToast.classList.add('show')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pwaCloseBtn.addEventListener('click', () => hidePwaToast(true))
|
||||||
|
|
||||||
|
refreshSW = registerSW({
|
||||||
|
immediate: true,
|
||||||
|
onOfflineReady() {
|
||||||
|
pwaToastMessage.innerHTML = 'App ready to work offline'
|
||||||
|
showPwaToast(true)
|
||||||
|
},
|
||||||
|
onNeedRefresh() {
|
||||||
|
pwaToastMessage.innerHTML = 'New content available, click on reload button to update'
|
||||||
|
showPwaToast(false)
|
||||||
|
},
|
||||||
|
onRegisteredSW(swScriptUrl) {
|
||||||
|
console.log('SW registered: ', swScriptUrl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// Helper function to handle media URLs from Strapi
|
|
||||||
export function getStrapiMedia(url: string | null, baseUrl: string) {
|
|
||||||
if (url == null) return null;
|
|
||||||
// Return as-is if it's a data URL (base64)
|
|
||||||
if (url.startsWith("data:")) return url;
|
|
||||||
// Return as-is if it's an absolute URL
|
|
||||||
if (url.startsWith("http") || url.startsWith("//")) return url;
|
|
||||||
// Prepend baseUrl for relative URLs
|
|
||||||
return `${baseUrl}${url}`;
|
|
||||||
}
|
|
||||||
101
client/src/views/ElementView.astro
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
import MardownContent from '../components/MardownContent.astro';
|
||||||
|
import { getStrapiMedia, getStrapiBaseUrl } from '../lib/strapi';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entry: {
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
mainImage: {
|
||||||
|
url: string;
|
||||||
|
alternativeText: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entry } = Astro.props;
|
||||||
|
const BASE_URL = getStrapiBaseUrl();
|
||||||
|
---
|
||||||
|
<div class="element-view">
|
||||||
|
<div class="element-content">
|
||||||
|
<div class="element-text">
|
||||||
|
<h1>{entry.data.name}</h1>
|
||||||
|
<MardownContent content={entry.data.description} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entry.data.mainImage && (
|
||||||
|
<div class="element-image">
|
||||||
|
<img
|
||||||
|
src={getStrapiMedia(entry.data.mainImage.url, BASE_URL)}
|
||||||
|
alt={entry.data.mainImage.alternativeText}
|
||||||
|
class="main-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.element-view {
|
||||||
|
max-width: 56rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-text {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-image {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-view h1 {
|
||||||
|
font-size: 2.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-image {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop styles */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.element-content {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-text {
|
||||||
|
order: 1;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-image {
|
||||||
|
order: 2;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-image {
|
||||||
|
width: 300px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module 'virtual:pwa-register' {
|
||||||
|
export interface RegisterSWOptions {
|
||||||
|
immediate?: boolean
|
||||||
|
onNeedRefresh?: () => void
|
||||||
|
onOfflineReady?: () => void
|
||||||
|
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void
|
||||||
|
onRegisterError?: (error: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'virtual:pwa-info' {
|
||||||
|
export interface PWAInfo {
|
||||||
|
webManifest: {
|
||||||
|
linkTag: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pwaInfo: PWAInfo | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'virtual:pwa-assets/head' {
|
||||||
|
export interface PWAAssetsHead {
|
||||||
|
themeColor?: {
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
links: Array<{
|
||||||
|
rel: string
|
||||||
|
href: string
|
||||||
|
sizes?: string
|
||||||
|
type?: string
|
||||||
|
[key: string]: any
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pwaAssetsHead: PWAAssetsHead
|
||||||
|
}
|
||||||
30
docker-compose.env
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# ========================================
|
||||||
|
# Environment Variables for Docker Compose
|
||||||
|
# ========================================
|
||||||
|
# Copy this file to .env and modify values as needed
|
||||||
|
# cp docker-compose.env .env
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_CLIENT=postgres
|
||||||
|
DATABASE_HOST=postgres
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_NAME=strapi
|
||||||
|
DATABASE_USERNAME=strapi
|
||||||
|
DATABASE_PASSWORD=strapi_password
|
||||||
|
DATABASE_SSL=false
|
||||||
|
|
||||||
|
# Strapi Configuration
|
||||||
|
NODE_ENV=development
|
||||||
|
JWT_SECRET=your-jwt-secret-here-change-this-in-production
|
||||||
|
ADMIN_JWT_SECRET=your-admin-jwt-secret-here-change-this-in-production
|
||||||
|
APP_KEYS=your-app-keys-here-change-this-in-production
|
||||||
|
API_TOKEN_SALT=your-api-token-salt-here-change-this-in-production
|
||||||
|
TRANSFER_TOKEN_SALT=your-transfer-token-salt-here-change-this-in-production
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=1337
|
||||||
|
|
||||||
|
# Client Configuration
|
||||||
|
CLIENT_NODE_ENV=development
|
||||||
|
STRAPI_URL=http://localhost:1337
|
||||||
69
docker-compose.yml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
strapi:
|
||||||
|
build:
|
||||||
|
context: ./server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: pole-book-strapi-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1337:1337"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DATABASE_CLIENT: ${DATABASE_CLIENT}
|
||||||
|
DATABASE_HOST: ${DATABASE_HOST}
|
||||||
|
DATABASE_PORT: ${DATABASE_PORT}
|
||||||
|
DATABASE_NAME: ${DATABASE_NAME}
|
||||||
|
DATABASE_USERNAME: ${DATABASE_USERNAME}
|
||||||
|
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
|
||||||
|
DATABASE_SSL: ${DATABASE_SSL}
|
||||||
|
NODE_ENV: ${NODE_ENV}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET}
|
||||||
|
APP_KEYS: ${APP_KEYS}
|
||||||
|
API_TOKEN_SALT: ${API_TOKEN_SALT}
|
||||||
|
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT}
|
||||||
|
volumes:
|
||||||
|
- ../pole-book-data/public/uploads:/opt/app/public/uploads
|
||||||
|
- ../pole-book-data/.tmp:/opt/app/.tmp
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "nc", "-z", "localhost", "1337"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- strapi-network
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: pole-book-strapi-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- docker-compose.env
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DATABASE_NAME}
|
||||||
|
POSTGRES_USER: ${DATABASE_USERNAME}
|
||||||
|
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ../pole-book-data/postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USERNAME} -d ${DATABASE_NAME}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- strapi-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
strapi-network:
|
||||||
|
driver: bridge
|
||||||
17
server/.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.nyc_output
|
||||||
|
coverage
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
.next
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.strapi-updater.json
|
||||||
|
.strapi
|
||||||
|
.tmp
|
||||||
22
server/.env
@@ -1,22 +0,0 @@
|
|||||||
|
|
||||||
# Server
|
|
||||||
HOST=0.0.0.0
|
|
||||||
PORT=1337
|
|
||||||
|
|
||||||
# Secrets
|
|
||||||
APP_KEYS=58Ak6T00Yd4tatybLqdT+g==,D5rtyrkpvP39ob0uGw2t/w==,uSd7HTABiHzhaZMNQ1X9og==,un/3ZhvRpwaPeRafk6MzFg==
|
|
||||||
API_TOKEN_SALT=eyq17CXyGeimONpQa5GwLQ==
|
|
||||||
ADMIN_JWT_SECRET=E+YDtxuMstheljK35LT0sg==
|
|
||||||
TRANSFER_TOKEN_SALT=3wbGxS+rs1gFe9KxfTWwWA==
|
|
||||||
ENCRYPTION_KEY=Nl+XKUJn3MIwS71P+KNFfg==
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_CLIENT=sqlite
|
|
||||||
DATABASE_HOST=
|
|
||||||
DATABASE_PORT=
|
|
||||||
DATABASE_NAME=
|
|
||||||
DATABASE_USERNAME=
|
|
||||||
DATABASE_PASSWORD=
|
|
||||||
DATABASE_SSL=false
|
|
||||||
DATABASE_FILENAME=.tmp/data.db
|
|
||||||
JWT_SECRET=2jSJ125b/iVC4jMPSjSYKw==
|
|
||||||
2
server/.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
public/uploads
|
||||||
|
.tmp/
|
||||||
28
server/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
# Install netcat for port checking
|
||||||
|
RUN apk add --no-cache netcat-openbsd
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /opt/app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Make startup script executable
|
||||||
|
RUN chmod +x start.sh
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 1337
|
||||||
|
|
||||||
|
# Start the application with the startup script
|
||||||
|
CMD ["./start.sh"]
|
||||||
138
server/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@strapi/plugin-users-permissions": "^5.16.0",
|
"@strapi/plugin-users-permissions": "^5.16.0",
|
||||||
"@strapi/strapi": "^5.16.0",
|
"@strapi/strapi": "^5.16.0",
|
||||||
"better-sqlite3": "11.3.0",
|
"better-sqlite3": "11.3.0",
|
||||||
|
"pg": "^8.16.2",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
"react-router-dom": "^6.0.0",
|
"react-router-dom": "^6.0.0",
|
||||||
@@ -13894,12 +13895,101 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.2.tgz",
|
||||||
|
"integrity": "sha512-OtLWF0mKLmpxelOt9BqVq83QV6bTfsS0XLegIeAKqKjurRnRKie1Dc1iL89MugmSLhftxw6NNCyZhm1yQFLMEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.9.1",
|
||||||
|
"pg-pool": "^3.10.1",
|
||||||
|
"pg-protocol": "^1.10.2",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.2.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-uxmJAnmIgmYgnSFzgOf2cqGQBzwnRYcrEgXuFjJNEkpedEIPBSEzxY7ph4uA9k1mI+l/GR0HjPNS6FKNZe8SBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/pg-connection-string": {
|
"node_modules/pg-connection-string": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz",
|
||||||
"integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==",
|
"integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.2.tgz",
|
||||||
|
"integrity": "sha512-Ci7jy8PbaWxfsck2dwZdERcDG2A0MG8JoQILs+uZNjABFuBuItAZCWUNz8sXRDMoui24rJw7WlXqgpMdBSN/vQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg/node_modules/pg-connection-string": {
|
||||||
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -14861,6 +14951,45 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prebuild-install": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
@@ -16981,6 +17110,15 @@
|
|||||||
"integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==",
|
"integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==",
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sprintf-js": {
|
"node_modules/sprintf-js": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||||
|
|||||||
@@ -14,13 +14,13 @@
|
|||||||
"upgrade": "npx @strapi/upgrade latest",
|
"upgrade": "npx @strapi/upgrade latest",
|
||||||
"upgrade:dry": "npx @strapi/upgrade latest --dry",
|
"upgrade:dry": "npx @strapi/upgrade latest --dry",
|
||||||
"cs": "config-sync"
|
"cs": "config-sync"
|
||||||
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@strapi/plugin-cloud": "5.13.0",
|
"@strapi/plugin-cloud": "5.13.0",
|
||||||
"@strapi/plugin-users-permissions": "^5.16.0",
|
"@strapi/plugin-users-permissions": "^5.16.0",
|
||||||
"@strapi/strapi": "^5.16.0",
|
"@strapi/strapi": "^5.16.0",
|
||||||
"better-sqlite3": "11.3.0",
|
"better-sqlite3": "11.3.0",
|
||||||
|
"pg": "^8.16.2",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
"react-router-dom": "^6.0.0",
|
"react-router-dom": "^6.0.0",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 642 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 36 KiB |
17
server/start.sh
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Start Strapi in the background
|
||||||
|
npm start &
|
||||||
|
STRAPI_PID=$!
|
||||||
|
|
||||||
|
# Wait for Strapi to be ready (check if port 1337 is listening)
|
||||||
|
echo "Waiting for Strapi to be ready..."
|
||||||
|
while ! nc -z localhost 1337; do
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Strapi is ready! Running config-sync import..."
|
||||||
|
npm run cs import
|
||||||
|
|
||||||
|
# Wait for the Strapi process
|
||||||
|
wait $STRAPI_PID
|
||||||
57
setup-env.sh
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Setup Environment Variables Script
|
||||||
|
echo "Setting up environment variables..."
|
||||||
|
|
||||||
|
# Copy the docker-compose.env to .env if it doesn't exist
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "Creating .env file from docker-compose.env..."
|
||||||
|
cp docker-compose.env .env
|
||||||
|
echo "✅ .env file created successfully!"
|
||||||
|
else
|
||||||
|
echo "⚠️ .env file already exists. Skipping..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate secure secrets if they haven't been changed from defaults
|
||||||
|
echo ""
|
||||||
|
echo "Checking for default secrets..."
|
||||||
|
|
||||||
|
# Function to generate a random string (alphanumeric only)
|
||||||
|
generate_secret() {
|
||||||
|
openssl rand -hex 32
|
||||||
|
}
|
||||||
|
|
||||||
|
# Simple approach: create a new .env file with replacements
|
||||||
|
if grep -q "your-jwt-secret-here-change-this-in-production" .env; then
|
||||||
|
echo "Generating new JWT_SECRET..."
|
||||||
|
sed "s/your-jwt-secret-here-change-this-in-production/$(generate_secret)/g" .env > .env.tmp && mv .env.tmp .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q "your-admin-jwt-secret-here-change-this-in-production" .env; then
|
||||||
|
echo "Generating new ADMIN_JWT_SECRET..."
|
||||||
|
sed "s/your-admin-jwt-secret-here-change-this-in-production/$(generate_secret)/g" .env > .env.tmp && mv .env.tmp .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q "your-app-keys-here-change-this-in-production" .env; then
|
||||||
|
echo "Generating new APP_KEYS..."
|
||||||
|
sed "s/your-app-keys-here-change-this-in-production/$(generate_secret)/g" .env > .env.tmp && mv .env.tmp .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q "your-api-token-salt-here-change-this-in-production" .env; then
|
||||||
|
echo "Generating new API_TOKEN_SALT..."
|
||||||
|
sed "s/your-api-token-salt-here-change-this-in-production/$(generate_secret)/g" .env > .env.tmp && mv .env.tmp .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q "your-transfer-token-salt-here-change-this-in-production" .env; then
|
||||||
|
echo "Generating new TRANSFER_TOKEN_SALT..."
|
||||||
|
sed "s/your-transfer-token-salt-here-change-this-in-production/$(generate_secret)/g" .env > .env.tmp && mv .env.tmp .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Environment setup complete!"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo "1. Review and modify the .env file if needed"
|
||||||
|
echo "2. Run: docker-compose up -d"
|
||||||
|
echo ""
|
||||||
|
echo "Note: The .env file is gitignored for security reasons."
|
||||||