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. * Handle admin login.
* @param formData - The form data containing the password.
*/ */
export async function loginAdmin(formData: FormData) { export async function loginAdmin(formData: FormData) {
const password = formData.get('password') as string; const password = formData.get('password') as string;
@@ -28,7 +27,7 @@ export async function loginAdmin(formData: FormData) {
cookieStore.set('admin_auth', 'true', { cookieStore.set('admin_auth', 'true', {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24, // 24 hours maxAge: 60 * 60 * 24,
path: '/', path: '/',
}); });
redirect('/admin'); 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) { export async function addButton(formData: FormData) {
if (!(await isAuthenticated())) throw new Error('Non autorisé'); if (!(await isAuthenticated())) throw new Error('Non autorisé');
const name = formData.get('name') as string; const name = formData.get('name') as string;
const url = formData.get('url') 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.'); if (!name || !url) throw new Error('Champs requis manquants.');
await prisma.button.create({ 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('/admin');
revalidatePath('/'); revalidatePath('/');
} }
@@ -81,7 +102,6 @@ export async function deleteButton(id: number) {
if (!(await isAuthenticated())) throw new Error('Non autorisé'); if (!(await isAuthenticated())) throw new Error('Non autorisé');
await prisma.button.delete({ where: { id } }); await prisma.button.delete({ where: { id } });
// Revalidate both admin and home pages to reflect deletion immediately
revalidatePath('/admin'); revalidatePath('/admin');
revalidatePath('/'); revalidatePath('/');
} }
+54 -101
View File
@@ -1,162 +1,118 @@
import { prisma } from '@/lib/db'; 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 { redirect } from 'next/navigation';
import { GlassCard } from '@/components/GlassCard';
import { ThemeToggle } from '@/components/ThemeToggle'; 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 Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { AddButtonForm } from '@/components/AddButtonForm';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
function getDomainFromUrl(url: string): string { function getDomain(url: string): string {
try { try { return new URL(url).hostname; } catch { return url; }
return new URL(url).hostname;
} catch {
return url;
}
} }
export default async function AdminPage() { export default async function AdminPage() {
const auth = await isAuthenticated(); const auth = await isAuthenticated();
if (!auth) redirect('/admin/login');
if (!auth) {
redirect('/admin/login');
}
const buttons = await prisma.button.findMany({ const buttons = await prisma.button.findMany({
orderBy: { order: 'asc' }, orderBy: { order: 'asc' },
}); });
return ( return (
<div className="page-container" style={{ maxWidth: '900px' }}> <div style={{ minHeight: '100vh', padding: '1.5rem 2rem', maxWidth: '860px', margin: '0 auto' }}>
{/* Header */} {/* Header */}
<header style={{ <header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2.5rem' }}>
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2.5rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Link href="/" aria-label="Retour" style={{ <Link href="/" style={{
background: 'var(--glass-bg)', background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(20px)', backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(20px)', border: '1px solid rgba(255,255,255,0.15)',
border: '1px solid var(--glass-border)',
borderRadius: '50%', borderRadius: '50%',
width: '38px', width: '36px', height: '36px',
height: '38px', display: 'flex', alignItems: 'center', justifyContent: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}> }}>
<ArrowLeft size={16} color="var(--fg-secondary)" /> <ArrowLeft size={15} color="rgba(255,255,255,0.8)" />
</Link> </Link>
<div> <div>
<h1 style={{ fontSize: '1.6rem', fontWeight: 700, letterSpacing: '-0.03em' }}> <h1 style={{ fontSize: '1.4rem', fontWeight: 700, letterSpacing: '-0.02em', color: '#fff' }}>
Administration Administration
</h1> </h1>
<p style={{ fontSize: '0.85rem', color: 'var(--fg-secondary)', marginTop: '0.1rem' }}> <p style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,0.45)', marginTop: '0.1rem' }}>
Gérez vos raccourcis d'applications {buttons.length} application{buttons.length !== 1 ? 's' : ''} configurée{buttons.length !== 1 ? 's' : ''}
</p> </p>
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<ThemeToggle /> <ThemeToggle />
<form action={logoutAdmin}> <form action={logoutAdmin}>
<button type="submit" style={{ <button type="submit" style={{
background: 'var(--glass-bg)', background: 'rgba(255,255,255,0.08)',
backdropFilter: 'blur(20px)', backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(20px)', border: '1px solid rgba(255,255,255,0.12)',
border: '1px solid var(--glass-border)',
borderRadius: '50px', borderRadius: '50px',
padding: '0.5rem 1rem', padding: '0.45rem 0.9rem',
display: 'flex', color: 'rgba(255,255,255,0.7)',
alignItems: 'center', fontSize: '0.82rem',
gap: '0.4rem',
fontSize: '0.85rem',
fontWeight: 500, fontWeight: 500,
color: 'var(--fg-secondary)', display: 'flex', alignItems: 'center', gap: '0.4rem',
}}> }}>
<LogOut size={15} /> Déconnexion <LogOut size={13} /> Déconnexion
</button> </button>
</form> </form>
</div> </div>
</header> </header>
{/* Add form */} {/* Add form */}
<GlassCard style={{ padding: '1.75rem', marginBottom: '2rem' }}> <AddButtonForm />
<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>
{/* Button list */} {/* Button list */}
{buttons.length === 0 ? ( {buttons.length === 0 ? (
<div style={{ textAlign: 'center', padding: '4rem', color: 'var(--fg-secondary)', fontSize: '0.9rem' }}> <div style={{ textAlign: 'center', padding: '4rem', color: 'rgba(255,255,255,0.35)', fontSize: '0.9rem' }}>
Aucun bouton configuré. Ajoutez-en un ci-dessus. Aucun bouton configuré.
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{buttons.map((btn: any) => { {(buttons as any[]).map((btn) => {
const domain = getDomainFromUrl(btn.url); const domain = getDomain(btn.url);
const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
return ( return (
<GlassCard key={btn.id} style={{ <div key={btn.id} style={{
padding: '0.9rem 1.25rem', 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', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: '1rem', gap: '1rem',
borderRadius: '14px',
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem', minWidth: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem', minWidth: 0 }}>
{/* Icon: emoji or favicon */}
<div style={{ <div style={{
width: '36px', width: '40px', height: '40px',
height: '36px',
borderRadius: '10px', borderRadius: '10px',
background: 'var(--bg-base)', background: 'rgba(255,255,255,0.08)',
border: '1px solid var(--glass-border)', display: 'flex', alignItems: 'center', justifyContent: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0, flexShrink: 0,
overflow: 'hidden', fontSize: '1.3rem',
}}> }}>
{btn.icon ? btn.icon : (
<Image src={faviconUrl} alt={btn.name} width={20} height={20} unoptimized /> <Image src={faviconUrl} alt={btn.name} width={20} height={20} unoptimized />
)}
</div> </div>
<div style={{ minWidth: 0 }}> <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} {btn.name}
</div> </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} {btn.url}
</div> </div>
</div> </div>
@@ -168,21 +124,18 @@ export default async function AdminPage() {
}}> }}>
<button type="submit" style={{ <button type="submit" style={{
background: 'transparent', background: 'transparent',
color: 'var(--fg-secondary)', color: 'rgba(255,255,255,0.3)',
padding: '0.4rem', padding: '0.4rem',
borderRadius: '8px', borderRadius: '8px',
display: 'flex',
alignItems: 'center',
flexShrink: 0, flexShrink: 0,
transition: 'color 200ms',
}} }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--error)')} onMouseEnter={(e) => (e.currentTarget.style.color = '#ff6b7a')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--fg-secondary)')} onMouseLeave={(e) => (e.currentTarget.style.color = 'rgba(255,255,255,0.3)')}
> >
<Trash2 size={17} /> <Trash2 size={16} />
</button> </button>
</form> </form>
</GlassCard> </div>
); );
})} })}
</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 { :root {
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; --font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--ease: cubic-bezier(0.4, 0, 0.2, 1); --ease: cubic-bezier(0.4, 0, 0.2, 1);
--duration: 260ms;
/* ─── Dark (Default: Purple → Wine) ─── */ /* Dark (default) */
--bg-from: #3a0f3c; --bg-from: #2c0a30;
--bg-to: #7b1535; --bg-mid: #5a0f28;
--bg-to: #8c1525;
--fg: #ffffff; --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-bg: rgba(255, 255, 255, 0.09);
--card-border: rgba(255, 255, 255, 0.12); --card-border: rgba(255, 255, 255, 0.13);
--card-shadow: 0 4px 24px rgba(0, 0, 0, 0.25); --card-shadow: 0 2px 12px rgba(0,0,0,0.3), 0 1px 0 rgba(255,255,255,0.06) inset;
--card-hover-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); --card-hover-bg: rgba(255, 255, 255, 0.14);
--card-hover-bg: rgba(255, 255, 255, 0.15); --card-hover-shadow: 0 16px 48px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.10) inset;
--accent: #ffffff;
--accent-btn: rgba(255, 255, 255, 0.18);
--error: #FF6B7A;
} }
[data-theme='light'] { [data-theme='light'] {
/* ─── Light: Lavender → Pink ─── */ --bg-from: #e0c4ef;
--bg-from: #e8d5f5; --bg-mid: #e8b8cc;
--bg-to: #f5c2d0; --bg-to: #f0b8be;
--fg: #1c1c2e;
--fg-secondary: rgba(28, 28, 46, 0.55);
--card-bg: rgba(255, 255, 255, 0.45); --fg: #1c0a22;
--card-border: rgba(255, 255, 255, 0.8); --fg-secondary: rgba(28, 10, 34, 0.5);
--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);
--accent: #1c1c2e; --card-bg: rgba(255, 255, 255, 0.50);
--accent-btn: rgba(28, 28, 46, 0.12); --card-border: rgba(255, 255, 255, 0.85);
--error: #e0334e; --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 { *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
box-sizing: border-box; html, body { height: 100%; }
margin: 0;
padding: 0;
}
html, body {
height: 100%;
}
body { body {
min-height: 100vh; min-height: 100vh;
@@ -57,80 +44,45 @@ body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
overflow-x: hidden; 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; 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; } a { text-decoration: none; color: inherit; }
button { cursor: pointer; border: none; outline: none; font-family: var(--font); } button { cursor: pointer; border: none; outline: none; font-family: var(--font); }
/* ── Scrollbar ── */ /* Scrollbar */
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 10px; }
background: rgba(255,255,255,0.2);
border-radius: 10px;
}
/* ── Input ── */ /* Inputs */
input[type="text"], input[type="text"],
input[type="password"], input[type="password"],
input[type="url"], input[type="url"],
input[type="email"] { input[type="email"] {
width: 100%; width: 100%;
padding: 0.85rem 1rem; padding: 0.82rem 1rem;
background: var(--card-bg); background: rgba(255,255,255,0.07);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-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; border-radius: 12px;
color: var(--fg); color: var(--fg);
font-family: var(--font); font-family: var(--font);
font-size: 0.95rem; font-size: 0.92rem;
outline: none; outline: none;
transition: all var(--duration) var(--ease); transition: all 240ms var(--ease);
} }
input:focus { input:focus {
border-color: rgba(255,255,255,0.5); border-color: rgba(255,255,255,0.45);
box-shadow: 0 0 0 3px rgba(255,255,255,0.10); 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); } 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 ( return (
<div style={{ <div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
minHeight: '100vh',
display: 'flex', {/* Top controls */}
flexDirection: 'column',
}}>
{/* Top bar */}
<header style={{ <header style={{
display: 'flex', display: 'flex',
justifyContent: 'flex-end', justifyContent: 'flex-end',
alignItems: 'center', alignItems: 'center',
padding: '1.5rem 2rem', padding: '1.5rem 2rem',
gap: '0.75rem', gap: '0.6rem',
}}> }}>
<Link href="/admin" aria-label="Administration" style={{ <Link href="/admin" aria-label="Administration" style={{
background: 'rgba(255,255,255,0.12)', background: 'rgba(255,255,255,0.10)',
backdropFilter: 'blur(16px)', backdropFilter: 'blur(16px)',
WebkitBackdropFilter: '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%', borderRadius: '50%',
width: '38px', width: '36px', height: '36px',
height: '38px', display: 'flex', alignItems: 'center', justifyContent: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 260ms ease',
}}> }}>
<Settings size={16} color="rgba(255,255,255,0.8)" /> <Settings size={15} color="rgba(255,255,255,0.7)" />
</Link> </Link>
<ThemeToggle /> <ThemeToggle />
</header> </header>
@@ -52,27 +45,48 @@ export default async function Home() {
<main style={{ <main style={{
flex: 1, flex: 1,
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: '2rem', padding: '2rem',
gap: '3rem',
}}> }}>
{buttons.length === 0 ? ( {/* Title */}
<div style={{ textAlign: 'center', color: 'rgba(255,255,255,0.7)' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>🔗</div> <h1 style={{
<p style={{ fontWeight: 600, fontSize: '1.1rem', marginBottom: '0.5rem', color: '#fff' }}> fontSize: 'clamp(1.8rem, 4vw, 2.8rem)',
Aucune application configurée 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>
<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> </p>
<Link href="/admin" style={{ <Link href="/admin" style={{
padding: '0.7rem 1.5rem', padding: '0.65rem 1.4rem',
background: 'rgba(255,255,255,0.18)', background: 'rgba(255,255,255,0.15)',
border: '1px solid rgba(255,255,255,0.25)', border: '1px solid rgba(255,255,255,0.22)',
borderRadius: '50px', borderRadius: '50px',
color: '#fff', color: '#fff',
fontWeight: 600, fontWeight: 600,
fontSize: '0.9rem', fontSize: '0.88rem',
backdropFilter: 'blur(12px)', backdropFilter: 'blur(12px)',
}}> }}>
Configurer → Configurer →
@@ -84,10 +98,10 @@ export default async function Home() {
flexWrap: 'wrap', flexWrap: 'wrap',
gap: '1.25rem', gap: '1.25rem',
justifyContent: 'center', justifyContent: 'center',
maxWidth: '900px', maxWidth: '960px',
}}> }}>
{buttons.map((btn: any) => ( {(buttons as any[]).map((btn) => (
<MenuButton key={btn.id} name={btn.name} url={btn.url} /> <MenuButton key={btn.id} name={btn.name} url={btn.url} icon={btn.icon} />
))} ))}
</div> </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 { interface MenuButtonProps {
name: string; name: string;
url: string; url: string;
icon?: string | null;
} }
function getDomain(url: string): string { function getDomain(url: string): string {
try { try { return new URL(url).hostname; } catch { return url; }
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 domain = getDomain(url);
const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=64`; const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
const [faviconError, setFaviconError] = useState(false); const [faviconError, setFaviconError] = useState(false);
@@ -27,7 +27,11 @@ export const MenuButton: React.FC<MenuButtonProps> = ({ name, url }) => {
return ( return (
<Link href={url} target="_blank" rel="noopener noreferrer" className={styles.menuButton}> <Link href={url} target="_blank" rel="noopener noreferrer" className={styles.menuButton}>
<div className={styles.faviconWrapper}> <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 <Image
src={faviconUrl} src={faviconUrl}
alt={`${name} favicon`} 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. * Initializes the SQLite database schema using raw SQL.
* This bypasses Prisma CLI entirely, avoiding CLI environment issues in Docker. * Safe to run multiple times — uses IF NOT EXISTS and handles migrations.
* Safe to run multiple times (CREATE TABLE IF NOT EXISTS).
*/ */
export function initializeDatabase(): void { export function initializeDatabase(): void {
const dbUrl = process.env.DATABASE_URL || 'file:/app/prisma/dev.db'; const dbUrl = process.env.DATABASE_URL || 'file:/app/prisma/dev.db';
@@ -18,7 +17,7 @@ export function initializeDatabase(): void {
try { try {
const db = new Database(resolvedPath); const db = new Database(resolvedPath);
// Create Config table // Config table
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS "Config" ( CREATE TABLE IF NOT EXISTS "Config" (
"id" TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
@@ -26,7 +25,7 @@ export function initializeDatabase(): void {
); );
`); `);
// Create Button table // Button table (base)
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS "Button" ( CREATE TABLE IF NOT EXISTS "Button" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "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(); db.close();
console.log('[DB Init] Schema ready.'); console.log('[DB Init] Schema ready.');
} catch (err) { } catch (err) {
+1
View File
@@ -20,5 +20,6 @@ model Button {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
url String url String
icon String?
order Int @default(0) order Int @default(0)
} }