feat: initialize Prisma schema with Config and Button models

This commit is contained in:
Michael committed 2026-04-10 10:57:42 +02:00
1 parent ca99378348
commit 485aba96fd
10 files changed
+405 -238

No files matched your search

+26 -6
View File
@@ -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('/');
}
+54 -101
View File
@@ -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 (
<div className="page-container" style={{ maxWidth: '900px' }}>
<div style={{ minHeight: '100vh', padding: '1.5rem 2rem', maxWidth: '860px', margin: '0 auto' }}>
{/* Header */}
<header style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2.5rem',
}}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Link href="/" aria-label="Retour" style={{
background: 'var(--glass-bg)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: '1px solid var(--glass-border)',
<Link href="/" style={{
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(16px)',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: '50%',
width: '38px',
height: '38px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '36px', height: '36px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ArrowLeft size={16} color="var(--fg-secondary)" />
<ArrowLeft size={15} color="rgba(255,255,255,0.8)" />
</Link>
<div>
<h1 style={{ fontSize: '1.6rem', fontWeight: 700, letterSpacing: '-0.03em' }}>
<h1 style={{ fontSize: '1.4rem', fontWeight: 700, letterSpacing: '-0.02em', color: '#fff' }}>
Administration
</h1>
<p style={{ fontSize: '0.85rem', color: 'var(--fg-secondary)', marginTop: '0.1rem' }}>
Gérez vos raccourcis d'applications
<p style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,0.45)', marginTop: '0.1rem' }}>
{buttons.length} application{buttons.length !== 1 ? 's' : ''} configurée{buttons.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<ThemeToggle />
<form action={logoutAdmin}>
<button type="submit" style={{
background: 'var(--glass-bg)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: '1px solid var(--glass-border)',
background: 'rgba(255,255,255,0.08)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: '50px',
padding: '0.5rem 1rem',
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
fontSize: '0.85rem',
padding: '0.45rem 0.9rem',
color: 'rgba(255,255,255,0.7)',
fontSize: '0.82rem',
fontWeight: 500,
color: 'var(--fg-secondary)',
display: 'flex', alignItems: 'center', gap: '0.4rem',
}}>
<LogOut size={15} /> Déconnexion
<LogOut size={13} /> Déconnexion
</button>
</form>
</div>
</header>
{/* Add form */}
<GlassCard style={{ padding: '1.75rem', marginBottom: '2rem' }}>
<h2 style={{
fontSize: '1rem',
fontWeight: 600,
marginBottom: '1.25rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}>
<PlusCircle size={18} color="var(--accent)" />
Ajouter une application
</h2>
<form action={addButton} style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 180px' }}>
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: 'var(--fg-secondary)', marginBottom: '0.4rem' }}>Nom</label>
<input name="name" type="text" placeholder="Ex: Slack, Notion..." required />
</div>
<div style={{ flex: '2 1 280px' }}>
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: 'var(--fg-secondary)', marginBottom: '0.4rem' }}>URL</label>
<input name="url" type="url" placeholder="https://..." required />
</div>
<div style={{ alignSelf: 'flex-end' }}>
<button type="submit" className="btn-primary" style={{ whiteSpace: 'nowrap' }}>
Ajouter →
</button>
</div>
</form>
</GlassCard>
<AddButtonForm />
{/* Button list */}
{buttons.length === 0 ? (
<div style={{ textAlign: 'center', padding: '4rem', color: 'var(--fg-secondary)', fontSize: '0.9rem' }}>
Aucun bouton configuré. Ajoutez-en un ci-dessus.
<div style={{ textAlign: 'center', padding: '4rem', color: 'rgba(255,255,255,0.35)', fontSize: '0.9rem' }}>
Aucun bouton configuré.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{buttons.map((btn: any) => {
const domain = getDomainFromUrl(btn.url);
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{(buttons as any[]).map((btn) => {
const domain = getDomain(btn.url);
const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
return (
<GlassCard key={btn.id} style={{
padding: '0.9rem 1.25rem',
<div key={btn.id} style={{
background: 'rgba(255,255,255,0.06)',
backdropFilter: 'blur(16px)',
border: '1px solid rgba(255,255,255,0.10)',
borderRadius: '14px',
padding: '0.85rem 1.2rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
borderRadius: '14px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem', minWidth: 0 }}>
{/* Icon: emoji or favicon */}
<div style={{
width: '36px',
height: '36px',
width: '40px', height: '40px',
borderRadius: '10px',
background: 'var(--bg-base)',
border: '1px solid var(--glass-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(255,255,255,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
overflow: 'hidden',
fontSize: '1.3rem',
}}>
{btn.icon ? btn.icon : (
<Image src={faviconUrl} alt={btn.name} width={20} height={20} unoptimized />
)}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: '0.9rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<div style={{ fontWeight: 600, fontSize: '0.9rem', color: '#fff', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{btn.name}
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--fg-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<div style={{ fontSize: '0.76rem', color: 'rgba(255,255,255,0.4)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '360px' }}>
{btn.url}
</div>
</div>
@@ -168,21 +124,18 @@ export default async function AdminPage() {
}}>
<button type="submit" style={{
background: 'transparent',
color: 'var(--fg-secondary)',
color: 'rgba(255,255,255,0.3)',
padding: '0.4rem',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
transition: 'color 200ms',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--error)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--fg-secondary)')}
onMouseEnter={(e) => (e.currentTarget.style.color = '#ff6b7a')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'rgba(255,255,255,0.3)')}
>
<Trash2 size={17} />
<Trash2 size={16} />
</button>
</form>
</GlassCard>
</div>
);
})}
</div>
+42 -90
View File
@@ -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;
/* Dark (default) */
--bg-from: #2c0a30;
--bg-mid: #5a0f28;
--bg-to: #8c1525;
--fg: #ffffff;
--fg-secondary: rgba(255, 255, 255, 0.55);
--fg-secondary: rgba(255, 255, 255, 0.5);
--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);
--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;
}
+44 -30
View File
@@ -17,33 +17,26 @@ export default async function Home() {
});
return (
<div style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
}}>
{/* Top bar */}
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Top controls */}
<header style={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
padding: '1.5rem 2rem',
gap: '0.75rem',
gap: '0.6rem',
}}>
<Link href="/admin" aria-label="Administration" style={{
background: 'rgba(255,255,255,0.12)',
background: 'rgba(255,255,255,0.10)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
border: '1px solid rgba(255,255,255,0.18)',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: '50%',
width: '38px',
height: '38px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 260ms ease',
width: '36px', height: '36px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Settings size={16} color="rgba(255,255,255,0.8)" />
<Settings size={15} color="rgba(255,255,255,0.7)" />
</Link>
<ThemeToggle />
</header>
@@ -52,27 +45,48 @@ export default async function Home() {
<main style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
gap: '3rem',
}}>
{buttons.length === 0 ? (
<div style={{ textAlign: 'center', color: 'rgba(255,255,255,0.7)' }}>
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>🔗</div>
<p style={{ fontWeight: 600, fontSize: '1.1rem', marginBottom: '0.5rem', color: '#fff' }}>
Aucune application configurée
{/* Title */}
<div style={{ textAlign: 'center' }}>
<h1 style={{
fontSize: 'clamp(1.8rem, 4vw, 2.8rem)',
fontWeight: 800,
letterSpacing: '-0.04em',
color: '#fff',
textShadow: '0 2px 20px rgba(0,0,0,0.3)',
}}>
Menu Application
</h1>
{buttons.length > 0 && (
<p style={{ color: 'rgba(255,255,255,0.45)', fontSize: '0.9rem', marginTop: '0.4rem', fontWeight: 400 }}>
{buttons.length} application{buttons.length > 1 ? 's' : ''} disponible{buttons.length > 1 ? 's' : ''}
</p>
<p style={{ fontSize: '0.9rem', marginBottom: '2rem' }}>
Ajoutez vos premiers raccourcis depuis l'administration.
)}
</div>
{/* Grid */}
{buttons.length === 0 ? (
<div style={{ textAlign: 'center', color: 'rgba(255,255,255,0.5)' }}>
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>🔗</div>
<p style={{ fontWeight: 600, fontSize: '1.05rem', color: '#fff', marginBottom: '0.5rem' }}>
Aucune application
</p>
<p style={{ fontSize: '0.88rem', marginBottom: '1.75rem' }}>
Ajoutez vos raccourcis depuis l'administration.
</p>
<Link href="/admin" style={{
padding: '0.7rem 1.5rem',
background: 'rgba(255,255,255,0.18)',
border: '1px solid rgba(255,255,255,0.25)',
padding: '0.65rem 1.4rem',
background: 'rgba(255,255,255,0.15)',
border: '1px solid rgba(255,255,255,0.22)',
borderRadius: '50px',
color: '#fff',
fontWeight: 600,
fontSize: '0.9rem',
fontSize: '0.88rem',
backdropFilter: 'blur(12px)',
}}>
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) => (
<MenuButton key={btn.id} name={btn.name} url={btn.url} />
{(buttons as any[]).map((btn) => (
<MenuButton key={btn.id} name={btn.name} url={btn.url} icon={btn.icon} />
))}
</div>
)}
+99
View File
@@ -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<HTMLFormElement>) => {
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 (
<div style={{
background: 'rgba(255,255,255,0.07)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: '20px',
padding: '1.75rem',
marginBottom: '2rem',
}}>
<h2 style={{
fontSize: '0.85rem',
fontWeight: 700,
color: 'rgba(255,255,255,0.5)',
marginBottom: '1.25rem',
letterSpacing: '0.08em',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}>
<PlusCircle size={15} /> AJOUTER UNE APPLICATION
</h2>
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
{/* Emoji picker */}
<div>
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, color: 'rgba(255,255,255,0.45)', marginBottom: '0.45rem' }}>
ICÔNE
</label>
<EmojiPicker value={icon} onChange={setIcon} />
</div>
{/* Name */}
<div style={{ flex: '1 1 140px' }}>
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, color: 'rgba(255,255,255,0.45)', marginBottom: '0.45rem' }}>
NOM
</label>
<input name="name" type="text" placeholder="Ex: Slack, Notion…" required />
</div>
{/* URL */}
<div style={{ flex: '2 1 240px' }}>
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, color: 'rgba(255,255,255,0.45)', marginBottom: '0.45rem' }}>
URL
</label>
<input name="url" type="url" placeholder="https://…" required />
</div>
{/* Submit */}
<button
type="submit"
disabled={loading}
style={{
background: 'rgba(255,255,255,0.16)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(255,255,255,0.22)',
color: '#fff',
padding: '0.75rem 1.25rem',
borderRadius: '12px',
fontWeight: 600,
fontSize: '0.88rem',
cursor: 'pointer',
whiteSpace: 'nowrap',
flexShrink: 0,
transition: 'all 200ms ease',
opacity: loading ? 0.6 : 1,
}}
>
{loading ? '…' : 'Ajouter →'}
</button>
</form>
</div>
);
}
+117
View File
@@ -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 (
<div style={{ position: 'relative' }}>
{/* Trigger button */}
<button
type="button"
onClick={() => setOpen(!open)}
style={{
width: '52px',
height: '52px',
borderRadius: '14px',
background: 'rgba(255,255,255,0.08)',
border: '1px solid rgba(255,255,255,0.15)',
fontSize: '1.6rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
transition: 'background 200ms ease',
}}
title="Choisir une icône"
>
{value || '🔗'}
</button>
{/* Dropdown picker */}
{open && (
<div style={{
position: 'absolute',
top: '60px',
left: 0,
zIndex: 100,
background: 'rgba(30, 10, 35, 0.92)',
backdropFilter: 'blur(30px)',
WebkitBackdropFilter: 'blur(30px)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: '16px',
padding: '1rem',
width: '268px',
boxShadow: '0 12px 40px rgba(0,0,0,0.5)',
}}>
<p style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.4)', marginBottom: '0.75rem', fontWeight: 600, letterSpacing: '0.05em' }}>
SÉLECTIONNER UNE ICÔNE
</p>
{/* Quick emoji grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px', marginBottom: '0.75rem' }}>
{QUICK_EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => { onChange(emoji); setOpen(false); }}
style={{
background: value === emoji ? 'rgba(255,255,255,0.18)' : 'transparent',
border: 'none',
borderRadius: '8px',
fontSize: '1.2rem',
padding: '4px',
cursor: 'pointer',
transition: 'background 150ms',
lineHeight: 1,
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.12)')}
onMouseLeave={(e) => (e.currentTarget.style.background = value === emoji ? 'rgba(255,255,255,0.18)' : 'transparent')}
>
{emoji}
</button>
))}
</div>
{/* Manual input */}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '0.75rem' }}>
<input
type="text"
placeholder="Coller un emoji…"
value={value}
onChange={(e) => 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}
/>
</div>
</div>
)}
</div>
);
}
+11 -7
View File
@@ -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<MenuButtonProps> = ({ name, url }) => {
/**
* MenuButton — displays emoji icon if set, falls back to Google favicon.
*/
export const MenuButton: React.FC<MenuButtonProps> = ({ 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<MenuButtonProps> = ({ name, url }) => {
return (
<Link href={url} target="_blank" rel="noopener noreferrer" className={styles.menuButton}>
<div className={styles.faviconWrapper}>
{!faviconError ? (
{icon ? (
// Custom emoji icon
<span style={{ fontSize: '2.2rem', lineHeight: 1 }}>{icon}</span>
) : !faviconError ? (
// Auto favicon from Google CDN
<Image
src={faviconUrl}
alt={`${name} favicon`}
BIN
View File
Binary file not shown.
+11 -4
View File
@@ -3,8 +3,7 @@ import path from 'path';
/**
* Initializes the SQLite database schema using raw SQL.
* This bypasses Prisma CLI entirely, avoiding CLI environment issues in Docker.
* Safe to run multiple times (CREATE TABLE IF NOT EXISTS).
* Safe to run multiple times — uses IF NOT EXISTS and handles migrations.
*/
export function initializeDatabase(): void {
const dbUrl = process.env.DATABASE_URL || 'file:/app/prisma/dev.db';
@@ -18,7 +17,7 @@ export function initializeDatabase(): void {
try {
const db = new Database(resolvedPath);
// Create Config table
// Config table
db.exec(`
CREATE TABLE IF NOT EXISTS "Config" (
"id" TEXT NOT NULL PRIMARY KEY,
@@ -26,7 +25,7 @@ export function initializeDatabase(): void {
);
`);
// Create Button table
// Button table (base)
db.exec(`
CREATE TABLE IF NOT EXISTS "Button" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
@@ -36,6 +35,14 @@ export function initializeDatabase(): void {
);
`);
// Migration: add icon column if missing (added in v2)
try {
db.exec(`ALTER TABLE "Button" ADD COLUMN "icon" TEXT;`);
console.log('[DB Init] Migration applied: Button.icon column added.');
} catch {
// Column already exists — no action needed
}
db.close();
console.log('[DB Init] Schema ready.');
} catch (err) {
+1
View File
@@ -20,5 +20,6 @@ model Button {
id Int @id @default(autoincrement())
name String
url String
icon String?
order Int @default(0)
}