feat: add ClockApp and SettingsPanel components with customizable settings
- Implemented ClockApp component to display time based on user settings. - Created SettingsPanel for users to adjust clock type, time format, timezone, and theme. - Added hooks for managing time, settings persistence, and fullscreen functionality. - Introduced types for clock settings and themes, including default settings. - Integrated URL parameter parsing for sharing clock configurations. - Enhanced user experience with loading states and visual transitions.
@@ -23,6 +23,7 @@
|
|||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
.vscode/
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -1,36 +1,73 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Clock - Horloge En Ligne
|
||||||
|
|
||||||
## Getting Started
|
Application d'horloge en ligne (Next.js + TypeScript) avec affichage plein ecran, modes digital/analogique/flip, fuseaux horaires et themes.
|
||||||
|
|
||||||
First, run the development server:
|
## Projet en ligne
|
||||||
|
|
||||||
|
Lien public: https://clock.arthurp.fr
|
||||||
|
|
||||||
|
Ce lien est volontairement present dans ce README pour renforcer le backlink vers le projet en production.
|
||||||
|
|
||||||
|
## Fonctionnalites
|
||||||
|
|
||||||
|
- Horloge en temps reel (rafraichissement fin)
|
||||||
|
- Modes: `digital`, `analog`, `flip`
|
||||||
|
- Format horaire: `12h` / `24h`
|
||||||
|
- Affichage optionnel des secondes
|
||||||
|
- Selection de fuseau horaire (liste IANA)
|
||||||
|
- Themes visuels
|
||||||
|
- Parametres persistants dans le navigateur
|
||||||
|
- URL partageable avec les parametres
|
||||||
|
|
||||||
|
## SEO
|
||||||
|
|
||||||
|
- `robots.ts` et `sitemap.ts` configures
|
||||||
|
- Pages `loading`, `error`, `not-found`
|
||||||
|
- Balises et structure optimisees pour l'indexation
|
||||||
|
|
||||||
|
## Parametres URL
|
||||||
|
|
||||||
|
| Parametre | Valeurs | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `tz` | ex: `Europe/Paris` | Fuseau horaire |
|
||||||
|
| `type` | `digital`, `analog`, `flip` | Type d'horloge |
|
||||||
|
| `format` | `12h`, `24h` | Format horaire |
|
||||||
|
| `seconds` | `true`, `false` | Afficher les secondes |
|
||||||
|
| `theme` | id du theme | Theme visuel |
|
||||||
|
|
||||||
|
Exemple:
|
||||||
|
|
||||||
|
`https://clock.arthurp.fr?tz=Europe/Paris&type=analog&format=24h&seconds=true&theme=midnight`
|
||||||
|
|
||||||
|
## Lancer le projet
|
||||||
|
|
||||||
|
Prerequis:
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm
|
||||||
|
|
||||||
|
Installation et dev:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Build production:
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
|
||||||
|
|
||||||
## Learn More
|
## Stack
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
- Next.js
|
||||||
|
- React
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
## Licence
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
MIT
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
clock-app:
|
||||||
|
image: node:21
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./:/app
|
||||||
|
command: sh -c "npm install && npm run build && npm start"
|
||||||
|
ports:
|
||||||
|
- "3007:3000"
|
||||||
|
restart: unless-stopped
|
||||||
@@ -1,7 +1,113 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
// Optimisations de production
|
||||||
|
reactStrictMode: true,
|
||||||
|
|
||||||
|
// Supprimer le header X-Powered-By pour la sécurité
|
||||||
|
poweredByHeader: false,
|
||||||
|
|
||||||
|
// Optimisation des images
|
||||||
|
images: {
|
||||||
|
formats: ['image/avif', 'image/webp'],
|
||||||
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Headers de sécurité, cache et SEO
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/:path*',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'X-DNS-Prefetch-Control',
|
||||||
|
value: 'on',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'X-XSS-Protection',
|
||||||
|
value: '1; mode=block',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'X-Frame-Options',
|
||||||
|
value: 'SAMEORIGIN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'X-Content-Type-Options',
|
||||||
|
value: 'nosniff',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Referrer-Policy',
|
||||||
|
value: 'strict-origin-when-cross-origin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Permissions-Policy',
|
||||||
|
value: 'camera=(), microphone=(), geolocation=()',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Strict-Transport-Security',
|
||||||
|
value: 'max-age=63072000; includeSubDomains; preload',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/manifest.json',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'Cache-Control',
|
||||||
|
value: 'public, max-age=31536000, immutable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Content-Type',
|
||||||
|
value: 'application/manifest+json',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Cache statiques (images, fonts, icons)
|
||||||
|
source: '/:path*.(ico|png|jpg|jpeg|svg|webp|avif|woff|woff2)',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'Cache-Control',
|
||||||
|
value: 'public, max-age=31536000, immutable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Sitemap et robots : cache court pour que Google voit les mises à jour
|
||||||
|
source: '/(sitemap.xml|robots.txt)',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'Cache-Control',
|
||||||
|
value: 'public, max-age=3600, s-maxage=86400',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Redirections pour des URL propres
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/index',
|
||||||
|
destination: '/',
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/home',
|
||||||
|
destination: '/',
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Compression
|
||||||
|
compress: true,
|
||||||
|
|
||||||
|
// Optimisation du bundle
|
||||||
|
experimental: {
|
||||||
|
optimizeCss: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -3217,6 +3217,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 7.6 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 145 KiB |
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "Horloge en ligne — Heure exacte gratuite",
|
||||||
|
"short_name": "Horloge",
|
||||||
|
"description": "Horloge en ligne gratuite avec affichage plein écran. Horloge numérique ou analogique personnalisable avec plus de 30 fuseaux horaires et 12 thèmes.",
|
||||||
|
"start_url": "/?source=pwa",
|
||||||
|
"scope": "/",
|
||||||
|
"id": "/",
|
||||||
|
"display": "fullscreen",
|
||||||
|
"display_override": ["fullscreen", "standalone"],
|
||||||
|
"background_color": "#0f172a",
|
||||||
|
"theme_color": "#3b82f6",
|
||||||
|
"orientation": "any",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["utilities", "productivity"],
|
||||||
|
"lang": "fr",
|
||||||
|
"dir": "ltr",
|
||||||
|
"prefer_related_applications": false,
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/screenshot-desktop.png",
|
||||||
|
"sizes": "1920x1080",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "wide",
|
||||||
|
"label": "Horloge numérique en mode plein écran"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/screenshot-mobile.png",
|
||||||
|
"sizes": "750x1334",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "narrow",
|
||||||
|
"label": "Horloge en ligne sur mobile"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
@@ -0,0 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function Error({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-900 text-white p-4">
|
||||||
|
<h1 className="text-4xl font-bold text-red-500 mb-4">Oups !</h1>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Une erreur s'est produite</h2>
|
||||||
|
<p className="text-slate-400 mb-8 text-center max-w-md">
|
||||||
|
Nous sommes désolés, quelque chose s'est mal passé. Veuillez réessayer.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="px-6 py-3 bg-blue-500 text-white rounded-lg font-medium hover:bg-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,26 +1,341 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #0f172a;
|
||||||
--foreground: #171717;
|
--foreground: #f8fafc;
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--secondary: #64748b;
|
||||||
|
--accent: #8b5cf6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-accent: var(--accent);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
* {
|
||||||
:root {
|
margin: 0;
|
||||||
--background: #0a0a0a;
|
padding: 0;
|
||||||
--foreground: #ededed;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
transition: background-color 0.5s ease, color 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions fluides pour les thèmes */
|
||||||
|
.theme-transition {
|
||||||
|
transition: background-color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
border-color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation de l'horloge numérique */
|
||||||
|
@keyframes pulse-subtle {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-pulse {
|
||||||
|
animation: pulse-subtle 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation pour les secondes de l'horloge analogique */
|
||||||
|
@keyframes smooth-second {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.second-hand-smooth {
|
||||||
|
animation: smooth-second 60s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles pour le mode plein écran */
|
||||||
|
.fullscreen-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus visible pour l'accessibilité */
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Amélioration du contraste pour l'accessibilité */
|
||||||
|
.high-contrast {
|
||||||
|
--background: #000000;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar personnalisée */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles pour les sélecteurs */
|
||||||
|
select {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.75rem center;
|
||||||
|
background-size: 1rem;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation d'apparition */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles pour le panneau de paramètres */
|
||||||
|
.settings-panel {
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Horloge analogique - styles de base */
|
||||||
|
.clock-face {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(145deg, rgba(255,255,255,0.1), rgba(0,0,0,0.2));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 30px rgba(0,0,0,0.3),
|
||||||
|
0 0 50px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-hand {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-center {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Contenu SEO visible en bas de page ===== */
|
||||||
|
.seo-content {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 1.5rem 4rem;
|
||||||
|
color: rgba(148, 163, 184, 0.85);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-content h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(248, 250, 252, 0.95);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-content .seo-intro {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
color: rgba(148, 163, 184, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-content h2 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(248, 250, 252, 0.9);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-content h3 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(248, 250, 252, 0.85);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-features {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-feature {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-feature p {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FAQ en bas de page */
|
||||||
|
.seo-faq {
|
||||||
|
margin-top: 3rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-faq details {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-faq details summary {
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(248, 250, 252, 0.9);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
transition: background 0.2s;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-faq details summary::before {
|
||||||
|
content: '▸';
|
||||||
|
transition: transform 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-faq details[open] summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-faq details summary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-faq details summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-faq details > div {
|
||||||
|
padding: 0 1.25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-faq details p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.seo-content {
|
||||||
|
padding: 2rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-content h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-features {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Skip to content (accessibilité) ===== */
|
||||||
|
.skip-to-content {
|
||||||
|
position: absolute;
|
||||||
|
top: -100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 0 0 0.5rem 0.5rem;
|
||||||
|
z-index: 9999;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: top 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-to-content:focus {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Footer SEO ===== */
|
||||||
|
.seo-footer {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.5rem 3rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(148, 163, 184, 0.6);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-footer nav {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-footer nav ul {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-footer nav a {
|
||||||
|
color: rgba(148, 163, 184, 0.7);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-footer nav a:hover {
|
||||||
|
color: rgba(248, 250, 252, 0.9);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,265 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
const SITE_URL = 'https://clock.arthurp.fr';
|
||||||
|
const SITE_NAME = 'Horloge en ligne';
|
||||||
|
const SITE_DESCRIPTION = 'Horloge en ligne gratuite avec affichage plein écran. Choisissez entre horloge numérique ou analogique, personnalisez les couleurs et le fuseau horaire. Heure exacte en temps réel.';
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
display: "optional",
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const geistMono = Geist_Mono({
|
||||||
variable: "--font-geist-mono",
|
variable: "--font-geist-mono",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
display: "optional",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Métadonnées SEO optimisées
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
metadataBase: new URL(SITE_URL),
|
||||||
description: "Generated by create next app",
|
title: {
|
||||||
|
default: "Horloge en ligne gratuite — Heure exacte, plein écran, personnalisable",
|
||||||
|
template: "%s | Horloge en ligne"
|
||||||
|
},
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
keywords: [
|
||||||
|
"horloge en ligne",
|
||||||
|
"heure exacte",
|
||||||
|
"horloge numérique",
|
||||||
|
"horloge analogique",
|
||||||
|
"plein écran",
|
||||||
|
"fuseau horaire",
|
||||||
|
"horloge gratuite",
|
||||||
|
"heure mondiale",
|
||||||
|
"quelle heure est-il",
|
||||||
|
"heure en ligne",
|
||||||
|
"horloge plein écran",
|
||||||
|
"horloge personnalisable",
|
||||||
|
"heure Paris",
|
||||||
|
"heure New York",
|
||||||
|
"heure Tokyo",
|
||||||
|
"heure Londres",
|
||||||
|
"online clock",
|
||||||
|
"digital clock",
|
||||||
|
"analog clock",
|
||||||
|
"fullscreen clock",
|
||||||
|
"world time",
|
||||||
|
"current time",
|
||||||
|
"exact time",
|
||||||
|
"clock app",
|
||||||
|
"pendule en ligne",
|
||||||
|
"montre en ligne",
|
||||||
|
],
|
||||||
|
authors: [{ name: SITE_NAME, url: SITE_URL }],
|
||||||
|
creator: SITE_NAME,
|
||||||
|
publisher: SITE_NAME,
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
googleBot: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
'max-video-preview': -1,
|
||||||
|
'max-image-preview': 'large',
|
||||||
|
'max-snippet': -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: 'website',
|
||||||
|
locale: 'fr_FR',
|
||||||
|
url: SITE_URL,
|
||||||
|
siteName: SITE_NAME,
|
||||||
|
title: 'Horloge en ligne gratuite — Heure exacte, plein écran, personnalisable',
|
||||||
|
description: 'Horloge en ligne gratuite avec affichage plein écran. Choisissez entre horloge numérique ou analogique, personnalisez les couleurs et le fuseau horaire.',
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: `${SITE_URL}/og-image.png`,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: 'Horloge en ligne — Affichage de l\'heure exacte en temps réel',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Horloge en ligne — Heure exacte, plein écran',
|
||||||
|
description: 'Horloge en ligne gratuite avec affichage plein écran. Horloge numérique ou analogique personnalisable.',
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: `${SITE_URL}/og-image.png`,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: 'Horloge en ligne — Heure exacte en temps réel',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
alternates: {
|
||||||
|
canonical: SITE_URL,
|
||||||
|
languages: {
|
||||||
|
'fr-FR': SITE_URL,
|
||||||
|
'x-default': SITE_URL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: 'utility',
|
||||||
|
classification: 'Horloge, Outils, Temps',
|
||||||
|
other: {
|
||||||
|
'application-name': SITE_NAME,
|
||||||
|
'mobile-web-app-capable': 'yes',
|
||||||
|
'apple-mobile-web-app-capable': 'yes',
|
||||||
|
'apple-mobile-web-app-status-bar-style': 'black-translucent',
|
||||||
|
'apple-mobile-web-app-title': SITE_NAME,
|
||||||
|
'format-detection': 'telephone=no',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: [
|
||||||
|
{ media: '(prefers-color-scheme: light)', color: '#f8fafc' },
|
||||||
|
{ media: '(prefers-color-scheme: dark)', color: '#0f172a' },
|
||||||
|
],
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 5,
|
||||||
|
userScalable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Données structurées Schema.org — WebApplication
|
||||||
|
const jsonLdApp = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebApplication',
|
||||||
|
'@id': `${SITE_URL}/#app`,
|
||||||
|
name: SITE_NAME,
|
||||||
|
headline: 'Horloge en ligne gratuite — Heure exacte en temps réel',
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
url: SITE_URL,
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'All',
|
||||||
|
browserRequirements: 'Requires JavaScript',
|
||||||
|
softwareVersion: '1.0.0',
|
||||||
|
inLanguage: 'fr',
|
||||||
|
isAccessibleForFree: true,
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'EUR',
|
||||||
|
availability: 'https://schema.org/InStock',
|
||||||
|
},
|
||||||
|
featureList: [
|
||||||
|
'Horloge numérique en temps réel',
|
||||||
|
'Horloge analogique classique',
|
||||||
|
'Mode plein écran',
|
||||||
|
'Personnalisation des couleurs et thèmes',
|
||||||
|
'Plus de 30 fuseaux horaires',
|
||||||
|
'Format 12h / 24h',
|
||||||
|
'Sauvegarde automatique des préférences',
|
||||||
|
'URL partageable',
|
||||||
|
'Progressive Web App (PWA)',
|
||||||
|
],
|
||||||
|
screenshot: `${SITE_URL}/og-image.png`,
|
||||||
|
image: `${SITE_URL}/og-image.png`,
|
||||||
|
author: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: SITE_NAME,
|
||||||
|
url: SITE_URL,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Données structurées Schema.org — FAQPage
|
||||||
|
const jsonLdFAQ = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
'@id': `${SITE_URL}/#faq`,
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Comment passer en mode plein écran ?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Cliquez sur l\'icône plein écran en haut à droite de l\'écran ou appuyez sur F11 sur votre clavier. Pour quitter, appuyez sur Échap ou cliquez à nouveau sur l\'icône.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Comment changer de fuseau horaire ?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Ouvrez le panneau de paramètres en cliquant sur l\'icône engrenage, puis sélectionnez votre fuseau horaire dans la liste déroulante. L\'heure s\'actualise instantanément.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Comment partager ma configuration d\'horloge ?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Dans le panneau de paramètres, cliquez sur « Copier le lien » pour obtenir une URL unique contenant tous vos paramètres. Partagez ce lien avec qui vous voulez !',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'L\'horloge est-elle précise ?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Oui, notre horloge utilise l\'heure système de votre appareil et la met à jour en temps réel toutes les 100 millisecondes pour un affichage fluide et précis.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Quelle heure est-il dans un autre pays ?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Utilisez le sélecteur de fuseau horaire dans les paramètres pour choisir parmi plus de 30 fuseaux horaires : Paris, Londres, New York, Tokyo, Sydney, et bien d\'autres.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'L\'horloge fonctionne-t-elle hors ligne ?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Oui, notre horloge est une Progressive Web App (PWA). Une fois chargée, elle peut fonctionner hors connexion. Installez-la sur votre appareil pour un accès rapide.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Données structurées Schema.org — BreadcrumbList
|
||||||
|
const jsonLdBreadcrumb = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 1,
|
||||||
|
name: 'Accueil',
|
||||||
|
item: SITE_URL,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Données structurées Schema.org — WebSite (pour le sitelinks search box)
|
||||||
|
const jsonLdWebSite = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
'@id': `${SITE_URL}/#website`,
|
||||||
|
name: SITE_NAME,
|
||||||
|
url: SITE_URL,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
inLanguage: 'fr',
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: SITE_NAME,
|
||||||
|
url: SITE_URL,
|
||||||
|
logo: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
url: `${SITE_URL}/icon-512.png`,
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,10 +268,44 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="fr" dir="ltr">
|
||||||
|
<head>
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
|
<link rel="icon" href="/icon-192.png" type="image/png" sizes="192x192" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" crossOrigin="anonymous" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdApp) }}
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdFAQ) }}
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdBreadcrumb) }}
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdWebSite) }}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
<noscript>
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center', color: '#f8fafc', backgroundColor: '#0f172a', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h1>Horloge en ligne</h1>
|
||||||
|
<p>Cette application nécessite JavaScript pour afficher l'heure en temps réel.</p>
|
||||||
|
<p>Veuillez activer JavaScript dans les paramètres de votre navigateur.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-900">
|
||||||
|
<div className="animate-pulse text-6xl font-mono text-slate-500">
|
||||||
|
--:--:--
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-slate-600">Chargement de l'horloge...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Page non trouvée (404)',
|
||||||
|
description: 'La page que vous recherchez n\'existe pas. Retournez à l\'horloge en ligne gratuite.',
|
||||||
|
robots: {
|
||||||
|
index: false,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-900 text-white p-4">
|
||||||
|
<h1 className="text-6xl font-bold text-blue-500 mb-4">404</h1>
|
||||||
|
<h2 className="text-2xl font-semibold mb-4">Page non trouvée</h2>
|
||||||
|
<p className="text-slate-400 mb-8 text-center max-w-md">
|
||||||
|
Désolé, la page que vous recherchez n'existe pas ou a été déplacée.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="px-6 py-3 bg-blue-500 text-white rounded-lg font-medium hover:bg-blue-600 transition-colors"
|
||||||
|
title="Retour à l'horloge en ligne"
|
||||||
|
>
|
||||||
|
Retour à l'horloge
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +1,162 @@
|
|||||||
import Image from "next/image";
|
import { ClockApp } from '@/components/ClockApp';
|
||||||
|
|
||||||
|
// Contenu SEO visible en bas de page pour l'indexation Google
|
||||||
|
function SEOContent() {
|
||||||
|
return (
|
||||||
|
<article className="seo-content" itemScope itemType="https://schema.org/Article">
|
||||||
|
<header>
|
||||||
|
<h1 itemProp="headline">Horloge en ligne gratuite — Heure exacte en temps réel</h1>
|
||||||
|
<p className="seo-intro" itemProp="description">
|
||||||
|
Découvrez notre horloge en ligne gratuite avec affichage en temps réel.
|
||||||
|
Choisissez entre une horloge numérique moderne ou une horloge analogique
|
||||||
|
classique pour consulter l'heure exacte n'importe quand, où que vous soyez.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="features">
|
||||||
|
<h2>Fonctionnalités de l'horloge en ligne</h2>
|
||||||
|
<div className="seo-features">
|
||||||
|
<div className="seo-feature">
|
||||||
|
<h3>🕐 Horloge numérique et analogique</h3>
|
||||||
|
<p>
|
||||||
|
Basculez entre un affichage numérique moderne avec ou sans secondes,
|
||||||
|
et une horloge analogique au design élégant. Format 12 heures ou 24 heures au choix.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="seo-feature">
|
||||||
|
<h3>🖥️ Mode plein écran</h3>
|
||||||
|
<p>
|
||||||
|
Transformez votre écran en une horloge géante. Idéal pour les bureaux,
|
||||||
|
les salles de réunion, les événements ou pour garder un œil sur l'heure
|
||||||
|
depuis n'importe où dans la pièce.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="seo-feature">
|
||||||
|
<h3>🌍 Plus de 30 fuseaux horaires</h3>
|
||||||
|
<p>
|
||||||
|
Consultez l'heure dans le monde entier : Paris, Londres, New York,
|
||||||
|
Los Angeles, Tokyo, Sydney, Hong Kong, Dubaï, Singapour, et bien d'autres villes.
|
||||||
|
Notre horloge mondiale affiche l'heure exacte dans n'importe quel fuseau horaire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="seo-feature">
|
||||||
|
<h3>🎨 12 thèmes personnalisables</h3>
|
||||||
|
<p>
|
||||||
|
Personnalisez votre horloge avec nos thèmes : Minuit, Océan, Forêt,
|
||||||
|
Coucher de soleil, Lavande, Rose, Charbon, Neige, Ambre, Émeraude, Rubis et Cyberpunk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="seo-feature">
|
||||||
|
<h3>💾 Sauvegarde automatique</h3>
|
||||||
|
<p>
|
||||||
|
Vos préférences sont automatiquement sauvegardées et restaurées à chaque visite.
|
||||||
|
Partagez votre configuration avec un lien unique.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="seo-feature">
|
||||||
|
<h3>📱 Progressive Web App</h3>
|
||||||
|
<p>
|
||||||
|
Installez l'horloge sur votre appareil comme une application native.
|
||||||
|
Fonctionne hors ligne une fois chargée.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="faq" className="seo-faq" itemScope itemType="https://schema.org/FAQPage">
|
||||||
|
<h2>Questions fréquentes</h2>
|
||||||
|
|
||||||
|
<details itemScope itemProp="mainEntity" itemType="https://schema.org/Question" open>
|
||||||
|
<summary itemProp="name">Comment passer en mode plein écran ?</summary>
|
||||||
|
<div itemScope itemProp="acceptedAnswer" itemType="https://schema.org/Answer">
|
||||||
|
<p itemProp="text">
|
||||||
|
Cliquez sur l'icône plein écran en haut à droite de l'écran ou appuyez
|
||||||
|
sur F11 sur votre clavier. Pour quitter le mode plein écran, appuyez sur Échap
|
||||||
|
ou cliquez à nouveau sur l'icône.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details itemScope itemProp="mainEntity" itemType="https://schema.org/Question">
|
||||||
|
<summary itemProp="name">Comment changer de fuseau horaire ?</summary>
|
||||||
|
<div itemScope itemProp="acceptedAnswer" itemType="https://schema.org/Answer">
|
||||||
|
<p itemProp="text">
|
||||||
|
Ouvrez le panneau de paramètres en cliquant sur l'icône engrenage,
|
||||||
|
puis sélectionnez votre fuseau horaire dans la liste déroulante.
|
||||||
|
L'heure s'actualise instantanément.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details itemScope itemProp="mainEntity" itemType="https://schema.org/Question">
|
||||||
|
<summary itemProp="name">Comment partager ma configuration d'horloge ?</summary>
|
||||||
|
<div itemScope itemProp="acceptedAnswer" itemType="https://schema.org/Answer">
|
||||||
|
<p itemProp="text">
|
||||||
|
Dans le panneau de paramètres, cliquez sur « Copier le lien » pour
|
||||||
|
obtenir une URL unique contenant tous vos paramètres. Partagez
|
||||||
|
ce lien avec qui vous voulez !
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details itemScope itemProp="mainEntity" itemType="https://schema.org/Question">
|
||||||
|
<summary itemProp="name">L'horloge est-elle précise ?</summary>
|
||||||
|
<div itemScope itemProp="acceptedAnswer" itemType="https://schema.org/Answer">
|
||||||
|
<p itemProp="text">
|
||||||
|
Oui, notre horloge utilise l'heure système de votre appareil et
|
||||||
|
la met à jour en temps réel toutes les 100 millisecondes pour un
|
||||||
|
affichage fluide et précis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details itemScope itemProp="mainEntity" itemType="https://schema.org/Question">
|
||||||
|
<summary itemProp="name">Quelle heure est-il dans un autre pays ?</summary>
|
||||||
|
<div itemScope itemProp="acceptedAnswer" itemType="https://schema.org/Answer">
|
||||||
|
<p itemProp="text">
|
||||||
|
Utilisez le sélecteur de fuseau horaire dans les paramètres pour choisir
|
||||||
|
parmi plus de 30 fuseaux horaires incluant Paris, Londres, New York,
|
||||||
|
Tokyo, Sydney, et bien d'autres villes du monde.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details itemScope itemProp="mainEntity" itemType="https://schema.org/Question">
|
||||||
|
<summary itemProp="name">L'horloge fonctionne-t-elle hors ligne ?</summary>
|
||||||
|
<div itemScope itemProp="acceptedAnswer" itemType="https://schema.org/Answer">
|
||||||
|
<p itemProp="text">
|
||||||
|
Oui, notre horloge est une Progressive Web App (PWA). Une fois chargée,
|
||||||
|
elle peut fonctionner hors connexion. Installez-la sur votre appareil
|
||||||
|
pour un accès rapide depuis votre écran d'accueil.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<>
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<a href="#main-clock" className="skip-to-content">
|
||||||
<Image
|
Aller au contenu principal
|
||||||
className="dark:invert"
|
</a>
|
||||||
src="/next.svg"
|
<ClockApp />
|
||||||
alt="Next.js logo"
|
<SEOContent />
|
||||||
width={100}
|
<footer className="seo-footer" role="contentinfo">
|
||||||
height={20}
|
<p>
|
||||||
priority
|
© {new Date().getFullYear()} Horloge en ligne — Heure exacte gratuite en temps réel.
|
||||||
/>
|
Horloge numérique, analogique et à bascule avec personnalisation complète.
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
</p>
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
<nav aria-label="Liens rapides">
|
||||||
To get started, edit the page.tsx file.
|
<ul>
|
||||||
</h1>
|
<li><a href="#main-clock">Horloge</a></li>
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
<li><a href="#features">Fonctionnalités</a></li>
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
<li><a href="#faq">FAQ</a></li>
|
||||||
<a
|
</ul>
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</nav>
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
</footer>
|
||||||
>
|
</>
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/',
|
||||||
|
disallow: ['/api/', '/_next/'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userAgent: 'Googlebot',
|
||||||
|
allow: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userAgent: 'Bingbot',
|
||||||
|
allow: '/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sitemap: 'https://clock.arthurp.fr/sitemap.xml',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const baseUrl = 'https://clock.arthurp.fr';
|
||||||
|
const lastModified = new Date('2026-03-01');
|
||||||
|
|
||||||
|
// Page principale
|
||||||
|
const staticPages: MetadataRoute.Sitemap = [
|
||||||
|
{
|
||||||
|
url: baseUrl,
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: 'weekly',
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Pages par type d'horloge
|
||||||
|
const clockTypePages: MetadataRoute.Sitemap = [
|
||||||
|
{
|
||||||
|
url: `${baseUrl}?type=digital`,
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}?type=analog`,
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Pages pour chaque fuseau horaire populaire
|
||||||
|
const timezones = [
|
||||||
|
'Europe/Paris',
|
||||||
|
'Europe/London',
|
||||||
|
'America/New_York',
|
||||||
|
'America/Los_Angeles',
|
||||||
|
'America/Chicago',
|
||||||
|
'America/Denver',
|
||||||
|
'America/Toronto',
|
||||||
|
'America/Sao_Paulo',
|
||||||
|
'America/Mexico_City',
|
||||||
|
'America/Argentina/Buenos_Aires',
|
||||||
|
'Asia/Tokyo',
|
||||||
|
'Asia/Shanghai',
|
||||||
|
'Asia/Hong_Kong',
|
||||||
|
'Asia/Singapore',
|
||||||
|
'Asia/Dubai',
|
||||||
|
'Asia/Kolkata',
|
||||||
|
'Asia/Seoul',
|
||||||
|
'Asia/Bangkok',
|
||||||
|
'Asia/Jakarta',
|
||||||
|
'Australia/Sydney',
|
||||||
|
'Australia/Melbourne',
|
||||||
|
'Europe/Berlin',
|
||||||
|
'Europe/Madrid',
|
||||||
|
'Europe/Rome',
|
||||||
|
'Europe/Amsterdam',
|
||||||
|
'Europe/Brussels',
|
||||||
|
'Europe/Zurich',
|
||||||
|
'Europe/Moscow',
|
||||||
|
'Europe/Istanbul',
|
||||||
|
'Africa/Cairo',
|
||||||
|
'Africa/Johannesburg',
|
||||||
|
'Pacific/Auckland',
|
||||||
|
'Pacific/Honolulu',
|
||||||
|
];
|
||||||
|
|
||||||
|
const timezonePages: MetadataRoute.Sitemap = timezones.map((tz) => ({
|
||||||
|
url: `${baseUrl}?tz=${encodeURIComponent(tz)}`,
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.7,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Pages par thème
|
||||||
|
const themes = [
|
||||||
|
'midnight', 'ocean', 'forest', 'sunset', 'lavender', 'rose',
|
||||||
|
'charcoal', 'snow', 'amber', 'emerald', 'ruby', 'cyberpunk',
|
||||||
|
];
|
||||||
|
|
||||||
|
const themePages: MetadataRoute.Sitemap = themes.map((theme) => ({
|
||||||
|
url: `${baseUrl}?theme=${theme}`,
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...staticPages, ...clockTypePages, ...timezonePages, ...themePages];
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { memo, useMemo } from 'react';
|
||||||
|
import { TimeData, Theme, THEMES } from '@/lib/types';
|
||||||
|
import { formatTime } from '@/lib/hooks';
|
||||||
|
|
||||||
|
interface DigitalClockProps {
|
||||||
|
time: TimeData;
|
||||||
|
format: '12h' | '24h';
|
||||||
|
showSeconds: boolean;
|
||||||
|
theme: Theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DigitalClockComponent({ time, format, showSeconds, theme }: DigitalClockProps) {
|
||||||
|
const displayTime = useMemo(() => {
|
||||||
|
return formatTime(time, format, showSeconds);
|
||||||
|
}, [time.hours, time.minutes, time.seconds, format, showSeconds]);
|
||||||
|
|
||||||
|
const period = format === '12h' ? time.period : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center animate-fade-in"
|
||||||
|
role="timer"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
aria-label={`Heure actuelle : ${displayTime}${period ? ` ${period}` : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline gap-2 md:gap-4">
|
||||||
|
<time
|
||||||
|
dateTime={time.date.toISOString()}
|
||||||
|
className="font-mono font-bold tracking-tight theme-transition"
|
||||||
|
style={{
|
||||||
|
color: theme.foreground,
|
||||||
|
fontSize: 'clamp(3rem, 15vw, 12rem)',
|
||||||
|
textShadow: `0 0 40px ${theme.primary}40`,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayTime}
|
||||||
|
</time>
|
||||||
|
{period && (
|
||||||
|
<span
|
||||||
|
className="font-mono font-medium theme-transition"
|
||||||
|
style={{
|
||||||
|
color: theme.secondary,
|
||||||
|
fontSize: 'clamp(1rem, 4vw, 3rem)',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{period}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indicateur de secondes animé */}
|
||||||
|
{showSeconds && (
|
||||||
|
<div
|
||||||
|
className="mt-4 h-1 rounded-full overflow-hidden theme-transition"
|
||||||
|
style={{
|
||||||
|
width: 'clamp(100px, 30vw, 300px)',
|
||||||
|
backgroundColor: `${theme.secondary}30`,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-100 ease-linear"
|
||||||
|
style={{
|
||||||
|
width: `${(time.milliseconds / 1000) * 100}%`,
|
||||||
|
backgroundColor: theme.primary,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DigitalClock = memo(DigitalClockComponent);
|
||||||
|
|
||||||
|
interface AnalogClockProps {
|
||||||
|
time: TimeData;
|
||||||
|
showSeconds: boolean;
|
||||||
|
theme: Theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalogClockComponent({ time, showSeconds, theme }: AnalogClockProps) {
|
||||||
|
// Calculer les angles des aiguilles
|
||||||
|
const secondAngle = (time.seconds + time.milliseconds / 1000) * 6;
|
||||||
|
const minuteAngle = (time.minutes + time.seconds / 60) * 6;
|
||||||
|
const hourAngle = ((time.hours % 12) + time.minutes / 60) * 30;
|
||||||
|
|
||||||
|
// Générer les 60 marqueurs de secondes/minutes
|
||||||
|
const secondMarkers = useMemo(() => {
|
||||||
|
return Array.from({ length: 60 }, (_, i) => ({
|
||||||
|
index: i,
|
||||||
|
isHourMark: i % 5 === 0,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Générer les 12 chiffres d'heures
|
||||||
|
const hourNumbers = useMemo(() => {
|
||||||
|
return Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
index: i + 1,
|
||||||
|
hour: i + 1,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative animate-fade-in flex justify-center items-center"
|
||||||
|
role="timer"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
aria-label={`Heure actuelle : ${time.hours.toString().padStart(2, '0')}:${time.minutes.toString().padStart(2, '0')}`}
|
||||||
|
style={{
|
||||||
|
width: 'clamp(250px, 60vmin, 500px)',
|
||||||
|
height: 'clamp(250px, 60vmin, 500px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Cadran */}
|
||||||
|
<div
|
||||||
|
className="clock-face w-full h-full theme-transition"
|
||||||
|
style={{
|
||||||
|
border: `4px solid ${theme.primary}`,
|
||||||
|
background: `radial-gradient(circle at 30% 30%, ${theme.background}dd, ${theme.background})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Marqueurs des secondes/minutes (60 graduations) */}
|
||||||
|
<div className="absolute inset-0 rounded-full">
|
||||||
|
{secondMarkers.map(({ index, isHourMark }) => (
|
||||||
|
<span
|
||||||
|
key={`second-marker-${index}`}
|
||||||
|
className="absolute inset-[-20px] text-center"
|
||||||
|
style={{
|
||||||
|
transform: `rotate(${index * 6}deg)`,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="inline-block rounded-sm"
|
||||||
|
style={{
|
||||||
|
width: isHourMark ? '6px' : '2px',
|
||||||
|
height: isHourMark ? '18px' : '12px',
|
||||||
|
backgroundColor: theme.primary,
|
||||||
|
boxShadow: `0 0 10px ${theme.primary}`,
|
||||||
|
transform: isHourMark ? 'translateY(1px)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chiffres des heures */}
|
||||||
|
<div className="absolute inset-0 rounded-full">
|
||||||
|
{hourNumbers.map(({ index, hour }) => (
|
||||||
|
<span
|
||||||
|
key={`hour-${hour}`}
|
||||||
|
className="absolute inset-[6px] text-center"
|
||||||
|
style={{
|
||||||
|
transform: `rotate(${index * 30}deg)`,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="font-bold theme-transition"
|
||||||
|
style={{
|
||||||
|
fontSize: 'clamp(1.2rem, 4vmin, 2.5rem)',
|
||||||
|
color: theme.primary,
|
||||||
|
textShadow: `0 0 10px ${theme.primary}`,
|
||||||
|
transform: `rotate(${-index * 30}deg)`,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hour}
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aiguille des heures */}
|
||||||
|
<div
|
||||||
|
className="clock-hand theme-transition"
|
||||||
|
style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '25%',
|
||||||
|
backgroundColor: theme.foreground,
|
||||||
|
transform: `translateX(-50%) rotate(${hourAngle}deg)`,
|
||||||
|
boxShadow: `0 0 10px ${theme.primary}60`,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Aiguille des minutes */}
|
||||||
|
<div
|
||||||
|
className="clock-hand theme-transition"
|
||||||
|
style={{
|
||||||
|
width: '4px',
|
||||||
|
height: '35%',
|
||||||
|
backgroundColor: theme.foreground,
|
||||||
|
transform: `translateX(-50%) rotate(${minuteAngle}deg)`,
|
||||||
|
boxShadow: `0 0 10px ${theme.primary}60`,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Aiguille des secondes */}
|
||||||
|
{showSeconds && (
|
||||||
|
<div
|
||||||
|
className="clock-hand"
|
||||||
|
style={{
|
||||||
|
width: '2px',
|
||||||
|
height: '40%',
|
||||||
|
backgroundColor: theme.primary,
|
||||||
|
transform: `translateX(-50%) rotate(${secondAngle}deg)`,
|
||||||
|
transition: 'transform 0.1s linear',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Centre */}
|
||||||
|
<div
|
||||||
|
className="clock-center theme-transition"
|
||||||
|
style={{
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
backgroundColor: theme.primary,
|
||||||
|
boxShadow: `0 0 15px ${theme.primary}`,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnalogClock = memo(AnalogClockComponent);
|
||||||
|
|
||||||
|
// Composant Flip Clock (horloge à bascule)
|
||||||
|
interface FlipClockProps {
|
||||||
|
time: TimeData;
|
||||||
|
format: '12h' | '24h';
|
||||||
|
showSeconds: boolean;
|
||||||
|
theme: Theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlipCardProps {
|
||||||
|
value: string;
|
||||||
|
theme: Theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlipCard({ value, theme }: FlipCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flip-card relative"
|
||||||
|
style={{
|
||||||
|
width: 'clamp(60px, 12vw, 120px)',
|
||||||
|
height: 'clamp(80px, 16vw, 160px)',
|
||||||
|
perspective: '300px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Carte supérieure */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 top-0 h-1/2 overflow-hidden rounded-t-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${theme.background}`,
|
||||||
|
borderBottom: `1px solid ${theme.secondary}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 top-0 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
height: '200%',
|
||||||
|
background: `linear-gradient(180deg, ${theme.secondary}20 0%, transparent 50%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono font-bold"
|
||||||
|
style={{
|
||||||
|
fontSize: 'clamp(3rem, 10vw, 8rem)',
|
||||||
|
color: theme.primary,
|
||||||
|
textShadow: `0 0 20px ${theme.primary}60`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Carte inférieure */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 h-1/2 overflow-hidden rounded-b-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${theme.background}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
height: '200%',
|
||||||
|
background: `linear-gradient(0deg, ${theme.secondary}20 0%, transparent 50%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono font-bold"
|
||||||
|
style={{
|
||||||
|
fontSize: 'clamp(3rem, 10vw, 8rem)',
|
||||||
|
color: theme.primary,
|
||||||
|
textShadow: `0 0 20px ${theme.primary}60`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ligne de séparation centrale */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 top-1/2 h-[2px] -translate-y-1/2 z-10"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.secondary,
|
||||||
|
boxShadow: `0 0 10px ${theme.background}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bordure */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-lg pointer-events-none"
|
||||||
|
style={{
|
||||||
|
border: `3px solid ${theme.primary}40`,
|
||||||
|
boxShadow: `0 10px 30px ${theme.background}80, inset 0 0 20px ${theme.primary}10`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlipClockComponent({ time, format, showSeconds, theme }: FlipClockProps) {
|
||||||
|
const hours = format === '12h'
|
||||||
|
? (time.hours % 12 || 12).toString().padStart(2, '0')
|
||||||
|
: time.hours.toString().padStart(2, '0');
|
||||||
|
const minutes = time.minutes.toString().padStart(2, '0');
|
||||||
|
const seconds = time.seconds.toString().padStart(2, '0');
|
||||||
|
const period = format === '12h' ? time.period : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center animate-fade-in"
|
||||||
|
role="timer"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
aria-label={`Heure actuelle : ${hours}:${minutes}${showSeconds ? ':' + seconds : ''}${period ? ' ' + period : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 md:gap-4">
|
||||||
|
{/* Heures */}
|
||||||
|
<div className="flex gap-1 md:gap-2">
|
||||||
|
<FlipCard value={hours[0]} theme={theme} />
|
||||||
|
<FlipCard value={hours[1]} theme={theme} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Séparateur */}
|
||||||
|
<div className="flex flex-col gap-3" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 md:w-4 md:h-4 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.primary,
|
||||||
|
boxShadow: `0 0 10px ${theme.primary}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 md:w-4 md:h-4 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.primary,
|
||||||
|
boxShadow: `0 0 10px ${theme.primary}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Minutes */}
|
||||||
|
<div className="flex gap-1 md:gap-2">
|
||||||
|
<FlipCard value={minutes[0]} theme={theme} />
|
||||||
|
<FlipCard value={minutes[1]} theme={theme} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondes */}
|
||||||
|
{showSeconds && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-3" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 md:w-4 md:h-4 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.primary,
|
||||||
|
boxShadow: `0 0 10px ${theme.primary}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 md:w-4 md:h-4 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.primary,
|
||||||
|
boxShadow: `0 0 10px ${theme.primary}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 md:gap-2">
|
||||||
|
<FlipCard value={seconds[0]} theme={theme} />
|
||||||
|
<FlipCard value={seconds[1]} theme={theme} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AM/PM */}
|
||||||
|
{period && (
|
||||||
|
<div
|
||||||
|
className="ml-2 md:ml-4 font-mono font-bold self-end mb-2"
|
||||||
|
style={{
|
||||||
|
fontSize: 'clamp(1rem, 3vw, 2rem)',
|
||||||
|
color: theme.secondary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{period}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FlipClock = memo(FlipClockComponent);
|
||||||
|
|
||||||
|
// Composant wrapper pour afficher le bon type d'horloge
|
||||||
|
interface ClockDisplayProps {
|
||||||
|
time: TimeData;
|
||||||
|
clockType: 'digital' | 'analog' | 'flip';
|
||||||
|
format: '12h' | '24h';
|
||||||
|
showSeconds: boolean;
|
||||||
|
themeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClockDisplay({ time, clockType, format, showSeconds, themeId }: ClockDisplayProps) {
|
||||||
|
const theme = THEMES.find(t => t.id === themeId) || THEMES[0];
|
||||||
|
|
||||||
|
if (clockType === 'analog') {
|
||||||
|
return <AnalogClock time={time} showSeconds={showSeconds} theme={theme} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clockType === 'flip') {
|
||||||
|
return <FlipClock time={time} format={format} showSeconds={showSeconds} theme={theme} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DigitalClock time={time} format={format} showSeconds={showSeconds} theme={theme} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, Suspense } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { ClockDisplay } from '@/components/Clock';
|
||||||
|
import { SettingsPanel } from '@/components/SettingsPanel';
|
||||||
|
import { useTime, useClockSettings, useFullscreen, parseUrlParams } from '@/lib/hooks';
|
||||||
|
import { THEMES, TIMEZONES } from '@/lib/types';
|
||||||
|
|
||||||
|
function ClockAppContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [urlSettings, setUrlSettings] = useState<ReturnType<typeof parseUrlParams>>({});
|
||||||
|
|
||||||
|
// Parser les paramètres URL au montage
|
||||||
|
useEffect(() => {
|
||||||
|
const params = parseUrlParams();
|
||||||
|
setUrlSettings(params);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const { settings, updateSettings, isLoaded } = useClockSettings(urlSettings);
|
||||||
|
const time = useTime(settings.timezone);
|
||||||
|
const { isFullscreen, toggleFullscreen } = useFullscreen();
|
||||||
|
|
||||||
|
const currentTheme = THEMES.find(t => t.id === settings.themeId) || THEMES[0];
|
||||||
|
const currentTimezone = TIMEZONES.find(tz => tz.value === settings.timezone);
|
||||||
|
|
||||||
|
// Appliquer le thème au body
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.backgroundColor = currentTheme.background;
|
||||||
|
document.body.style.color = currentTheme.foreground;
|
||||||
|
}, [currentTheme]);
|
||||||
|
|
||||||
|
// Mettre à jour l'URL quand les paramètres changent
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoaded && typeof window !== 'undefined') {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('tz', settings.timezone);
|
||||||
|
params.set('type', settings.clockType);
|
||||||
|
params.set('format', settings.timeFormat);
|
||||||
|
params.set('seconds', settings.showSeconds.toString());
|
||||||
|
params.set('theme', settings.themeId);
|
||||||
|
|
||||||
|
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||||
|
window.history.replaceState({}, '', newUrl);
|
||||||
|
}
|
||||||
|
}, [settings, isLoaded]);
|
||||||
|
|
||||||
|
if (!isLoaded) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fullscreen-container"
|
||||||
|
style={{ backgroundColor: currentTheme.background }}
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label="Chargement de l'horloge"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="animate-pulse font-mono font-bold tracking-tight"
|
||||||
|
style={{
|
||||||
|
color: currentTheme.secondary,
|
||||||
|
fontSize: 'clamp(3rem, 15vw, 12rem)',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
--:--:--
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-8 text-center"
|
||||||
|
style={{ color: currentTheme.secondary, opacity: 0.5 }}
|
||||||
|
>
|
||||||
|
<p className="text-lg md:text-xl font-medium"> </p>
|
||||||
|
<p className="text-sm mt-1"> </p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fullscreen-container theme-transition"
|
||||||
|
style={{ backgroundColor: currentTheme.background }}
|
||||||
|
>
|
||||||
|
{/* Horloge principale */}
|
||||||
|
<div id="main-clock" className="flex-1 flex flex-col items-center justify-center w-full" role="main">
|
||||||
|
<ClockDisplay
|
||||||
|
time={time}
|
||||||
|
clockType={settings.clockType}
|
||||||
|
format={settings.timeFormat}
|
||||||
|
showSeconds={settings.showSeconds}
|
||||||
|
themeId={settings.themeId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Information sur le fuseau horaire */}
|
||||||
|
<div
|
||||||
|
className="mt-8 text-center animate-fade-in"
|
||||||
|
style={{ color: currentTheme.secondary }}
|
||||||
|
>
|
||||||
|
<p className="text-lg md:text-xl font-medium">
|
||||||
|
{currentTimezone?.label || settings.timezone}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-1 opacity-75">
|
||||||
|
{new Date().toLocaleDateString('fr-FR', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: settings.timezone
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panneau de paramètres */}
|
||||||
|
<SettingsPanel
|
||||||
|
settings={settings}
|
||||||
|
onUpdate={updateSettings}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onToggleFullscreen={toggleFullscreen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper avec Suspense pour useSearchParams
|
||||||
|
export function ClockApp() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div
|
||||||
|
className="fullscreen-container"
|
||||||
|
style={{ backgroundColor: '#0f172a' }}
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label="Chargement de l'horloge"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="animate-pulse font-mono font-bold tracking-tight text-slate-500"
|
||||||
|
style={{
|
||||||
|
fontSize: 'clamp(3rem, 15vw, 12rem)',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
--:--:--
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 text-center text-slate-600">
|
||||||
|
<p className="text-lg md:text-xl font-medium"> </p>
|
||||||
|
<p className="text-sm mt-1"> </p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<ClockAppContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { ClockSettings, THEMES, TIMEZONES, Theme } from '@/lib/types';
|
||||||
|
import { generateShareableUrl } from '@/lib/hooks';
|
||||||
|
|
||||||
|
interface SettingsPanelProps {
|
||||||
|
settings: ClockSettings;
|
||||||
|
onUpdate: (updates: Partial<ClockSettings>) => void;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPanel({
|
||||||
|
settings,
|
||||||
|
onUpdate,
|
||||||
|
isFullscreen,
|
||||||
|
onToggleFullscreen
|
||||||
|
}: SettingsPanelProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const currentTheme = THEMES.find(t => t.id === settings.themeId) || THEMES[0];
|
||||||
|
|
||||||
|
const handleCopyUrl = useCallback(async () => {
|
||||||
|
const url = generateShareableUrl(settings);
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur lors de la copie:', e);
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Bouton pour ouvrir les paramètres */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`
|
||||||
|
fixed top-4 right-4 z-50 p-3 rounded-full
|
||||||
|
transition-all duration-300 ease-out
|
||||||
|
hover:scale-110 active:scale-95
|
||||||
|
${isOpen ? 'rotate-90' : ''}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${currentTheme.primary}20`,
|
||||||
|
color: currentTheme.foreground,
|
||||||
|
border: `1px solid ${currentTheme.primary}40`,
|
||||||
|
}}
|
||||||
|
aria-label={isOpen ? 'Fermer les paramètres' : 'Ouvrir les paramètres'}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Bouton plein écran */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
className={`
|
||||||
|
fixed top-4 right-16 z-50 p-3 rounded-full
|
||||||
|
transition-all duration-300 ease-out
|
||||||
|
hover:scale-110 active:scale-95
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${currentTheme.primary}20`,
|
||||||
|
color: currentTheme.foreground,
|
||||||
|
border: `1px solid ${currentTheme.primary}40`,
|
||||||
|
}}
|
||||||
|
aria-label={isFullscreen ? 'Quitter le plein écran' : 'Passer en plein écran'}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Panneau de paramètres */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
fixed top-0 right-0 h-full w-full sm:w-96 z-40
|
||||||
|
settings-panel transform transition-transform duration-300 ease-out
|
||||||
|
overflow-y-auto
|
||||||
|
${isOpen ? 'translate-x-0' : 'translate-x-full'}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${currentTheme.background}f0`,
|
||||||
|
color: currentTheme.foreground,
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Paramètres de l'horloge"
|
||||||
|
>
|
||||||
|
<div className="p-6 pt-20 space-y-8">
|
||||||
|
<h2 className="text-2xl font-bold mb-6" style={{ color: currentTheme.primary }}>
|
||||||
|
Paramètres
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Type d'horloge */}
|
||||||
|
<section aria-labelledby="clock-type-label">
|
||||||
|
<h3 id="clock-type-label" className="text-lg font-semibold mb-3">
|
||||||
|
Type d'horloge
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => onUpdate({ clockType: 'digital' })}
|
||||||
|
className={`
|
||||||
|
flex-1 py-3 px-4 rounded-lg font-medium
|
||||||
|
transition-all duration-200 min-w-[80px]
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: settings.clockType === 'digital'
|
||||||
|
? currentTheme.primary
|
||||||
|
: `${currentTheme.secondary}30`,
|
||||||
|
color: settings.clockType === 'digital'
|
||||||
|
? currentTheme.background
|
||||||
|
: currentTheme.foreground,
|
||||||
|
}}
|
||||||
|
aria-pressed={settings.clockType === 'digital'}
|
||||||
|
>
|
||||||
|
Numérique
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onUpdate({ clockType: 'analog' })}
|
||||||
|
className={`
|
||||||
|
flex-1 py-3 px-4 rounded-lg font-medium
|
||||||
|
transition-all duration-200 min-w-[80px]
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: settings.clockType === 'analog'
|
||||||
|
? currentTheme.primary
|
||||||
|
: `${currentTheme.secondary}30`,
|
||||||
|
color: settings.clockType === 'analog'
|
||||||
|
? currentTheme.background
|
||||||
|
: currentTheme.foreground,
|
||||||
|
}}
|
||||||
|
aria-pressed={settings.clockType === 'analog'}
|
||||||
|
>
|
||||||
|
Analogique
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onUpdate({ clockType: 'flip' })}
|
||||||
|
className={`
|
||||||
|
flex-1 py-3 px-4 rounded-lg font-medium
|
||||||
|
transition-all duration-200 min-w-[80px]
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: settings.clockType === 'flip'
|
||||||
|
? currentTheme.primary
|
||||||
|
: `${currentTheme.secondary}30`,
|
||||||
|
color: settings.clockType === 'flip'
|
||||||
|
? currentTheme.background
|
||||||
|
: currentTheme.foreground,
|
||||||
|
}}
|
||||||
|
aria-pressed={settings.clockType === 'flip'}
|
||||||
|
>
|
||||||
|
À bascule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Format de l'heure */}
|
||||||
|
<section aria-labelledby="time-format-label">
|
||||||
|
<h3 id="time-format-label" className="text-lg font-semibold mb-3">
|
||||||
|
Format de l'heure
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onUpdate({ timeFormat: '24h' })}
|
||||||
|
className={`
|
||||||
|
flex-1 py-3 px-4 rounded-lg font-medium
|
||||||
|
transition-all duration-200
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: settings.timeFormat === '24h'
|
||||||
|
? currentTheme.primary
|
||||||
|
: `${currentTheme.secondary}30`,
|
||||||
|
color: settings.timeFormat === '24h'
|
||||||
|
? currentTheme.background
|
||||||
|
: currentTheme.foreground,
|
||||||
|
}}
|
||||||
|
aria-pressed={settings.timeFormat === '24h'}
|
||||||
|
>
|
||||||
|
24 heures
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onUpdate({ timeFormat: '12h' })}
|
||||||
|
className={`
|
||||||
|
flex-1 py-3 px-4 rounded-lg font-medium
|
||||||
|
transition-all duration-200
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: settings.timeFormat === '12h'
|
||||||
|
? currentTheme.primary
|
||||||
|
: `${currentTheme.secondary}30`,
|
||||||
|
color: settings.timeFormat === '12h'
|
||||||
|
? currentTheme.background
|
||||||
|
: currentTheme.foreground,
|
||||||
|
}}
|
||||||
|
aria-pressed={settings.timeFormat === '12h'}
|
||||||
|
>
|
||||||
|
12 heures
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Afficher les secondes */}
|
||||||
|
<section className="flex items-center justify-between">
|
||||||
|
<label htmlFor="show-seconds" className="text-lg font-semibold">
|
||||||
|
Afficher les secondes
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
id="show-seconds"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={settings.showSeconds}
|
||||||
|
onClick={() => onUpdate({ showSeconds: !settings.showSeconds })}
|
||||||
|
className={`
|
||||||
|
relative w-14 h-7 rounded-full
|
||||||
|
transition-colors duration-200
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: settings.showSeconds
|
||||||
|
? currentTheme.primary
|
||||||
|
: `${currentTheme.secondary}50`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
absolute top-1 left-1 w-5 h-5 rounded-full bg-white
|
||||||
|
transition-transform duration-200
|
||||||
|
${settings.showSeconds ? 'translate-x-7' : 'translate-x-0'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Fuseau horaire */}
|
||||||
|
<section aria-labelledby="timezone-label">
|
||||||
|
<h3 id="timezone-label" className="text-lg font-semibold mb-3">
|
||||||
|
Fuseau horaire
|
||||||
|
</h3>
|
||||||
|
<select
|
||||||
|
value={settings.timezone}
|
||||||
|
onChange={(e) => onUpdate({ timezone: e.target.value })}
|
||||||
|
className="w-full py-3 px-4 rounded-lg transition-colors duration-200"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${currentTheme.secondary}30`,
|
||||||
|
color: currentTheme.foreground,
|
||||||
|
border: `1px solid ${currentTheme.secondary}50`,
|
||||||
|
}}
|
||||||
|
aria-label="Sélectionner un fuseau horaire"
|
||||||
|
>
|
||||||
|
{TIMEZONES.map((tz) => (
|
||||||
|
<option key={tz.value} value={tz.value} style={{ backgroundColor: currentTheme.background }}>
|
||||||
|
{tz.label} (UTC{tz.offset})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Thème */}
|
||||||
|
<section aria-labelledby="theme-label">
|
||||||
|
<h3 id="theme-label" className="text-lg font-semibold mb-3">
|
||||||
|
Thème visuel
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{THEMES.map((theme: Theme) => (
|
||||||
|
<button
|
||||||
|
key={theme.id}
|
||||||
|
onClick={() => onUpdate({ themeId: theme.id })}
|
||||||
|
className={`
|
||||||
|
relative w-full aspect-square rounded-lg
|
||||||
|
transition-transform duration-200
|
||||||
|
hover:scale-105 active:scale-95
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
border: `2px solid ${theme.primary}`,
|
||||||
|
boxShadow: settings.themeId === theme.id
|
||||||
|
? `0 0 0 2px ${currentTheme.background}, 0 0 0 4px ${theme.primary}`
|
||||||
|
: 'none',
|
||||||
|
}}
|
||||||
|
aria-label={`Thème ${theme.name}`}
|
||||||
|
aria-pressed={settings.themeId === theme.id}
|
||||||
|
title={theme.name}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute inset-2 rounded"
|
||||||
|
style={{ backgroundColor: theme.primary }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm" style={{ color: currentTheme.secondary }}>
|
||||||
|
Thème actuel : {currentTheme.name}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Partager */}
|
||||||
|
<section aria-labelledby="share-label">
|
||||||
|
<h3 id="share-label" className="text-lg font-semibold mb-3">
|
||||||
|
Partager cette horloge
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyUrl}
|
||||||
|
className={`
|
||||||
|
w-full py-3 px-4 rounded-lg font-medium
|
||||||
|
flex items-center justify-center gap-2
|
||||||
|
transition-all duration-200
|
||||||
|
hover:opacity-90
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: copied ? '#22c55e' : currentTheme.primary,
|
||||||
|
color: currentTheme.background,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
Copié !
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
|
||||||
|
<polyline points="16 6 12 2 8 6"/>
|
||||||
|
<line x1="12" y1="2" x2="12" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
Copier le lien
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<section className="pt-4 border-t" style={{ borderColor: `${currentTheme.secondary}30` }}>
|
||||||
|
<p className="text-sm" style={{ color: currentTheme.secondary }}>
|
||||||
|
Les paramètres sont automatiquement sauvegardés dans votre navigateur.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-30 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { ClockDisplay, DigitalClock, AnalogClock } from './Clock';
|
||||||
|
export { SettingsPanel } from './SettingsPanel';
|
||||||
|
export { ClockApp } from './ClockApp';
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { ClockSettings, TimeData, DEFAULT_SETTINGS } from './types';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'clock-settings';
|
||||||
|
|
||||||
|
// Hook personnalisé pour gérer le temps
|
||||||
|
export function useTime(timezone: string, updateInterval: number = 100): TimeData {
|
||||||
|
const [time, setTime] = useState<TimeData>(() => getTimeInTimezone(timezone));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setTime(getTimeInTimezone(timezone));
|
||||||
|
}, updateInterval);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [timezone, updateInterval]);
|
||||||
|
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour obtenir l'heure dans un fuseau horaire spécifique
|
||||||
|
export function getTimeInTimezone(timezone: string): TimeData {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
|
timeZone: timezone,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatter = new Intl.DateTimeFormat('fr-FR', options);
|
||||||
|
const parts = formatter.formatToParts(now);
|
||||||
|
|
||||||
|
const hours = parseInt(parts.find(p => p.type === 'hour')?.value || '0', 10);
|
||||||
|
const minutes = parseInt(parts.find(p => p.type === 'minute')?.value || '0', 10);
|
||||||
|
const seconds = parseInt(parts.find(p => p.type === 'second')?.value || '0', 10);
|
||||||
|
const milliseconds = now.getMilliseconds();
|
||||||
|
|
||||||
|
const period = hours >= 12 ? 'PM' : 'AM';
|
||||||
|
|
||||||
|
return {
|
||||||
|
hours,
|
||||||
|
minutes,
|
||||||
|
seconds,
|
||||||
|
milliseconds,
|
||||||
|
period,
|
||||||
|
formattedTime: `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`,
|
||||||
|
date: now,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Fallback si le fuseau horaire n'est pas valide
|
||||||
|
return {
|
||||||
|
hours: now.getHours(),
|
||||||
|
minutes: now.getMinutes(),
|
||||||
|
seconds: now.getSeconds(),
|
||||||
|
milliseconds: now.getMilliseconds(),
|
||||||
|
period: now.getHours() >= 12 ? 'PM' : 'AM',
|
||||||
|
formattedTime: now.toLocaleTimeString('fr-FR'),
|
||||||
|
date: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook pour gérer les paramètres avec persistance
|
||||||
|
export function useClockSettings(initialSettings?: Partial<ClockSettings>) {
|
||||||
|
const [settings, setSettings] = useState<ClockSettings>(() => {
|
||||||
|
return { ...DEFAULT_SETTINGS, ...initialSettings };
|
||||||
|
});
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const [hasInitialized, setHasInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Charger les paramètres depuis localStorage au montage (une seule fois)
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && !hasInitialized) {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
setSettings(prev => ({ ...prev, ...parsed, ...initialSettings }));
|
||||||
|
} else if (initialSettings) {
|
||||||
|
setSettings(prev => ({ ...prev, ...initialSettings }));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur lors du chargement des paramètres:', e);
|
||||||
|
}
|
||||||
|
setHasInitialized(true);
|
||||||
|
setIsLoaded(true);
|
||||||
|
}
|
||||||
|
}, [initialSettings, hasInitialized]);
|
||||||
|
|
||||||
|
// Sauvegarder les paramètres dans localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoaded && typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur lors de la sauvegarde des paramètres:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [settings, isLoaded]);
|
||||||
|
|
||||||
|
const updateSettings = useCallback((updates: Partial<ClockSettings>) => {
|
||||||
|
setSettings(prev => ({ ...prev, ...updates }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { settings, updateSettings, isLoaded };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook pour le mode plein écran
|
||||||
|
export function useFullscreen() {
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
await document.documentElement.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur plein écran:', e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isFullscreen, toggleFullscreen };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formater l'heure selon le format choisi
|
||||||
|
export function formatTime(
|
||||||
|
time: TimeData,
|
||||||
|
format: '12h' | '24h',
|
||||||
|
showSeconds: boolean
|
||||||
|
): string {
|
||||||
|
let hours = time.hours;
|
||||||
|
|
||||||
|
if (format === '12h') {
|
||||||
|
hours = hours % 12 || 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoursStr = hours.toString().padStart(2, '0');
|
||||||
|
const minutesStr = time.minutes.toString().padStart(2, '0');
|
||||||
|
const secondsStr = time.seconds.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
let result = `${hoursStr}:${minutesStr}`;
|
||||||
|
|
||||||
|
if (showSeconds) {
|
||||||
|
result += `:${secondsStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer l'URL partageable avec les paramètres
|
||||||
|
export function generateShareableUrl(settings: ClockSettings): string {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('tz', settings.timezone);
|
||||||
|
params.set('type', settings.clockType);
|
||||||
|
params.set('format', settings.timeFormat);
|
||||||
|
params.set('seconds', settings.showSeconds.toString());
|
||||||
|
params.set('theme', settings.themeId);
|
||||||
|
|
||||||
|
return `${window.location.origin}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser les paramètres depuis l'URL
|
||||||
|
export function parseUrlParams(): Partial<ClockSettings> {
|
||||||
|
if (typeof window === 'undefined') return {};
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const settings: Partial<ClockSettings> = {};
|
||||||
|
|
||||||
|
const tz = params.get('tz');
|
||||||
|
if (tz) settings.timezone = tz;
|
||||||
|
|
||||||
|
const type = params.get('type');
|
||||||
|
if (type === 'digital' || type === 'analog') settings.clockType = type;
|
||||||
|
|
||||||
|
const format = params.get('format');
|
||||||
|
if (format === '12h' || format === '24h') settings.timeFormat = format;
|
||||||
|
|
||||||
|
const seconds = params.get('seconds');
|
||||||
|
if (seconds !== null) settings.showSeconds = seconds === 'true';
|
||||||
|
|
||||||
|
const theme = params.get('theme');
|
||||||
|
if (theme) settings.themeId = theme;
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Types pour l'application d'horloge
|
||||||
|
|
||||||
|
export type ClockType = 'digital' | 'analog' | 'flip';
|
||||||
|
|
||||||
|
export type TimeFormat = '12h' | '24h';
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
background: string;
|
||||||
|
foreground: string;
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
accent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClockSettings {
|
||||||
|
clockType: ClockType;
|
||||||
|
timeFormat: TimeFormat;
|
||||||
|
showSeconds: boolean;
|
||||||
|
timezone: string;
|
||||||
|
themeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeData {
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
milliseconds: number;
|
||||||
|
period: 'AM' | 'PM';
|
||||||
|
formattedTime: string;
|
||||||
|
date: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Themes disponibles
|
||||||
|
export const THEMES: Theme[] = [
|
||||||
|
{
|
||||||
|
id: 'midnight',
|
||||||
|
name: 'Minuit',
|
||||||
|
background: '#0f172a',
|
||||||
|
foreground: '#f8fafc',
|
||||||
|
primary: '#3b82f6',
|
||||||
|
secondary: '#64748b',
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ocean',
|
||||||
|
name: 'Océan',
|
||||||
|
background: '#0c4a6e',
|
||||||
|
foreground: '#f0f9ff',
|
||||||
|
primary: '#0ea5e9',
|
||||||
|
secondary: '#7dd3fc',
|
||||||
|
accent: '#38bdf8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'forest',
|
||||||
|
name: 'Forêt',
|
||||||
|
background: '#14532d',
|
||||||
|
foreground: '#f0fdf4',
|
||||||
|
primary: '#22c55e',
|
||||||
|
secondary: '#86efac',
|
||||||
|
accent: '#4ade80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sunset',
|
||||||
|
name: 'Coucher de soleil',
|
||||||
|
background: '#7c2d12',
|
||||||
|
foreground: '#fff7ed',
|
||||||
|
primary: '#f97316',
|
||||||
|
secondary: '#fdba74',
|
||||||
|
accent: '#fb923c',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lavender',
|
||||||
|
name: 'Lavande',
|
||||||
|
background: '#4c1d95',
|
||||||
|
foreground: '#f5f3ff',
|
||||||
|
primary: '#a78bfa',
|
||||||
|
secondary: '#c4b5fd',
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rose',
|
||||||
|
name: 'Rose',
|
||||||
|
background: '#831843',
|
||||||
|
foreground: '#fdf2f8',
|
||||||
|
primary: '#ec4899',
|
||||||
|
secondary: '#f9a8d4',
|
||||||
|
accent: '#f472b6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'charcoal',
|
||||||
|
name: 'Charbon',
|
||||||
|
background: '#18181b',
|
||||||
|
foreground: '#fafafa',
|
||||||
|
primary: '#a1a1aa',
|
||||||
|
secondary: '#71717a',
|
||||||
|
accent: '#d4d4d8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snow',
|
||||||
|
name: 'Neige',
|
||||||
|
background: '#f8fafc',
|
||||||
|
foreground: '#0f172a',
|
||||||
|
primary: '#1e293b',
|
||||||
|
secondary: '#64748b',
|
||||||
|
accent: '#334155',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amber',
|
||||||
|
name: 'Ambre',
|
||||||
|
background: '#78350f',
|
||||||
|
foreground: '#fffbeb',
|
||||||
|
primary: '#f59e0b',
|
||||||
|
secondary: '#fcd34d',
|
||||||
|
accent: '#fbbf24',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emerald',
|
||||||
|
name: 'Émeraude',
|
||||||
|
background: '#064e3b',
|
||||||
|
foreground: '#ecfdf5',
|
||||||
|
primary: '#10b981',
|
||||||
|
secondary: '#6ee7b7',
|
||||||
|
accent: '#34d399',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ruby',
|
||||||
|
name: 'Rubis',
|
||||||
|
background: '#7f1d1d',
|
||||||
|
foreground: '#fef2f2',
|
||||||
|
primary: '#ef4444',
|
||||||
|
secondary: '#fca5a5',
|
||||||
|
accent: '#f87171',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cyberpunk',
|
||||||
|
name: 'Cyberpunk',
|
||||||
|
background: '#0a0a0a',
|
||||||
|
foreground: '#00ff9f',
|
||||||
|
primary: '#ff00ff',
|
||||||
|
secondary: '#00ffff',
|
||||||
|
accent: '#ffff00',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Liste des fuseaux horaires populaires
|
||||||
|
export const TIMEZONES = [
|
||||||
|
{ value: 'Europe/Paris', label: 'Paris (CET)', offset: '+1:00' },
|
||||||
|
{ value: 'Europe/London', label: 'Londres (GMT)', offset: '+0:00' },
|
||||||
|
{ value: 'America/New_York', label: 'New York (EST)', offset: '-5:00' },
|
||||||
|
{ value: 'America/Los_Angeles', label: 'Los Angeles (PST)', offset: '-8:00' },
|
||||||
|
{ value: 'America/Chicago', label: 'Chicago (CST)', offset: '-6:00' },
|
||||||
|
{ value: 'America/Toronto', label: 'Toronto (EST)', offset: '-5:00' },
|
||||||
|
{ value: 'America/Sao_Paulo', label: 'São Paulo (BRT)', offset: '-3:00' },
|
||||||
|
{ value: 'America/Mexico_City', label: 'Mexico (CST)', offset: '-6:00' },
|
||||||
|
{ value: 'Europe/Berlin', label: 'Berlin (CET)', offset: '+1:00' },
|
||||||
|
{ value: 'Europe/Madrid', label: 'Madrid (CET)', offset: '+1:00' },
|
||||||
|
{ value: 'Europe/Rome', label: 'Rome (CET)', offset: '+1:00' },
|
||||||
|
{ value: 'Europe/Amsterdam', label: 'Amsterdam (CET)', offset: '+1:00' },
|
||||||
|
{ value: 'Europe/Brussels', label: 'Bruxelles (CET)', offset: '+1:00' },
|
||||||
|
{ value: 'Europe/Zurich', label: 'Zurich (CET)', offset: '+1:00' },
|
||||||
|
{ value: 'Europe/Moscow', label: 'Moscou (MSK)', offset: '+3:00' },
|
||||||
|
{ value: 'Europe/Istanbul', label: 'Istanbul (TRT)', offset: '+3:00' },
|
||||||
|
{ value: 'Asia/Dubai', label: 'Dubaï (GST)', offset: '+4:00' },
|
||||||
|
{ value: 'Asia/Kolkata', label: 'Mumbai (IST)', offset: '+5:30' },
|
||||||
|
{ value: 'Asia/Bangkok', label: 'Bangkok (ICT)', offset: '+7:00' },
|
||||||
|
{ value: 'Asia/Singapore', label: 'Singapour (SGT)', offset: '+8:00' },
|
||||||
|
{ value: 'Asia/Hong_Kong', label: 'Hong Kong (HKT)', offset: '+8:00' },
|
||||||
|
{ value: 'Asia/Shanghai', label: 'Shanghai (CST)', offset: '+8:00' },
|
||||||
|
{ value: 'Asia/Tokyo', label: 'Tokyo (JST)', offset: '+9:00' },
|
||||||
|
{ value: 'Asia/Seoul', label: 'Séoul (KST)', offset: '+9:00' },
|
||||||
|
{ value: 'Australia/Sydney', label: 'Sydney (AEST)', offset: '+10:00' },
|
||||||
|
{ value: 'Australia/Melbourne', label: 'Melbourne (AEST)', offset: '+10:00' },
|
||||||
|
{ value: 'Pacific/Auckland', label: 'Auckland (NZST)', offset: '+12:00' },
|
||||||
|
{ value: 'Pacific/Honolulu', label: 'Honolulu (HST)', offset: '-10:00' },
|
||||||
|
{ value: 'Africa/Cairo', label: 'Le Caire (EET)', offset: '+2:00' },
|
||||||
|
{ value: 'Africa/Johannesburg', label: 'Johannesburg (SAST)', offset: '+2:00' },
|
||||||
|
{ value: 'UTC', label: 'UTC', offset: '+0:00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Paramètres par défaut
|
||||||
|
export const DEFAULT_SETTINGS: ClockSettings = {
|
||||||
|
clockType: 'digital',
|
||||||
|
timeFormat: '24h',
|
||||||
|
showSeconds: true,
|
||||||
|
timezone: 'Europe/Paris',
|
||||||
|
themeId: 'midnight',
|
||||||
|
};
|
||||||