Compare commits
5 Commits
0dcc1323bd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 89dad2ee13 | |||
| a31c99453b | |||
| eb036dece5 | |||
| 67e3eef364 | |||
| 7eb3a08b20 |
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,4 +1,5 @@
|
||||
// @ts-check
|
||||
import AstroPWA from '@vite-pwa/astro'
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
@@ -7,6 +8,49 @@ import node from '@astrojs/node';
|
||||
|
||||
// https://astro.build/config
|
||||
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: {
|
||||
plugins: [tailwindcss()]
|
||||
|
||||
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
4358
client/package-lock.json
generated
@@ -6,15 +6,20 @@
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
"astro": "astro",
|
||||
"generate-pwa-assets": "pwa-assets-generator --preset minimal-2023 public/logo.png"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.2.2",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@types/qs": "^6.14.0",
|
||||
"@vite-pwa/astro": "^1.1.0",
|
||||
"astro": "^5.7.12",
|
||||
"marked": "^15.0.12",
|
||||
"qs": "^6.14.0",
|
||||
"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 |
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
|
||||
import { t, getLanguageFromPath, getLocalizedPath } from "../lib/i18n";
|
||||
|
||||
// Get current language from URL
|
||||
const currentLang = getLanguageFromPath(Astro.url.pathname);
|
||||
---
|
||||
|
||||
<nav class="bg-white shadow-lg">
|
||||
@@ -7,18 +11,18 @@
|
||||
<div class="flex justify-between items-center py-4">
|
||||
<!-- Logo/Brand -->
|
||||
<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 Book
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="hidden md:flex space-x-8">
|
||||
<a href="/" class="text-gray-600 hover:text-gray-900 transition-colors font-medium">
|
||||
Home
|
||||
<a href={getLocalizedPath("/", currentLang)} class="text-gray-600 hover:text-gray-900 transition-colors font-medium">
|
||||
{t('nav.home', currentLang)}
|
||||
</a>
|
||||
<a href="/elements" class="text-gray-600 hover:text-gray-900 transition-colors font-medium">
|
||||
Elements
|
||||
<a href={getLocalizedPath("/elements", currentLang)} class="text-gray-600 hover:text-gray-900 transition-colors font-medium">
|
||||
{t('nav.elements', currentLang)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -35,11 +39,11 @@
|
||||
<!-- Mobile menu -->
|
||||
<div id="mobile-menu" class="md:hidden hidden">
|
||||
<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">
|
||||
Home
|
||||
<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">
|
||||
{t('nav.home', currentLang)}
|
||||
</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">
|
||||
Elements
|
||||
<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">
|
||||
{t('nav.elements', currentLang)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
---
|
||||
// Import necessary components and utilities
|
||||
import MarkdownComponent from "./MardownContent.astro";
|
||||
import { getStrapiMedia } from "../lib/strapi";
|
||||
import { getStrapiBaseUrl } from "../config/strapi";
|
||||
import { getStrapiMedia, getStrapiBaseUrl } from "../lib/strapi";
|
||||
import { t, getLanguageFromPath, type SupportedLanguage } from "../lib/i18n";
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
|
||||
// Define the props interface
|
||||
@@ -13,28 +12,42 @@ export interface Props extends HTMLAttributes<'div'> {
|
||||
// Destructure props and get BASE_URL from global config
|
||||
const { elements, ...otherProps } = Astro.props;
|
||||
const BASE_URL = getStrapiBaseUrl();
|
||||
|
||||
// Get current language
|
||||
const currentLang = getLanguageFromPath(Astro.url.pathname);
|
||||
---
|
||||
|
||||
<div {...otherProps}>
|
||||
<div {...otherProps} data-elements-container>
|
||||
{
|
||||
elements.map((poleElement) => (
|
||||
<a href={`/elements/${poleElement.id}`} class="block">
|
||||
<article class="flex items-center bg-white rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow duration-200">
|
||||
<img
|
||||
src={getStrapiMedia(
|
||||
poleElement.mainImage.url,
|
||||
BASE_URL,
|
||||
)}
|
||||
alt={poleElement.mainImage.alternativeText}
|
||||
class="w-24 h-24 object-cover flex-shrink-0"
|
||||
/>
|
||||
<div class="p-4">
|
||||
<h2 class="text-xl font-bold">
|
||||
{poleElement.name}
|
||||
</h2>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
))
|
||||
elements.length === 0 ? (
|
||||
<div class="text-center py-8">
|
||||
<p class="text-gray-500 text-lg">{t('elements.noElements', currentLang)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{elements.map((poleElement) => (
|
||||
<a href={`/elements/${poleElement.id}`} class="block mb-4">
|
||||
<article class="flex items-center bg-white rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow duration-200">
|
||||
<img
|
||||
src={getStrapiMedia(
|
||||
poleElement.mainImage.url,
|
||||
BASE_URL,
|
||||
)}
|
||||
alt={poleElement.mainImage.alternativeText}
|
||||
class="w-24 h-24 object-cover flex-shrink-0"
|
||||
/>
|
||||
<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>
|
||||
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) || (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;
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
---
|
||||
const { title, description } = Astro.props;
|
||||
import { pwaInfo } from 'virtual:pwa-info';
|
||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
||||
|
||||
---
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={ Astro.generator} />
|
||||
<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>
|
||||
<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>
|
||||
@@ -2,16 +2,28 @@
|
||||
import "../styles/global.css";
|
||||
import Head from "./Head.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;
|
||||
|
||||
// Get current language from URL
|
||||
const currentLang = getLanguageFromPath(Astro.url.pathname);
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang={currentLang}>
|
||||
<Head {title} {description} />
|
||||
<body>
|
||||
<Navigation />
|
||||
<div class="container max-w-4xl mx-auto p-4">
|
||||
<header class="bg-white shadow-sm">
|
||||
<Navigation />
|
||||
</header>
|
||||
<main class="container max-w-4xl mx-auto p-4">
|
||||
<slot />
|
||||
</div>
|
||||
<ReloadPrompt />
|
||||
|
||||
</main>
|
||||
<SwipeNavigation />
|
||||
</body>
|
||||
</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);
|
||||
}
|
||||
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>
|
||||
@@ -2,26 +2,48 @@
|
||||
// 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, DEFAULT_LANGUAGE } from "../lib/i18n";
|
||||
|
||||
import fetchApi from '../lib/strapi';
|
||||
import fetchApi from "../lib/strapi";
|
||||
import type PoleElement from "../interfaces/poleElement";
|
||||
|
||||
// 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?populate=*', // the content type to fetch
|
||||
wrappedByKey: 'data', // the key to unwrap the response
|
||||
endpoint: "elements",
|
||||
query: queryParams,
|
||||
wrappedByKey: "data",
|
||||
});
|
||||
---
|
||||
|
||||
<Layout title="Pole Elements" description="Pole Elements">
|
||||
<!-- Main heading -->
|
||||
<h1 class="text-3xl font-bold mb-8">
|
||||
Pole Elements
|
||||
</h1>
|
||||
<!-- Pole Elements List -->
|
||||
<PoleElementsList
|
||||
elements={strapiPoleElements}
|
||||
class="max-w-4xl mx-auto space-y-4"
|
||||
id="pole-elements-list"
|
||||
data-testid="pole-elements"
|
||||
/>
|
||||
<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={strapiPoleElements}
|
||||
class="max-w-4xl mx-auto space-y-4"
|
||||
id="pole-elements-list"
|
||||
data-testid="pole-elements"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
// Import necessary components and utilities
|
||||
import Layout from "../layouts/Layout.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="py-12">
|
||||
<BigCard
|
||||
url="/elements"
|
||||
title="Pole Elements"
|
||||
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="Explore Elements"
|
||||
title={t('elements.title', currentLang)}
|
||||
description={t('elements.description', currentLang)}
|
||||
ctaText={t('elements.view', currentLang)}
|
||||
/>
|
||||
</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,7 +1,6 @@
|
||||
---
|
||||
import MardownContent from '../components/MardownContent.astro';
|
||||
import { getStrapiMedia } from '../lib/strapi';
|
||||
import { getStrapiBaseUrl } from '../config/strapi';
|
||||
import { getStrapiMedia, getStrapiBaseUrl } from '../lib/strapi';
|
||||
|
||||
interface Props {
|
||||
entry: {
|
||||
|
||||
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
|
||||
}
|
||||