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.
This commit is contained in:
Puechberty Arthur
2026-03-30 20:27:33 +02:00
parent 823587b05e
commit c061960e57
31 changed files with 2563 additions and 101 deletions
+1
View File
@@ -23,6 +23,7 @@
# misc
.DS_Store
*.pem
.vscode/
# debug
npm-debug.log*
+60 -23
View File
@@ -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
npm install
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.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## Licence
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## 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.
MIT
+10
View File
@@ -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
+107 -1
View File
@@ -1,7 +1,113 @@
import type { NextConfig } from "next";
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;
+1
View File
@@ -3217,6 +3217,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

-1
View File
@@ -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
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

+59
View File
@@ -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
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

-1
View File
@@ -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
View File
@@ -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

+31
View File
@@ -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&apos;est produite</h2>
<p className="text-slate-400 mb-8 text-center max-w-md">
Nous sommes désolés, quelque chose s&apos;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>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+322 -7
View File
@@ -1,26 +1,341 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #0f172a;
--foreground: #f8fafc;
--primary: #3b82f6;
--secondary: #64748b;
--accent: #8b5cf6;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-secondary: var(--secondary);
--color-accent: var(--accent);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
background: var(--background);
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);
}
+283 -4
View File
@@ -1,20 +1,265 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
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({
variable: "--font-geist-sans",
subsets: ["latin"],
display: "optional",
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
display: "optional",
});
// Métadonnées SEO optimisées
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
metadataBase: new URL(SITE_URL),
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({
@@ -23,10 +268,44 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
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
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&apos;heure en temps réel.</p>
<p>Veuillez activer JavaScript dans les paramètres de votre navigateur.</p>
</div>
</div>
</noscript>
{children}
</body>
</html>
+10
View File
@@ -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&apos;horloge...</p>
</div>
);
}
+30
View File
@@ -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&apos;existe pas ou a é 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&apos;horloge
</Link>
</div>
);
}
+156 -59
View File
@@ -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&apos;heure exacte n&apos;importe quand, que vous soyez.
</p>
</header>
<section id="features">
<h2>Fonctionnalités de l&apos;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&apos;heure
depuis n&apos;importe dans la pièce.
</p>
</div>
<div className="seo-feature">
<h3>🌍 Plus de 30 fuseaux horaires</h3>
<p>
Consultez l&apos;heure dans le monde entier : Paris, Londres, New York,
Los Angeles, Tokyo, Sydney, Hong Kong, Dubaï, Singapour, et bien d&apos;autres villes.
Notre horloge mondiale affiche l&apos;heure exacte dans n&apos;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&apos;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&apos;icône plein écran en haut à droite de l&apos;écran ou appuyez
sur F11 sur votre clavier. Pour quitter le mode plein écran, appuyez sur Échap
ou cliquez à nouveau sur l&apos;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&apos;icône engrenage,
puis sélectionnez votre fuseau horaire dans la liste déroulante.
L&apos;heure s&apos;actualise instantanément.
</p>
</div>
</details>
<details itemScope itemProp="mainEntity" itemType="https://schema.org/Question">
<summary itemProp="name">Comment partager ma configuration d&apos;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&apos;horloge est-elle précise ?</summary>
<div itemScope itemProp="acceptedAnswer" itemType="https://schema.org/Answer">
<p itemProp="text">
Oui, notre horloge utilise l&apos;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&apos;autres villes du monde.
</p>
</div>
</details>
<details itemScope itemProp="mainEntity" itemType="https://schema.org/Question">
<summary itemProp="name">L&apos;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&apos;accueil.
</p>
</div>
</details>
</section>
</article>
);
}
export default function Home() {
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">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&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"
>
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.
<>
<a href="#main-clock" className="skip-to-content">
Aller au contenu principal
</a>
<ClockApp />
<SEOContent />
<footer className="seo-footer" role="contentinfo">
<p>
&copy; {new Date().getFullYear()} Horloge en ligne &mdash; Heure exacte gratuite en temps réel.
Horloge numérique, analogique et à bascule avec personnalisation complète.
</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>
<nav aria-label="Liens rapides">
<ul>
<li><a href="#main-clock">Horloge</a></li>
<li><a href="#features">Fonctionnalités</a></li>
<li><a href="#faq">FAQ</a></li>
</ul>
</nav>
</footer>
</>
);
}
+22
View File
@@ -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',
};
}
+91
View File
@@ -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];
}
+452
View File
@@ -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} />;
}
+155
View File
@@ -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">&nbsp;</p>
<p className="text-sm mt-1">&nbsp;</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">&nbsp;</p>
<p className="text-sm mt-1">&nbsp;</p>
</div>
</div>
</div>
}>
<ClockAppContent />
</Suspense>
);
}
+375
View File
@@ -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&apos;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&apos;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"
/>
)}
</>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { ClockDisplay, DigitalClock, AnalogClock } from './Clock';
export { SettingsPanel } from './SettingsPanel';
export { ClockApp } from './ClockApp';
+204
View File
@@ -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;
}
+189
View File
@@ -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',
};