mirror of
https://github.com/R0m1k3/Menu.git
synced 2026-10-11 17:28:28 +02:00
feat: initialize Prisma schema with Config and Button models
This commit is contained in:
1 parent
ca99378348
commit
485aba96fd
10 files changed
+405
-238
No files matched your search
+26
-6
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`}
|
||||
|
||||
+11
-4
@@ -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) {
|
||||
|
||||
@@ -20,5 +20,6 @@ model Button {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
url String
|
||||
icon String?
|
||||
order Int @default(0)
|
||||
}
|
||||
Reference in new issue
Block a user