From 5ff478459606e3a988d8e606d8c61d9faa07a1f0 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Fri, 10 Apr 2026 10:43:04 +0200 Subject: [PATCH] feat: implement admin dashboard with authentication, menu management, and responsive UI components --- app/actions/admin.ts | 9 ++ app/admin/login/page.tsx | 77 +++++++---- app/admin/page.tsx | 227 ++++++++++++++++++++++--------- app/globals.css | 200 ++++++++++++++++++--------- app/page.tsx | 108 ++++++++++++--- app/setup/page.tsx | 88 +++++++----- components/GlassCard.module.css | 19 +-- components/MenuButton.module.css | 94 ++++++++++--- components/MenuButton.tsx | 51 +++++-- components/ThemeToggle.tsx | 36 +++-- next.config.ts | 13 ++ 11 files changed, 668 insertions(+), 254 deletions(-) diff --git a/app/actions/admin.ts b/app/actions/admin.ts index 6d9bc3b..d64375d 100644 --- a/app/actions/admin.ts +++ b/app/actions/admin.ts @@ -4,6 +4,7 @@ import { prisma } from '@/lib/db'; import { verifyPassword } from '@/lib/auth'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; +import { revalidatePath } from 'next/cache'; /** * Handle admin login. @@ -67,6 +68,10 @@ export async function addButton(formData: FormData) { await prisma.button.create({ data: { name, url, order: 0 }, }); + + // Revalidate both admin and home pages to reflect new button immediately + revalidatePath('/admin'); + revalidatePath('/'); } /** @@ -75,4 +80,8 @@ export async function addButton(formData: FormData) { export async function deleteButton(id: number) { if (!(await isAuthenticated())) throw new Error('Non autorisé'); await prisma.button.delete({ where: { id } }); + + // Revalidate both admin and home pages to reflect deletion immediately + revalidatePath('/admin'); + revalidatePath('/'); } diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx index 92f7e38..8a24c39 100644 --- a/app/admin/login/page.tsx +++ b/app/admin/login/page.tsx @@ -3,6 +3,7 @@ import React, { useState } from 'react'; import { GlassCard } from '@/components/GlassCard'; import { loginAdmin } from '@/app/actions/admin'; +import { Lock } from 'lucide-react'; export default function AdminLoginPage() { const [error, setError] = useState(null); @@ -23,35 +24,59 @@ export default function AdminLoginPage() { }; return ( -
- -

Accès Admin

- -
-
- - +
+
+ + {/* Logo */} +
+
+
+

+ Administration +

+

+ Accès restreint aux administrateurs +

+
- {error &&

{error}

} + + +
+ + +
- - -
+ {error && ( +

+ {error} +

+ )} + + + + +
); } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index c740827..027b7a2 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -2,10 +2,21 @@ import { prisma } from '@/lib/db'; import { isAuthenticated, logoutAdmin, addButton, deleteButton } from '@/app/actions/admin'; import { redirect } from 'next/navigation'; import { GlassCard } from '@/components/GlassCard'; -import { Trash2, LogOut, PlusCircle, Globe } from 'lucide-react'; +import { ThemeToggle } from '@/components/ThemeToggle'; +import { Trash2, LogOut, PlusCircle, Globe, ArrowLeft } from 'lucide-react'; +import Image from 'next/image'; +import Link from 'next/link'; export const dynamic = 'force-dynamic'; +function getDomainFromUrl(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + export default async function AdminPage() { const auth = await isAuthenticated(); @@ -18,76 +29,164 @@ export default async function AdminPage() { }); return ( -
-
-

Panel d'Administration

-
- -
-
+
-
- {/* Form to add button */} - -

- Ajouter un bouton -

-
-
- - -
-
- - -
-
-
+
+ - {/* List of buttons */} -
-

Boutons configurés

- {buttons.length === 0 ? ( -

Aucun bouton configuré pour le moment.

- ) : ( -
- {buttons.map((btn: any) => ( - -
-
- + {/* Add form */} + +

+ + Ajouter une application +

+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ + {/* Button list */} + {buttons.length === 0 ? ( +
+ Aucun bouton configuré. Ajoutez-en un ci-dessus. +
+ ) : ( +
+ {buttons.map((btn: any) => { + const domain = getDomainFromUrl(btn.url); + const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; + + return ( + +
+
+ {btn.name} +
+
+
+ {btn.name}
-
-
{btn.name}
-
{btn.url}
+
+ {btn.url}
- -
{ - 'use server'; - await deleteButton(btn.id); - }}> - -
- - ))} -
- )} +
+ +
{ + 'use server'; + await deleteButton(btn.id); + }}> + +
+
+ ); + })}
-
-
+ )} +
); } diff --git a/app/globals.css b/app/globals.css index c581a63..cca20f1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,105 +1,173 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + :root { - /* Common */ - --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - --transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - - /* Light Theme */ - --background: #f5f5f7; - --foreground: #1d1d1f; - --glass-bg: rgba(255, 255, 255, 0.7); - --glass-border: rgba(255, 255, 255, 0.3); - --card-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07); - --accent: #0071e3; - --error: #ff3b30; - --bg-gradient: radial-gradient(circle at 0% 0%, #fff 0%, #f5f5f7 100%); + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --ease: cubic-bezier(0.4, 0, 0.2, 1); + --duration: 280ms; + + /* Light */ + --bg-base: #f2f2f7; + --fg: #1c1c1e; + --fg-secondary: #6c6c70; + --glass-bg: rgba(255, 255, 255, 0.62); + --glass-border: rgba(255, 255, 255, 0.8); + --glass-shadow: 0 8px 40px rgba(0, 0, 0, 0.08), 0 1px 0 rgba(255,255,255,0.9) inset; + --glass-hover-shadow: 0 20px 60px rgba(0, 0, 0, 0.12), 0 1px 0 rgba(255,255,255,0.9) inset; + --accent: #007AFF; + --accent-glow: rgba(0, 122, 255, 0.25); + --error: #FF3B30; + --success: #34C759; + + --orb-1: radial-gradient(ellipse at 20% 20%, rgba(0, 122, 255, 0.15) 0%, transparent 60%); + --orb-2: radial-gradient(ellipse at 80% 80%, rgba(88, 86, 214, 0.12) 0%, transparent 60%); + --orb-3: radial-gradient(ellipse at 60% 10%, rgba(52, 199, 89, 0.08) 0%, transparent 50%); } [data-theme='dark'] { - /* Dark Theme */ - --background: #000000; - --foreground: #f5f5f7; - --glass-bg: rgba(28, 28, 30, 0.7); - --glass-border: rgba(255, 255, 255, 0.1); - --card-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4); - --accent: #0a84ff; - --error: #ff453a; - --bg-gradient: radial-gradient(circle at 100% 100%, #1c1c1e 0%, #000 100%); + --bg-base: #000000; + --fg: #f5f5f7; + --fg-secondary: #8e8e93; + --glass-bg: rgba(28, 28, 30, 0.72); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-shadow: 0 8px 40px rgba(0, 0, 0, 0.4), 0 1px 0 rgba(255,255,255,0.05) inset; + --glass-hover-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(0, 122, 255, 0.3); + --accent: #0A84FF; + --accent-glow: rgba(10, 132, 255, 0.35); + --error: #FF453A; + --success: #30D158; + + --orb-1: radial-gradient(ellipse at 20% 20%, rgba(10, 132, 255, 0.18) 0%, transparent 60%); + --orb-2: radial-gradient(ellipse at 80% 80%, rgba(94, 92, 230, 0.18) 0%, transparent 60%); + --orb-3: radial-gradient(ellipse at 60% 10%, rgba(48, 209, 88, 0.08) 0%, transparent 50%); } -* { +*, *::before, *::after { box-sizing: border-box; - padding: 0; margin: 0; + padding: 0; +} + +html { + height: 100%; } body { - background: var(--bg-gradient); - background-attachment: fixed; - color: var(--foreground); - font-family: var(--font-family); min-height: 100vh; - transition: var(--transition-smooth); + background-color: var(--bg-base); + color: var(--fg); + font-family: var(--font); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; overflow-x: hidden; + transition: background-color var(--duration) var(--ease), color var(--duration) var(--ease); } -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 10px; +/* Ambient background orbs */ +body::before { + content: ''; + position: fixed; + inset: 0; + background: var(--orb-1), var(--orb-2), var(--orb-3); + pointer-events: none; + z-index: 0; } -::-webkit-scrollbar-track { - background: transparent; + +#__next, main { + position: relative; + z-index: 1; } + +/* Scrollbar */ +::-webkit-scrollbar { width: 8px; } +::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { - background: rgba(125, 125, 125, 0.3); + background: rgba(128, 128, 128, 0.25); border-radius: 10px; - border: 3px solid transparent; + border: 2px solid transparent; background-clip: padding-box; } -::-webkit-scrollbar-thumb:hover { - background: rgba(125, 125, 125, 0.5); -} +::-webkit-scrollbar-thumb:hover { background: rgba(128, 128, 128, 0.4); background-clip: padding-box; } +/* Glass utility */ .glass { background: var(--glass-bg); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); + backdrop-filter: blur(24px) saturate(1.8); + -webkit-backdrop-filter: blur(24px) saturate(1.8); border: 1px solid var(--glass-border); - box-shadow: var(--card-shadow); + box-shadow: var(--glass-shadow); border-radius: 20px; } -.container { - max-width: 1200px; +/* Page layout */ +.page-container { + max-width: 1400px; margin: 0 auto; padding: 2rem; + min-height: 100vh; } -h1 { - font-size: 2.5rem; - font-weight: 700; - letter-spacing: -0.022em; - margin-bottom: 2rem; - text-align: center; -} - -button, .button { - cursor: pointer; - border: none; - outline: none; - font-family: inherit; - transition: var(--transition-smooth); -} - -input { +/* Forms */ +input[type="text"], +input[type="password"], +input[type="url"], +input[type="email"] { + width: 100%; + padding: 0.85rem 1rem; background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: 12px; - padding: 0.8rem 1rem; - color: var(--foreground); - font-family: inherit; - width: 100%; + color: var(--fg); + font-family: var(--font); + font-size: 0.95rem; + transition: all var(--duration) var(--ease); + backdrop-filter: blur(12px); + outline: none; } input:focus { - outline: 2px solid var(--accent); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +input::placeholder { + color: var(--fg-secondary); +} + +button { + cursor: pointer; + border: none; + outline: none; + font-family: var(--font); + transition: all var(--duration) var(--ease); +} + +a { + text-decoration: none; + color: inherit; +} + +/* Btn primary */ +.btn-primary { + background: var(--accent); + color: white; + padding: 0.85rem 1.5rem; + border-radius: 12px; + font-size: 0.95rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.btn-primary:hover { + filter: brightness(1.1); + transform: translateY(-1px); + box-shadow: 0 8px 20px var(--accent-glow); +} + +.btn-primary:active { + transform: translateY(0); +} + +.btn-primary:disabled { + opacity: 0.6; + pointer-events: none; } diff --git a/app/page.tsx b/app/page.tsx index 46f7944..c775df5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,8 @@ import { isConfigured } from '@/app/actions/setup'; import { redirect } from 'next/navigation'; import { MenuButton } from '@/components/MenuButton'; import { ThemeToggle } from '@/components/ThemeToggle'; +import { Settings } from 'lucide-react'; +import Link from 'next/link'; export const dynamic = 'force-dynamic'; @@ -18,33 +20,103 @@ export default async function Home() { }); return ( -
-
-

Application Menu

- +
+ {/* Header */} +
+
+

+ Applications +

+ {buttons.length > 0 && ( +

+ {buttons.length} {buttons.length > 1 ? 'liens configurés' : 'lien configuré'} +

+ )} +
+ +
+ + + + +
+ {/* Button grid */} {buttons.length === 0 ? ( -
-

Aucune application configurée. Rendez-vous dans le panel admin.

- Accéder à l'administration +
+
+ 🔗 +
+
+

Aucune application

+

+ Ajoutez vos premiers liens depuis le panel d'administration. +

+
+ + Configurer → +
) : ( -
{buttons.map((btn: any) => ( - + ))}
)} -
+
); } diff --git a/app/setup/page.tsx b/app/setup/page.tsx index 45c8c87..ca83aa7 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -3,6 +3,7 @@ import React, { useState } from 'react'; import { GlassCard } from '@/components/GlassCard'; import { setupAdmin } from '@/app/actions/setup'; +import { Lock } from 'lucide-react'; export default function SetupPage() { const [error, setError] = useState(null); @@ -23,42 +24,65 @@ export default function SetupPage() { }; return ( -
- -

Configuration Initiale

-

- Définissez le mot de passe maître pour accéder au panel d'administration. -

+
+
-
-
- - -
-
- - + {/* Logo */} +
+
+
+

+ Configuration +

+

+ Définissez votre mot de passe administrateur +

+
- {error &&

{error}

} + + +
+ + +
+
+ + +
- - -
+ {error && ( +

+ {error} +

+ )} + + + + +
); } diff --git a/components/GlassCard.module.css b/components/GlassCard.module.css index 77f8fe4..b4e964b 100644 --- a/components/GlassCard.module.css +++ b/components/GlassCard.module.css @@ -1,16 +1,19 @@ .card { background: var(--glass-bg); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); + backdrop-filter: blur(24px) saturate(1.8); + -webkit-backdrop-filter: blur(24px) saturate(1.8); border: 1px solid var(--glass-border); - box-shadow: var(--card-shadow); - border-radius: 24px; - padding: 2rem; - transition: var(--transition-smooth); + box-shadow: var(--glass-shadow); + border-radius: 20px; + transition: all 280ms cubic-bezier(0.4, 0, 0.2, 1); } .hoverable:hover { + box-shadow: var(--glass-hover-shadow); transform: translateY(-4px); - box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.15); - border-color: var(--accent); +} + +.hoverable:active { + transform: translateY(-2px); + transition-duration: 80ms; } diff --git a/components/MenuButton.module.css b/components/MenuButton.module.css index 217e1cf..55e12ec 100644 --- a/components/MenuButton.module.css +++ b/components/MenuButton.module.css @@ -3,35 +3,91 @@ flex-direction: column; align-items: center; justify-content: center; - padding: 2.5rem; + gap: 1rem; + padding: 2rem 1.5rem; text-decoration: none; - text-align: center; - color: var(--foreground); - gap: 1.5rem; - font-size: 1.25rem; - font-weight: 600; + color: var(--fg); + min-height: 180px; cursor: pointer; - aspect-ratio: 1 / 1; + position: relative; + overflow: hidden; } -.iconWrapper { - background: var(--accent); - color: white; - padding: 1rem; - border-radius: 18px; +/* Glow accent on hover */ +.menuButton::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: radial-gradient(ellipse at center, var(--accent-glow) 0%, transparent 70%); + opacity: 0; + transition: opacity 280ms cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; +} + +.menuButton:hover::after { + opacity: 1; +} + +/* Favicon wrapper */ +.faviconWrapper { + width: 64px; + height: 64px; + border-radius: 16px; + background: var(--glass-bg); + border: 1px solid var(--glass-border); display: flex; align-items: center; justify-content: center; - width: 64px; - height: 64px; - transition: var(--transition-smooth); + overflow: hidden; + transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 280ms cubic-bezier(0.4, 0, 0.2, 1); + flex-shrink: 0; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); } -.menuButton:hover .iconWrapper { - transform: scale(1.1); - box-shadow: 0 0 20px var(--accent); +.menuButton:hover .faviconWrapper { + transform: scale(1.08); + box-shadow: 0 8px 24px var(--accent-glow); } +.favicon { + width: 40px; + height: 40px; + object-fit: contain; +} + +.faviconFallback { + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; +} + +/* Label */ .label { - letter-spacing: -0.01em; + font-size: 0.95rem; + font-weight: 600; + letter-spacing: -0.02em; + text-align: center; + line-height: 1.3; + max-width: 160px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Arrow indicator */ +.arrowIcon { + position: absolute; + top: 12px; + right: 14px; + color: var(--fg-secondary); + opacity: 0; + transform: translate(-4px, 4px); + transition: all 280ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.menuButton:hover .arrowIcon { + opacity: 1; + transform: translate(0, 0); } diff --git a/components/MenuButton.tsx b/components/MenuButton.tsx index 93a2bbc..eb616a2 100644 --- a/components/MenuButton.tsx +++ b/components/MenuButton.tsx @@ -1,26 +1,61 @@ 'use client'; -import React from 'react'; +import React, { useState } from 'react'; import Link from 'next/link'; -import * as Icons from 'lucide-react'; +import Image from 'next/image'; +import { ArrowUpRight, Globe } from 'lucide-react'; import { GlassCard } from './GlassCard'; import styles from './MenuButton.module.css'; interface MenuButtonProps { name: string; url: string; - iconName?: keyof typeof Icons; } -export const MenuButton: React.FC = ({ name, url, iconName = 'ExternalLink' }) => { - const Icon = (Icons[iconName] as React.ElementType) || Icons.ExternalLink; +/** + * Extract base domain from URL for favicon fetch. + */ +function getDomain(url: string): string { + try { + const { hostname } = new URL(url); + return hostname; + } catch { + return url; + } +} + +/** + * MenuButton component displaying a favicon and app name. + * Favicons are fetched from Google's favicon CDN. + */ +export const MenuButton: React.FC = ({ name, url }) => { + const domain = getDomain(url); + const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=64`; + const [faviconError, setFaviconError] = useState(false); return ( - + -
- + + +
+ {!faviconError ? ( + {`${name} setFaviconError(true)} + unoptimized + /> + ) : ( +
+ +
+ )}
+ {name} diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx index 17bd8f2..e5c269d 100644 --- a/components/ThemeToggle.tsx +++ b/components/ThemeToggle.tsx @@ -3,33 +3,43 @@ import * as React from 'react'; import { Moon, Sun } from 'lucide-react'; import { useTheme } from 'next-themes'; -import { GlassCard } from './GlassCard'; export function ThemeToggle() { const { theme, setTheme } = useTheme(); const [mounted, setMounted] = React.useState(false); - // Avoid hydration mismatch React.useEffect(() => { setMounted(true); }, []); if (!mounted) { - return ( - -
-
- ); + return
; } return ( - setTheme(theme === 'dark' ? 'light' : 'dark')} + aria-label="Basculer le thème" + style={{ + background: 'var(--glass-bg)', + backdropFilter: 'blur(20px)', + WebkitBackdropFilter: 'blur(20px)', + border: '1px solid var(--glass-border)', + boxShadow: 'var(--glass-shadow)', + borderRadius: '50%', + width: '40px', + height: '40px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + flexShrink: 0, + }} > - {theme === 'dark' ? : } - + {theme === 'dark' + ? + : + } + ); } diff --git a/next.config.ts b/next.config.ts index d7cb2e1..4005c57 100644 --- a/next.config.ts +++ b/next.config.ts @@ -9,6 +9,19 @@ const nextConfig: NextConfig = { 'bcrypt', 'prisma' ], + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'www.google.com', + pathname: '/s2/favicons/**', + }, + { + protocol: 'https', + hostname: 't0.gstatic.com', + }, + ], + }, }; export default nextConfig;