feat: initialize project with Next.js, Prisma, and admin dashboard functionality

This commit is contained in:
Michael committed 2026-04-10 09:58:09 +02:00
commit b73aa8616b
37 files changed
+8711

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
'use server';
import { prisma } from '@/lib/db';
import { verifyPassword } from '@/lib/auth';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
/**
* Handle admin login.
* @param formData - The form data containing the password.
*/
export async function loginAdmin(formData: FormData) {
const password = formData.get('password') as string;
const config = await prisma.config.findUnique({
where: { id: 'admin_password' },
});
if (!config) {
redirect('/setup');
}
const isValid = await verifyPassword(password, config.value);
if (isValid) {
const cookieStore = await cookies();
cookieStore.set('admin_auth', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
});
redirect('/admin');
} else {
throw new Error('Mot de passe incorrect.');
}
}
/**
* Logout admin.
*/
export async function logoutAdmin() {
const cookieStore = await cookies();
cookieStore.delete('admin_auth');
redirect('/');
}
/**
* Is authenticated check.
*/
export async function isAuthenticated() {
const cookieStore = await cookies();
return cookieStore.has('admin_auth');
}
/**
* Add a new menu button.
*/
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;
if (!name || !url) throw new Error('Champs requis manquants.');
await prisma.button.create({
data: { name, url, order: 0 },
});
}
/**
* Delete a menu button.
*/
export async function deleteButton(id: number) {
if (!(await isAuthenticated())) throw new Error('Non autorisé');
await prisma.button.delete({ where: { id } });
}
+48
View File
@@ -0,0 +1,48 @@
'use server';
import { prisma } from '@/lib/db';
import { hashPassword } from '@/lib/auth';
import { redirect } from 'next/navigation';
/**
* Save the initial admin password.
* @param formData - The form data containing the password.
*/
export async function setupAdmin(formData: FormData) {
const password = formData.get('password') as string;
const confirm = formData.get('confirm') as string;
if (!password || password !== confirm) {
throw new Error('Les mots de passe ne correspondent pas.');
}
// Check if already setup
const existing = await prisma.config.findUnique({
where: { id: 'admin_password' },
});
if (existing) {
throw new Error('La configuration a déjà été effectuée.');
}
const hashedPassword = await hashPassword(password);
await prisma.config.create({
data: {
id: 'admin_password',
value: hashedPassword,
},
});
redirect('/admin');
}
/**
* Check if the application is configured.
*/
export async function isConfigured() {
const config = await prisma.config.findUnique({
where: { id: 'admin_password' },
});
return !!config;
}
+57
View File
@@ -0,0 +1,57 @@
'use client';
import React, { useState } from 'react';
import { GlassCard } from '@/components/GlassCard';
import { loginAdmin } from '@/app/actions/admin';
export default function AdminLoginPage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
try {
await loginAdmin(formData);
} catch (err: any) {
setError(err.message || 'Une erreur est survenue.');
setLoading(false);
}
};
return (
<div className="container" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<GlassCard style={{ width: '100%', maxWidth: '400px' }}>
<h1 style={{ marginBottom: '2rem', fontSize: '1.8rem' }}>Accès Admin</h1>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem', fontWeight: 600 }}>Mot de passe</label>
<input name="password" type="password" required placeholder="••••••••" />
</div>
{error && <p style={{ color: 'var(--error)', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
<button
type="submit"
disabled={loading}
style={{
background: 'var(--accent)',
color: 'white',
padding: '1rem',
borderRadius: '12px',
fontWeight: 600,
marginTop: '1rem',
opacity: loading ? 0.7 : 1
}}
>
{loading ? 'Connexion...' : 'Se connecter'}
</button>
</form>
</GlassCard>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { prisma } from '@/lib/db';
import { isAuthenticated, logoutAdmin, addButton, deleteButton } from '@/app/actions/admin';
import { redirect } from 'next/navigation';
import { GlassCard } from '@/components/GlassCard';
import { Trash2, LogOut, PlusCircle, Globe } from 'lucide-react';
export default async function AdminPage() {
const auth = await isAuthenticated();
if (!auth) {
redirect('/admin/login');
}
const buttons = await prisma.button.findMany({
orderBy: { order: 'asc' },
});
return (
<main className="container">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '3rem' }}>
<h1 style={{ marginBottom: 0, fontSize: '2rem' }}>Panel d'Administration</h1>
<form action={logoutAdmin}>
<button type="submit" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', background: 'transparent', color: 'var(--foreground)', opacity: 0.7 }}>
<LogOut size={20} /> Déconnexion
</button>
</form>
</header>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(300px, 1fr) 2fr', gap: '3rem', alignItems: 'start' }}>
{/* Form to add button */}
<GlassCard>
<h2 style={{ fontSize: '1.25rem', marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<PlusCircle size={20} color="var(--accent)" /> Ajouter un bouton
</h2>
<form action={addButton} style={{ display: 'flex', flexDirection: 'column', gap: '1.2rem' }}>
<div>
<label style={{ display: 'block', marginBottom: '0.4rem', fontSize: '0.85rem', fontWeight: 600 }}>Nom de l'application</label>
<input name="name" type="text" placeholder="Ex: Slack, Jira..." required />
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.4rem', fontSize: '0.85rem', fontWeight: 600 }}>Lien (URL)</label>
<input name="url" type="url" placeholder="https://..." required />
</div>
<button
type="submit"
style={{ background: 'var(--accent)', color: 'white', padding: '0.8rem', borderRadius: '10px', fontWeight: 600, marginTop: '0.5rem' }}
>
Ajouter au menu
</button>
</form>
</GlassCard>
{/* List of buttons */}
<div>
<h2 style={{ fontSize: '1.25rem', marginBottom: '1.5rem' }}>Boutons configurés</h2>
{buttons.length === 0 ? (
<p style={{ opacity: 0.5 }}>Aucun bouton configuré pour le moment.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{buttons.map((btn: any) => (
<GlassCard key={btn.id} style={{ padding: '1.2rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div style={{ background: 'var(--accent)', padding: '0.5rem', borderRadius: '8px', color: 'white' }}>
<Globe size={18} />
</div>
<div>
<div style={{ fontWeight: 600 }}>{btn.name}</div>
<div style={{ fontSize: '0.8rem', opacity: 0.6 }}>{btn.url}</div>
</div>
</div>
<form action={async () => {
'use server';
await deleteButton(btn.id);
}}>
<button
type="submit"
style={{ background: 'transparent', color: 'var(--error)', padding: '0.5rem', borderRadius: '8px' }}
>
<Trash2 size={20} />
</button>
</form>
</GlassCard>
))}
</div>
)}
</div>
</div>
</main>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+105
View File
@@ -0,0 +1,105 @@
:root {
/* Common */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
/* Light Theme */
--background: #f5f5f7;
--foreground: #1d1d1f;
--glass-bg: rgba(255, 255, 255, 0.7);
--glass-border: rgba(255, 255, 255, 0.3);
--card-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07);
--accent: #0071e3;
--error: #ff3b30;
--bg-gradient: radial-gradient(circle at 0% 0%, #fff 0%, #f5f5f7 100%);
}
[data-theme='dark'] {
/* Dark Theme */
--background: #000000;
--foreground: #f5f5f7;
--glass-bg: rgba(28, 28, 30, 0.7);
--glass-border: rgba(255, 255, 255, 0.1);
--card-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
--accent: #0a84ff;
--error: #ff453a;
--bg-gradient: radial-gradient(circle at 100% 100%, #1c1c1e 0%, #000 100%);
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
body {
background: var(--bg-gradient);
background-attachment: fixed;
color: var(--foreground);
font-family: var(--font-family);
min-height: 100vh;
transition: var(--transition-smooth);
overflow-x: hidden;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(125, 125, 125, 0.3);
border-radius: 10px;
border: 3px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(125, 125, 125, 0.5);
}
.glass {
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
box-shadow: var(--card-shadow);
border-radius: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.022em;
margin-bottom: 2rem;
text-align: center;
}
button, .button {
cursor: pointer;
border: none;
outline: none;
font-family: inherit;
transition: var(--transition-smooth);
}
input {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 0.8rem 1rem;
color: var(--foreground);
font-family: inherit;
width: 100%;
}
input:focus {
outline: 2px solid var(--accent);
}
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { ThemeProvider } from '@/components/ThemeProvider';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Menu Professional',
description: 'Industrial dashboard for SaaS applications',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider
attribute="data-theme"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
+142
View File
@@ -0,0 +1,142 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
flex: 1;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--button-secondary-border);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { prisma } from '@/lib/db';
import { isConfigured } from '@/app/actions/setup';
import { redirect } from 'next/navigation';
import { MenuButton } from '@/components/MenuButton';
import { ThemeToggle } from '@/components/ThemeToggle';
export default async function Home() {
const configured = await isConfigured();
if (!configured) {
redirect('/setup');
}
const buttons = await prisma.button.findMany({
orderBy: { order: 'asc' },
});
return (
<main className="container">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4rem' }}>
<h1 style={{ marginBottom: 0 }}>Application Menu</h1>
<ThemeToggle />
</header>
{buttons.length === 0 ? (
<div style={{ textAlign: 'center', marginTop: '4rem' }}>
<p style={{ opacity: 0.6 }}>Aucune application configurée. Rendez-vous dans le panel admin.</p>
<a href="/admin" style={{ color: 'var(--accent)', marginTop: '1rem', display: 'inline-block' }}>Accéder à l'administration</a>
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: '2.5rem'
}}>
{buttons.map((btn: any) => (
<MenuButton
key={btn.id}
name={btn.name}
url={btn.url}
iconName="ExternalLink"
/>
))}
</div>
)}
</main>
);
}
+64
View File
@@ -0,0 +1,64 @@
'use client';
import React, { useState } from 'react';
import { GlassCard } from '@/components/GlassCard';
import { setupAdmin } from '@/app/actions/setup';
export default function SetupPage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
try {
await setupAdmin(formData);
} catch (err: any) {
setError(err.message || 'Une erreur est survenue.');
setLoading(false);
}
};
return (
<div className="container" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<GlassCard style={{ width: '100%', maxWidth: '450px' }}>
<h1 style={{ marginBottom: '1rem', fontSize: '1.8rem' }}>Configuration Initiale</h1>
<p style={{ textAlign: 'center', marginBottom: '2rem', color: 'var(--foreground)', opacity: 0.8 }}>
Définissez le mot de passe maître pour accéder au panel d'administration.
</p>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem', fontWeight: 600 }}>Mot de passe Admin</label>
<input name="password" type="password" required placeholder="••••••••" />
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem', fontWeight: 600 }}>Confirmer le mot de passe</label>
<input name="confirm" type="password" required placeholder="••••••••" />
</div>
{error && <p style={{ color: 'var(--error)', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
<button
type="submit"
disabled={loading}
style={{
background: 'var(--accent)',
color: 'white',
padding: '1rem',
borderRadius: '12px',
fontWeight: 600,
marginTop: '1rem',
opacity: loading ? 0.7 : 1
}}
>
{loading ? 'Configuration...' : 'Finaliser la configuration'}
</button>
</form>
</GlassCard>
</div>
);
}