diff --git a/app/actions/admin.ts b/app/actions/admin.ts index d64375d..1441b2e 100644 --- a/app/actions/admin.ts +++ b/app/actions/admin.ts @@ -8,7 +8,6 @@ import { revalidatePath } from 'next/cache'; /** * Handle admin login. - * @param formData - The form data containing the password. */ export async function loginAdmin(formData: FormData) { const password = formData.get('password') as string; @@ -28,7 +27,7 @@ export async function loginAdmin(formData: FormData) { cookieStore.set('admin_auth', 'true', { httpOnly: true, secure: process.env.NODE_ENV === 'production', - maxAge: 60 * 60 * 24, // 24 hours + maxAge: 60 * 60 * 24, path: '/', }); redirect('/admin'); @@ -55,21 +54,43 @@ export async function isAuthenticated() { } /** - * Add a new menu button. + * Add a new menu button with optional icon. */ export async function addButton(formData: FormData) { if (!(await isAuthenticated())) throw new Error('Non autorisé'); const name = formData.get('name') as string; const url = formData.get('url') as string; + const icon = (formData.get('icon') as string) || null; if (!name || !url) throw new Error('Champs requis manquants.'); await prisma.button.create({ - data: { name, url, order: 0 }, + data: { name, url, icon, order: 0 }, + }); + + revalidatePath('/admin'); + revalidatePath('/'); +} + +/** + * Update an existing button (name, url, icon). + */ +export async function updateButton(formData: FormData) { + if (!(await isAuthenticated())) throw new Error('Non autorisé'); + + const id = parseInt(formData.get('id') as string, 10); + const name = formData.get('name') as string; + const url = formData.get('url') as string; + const icon = (formData.get('icon') as string) || null; + + if (!id || !name || !url) throw new Error('Champs requis manquants.'); + + await prisma.button.update({ + where: { id }, + data: { name, url, icon }, }); - // Revalidate both admin and home pages to reflect new button immediately revalidatePath('/admin'); revalidatePath('/'); } @@ -81,7 +102,6 @@ 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/page.tsx b/app/admin/page.tsx index 027b7a2..bcf3f6f 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,162 +1,118 @@ import { prisma } from '@/lib/db'; -import { isAuthenticated, logoutAdmin, addButton, deleteButton } from '@/app/actions/admin'; +import { isAuthenticated, logoutAdmin, deleteButton } from '@/app/actions/admin'; import { redirect } from 'next/navigation'; -import { GlassCard } from '@/components/GlassCard'; import { ThemeToggle } from '@/components/ThemeToggle'; -import { Trash2, LogOut, PlusCircle, Globe, ArrowLeft } from 'lucide-react'; +import { LogOut, ArrowLeft, Trash2, Globe } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; +import { AddButtonForm } from '@/components/AddButtonForm'; export const dynamic = 'force-dynamic'; -function getDomainFromUrl(url: string): string { - try { - return new URL(url).hostname; - } catch { - return url; - } +function getDomain(url: string): string { + try { return new URL(url).hostname; } catch { return url; } } export default async function AdminPage() { const auth = await isAuthenticated(); - - if (!auth) { - redirect('/admin/login'); - } + if (!auth) redirect('/admin/login'); const buttons = await prisma.button.findMany({ orderBy: { order: 'asc' }, }); return ( -
+
{/* Header */} -
+
- - +
-

+

Administration

-

- Gérez vos raccourcis d'applications +

+ {buttons.length} application{buttons.length !== 1 ? 's' : ''} configurée{buttons.length !== 1 ? 's' : ''}

-
+
{/* Add form */} - -

- - Ajouter une application -

- -
-
- - -
-
- - -
-
- -
-
-
+ {/* Button list */} {buttons.length === 0 ? ( -
- Aucun bouton configuré. Ajoutez-en un ci-dessus. +
+ Aucun bouton configuré.
) : ( -
- {buttons.map((btn: any) => { - const domain = getDomainFromUrl(btn.url); +
+ {(buttons as any[]).map((btn) => { + const domain = getDomain(btn.url); const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; return ( -
+ {/* Icon: emoji or favicon */}
- {btn.name} + {btn.icon ? btn.icon : ( + {btn.name} + )}
+
-
+
{btn.name}
-
+
{btn.url}
@@ -168,21 +124,18 @@ export default async function AdminPage() { }}> - +
); })}
diff --git a/app/globals.css b/app/globals.css index 3fec6c7..0a4f291 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,54 +1,41 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); :root { --font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; --ease: cubic-bezier(0.4, 0, 0.2, 1); - --duration: 260ms; - /* ─── Dark (Default: Purple → Wine) ─── */ - --bg-from: #3a0f3c; - --bg-to: #7b1535; - --fg: #ffffff; - --fg-secondary: rgba(255, 255, 255, 0.55); + /* Dark (default) */ + --bg-from: #2c0a30; + --bg-mid: #5a0f28; + --bg-to: #8c1525; - --card-bg: rgba(255, 255, 255, 0.10); - --card-border: rgba(255, 255, 255, 0.12); - --card-shadow: 0 4px 24px rgba(0, 0, 0, 0.25); - --card-hover-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); - --card-hover-bg: rgba(255, 255, 255, 0.15); + --fg: #ffffff; + --fg-secondary: rgba(255, 255, 255, 0.5); - --accent: #ffffff; - --accent-btn: rgba(255, 255, 255, 0.18); - --error: #FF6B7A; + --card-bg: rgba(255, 255, 255, 0.09); + --card-border: rgba(255, 255, 255, 0.13); + --card-shadow: 0 2px 12px rgba(0,0,0,0.3), 0 1px 0 rgba(255,255,255,0.06) inset; + --card-hover-bg: rgba(255, 255, 255, 0.14); + --card-hover-shadow: 0 16px 48px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.10) inset; } [data-theme='light'] { - /* ─── Light: Lavender → Pink ─── */ - --bg-from: #e8d5f5; - --bg-to: #f5c2d0; - --fg: #1c1c2e; - --fg-secondary: rgba(28, 28, 46, 0.55); + --bg-from: #e0c4ef; + --bg-mid: #e8b8cc; + --bg-to: #f0b8be; - --card-bg: rgba(255, 255, 255, 0.45); - --card-border: rgba(255, 255, 255, 0.8); - --card-shadow: 0 4px 24px rgba(100, 30, 60, 0.10); - --card-hover-shadow: 0 12px 40px rgba(100, 30, 60, 0.18); - --card-hover-bg: rgba(255, 255, 255, 0.65); + --fg: #1c0a22; + --fg-secondary: rgba(28, 10, 34, 0.5); - --accent: #1c1c2e; - --accent-btn: rgba(28, 28, 46, 0.12); - --error: #e0334e; + --card-bg: rgba(255, 255, 255, 0.50); + --card-border: rgba(255, 255, 255, 0.85); + --card-shadow: 0 2px 12px rgba(120,30,60,0.08), 0 1px 0 rgba(255,255,255,0.9) inset; + --card-hover-bg: rgba(255, 255, 255, 0.70); + --card-hover-shadow: 0 16px 48px rgba(120,30,60,0.15), 0 1px 0 rgba(255,255,255,0.9) inset; } -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -html, body { - height: 100%; -} +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; } body { min-height: 100vh; @@ -57,80 +44,45 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; overflow-x: hidden; - background: linear-gradient(145deg, var(--bg-from) 0%, var(--bg-to) 100%); + /* Multi-stop radial mesh gradient pour plus de profondeur */ + background: + radial-gradient(ellipse at 10% 10%, rgba(120, 30, 180, 0.35) 0%, transparent 55%), + radial-gradient(ellipse at 90% 85%, rgba(160, 20, 40, 0.4) 0%, transparent 50%), + radial-gradient(ellipse at 50% 50%, rgba(80, 10, 50, 0.6) 0%, transparent 80%), + linear-gradient(160deg, var(--bg-from) 0%, var(--bg-mid) 50%, var(--bg-to) 100%); background-attachment: fixed; - transition: background var(--duration) var(--ease), color var(--duration) var(--ease); + transition: background var(--ease) 260ms; } a { text-decoration: none; color: inherit; } button { cursor: pointer; border: none; outline: none; font-family: var(--font); } -/* ── Scrollbar ── */ +/* Scrollbar */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { - background: rgba(255,255,255,0.2); - border-radius: 10px; -} +::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 10px; } -/* ── Input ── */ +/* Inputs */ input[type="text"], input[type="password"], input[type="url"], input[type="email"] { width: 100%; - padding: 0.85rem 1rem; - background: var(--card-bg); + padding: 0.82rem 1rem; + background: rgba(255,255,255,0.07); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); - border: 1px solid var(--card-border); + border: 1px solid rgba(255,255,255,0.13); border-radius: 12px; color: var(--fg); font-family: var(--font); - font-size: 0.95rem; + font-size: 0.92rem; outline: none; - transition: all var(--duration) var(--ease); + transition: all 240ms var(--ease); } - input:focus { - border-color: rgba(255,255,255,0.5); - box-shadow: 0 0 0 3px rgba(255,255,255,0.10); + border-color: rgba(255,255,255,0.45); + box-shadow: 0 0 0 3px rgba(255,255,255,0.08); + background: rgba(255,255,255,0.11); } - input::placeholder { color: var(--fg-secondary); } - -/* ── Glass card ── */ -.glass-card { - background: var(--card-bg); - backdrop-filter: blur(24px) saturate(1.4); - -webkit-backdrop-filter: blur(24px) saturate(1.4); - border: 1px solid var(--card-border); - box-shadow: var(--card-shadow); - border-radius: 20px; - transition: all var(--duration) var(--ease); -} - -/* ── Btn primary ── */ -.btn-primary { - background: rgba(255,255,255,0.18); - color: var(--fg); - border: 1px solid rgba(255,255,255,0.25); - padding: 0.75rem 1.4rem; - border-radius: 50px; - font-size: 0.88rem; - font-weight: 600; - letter-spacing: -0.01em; - backdrop-filter: blur(12px); - transition: all var(--duration) var(--ease); -} - -.btn-primary:hover { - background: rgba(255,255,255,0.28); - transform: translateY(-1px); - box-shadow: 0 4px 16px rgba(0,0,0,0.2); -} - -.btn-primary:disabled { - opacity: 0.5; - pointer-events: none; -} diff --git a/app/page.tsx b/app/page.tsx index f257845..357fc6f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -17,33 +17,26 @@ export default async function Home() { }); return ( -
- {/* Top bar */} +
+ + {/* Top controls */}
- +
@@ -52,27 +45,48 @@ export default async function Home() {
- {buttons.length === 0 ? ( -
-
🔗
-

- Aucune application configurée + {/* Title */} +

+

+ Menu Application +

+ {buttons.length > 0 && ( +

+ {buttons.length} application{buttons.length > 1 ? 's' : ''} disponible{buttons.length > 1 ? 's' : ''}

-

- Ajoutez vos premiers raccourcis depuis l'administration. + )} +

+ + {/* Grid */} + {buttons.length === 0 ? ( +
+
🔗
+

+ Aucune application +

+

+ Ajoutez vos raccourcis depuis l'administration.

Configurer → @@ -84,10 +98,10 @@ export default async function Home() { flexWrap: 'wrap', gap: '1.25rem', justifyContent: 'center', - maxWidth: '900px', + maxWidth: '960px', }}> - {buttons.map((btn: any) => ( - + {(buttons as any[]).map((btn) => ( + ))}
)} diff --git a/components/AddButtonForm.tsx b/components/AddButtonForm.tsx new file mode 100644 index 0000000..881c0e3 --- /dev/null +++ b/components/AddButtonForm.tsx @@ -0,0 +1,99 @@ +'use client'; + +import React, { useState } from 'react'; +import { addButton } from '@/app/actions/admin'; +import { EmojiPicker } from '@/components/EmojiPicker'; +import { PlusCircle } from 'lucide-react'; + +export function AddButtonForm() { + const [icon, setIcon] = useState('🔗'); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + const formData = new FormData(e.currentTarget); + formData.set('icon', icon); + try { + await addButton(formData); + (e.target as HTMLFormElement).reset(); + setIcon('🔗'); + } finally { + setLoading(false); + } + }; + + return ( +
+

+ AJOUTER UNE APPLICATION +

+ +
+ {/* Emoji picker */} +
+ + +
+ + {/* Name */} +
+ + +
+ + {/* URL */} +
+ + +
+ + {/* Submit */} + +
+
+ ); +} diff --git a/components/EmojiPicker.tsx b/components/EmojiPicker.tsx new file mode 100644 index 0000000..27659be --- /dev/null +++ b/components/EmojiPicker.tsx @@ -0,0 +1,117 @@ +/* ────────────────────────────────────────────────────────────── + EmojiPicker — composant client pour sélectionner un emoji + utilisé dans le formulaire admin +────────────────────────────────────────────────────────────── */ +'use client'; + +import React, { useState } from 'react'; + +const QUICK_EMOJIS = [ + '🚀','📊','💼','🔗','📁','📧','📅','🧠', + '💻','🖥️','📱','🔑','⚙️','🛠️','📈','🌐', + '💬','🤝','📝','🔒','☁️','🗂️','🎯','📌', + '🏠','🏢','👥','🔔','✅','❌','💡','🎨', +]; + +interface EmojiPickerProps { + value: string; + onChange: (emoji: string) => void; +} + +export function EmojiPicker({ value, onChange }: EmojiPickerProps) { + const [open, setOpen] = useState(false); + + return ( +
+ {/* Trigger button */} + + + {/* Dropdown picker */} + {open && ( +
+

+ SÉLECTIONNER UNE ICÔNE +

+ + {/* Quick emoji grid */} +
+ {QUICK_EMOJIS.map((emoji) => ( + + ))} +
+ + {/* Manual input */} +
+ onChange(e.target.value)} + style={{ + width: '100%', + background: 'rgba(255,255,255,0.06)', + border: '1px solid rgba(255,255,255,0.12)', + borderRadius: '8px', + padding: '0.4rem 0.6rem', + color: '#fff', + fontSize: '1rem', + }} + maxLength={4} + /> +
+
+ )} +
+ ); +} diff --git a/components/MenuButton.tsx b/components/MenuButton.tsx index 45ed8c0..d96af41 100644 --- a/components/MenuButton.tsx +++ b/components/MenuButton.tsx @@ -9,17 +9,17 @@ import styles from './MenuButton.module.css'; interface MenuButtonProps { name: string; url: string; + icon?: string | null; } function getDomain(url: string): string { - try { - return new URL(url).hostname; - } catch { - return url; - } + try { return new URL(url).hostname; } catch { return url; } } -export const MenuButton: React.FC = ({ name, url }) => { +/** + * MenuButton — displays emoji icon if set, falls back to Google favicon. + */ +export const MenuButton: React.FC = ({ name, url, icon }) => { const domain = getDomain(url); const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=64`; const [faviconError, setFaviconError] = useState(false); @@ -27,7 +27,11 @@ export const MenuButton: React.FC = ({ name, url }) => { return (
- {!faviconError ? ( + {icon ? ( + // Custom emoji icon + {icon} + ) : !faviconError ? ( + // Auto favicon from Google CDN